fix: include lesson stepper styles in build, restrict scramble to locale changes only

- Lesson stepper CSS was not committed (only in working tree), causing vertical layout in production
- ScrambleText now only animates when locale changes, not on any text prop change (e.g. on/off toggle)
This commit is contained in:
2026-04-18 22:38:08 +02:00
parent 049eaa1807
commit 64368b1f29
2 changed files with 167 additions and 9 deletions
+18 -8
View File
@@ -4,17 +4,20 @@
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
text: { type: String, required: true },
});
const { locale } = useI18n();
const displayText = ref(props.text);
let frameId = null;
let lastLocale = locale.value;
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZアイウエオカキクケコ漢字書道';
const DURATION = 400; // total animation time in ms
const STEPS_PER_CHAR = 3; // how many random chars before settling
const DURATION = 400;
const STEPS_PER_CHAR = 3;
function scrambleTo(newText) {
if (frameId) cancelAnimationFrame(frameId);
@@ -22,8 +25,6 @@ function scrambleTo(newText) {
const oldText = displayText.value;
const maxLen = Math.max(oldText.length, newText.length);
const startTime = performance.now();
// Each character position settles at a staggered time
const charDelay = DURATION / (maxLen || 1);
const tick = (now) => {
@@ -35,15 +36,12 @@ function scrambleTo(newText) {
const charElapsed = elapsed - charSettleTime;
if (i >= newText.length && elapsed > charSettleTime + charDelay) {
// This position should disappear — don't add anything
continue;
}
if (charElapsed >= charDelay) {
// This character has settled — show final
if (i < newText.length) result += newText[i];
} else if (charElapsed > 0) {
// This character is still scrambling
const step = Math.floor((charElapsed / charDelay) * STEPS_PER_CHAR);
if (step >= STEPS_PER_CHAR - 1 && i < newText.length) {
result += newText[i];
@@ -51,7 +49,6 @@ function scrambleTo(newText) {
result += CHARS[Math.floor(Math.random() * CHARS.length)];
}
} else {
// Hasn't started yet — show old char or space
result += i < oldText.length ? oldText[i] : ' ';
}
}
@@ -69,8 +66,21 @@ function scrambleTo(newText) {
frameId = requestAnimationFrame(tick);
}
// Only scramble when locale changes, otherwise update silently
watch(() => props.text, (newVal) => {
if (locale.value !== lastLocale) {
lastLocale = locale.value;
scrambleTo(newVal);
} else {
displayText.value = newVal;
}
});
// Also watch locale directly to catch the change
watch(locale, () => {
// The text prop will update right after locale changes,
// so we just mark it and let the text watcher handle animation
lastLocale = '__pending__';
});
onBeforeUnmount(() => {
+148
View File
@@ -19,3 +19,151 @@
.lesson-canvas-wrapper {
margin: 0 auto;
}
// ── Phase Stepper ──────────────────────────────────
.lesson-stepper {
display: flex;
align-items: flex-start;
gap: 0;
}
.lesson-stepper__step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.lesson-stepper__dot {
width: 1.75rem;
height: 1.75rem;
border-radius: $radius-circle;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-xs;
font-weight: $weight-bold;
background: $color-surface-light;
color: $color-text-grey;
border: $border-width-md solid transparent;
transition: all $duration-normal $ease-default;
.is-active & {
background: $color-primary;
color: $color-text-dark;
border-color: $color-primary;
box-shadow: $shadow-glow-base;
}
.is-done & {
background: $color-success;
color: $color-text-white;
border-color: $color-success;
}
}
.lesson-stepper__label {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: $weight-bold;
color: $color-text-grey;
opacity: $opacity-inactive;
transition: opacity $duration-normal $ease-default;
white-space: nowrap;
.is-active & {
opacity: $opacity-active;
color: $color-primary;
}
.is-done & {
opacity: 0.6;
color: $color-success;
}
}
.lesson-stepper__line {
flex: 1;
height: $border-width-md;
background: $color-surface-light;
border-radius: $radius-pill;
transition: background $duration-normal $ease-default;
margin: 0.875rem $spacing-xs 0;
&.is-done {
background: $color-success;
}
}
// ── Practice Dots ──────────────────────────────────
.practice-dots {
display: flex;
gap: $spacing-sm;
justify-content: center;
}
.practice-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: $radius-circle;
background: $color-surface-light;
transition: all $duration-normal $ease-out-back;
&.is-filled {
background: $color-primary;
box-shadow: 0 0 0.5rem $color-primary;
transform: scale(1.2);
}
}
// ── Mnemonic Box ───────────────────────────────────
.mnemonic-box {
width: 100%;
background: $bg-glass-subtle;
border: $border-width-sm solid $color-border;
}
// ── Lesson Summary ─────────────────────────────────
.lesson-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr));
gap: $spacing-sm;
}
.lesson-summary-item {
display: flex;
flex-direction: column;
align-items: center;
padding: $spacing-md $spacing-sm;
background: $bg-glass-subtle;
border-radius: $radius-lg;
border: $border-width-sm solid $color-border;
transition: border-color $duration-normal $ease-default;
&:hover {
border-color: $color-primary;
}
}
.lesson-summary-char {
font-size: $font-xl;
font-weight: $weight-bold;
margin-bottom: $spacing-xs;
}
.lesson-summary-meaning {
font-size: $font-xs;
text-align: center;
}
// ── Utility ────────────────────────────────────────
.stepper-spacer {
width: 2.5rem;
}