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:
@@ -4,17 +4,20 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onBeforeUnmount } from 'vue';
|
import { ref, watch, onBeforeUnmount } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
text: { type: String, required: true },
|
text: { type: String, required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locale } = useI18n();
|
||||||
const displayText = ref(props.text);
|
const displayText = ref(props.text);
|
||||||
let frameId = null;
|
let frameId = null;
|
||||||
|
let lastLocale = locale.value;
|
||||||
|
|
||||||
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZアイウエオカキクケコ漢字書道';
|
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZアイウエオカキクケコ漢字書道';
|
||||||
const DURATION = 400; // total animation time in ms
|
const DURATION = 400;
|
||||||
const STEPS_PER_CHAR = 3; // how many random chars before settling
|
const STEPS_PER_CHAR = 3;
|
||||||
|
|
||||||
function scrambleTo(newText) {
|
function scrambleTo(newText) {
|
||||||
if (frameId) cancelAnimationFrame(frameId);
|
if (frameId) cancelAnimationFrame(frameId);
|
||||||
@@ -22,8 +25,6 @@ function scrambleTo(newText) {
|
|||||||
const oldText = displayText.value;
|
const oldText = displayText.value;
|
||||||
const maxLen = Math.max(oldText.length, newText.length);
|
const maxLen = Math.max(oldText.length, newText.length);
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
// Each character position settles at a staggered time
|
|
||||||
const charDelay = DURATION / (maxLen || 1);
|
const charDelay = DURATION / (maxLen || 1);
|
||||||
|
|
||||||
const tick = (now) => {
|
const tick = (now) => {
|
||||||
@@ -35,15 +36,12 @@ function scrambleTo(newText) {
|
|||||||
const charElapsed = elapsed - charSettleTime;
|
const charElapsed = elapsed - charSettleTime;
|
||||||
|
|
||||||
if (i >= newText.length && elapsed > charSettleTime + charDelay) {
|
if (i >= newText.length && elapsed > charSettleTime + charDelay) {
|
||||||
// This position should disappear — don't add anything
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (charElapsed >= charDelay) {
|
if (charElapsed >= charDelay) {
|
||||||
// This character has settled — show final
|
|
||||||
if (i < newText.length) result += newText[i];
|
if (i < newText.length) result += newText[i];
|
||||||
} else if (charElapsed > 0) {
|
} else if (charElapsed > 0) {
|
||||||
// This character is still scrambling
|
|
||||||
const step = Math.floor((charElapsed / charDelay) * STEPS_PER_CHAR);
|
const step = Math.floor((charElapsed / charDelay) * STEPS_PER_CHAR);
|
||||||
if (step >= STEPS_PER_CHAR - 1 && i < newText.length) {
|
if (step >= STEPS_PER_CHAR - 1 && i < newText.length) {
|
||||||
result += newText[i];
|
result += newText[i];
|
||||||
@@ -51,7 +49,6 @@ function scrambleTo(newText) {
|
|||||||
result += CHARS[Math.floor(Math.random() * CHARS.length)];
|
result += CHARS[Math.floor(Math.random() * CHARS.length)];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Hasn't started yet — show old char or space
|
|
||||||
result += i < oldText.length ? oldText[i] : ' ';
|
result += i < oldText.length ? oldText[i] : ' ';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,8 +66,21 @@ function scrambleTo(newText) {
|
|||||||
frameId = requestAnimationFrame(tick);
|
frameId = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only scramble when locale changes, otherwise update silently
|
||||||
watch(() => props.text, (newVal) => {
|
watch(() => props.text, (newVal) => {
|
||||||
|
if (locale.value !== lastLocale) {
|
||||||
|
lastLocale = locale.value;
|
||||||
scrambleTo(newVal);
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -19,3 +19,151 @@
|
|||||||
.lesson-canvas-wrapper {
|
.lesson-canvas-wrapper {
|
||||||
margin: 0 auto;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user