From bf4bd20c3382fb0d659f286ca3cefd8ec71ca8e3 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Thu, 9 Apr 2026 00:44:46 +0530 Subject: [PATCH 1/4] add tumble typed effect --- frontend/src/html/pages/settings.html | 1 + frontend/src/styles/animations.scss | 14 +++++++++ frontend/src/styles/test.scss | 13 ++++++++ frontend/src/ts/test/test-ui.ts | 44 +++++++++++++++++++++++++++ packages/schemas/src/configs.ts | 8 ++++- 5 files changed, 79 insertions(+), 1 deletion(-) diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html index 7049cbd4ee1f..80ccc42e1769 100644 --- a/frontend/src/html/pages/settings.html +++ b/frontend/src/html/pages/settings.html @@ -1022,6 +1022,7 @@ +
diff --git a/frontend/src/styles/animations.scss b/frontend/src/styles/animations.scss index 2ddc5002d2ca..d5928cc1598c 100644 --- a/frontend/src/styles/animations.scss +++ b/frontend/src/styles/animations.scss @@ -173,3 +173,17 @@ color: transparent; } } + +@keyframes typedEffectTumble { + 0% { + transform: translate(0, 0) rotate(0deg); + opacity: 1; + } + 25% { + opacity: 1; + } + 100% { + transform: translate(var(--fall-x), 100vh) rotate(var(--fall-rotation)); + opacity: 0; + } +} diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 317e16f7b67e..e32352b0f124 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -533,6 +533,12 @@ } } + &.typed-effect-tumble { + .word.typed:not(.error) { + opacity: 0; + } + } + &.typed-effect-dots { /* transform already typed letters into appropriately colored dots */ @@ -602,6 +608,13 @@ } } +.tumble-clone { + animation: typedEffectTumble 1s cubic-bezier(0.5, 0, 1, 1) forwards; + display: inline-block; + pointer-events: none; + z-index: 1000; +} + .word { position: relative; font-size: 1em; diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 71e3ca0fa80c..b63f1a2cf26f 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -147,6 +147,7 @@ export function updateActiveElement( if (previousActiveWord !== null) { if (direction === "forward") { previousActiveWord.addClass("typed"); + triggerTumbleTypedEffect(previousActiveWord); Ligatures.set(previousActiveWord, true); } else if (direction === "back") { // @@ -164,6 +165,7 @@ export function updateActiveElement( newActiveWord.addClass("active"); newActiveWord.removeClass("error"); newActiveWord.removeClass("typed"); + newActiveWord.setStyle({ opacity: "" }); Ligatures.set(newActiveWord, false); activeWordTop = newActiveWord.getOffsetTop(); @@ -304,6 +306,46 @@ async function joinOverlappingHints( } } +function triggerTumbleTypedEffect(word: ElementWithUtils): void { + if (Config.typedEffect !== "tumble") return; + if (word.hasClass("error")) return; + + const rect = word.native.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return; + + const computedStyle = window.getComputedStyle(word.native); + const clone = word.native.cloneNode(true) as HTMLElement; + const randomRotation = (Math.random() - 0.5) * 45; + const randomX = (Math.random() - 0.5) * 100; + + clone.classList.add("tumble-clone"); + clone.style.position = "fixed"; + clone.style.top = `${rect.top}px`; + clone.style.left = `${rect.left}px`; + clone.style.width = `${rect.width}px`; + clone.style.height = `${rect.height}px`; + clone.style.fontSize = computedStyle.fontSize; + clone.style.fontFamily = computedStyle.fontFamily; + clone.style.color = computedStyle.color; + clone.style.margin = "0"; + clone.style.pointerEvents = "none"; + clone.style.zIndex = "1000"; + clone.style.setProperty("--fall-rotation", `${randomRotation}deg`); + clone.style.setProperty("--fall-x", `${randomX}px`); + + document.body.appendChild(clone); + word.setStyle({ opacity: "0" }); + + clone.addEventListener("animationend", () => { + clone.remove(); + }); +} + +function clearTumbleTypedEffect(): void { + qsa(".tumble-clone").remove(); + wordsEl.qsa(".word").setStyle({ opacity: "" }); +} + async function updateHintsPosition(): Promise { if ( getActivePage() !== "test" || @@ -495,6 +537,7 @@ function updateWordWrapperClasses(): void { } function showWords(): void { + clearTumbleTypedEffect(); wordsEl.setHtml(""); if (Config.mode === "zen") { @@ -1937,6 +1980,7 @@ export function onTestRestart(source: "testPage" | "resultPage"): void { } export function onTestFinish(): void { + clearTumbleTypedEffect(); Caret.hide(); LiveSpeed.hide(); LiveAcc.hide(); diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts index 77c257a54834..15ef1c92c88f 100644 --- a/packages/schemas/src/configs.ts +++ b/packages/schemas/src/configs.ts @@ -182,7 +182,13 @@ export const HighlightModeSchema = z.enum([ ]); export type HighlightMode = z.infer; -export const TypedEffectSchema = z.enum(["keep", "hide", "fade", "dots"]); +export const TypedEffectSchema = z.enum([ + "keep", + "hide", + "fade", + "dots", + "tumble", +]); export type TypedEffect = z.infer; export const TapeModeSchema = z.enum(["off", "letter", "word"]); From d42bfb55f0ea07aaa465d43991f50f11c2926cc2 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Thu, 9 Apr 2026 01:27:54 +0530 Subject: [PATCH 2/4] organize --- frontend/src/ts/test/test-ui.ts | 47 ++---------------------- frontend/src/ts/test/typed-effects.ts | 53 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 43 deletions(-) create mode 100644 frontend/src/ts/test/typed-effects.ts diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index b63f1a2cf26f..99f3601f21e3 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -59,6 +59,7 @@ import * as ThemeController from "../controllers/theme-controller"; import * as ModesNotice from "../elements/modes-notice"; import * as Last10Average from "../elements/last-10-average"; import * as MemoryFunboxTimer from "./funbox/memory-funbox-timer"; +import * as TypedEffects from "./typed-effects"; import { ElementsWithUtils, ElementWithUtils, @@ -147,7 +148,7 @@ export function updateActiveElement( if (previousActiveWord !== null) { if (direction === "forward") { previousActiveWord.addClass("typed"); - triggerTumbleTypedEffect(previousActiveWord); + TypedEffects.onWordTyped(previousActiveWord); Ligatures.set(previousActiveWord, true); } else if (direction === "back") { // @@ -306,46 +307,6 @@ async function joinOverlappingHints( } } -function triggerTumbleTypedEffect(word: ElementWithUtils): void { - if (Config.typedEffect !== "tumble") return; - if (word.hasClass("error")) return; - - const rect = word.native.getBoundingClientRect(); - if (rect.width === 0 && rect.height === 0) return; - - const computedStyle = window.getComputedStyle(word.native); - const clone = word.native.cloneNode(true) as HTMLElement; - const randomRotation = (Math.random() - 0.5) * 45; - const randomX = (Math.random() - 0.5) * 100; - - clone.classList.add("tumble-clone"); - clone.style.position = "fixed"; - clone.style.top = `${rect.top}px`; - clone.style.left = `${rect.left}px`; - clone.style.width = `${rect.width}px`; - clone.style.height = `${rect.height}px`; - clone.style.fontSize = computedStyle.fontSize; - clone.style.fontFamily = computedStyle.fontFamily; - clone.style.color = computedStyle.color; - clone.style.margin = "0"; - clone.style.pointerEvents = "none"; - clone.style.zIndex = "1000"; - clone.style.setProperty("--fall-rotation", `${randomRotation}deg`); - clone.style.setProperty("--fall-x", `${randomX}px`); - - document.body.appendChild(clone); - word.setStyle({ opacity: "0" }); - - clone.addEventListener("animationend", () => { - clone.remove(); - }); -} - -function clearTumbleTypedEffect(): void { - qsa(".tumble-clone").remove(); - wordsEl.qsa(".word").setStyle({ opacity: "" }); -} - async function updateHintsPosition(): Promise { if ( getActivePage() !== "test" || @@ -537,7 +498,7 @@ function updateWordWrapperClasses(): void { } function showWords(): void { - clearTumbleTypedEffect(); + TypedEffects.clear(); wordsEl.setHtml(""); if (Config.mode === "zen") { @@ -1980,7 +1941,7 @@ export function onTestRestart(source: "testPage" | "resultPage"): void { } export function onTestFinish(): void { - clearTumbleTypedEffect(); + TypedEffects.clear(); Caret.hide(); LiveSpeed.hide(); LiveAcc.hide(); diff --git a/frontend/src/ts/test/typed-effects.ts b/frontend/src/ts/test/typed-effects.ts new file mode 100644 index 000000000000..e36372492e6a --- /dev/null +++ b/frontend/src/ts/test/typed-effects.ts @@ -0,0 +1,53 @@ +import { Config } from "../config/store"; +import { ElementWithUtils, qsa, qsr } from "../utils/dom"; + +const wordsEl = qsr(".pageTest #words"); + +export function onWordTyped(word: ElementWithUtils): void { + switch (Config.typedEffect) { + case "tumble": + triggerTumble(word); + return; + default: + return; + } +} + +export function clear(): void { + qsa(".tumble-clone").remove(); + wordsEl.qsa(".word").setStyle({ opacity: "" }); +} + +function triggerTumble(word: ElementWithUtils): void { + if (word.hasClass("error")) return; + + const rect = word.native.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return; + + const computedStyle = window.getComputedStyle(word.native); + const clone = word.native.cloneNode(true) as HTMLElement; + const randomRotation = (Math.random() - 0.5) * 45; + const randomX = (Math.random() - 0.5) * 100; + + clone.classList.add("tumble-clone"); + clone.style.position = "fixed"; + clone.style.top = `${rect.top}px`; + clone.style.left = `${rect.left}px`; + clone.style.width = `${rect.width}px`; + clone.style.height = `${rect.height}px`; + clone.style.fontSize = computedStyle.fontSize; + clone.style.fontFamily = computedStyle.fontFamily; + clone.style.color = computedStyle.color; + clone.style.margin = "0"; + clone.style.pointerEvents = "none"; + clone.style.zIndex = "1000"; + clone.style.setProperty("--fall-rotation", `${randomRotation}deg`); + clone.style.setProperty("--fall-x", `${randomX}px`); + + document.body.appendChild(clone); + word.setStyle({ opacity: "0" }); + + clone.addEventListener("animationend", () => { + clone.remove(); + }); +} From 3a698b4782b7aeaed469b0b456b5595e11cce575 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Thu, 9 Apr 2026 20:15:20 +0530 Subject: [PATCH 3/4] fix for reduced motion scenario --- frontend/src/styles/test.scss | 6 ++++++ frontend/src/ts/test/typed-effects.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index e32352b0f124..cd8d4692cf38 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -537,6 +537,12 @@ .word.typed:not(.error) { opacity: 0; } + + @media (prefers-reduced-motion) { + .word.typed:not(.error) { + opacity: 1; + } + } } &.typed-effect-dots { diff --git a/frontend/src/ts/test/typed-effects.ts b/frontend/src/ts/test/typed-effects.ts index e36372492e6a..ac03f0c39936 100644 --- a/frontend/src/ts/test/typed-effects.ts +++ b/frontend/src/ts/test/typed-effects.ts @@ -1,7 +1,9 @@ import { Config } from "../config/store"; +import * as Misc from "../utils/misc"; import { ElementWithUtils, qsa, qsr } from "../utils/dom"; const wordsEl = qsr(".pageTest #words"); +const TUMBLE_DURATION_MS = 1000; export function onWordTyped(word: ElementWithUtils): void { switch (Config.typedEffect) { @@ -20,6 +22,7 @@ export function clear(): void { function triggerTumble(word: ElementWithUtils): void { if (word.hasClass("error")) return; + if (Misc.prefersReducedMotion()) return; const rect = word.native.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return; @@ -47,7 +50,10 @@ function triggerTumble(word: ElementWithUtils): void { document.body.appendChild(clone); word.setStyle({ opacity: "0" }); - clone.addEventListener("animationend", () => { + const cleanup = (): void => { clone.remove(); - }); + }; + + clone.addEventListener("animationend", cleanup, { once: true }); + window.setTimeout(cleanup, TUMBLE_DURATION_MS); } From 12f8e4a54d5d5fad266104ec6cf792f2c4530ab9 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Thu, 9 Apr 2026 20:21:37 +0530 Subject: [PATCH 4/4] fix for switching typed effects mid-test --- frontend/src/ts/test/test-ui.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 99f3601f21e3..07309d0ba367 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -2086,6 +2086,9 @@ configEvent.subscribe(({ key, newValue }) => { "tapeMargin", ].includes(key) ) { + if (key === "typedEffect") { + TypedEffects.clear(); + } if (key !== "fontFamily") updateWordWrapperClasses(); if (["typedEffect", "fontFamily", "fontSize"].includes(key)) { Ligatures.update(key, wordsEl);