From 766056180f269e4814c309940dda4a738e486d70 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sat, 13 Dec 2025 23:47:39 +0100 Subject: [PATCH 01/11] build: fix fontawesome font path in development mode (@fehmer) (#7233) --- frontend/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ac378fe37..aee372baf 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -289,7 +289,7 @@ function getCssOptions({ preprocessorOptions: { scss: { additionalData(source, fp) { - if (fp.endsWith("index.scss")) { + if (isDevelopment || fp.endsWith("index.scss")) { /** Enable for font awesome v6 */ /* const fontawesomeClasses = getFontawesomeConfig(); From 9654ac505c45bd16ba0e99441ea859b0f139effb Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Sun, 14 Dec 2025 00:48:15 +0200 Subject: [PATCH 02/11] fix(test-timer): test timer doesn't stop right after a test ends (@Leonabcd123) (#7230) ### Description In the current implementation, the time difference between `now` and when we call `TestTimer.clear()` is high enough, so that if the test duration is really close to a whole number (say `3.96`), then `TestTimer` will continue to run up until the whole number, and will push another wpm entry to `wpmHistory` and another raw entry to `rawHistory`, causing the result chart to be messed up. This implementation clears `TestTimer` right after calculating `now` to hopefully allow for a smaller time difference between `now` and `TestTimer`. Also fixed some typos in comments. --- .../src/ts/controllers/chart-controller.ts | 18 +++++++++--------- frontend/src/ts/test/test-logic.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/ts/controllers/chart-controller.ts b/frontend/src/ts/controllers/chart-controller.ts index 48759b8b6..a0c613227 100644 --- a/frontend/src/ts/controllers/chart-controller.ts +++ b/frontend/src/ts/controllers/chart-controller.ts @@ -85,24 +85,24 @@ class ChartWithUpdateColors< } async updateColors(): Promise { - //@ts-expect-error its too difficult to figure out these types, but this works + //@ts-expect-error it's too difficult to figure out these types, but this works await updateColors(this); } getDataset(id: DatasetIds): ChartDataset { - //@ts-expect-error its too difficult to figure out these types, but this works + //@ts-expect-error it's too difficult to figure out these types, but this works return this.data.datasets?.find((x) => x.yAxisID === id); } getScaleIds(): DatasetIds[] { - //@ts-expect-error its too difficult to figure out these types, but this works + //@ts-expect-error it's too difficult to figure out these types, but this works return typedKeys(this.options?.scales ?? {}) as DatasetIds[]; } getScale( id: DatasetIds extends never ? never : "x" | DatasetIds, ): DatasetIds extends never ? never : CartesianScaleOptions { - //@ts-expect-error its too difficult to figure out these types, but this works + //@ts-expect-error it's too difficult to figure out these types, but this works // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access return this.options.scales[id]; } @@ -120,7 +120,7 @@ export const result = new ChartWithUpdateColors< labels: [], datasets: [ { - //@ts-expect-error the type is defined incorrectly, have to ingore the error + //@ts-expect-error the type is defined incorrectly, have to ignore the error clip: false, label: "wpm", data: [], @@ -131,7 +131,7 @@ export const result = new ChartWithUpdateColors< pointRadius: 1, }, { - //@ts-expect-error the type is defined incorrectly, have to ingore the error + //@ts-expect-error the type is defined incorrectly, have to ignore the error clip: false, label: "raw", data: [], @@ -143,7 +143,7 @@ export const result = new ChartWithUpdateColors< pointRadius: 0, }, { - //@ts-expect-error the type is defined incorrectly, have to ingore the error + //@ts-expect-error the type is defined incorrectly, have to ignore the error clip: false, label: "errors", data: [], @@ -166,7 +166,7 @@ export const result = new ChartWithUpdateColors< }, }, { - //@ts-expect-error the type is defined incorrectly, have to ingore the error + //@ts-expect-error the type is defined incorrectly, have to ignore the error clip: false, label: "burst", data: [], @@ -1268,7 +1268,7 @@ async function updateColors< return; } - //@ts-expect-error its too difficult to figure out these types, but this works + //@ts-expect-error it's too difficult to figure out these types, but this works chart.data.datasets[0].borderColor = (ctx): string => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const isPb = ctx.raw?.isPb as boolean; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 5a8d449e8..8a680b8a0 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -930,6 +930,7 @@ export async function finish(difficultyFailed = false): Promise { if (!TestState.isActive) return; TestUI.setResultCalculating(true); const now = performance.now(); + TestTimer.clear(); TestStats.setEnd(now); // fade out the test and show loading @@ -977,7 +978,6 @@ export async function finish(difficultyFailed = false): Promise { LiveBurst.hide(); TimerProgress.hide(); OutOfFocus.hide(); - TestTimer.clear(); Monkey.hide(); void ModesNotice.update(); From e51550683aad755cbc0b08c81416fd262046d173 Mon Sep 17 00:00:00 2001 From: Jack Date: Sat, 13 Dec 2025 23:55:58 +0100 Subject: [PATCH 03/11] refactor: clean up test-ui and test-logic (@miodec) (#7229) Move ui code to test ui, remove unused code, remove duplicated code, merge the two config event listeners, merge a lot of the ui code to make it easier to grasp. --- .../test/funbox/layoutfluid-funbox-timer.ts | 4 + frontend/src/ts/test/live-acc.ts | 15 +- frontend/src/ts/test/live-burst.ts | 15 +- frontend/src/ts/test/live-speed.ts | 15 +- frontend/src/ts/test/monkey.ts | 7 + frontend/src/ts/test/test-logic.ts | 140 +++------ frontend/src/ts/test/test-ui.ts | 288 +++++++++--------- frontend/src/ts/test/timer-progress.ts | 10 + 8 files changed, 251 insertions(+), 243 deletions(-) diff --git a/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts b/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts index 3dfe9c319..f80d95a02 100644 --- a/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts +++ b/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts @@ -20,6 +20,10 @@ export function hide(): void { }); } +export function instantHide(): void { + timerEl.style.opacity = "0"; +} + export function updateTime(sec: number, layout: string): void { timerEl.textContent = `${capitalizeFirstLetter(layout)} in: ${sec}s`; } diff --git a/frontend/src/ts/test/live-acc.ts b/frontend/src/ts/test/live-acc.ts index a2b3925aa..3158d0e36 100644 --- a/frontend/src/ts/test/live-acc.ts +++ b/frontend/src/ts/test/live-acc.ts @@ -7,8 +7,8 @@ import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-fra const textEl = document.querySelector( "#liveStatsTextBottom .liveAcc", -) as Element; -const miniEl = document.querySelector("#liveStatsMini .acc") as Element; +) as HTMLElement; +const miniEl = document.querySelector("#liveStatsMini .acc") as HTMLElement; export function update(acc: number): void { requestDebouncedAnimationFrame("live-acc.update", () => { @@ -73,6 +73,17 @@ export function hide(): void { }); } +export function instantHide(): void { + if (!state) return; + + textEl.classList.add("hidden"); + textEl.style.opacity = "0"; + miniEl.classList.add("hidden"); + miniEl.style.opacity = "0"; + + state = false; +} + ConfigEvent.subscribe(({ key, newValue }) => { if (key === "liveAccStyle") newValue === "off" ? hide() : show(); }); diff --git a/frontend/src/ts/test/live-burst.ts b/frontend/src/ts/test/live-burst.ts index 93e37f5c9..b37537d5d 100644 --- a/frontend/src/ts/test/live-burst.ts +++ b/frontend/src/ts/test/live-burst.ts @@ -8,8 +8,8 @@ import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-fra const textEl = document.querySelector( "#liveStatsTextBottom .liveBurst", -) as Element; -const miniEl = document.querySelector("#liveStatsMini .burst") as Element; +) as HTMLElement; +const miniEl = document.querySelector("#liveStatsMini .burst") as HTMLElement; export function reset(): void { requestDebouncedAnimationFrame("live-burst.reset", () => { @@ -71,6 +71,17 @@ export function hide(): void { }); } +export function instantHide(): void { + if (!state) return; + + textEl.classList.add("hidden"); + textEl.style.opacity = "0"; + miniEl.classList.add("hidden"); + miniEl.style.opacity = "0"; + + state = false; +} + ConfigEvent.subscribe(({ key, newValue }) => { if (key === "liveBurstStyle") newValue === "off" ? hide() : show(); }); diff --git a/frontend/src/ts/test/live-speed.ts b/frontend/src/ts/test/live-speed.ts index 319b8daf2..cef8273e4 100644 --- a/frontend/src/ts/test/live-speed.ts +++ b/frontend/src/ts/test/live-speed.ts @@ -8,8 +8,10 @@ import { animate } from "animejs"; const textElement = document.querySelector( "#liveStatsTextBottom .liveSpeed", -) as Element; -const miniElement = document.querySelector("#liveStatsMini .speed") as Element; +) as HTMLElement; +const miniElement = document.querySelector( + "#liveStatsMini .speed", +) as HTMLElement; export function reset(): void { requestDebouncedAnimationFrame("live-speed.reset", () => { @@ -75,6 +77,15 @@ export function hide(): void { }); } +export function instantHide(): void { + if (!state) return; + miniElement.classList.add("hidden"); + miniElement.style.opacity = "0"; + textElement.classList.add("hidden"); + textElement.style.opacity = "0"; + state = false; +} + ConfigEvent.subscribe(({ key, newValue }) => { if (key === "liveSpeedStyle") newValue === "off" ? hide() : show(); }); diff --git a/frontend/src/ts/test/monkey.ts b/frontend/src/ts/test/monkey.ts index 49edd4300..b1966c3e4 100644 --- a/frontend/src/ts/test/monkey.ts +++ b/frontend/src/ts/test/monkey.ts @@ -162,3 +162,10 @@ export function hide(): void { }, }); } + +export function instantHide(): void { + monkeyEl.classList.add("hidden"); + monkeyEl.style.opacity = "0"; + monkeyEl.style.animationDuration = "0s"; + monkeyFastEl.style.opacity = "0"; +} diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 8a680b8a0..689cc34db 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -12,23 +12,12 @@ import * as CustomText from "./custom-text"; import * as CustomTextState from "../states/custom-text-name"; import * as TestStats from "./test-stats"; import * as PractiseWords from "./practise-words"; -import * as SoundController from "../controllers/sound-controller"; import * as ShiftTracker from "./shift-tracker"; import * as AltTracker from "./alt-tracker"; -import * as Focus from "./focus"; import * as Funbox from "./funbox/funbox"; -import * as Keymap from "../elements/keymap"; -import * as ThemeController from "../controllers/theme-controller"; -import * as ResultWordHighlight from "../elements/result-word-highlight"; import * as PaceCaret from "./pace-caret"; import * as Caret from "./caret"; -import * as LiveSpeed from "./live-speed"; -import * as LiveAcc from "./live-acc"; -import * as LiveBurst from "./live-burst"; -import * as TimerProgress from "./timer-progress"; - import * as TestTimer from "./test-timer"; -import * as OutOfFocus from "./out-of-focus"; import * as AccountButton from "../elements/account-button"; import * as DB from "../db"; import * as Replay from "./replay"; @@ -36,27 +25,20 @@ import * as TodayTracker from "./today-tracker"; import * as ChallengeContoller from "../controllers/challenge-controller"; import * as QuoteRateModal from "../modals/quote-rate"; import * as Result from "./result"; -import * as MonkeyPower from "../elements/monkey-power"; + import * as ActivePage from "../states/active-page"; import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as WordsGenerator from "./words-generator"; import * as TestState from "./test-state"; -import * as ModesNotice from "../elements/modes-notice"; import * as PageTransition from "../states/page-transition"; import * as ConfigEvent from "../observables/config-event"; import * as TimerEvent from "../observables/timer-event"; -import * as Last10Average from "../elements/last-10-average"; -import * as Monkey from "./monkey"; import objectHash from "object-hash"; import * as AnalyticsController from "../controllers/analytics-controller"; import { getAuthenticatedUser, isAuthenticated } from "../firebase"; -import * as AdController from "../controllers/ad-controller"; -import * as TestConfig from "./test-config"; import * as ConnectionState from "../states/connection"; -import * as MemoryFunboxTimer from "./funbox/memory-funbox-timer"; import * as KeymapEvent from "../observables/keymap-event"; -import * as LayoutfluidFunboxTimer from "../test/funbox/layoutfluid-funbox-timer"; import * as ArabicLazyMode from "../states/arabic-lazy-mode"; import Format from "../utils/format"; import { QuoteLength, QuoteLengthConfig } from "@monkeytype/schemas/configs"; @@ -84,11 +66,8 @@ import * as Loader from "../elements/loader"; import * as TestInitFailed from "../elements/test-init-failed"; import { canQuickRestart } from "../utils/quick-restart"; import { animate } from "animejs"; -import { - getInputElement, - isInputElementFocused, - setInputElementValue, -} from "../input/input-element"; +import { setInputElementValue } from "../input/input-element"; +import { debounce } from "throttle-debounce"; let failReason = ""; @@ -138,7 +117,7 @@ export function startTest(now: number): boolean { //use a recursive self-adjusting timer to avoid time drift TestStats.setStart(now); void TestTimer.start(); - TestUI.afterTestStart(); + TestUI.onTestStart(); return true; } @@ -281,30 +260,14 @@ export function restart(options = {} as RestartOptions): void { Caret.hide(); TestState.setActive(false); Replay.stopReplayRecording(); - LiveSpeed.hide(); - LiveAcc.hide(); - LiveBurst.hide(); - TimerProgress.hide(); Replay.pauseReplay(); TestState.setBailedOut(false); Caret.resetPosition(); PaceCaret.reset(); - Monkey.hide(); TestInput.input.setKoreanStatus(false); - LayoutfluidFunboxTimer.hide(); - MemoryFunboxTimer.reset(); QuoteRateModal.clearQuoteStats(); - TestUI.reset(); CompositionState.setComposing(false); CompositionState.setData(""); - void SoundController.clearAllSounds(); - - if (TestState.resultVisible) { - if (Config.randomTheme !== "off") { - void ThemeController.randomizeTheme(); - } - void XPBar.skipBreakdown(); - } if (!ConnectionState.get()) { ConnectionState.showOfflineBanner(); @@ -318,25 +281,14 @@ export function restart(options = {} as RestartOptions): void { //words are being displayed el = document.querySelector("#typingTest") as HTMLElement; } - TestState.setResultVisible(false); TestState.setTestRestarting(true); animate(el, { opacity: 0, duration: animationTime, onComplete: async () => { - $("#result").addClass("hidden"); - $("#typingTest").css("opacity", 0).removeClass("hidden"); - getInputElement().style.left = "0"; setInputElementValue(""); - Focus.set(false); - if (ActivePage.get() === "test") { - AdController.updateFooterAndVerticalAds(false); - } - TestConfig.show(); - AdController.destroyResult(); - await Funbox.rememberSettings(); testReinitCount = 0; @@ -364,18 +316,8 @@ export function restart(options = {} as RestartOptions): void { fb.functions.restart(); } - if (Config.showAverage !== "off") { - void Last10Average.update().then(() => { - void ModesNotice.update(); - }); - } else { - void ModesNotice.update(); - } - - if (isInputElementFocused()) OutOfFocus.hide(); - TestUI.focusWords(true); - TestUI.onTestRestart(); + TestState.setResultVisible(false); const typingTestEl = document.querySelector("#typingTest") as HTMLElement; animate(typingTestEl, { @@ -385,19 +327,12 @@ export function restart(options = {} as RestartOptions): void { }, duration: animationTime, onComplete: () => { - TimerProgress.reset(); - LiveSpeed.reset(); - LiveAcc.reset(); - LiveBurst.reset(); - TestUI.updatePremid(); ManualRestart.reset(); TestState.setTestRestarting(false); }, }); }, }); - - ResultWordHighlight.destroy(); } let lastInitError: Error | null = null; @@ -418,18 +353,9 @@ async function init(): Promise { TestInitFailed.show(); TestState.setTestRestarting(false); TestState.setTestInitSuccess(false); - Focus.set(false); - // Notifications.add( - // "Too many test reinitialization attempts. Something is going very wrong. Please contact support.", - // -1, - // { - // important: true, - // } - // ); return false; } - MonkeyPower.reset(); Replay.stopReplayRecording(); TestWords.words.reset(); TestState.setActiveWordIndex(0); @@ -581,8 +507,6 @@ async function init(): Promise { return await init(); } - const beforeHasNumbers = TestWords.hasNumbers; - let hasNumbers = false; for (const word of generatedWords) { @@ -595,10 +519,6 @@ async function init(): Promise { TestWords.setHasTab(wordsHaveTab); TestWords.setHasNewline(wordsHaveNewline); - if (beforeHasNumbers !== hasNumbers) { - void Keymap.refresh(); - } - if ( generatedWords .join() @@ -631,13 +551,11 @@ async function init(): Promise { TestUI.setLigatures(allLigatures ?? language.ligatures ?? false); const isLanguageRTL = allRightToLeft ?? language.rightToLeft ?? false; - TestUI.setRightToLeft(isLanguageRTL); TestState.setIsLanguageRightToLeft(isLanguageRTL); TestState.setIsDirectionReversed( isFunboxActiveWithProperty("reverseDirection"), ); - TestUI.showWords(); console.debug("Test initialized with words", generatedWords); console.debug( "Test initialized with section indexes", @@ -781,8 +699,6 @@ export async function retrySavingResult(): Promise { retrySaving.canRetry = false; $("#retrySavingResultButton").addClass("hidden"); - AccountButton.loading(true); - Notifications.add("Retrying to save..."); await saveResult(completedEvent, true); @@ -944,6 +860,8 @@ export async function finish(difficultyFailed = false): Promise { $(".pageTest .loading").removeClass("hidden"); await Misc.sleep(0); //allow ui update + TestUI.onTestFinish(); + if (TestState.isRepeated && Config.mode === "quote") { TestState.setRepeated(false); } @@ -972,14 +890,6 @@ export async function finish(difficultyFailed = false): Promise { TestState.setResultVisible(true); TestState.setActive(false); Replay.stopReplayRecording(); - Caret.hide(); - LiveSpeed.hide(); - LiveAcc.hide(); - LiveBurst.hide(); - TimerProgress.hide(); - OutOfFocus.hide(); - Monkey.hide(); - void ModesNotice.update(); //need one more calculation for the last word if test auto ended if (TestInput.burstHistory.length !== TestInput.input.getHistory()?.length) { @@ -1290,8 +1200,6 @@ export async function finish(difficultyFailed = false): Promise { Result.updateRateQuote(TestWords.currentQuote); - AccountButton.loading(true); - if (!completedEvent.bailedOut) { const challenge = ChallengeContoller.verify(completedEvent); if (challenge !== null) completedEvent.challenge = challenge; @@ -1306,6 +1214,8 @@ async function saveResult( completedEvent: CompletedEvent, isRetrying: boolean, ): Promise { + AccountButton.loading(true); + if (!TestState.savingEnabled) { Notifications.add("Result not saved: disabled by user", -1, { duration: 3, @@ -1468,6 +1378,32 @@ export function fail(reason: string): void { TestStats.pushIncompleteTest(acc, tt); } +const debouncedZipfCheck = debounce(250, async () => { + const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language); + if (supports === "no") { + Notifications.add( + `${Strings.capitalizeFirstLetter( + Strings.getLanguageDisplayString(Config.language), + )} does not support Zipf funbox, because the list is not ordered by frequency. Please try another word list.`, + 0, + { + duration: 7, + }, + ); + } + if (supports === "unknown") { + Notifications.add( + `${Strings.capitalizeFirstLetter( + Strings.getLanguageDisplayString(Config.language), + )} may not support Zipf funbox, because we don't know if it's ordered by frequency or not. If you would like to add this label, please contact us.`, + 0, + { + duration: 7, + }, + ); + } +}); + $(".pageTest").on("click", "#testModesNotice .textButton.restart", () => { restart(); }); @@ -1622,6 +1558,12 @@ ConfigEvent.subscribe(({ key, newValue, nosave }) => { ); }, 0); } + if ( + (key === "language" || key === "funbox") && + Config.funbox.includes("zipf") + ) { + debouncedZipfCheck(); + } } if (key === "lazyMode" && !nosave) { if (Config.language.startsWith("arabic")) { diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 4ec86b2f9..6ebe02254 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -8,13 +8,11 @@ import * as Caret from "./caret"; import * as OutOfFocus from "./out-of-focus"; import * as Misc from "../utils/misc"; import * as Strings from "../utils/strings"; -import * as JSONData from "../utils/json-data"; import { blendTwoHexColors } from "../utils/colors"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; import * as CompositionState from "../states/composition"; import * as ConfigEvent from "../observables/config-event"; import * as Hangul from "hangul-js"; -import { debounce } from "throttle-debounce"; import * as ResultWordHighlight from "../elements/result-word-highlight"; import * as ActivePage from "../states/active-page"; import Format from "../utils/format"; @@ -49,105 +47,22 @@ import { } from "../input/input-element"; import * as MonkeyPower from "../elements/monkey-power"; import * as SlowTimer from "../states/slow-timer"; +import * as TestConfig from "./test-config"; import * as CompositionDisplay from "../elements/composition-display"; - -const debouncedZipfCheck = debounce(250, async () => { - const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language); - if (supports === "no") { - Notifications.add( - `${Strings.capitalizeFirstLetter( - Strings.getLanguageDisplayString(Config.language), - )} does not support Zipf funbox, because the list is not ordered by frequency. Please try another word list.`, - 0, - { - duration: 7, - }, - ); - } - if (supports === "unknown") { - Notifications.add( - `${Strings.capitalizeFirstLetter( - Strings.getLanguageDisplayString(Config.language), - )} may not support Zipf funbox, because we don't know if it's ordered by frequency or not. If you would like to add this label, please contact us.`, - 0, - { - duration: 7, - }, - ); - } -}); +import * as AdController from "../controllers/ad-controller"; +import * as LayoutfluidFunboxTimer from "../test/funbox/layoutfluid-funbox-timer"; +import * as Keymap from "../elements/keymap"; +import * as ThemeController from "../controllers/theme-controller"; +import * as XPBar from "../elements/xp-bar"; +import * as ModesNotice from "../elements/modes-notice"; +import * as Last10Average from "../elements/last-10-average"; +import * as MemoryFunboxTimer from "./funbox/memory-funbox-timer"; export const updateHintsPositionDebounced = Misc.debounceUntilResolved( updateHintsPosition, { rejectSkippedCalls: false }, ); -ConfigEvent.subscribe(({ key, newValue, nosave }) => { - if ( - (key === "language" || key === "funbox") && - Config.funbox.includes("zipf") - ) { - debouncedZipfCheck(); - } - if (key === "fontSize") { - $( - "#caret, #paceCaret, #liveStatsMini, #typingTest, #wordsInput, #compositionDisplay", - ).css("fontSize", newValue + "rem"); - if (!nosave) { - OutOfFocus.hide(); - updateWordWrapperClasses(); - } - } - if ( - ["fontSize", "fontFamily", "blindMode", "hideExtraLetters"].includes( - key ?? "", - ) - ) { - void updateHintsPositionDebounced(); - } - - if (key === "theme") void applyBurstHeatmap(); - - if (newValue === undefined) return; - if (key === "highlightMode") { - if (ActivePage.get() === "test") { - void updateWordLetters({ - input: TestInput.input.current, - wordIndex: TestState.activeWordIndex, - compositionData: CompositionState.getData(), - }); - } - } - - if ( - [ - "highlightMode", - "blindMode", - "indicateTypos", - "tapeMode", - "hideExtraLetters", - ].includes(key) - ) { - updateWordWrapperClasses(); - } - - if (["tapeMode", "tapeMargin"].includes(key)) { - updateLiveStatsMargin(); - } - - if (key === "showAllLines") { - updateWordsWrapperHeight(true); - if (!newValue) { - void centerActiveLine(); - } - } - - if (typeof newValue !== "boolean") return; - if (key === "flipTestColors") flipColors(newValue); - if (key === "colorfulMode") colorful(newValue); - if (key === "burstHeatmap") void applyBurstHeatmap(); -}); - const wordsEl = document.querySelector(".pageTest #words") as HTMLElement; const wordsWrapperEl = document.querySelector( ".pageTest #wordsWrapper", @@ -163,11 +78,6 @@ export function setResultCalculating(val: boolean): void { resultCalculating = val; } -export function reset(): void { - currentTestLine = 0; - cancelPendingAnimationFramesStartingWith("test-ui"); -} - export function focusWords(force = false): void { if (force) { blurInputElement(); @@ -475,6 +385,9 @@ function buildWordHTML(word: string, wordIndex: number): string { } function updateWordWrapperClasses(): void { + // outoffocus applies transition, need to remove it + OutOfFocus.hide(); + if (Config.tapeMode !== "off") { wordsEl.classList.add("tape"); wordsWrapperEl.classList.add("tape"); @@ -507,6 +420,32 @@ function updateWordWrapperClasses(): void { wordsWrapperEl.classList.remove("hideExtraLetters"); } + if (Config.flipTestColors) { + wordsEl.classList.add("flipped"); + } else { + wordsEl.classList.remove("flipped"); + } + + if (Config.colorfulMode) { + wordsEl.classList.add("colorfulMode"); + } else { + wordsEl.classList.remove("colorfulMode"); + } + + $( + "#caret, #paceCaret, #liveStatsMini, #typingTest, #wordsInput, #compositionDisplay", + ).css("fontSize", Config.fontSize + "rem"); + + if (TestState.isLanguageRightToLeft) { + wordsEl.classList.add("rightToLeftTest"); + $("#resultWordsHistory .words").addClass("rightToLeftTest"); + $("#resultReplay .words").addClass("rightToLeftTest"); + } else { + wordsEl.classList.remove("rightToLeftTest"); + $("#resultWordsHistory .words").removeClass("rightToLeftTest"); + $("#resultReplay .words").removeClass("rightToLeftTest"); + } + const existing = wordsEl?.className .split(/\s+/) @@ -514,18 +453,24 @@ function updateWordWrapperClasses(): void { if (Config.highlightMode !== null) { existing.push("highlight-" + Config.highlightMode.replaceAll("_", "-")); } - wordsEl.className = existing.join(" "); updateWordsWidth(); updateWordsWrapperHeight(true); + if (!Config.showAllLines) { + void centerActiveLine(); + } updateWordsMargin(); updateWordsInputPosition(); void updateHintsPositionDebounced(); Caret.updatePosition(); + + if (document.activeElement !== getInputElement()) { + OutOfFocus.show(); + } } -export function showWords(): void { +function showWords(): void { wordsEl.innerHTML = ""; if (Config.mode === "zen") { @@ -751,22 +696,6 @@ export function addWord( // }); } -export function flipColors(tf: boolean): void { - if (tf) { - wordsEl.classList.add("flipped"); - } else { - wordsEl.classList.remove("flipped"); - } -} - -export function colorful(tc: boolean): void { - if (tc) { - wordsEl.classList.add("colorfulMode"); - } else { - wordsEl.classList.remove("colorfulMode"); - } -} - // because of the requestAnimationFrame, multiple calls to updateWordLetters // can be made before the actual update happens. This map keeps track of the // latest input for each word and is used in before-insert-text to @@ -1292,18 +1221,6 @@ export async function lineJump( return; } -export function setRightToLeft(isEnabled: boolean): void { - if (isEnabled) { - wordsEl.classList.add("rightToLeftTest"); - $("#resultWordsHistory .words").addClass("rightToLeftTest"); - $("#resultReplay .words").addClass("rightToLeftTest"); - } else { - wordsEl.classList.remove("rightToLeftTest"); - $("#resultWordsHistory .words").removeClass("rightToLeftTest"); - $("#resultReplay .words").removeClass("rightToLeftTest"); - } -} - export function setLigatures(isEnabled: boolean): void { if (isEnabled || Config.mode === "custom" || Config.mode === "zen") { wordsEl.classList.add("withLigatures"); @@ -1732,6 +1649,14 @@ function updateLiveStatsColor(value: TimerColor): void { } } +function showHideTestRestartButton(showHide: boolean): void { + if (showHide) { + $(".pageTest #restartTestButton").removeClass("hidden"); + } else { + $(".pageTest #restartTestButton").addClass("hidden"); + } +} + export function getActiveWordTopAndHeightWithDifferentData(data: string): { top: number; height: number; @@ -1909,7 +1834,7 @@ export async function afterTestWordChange( } } -export function afterTestStart(): void { +export function onTestStart(): void { Focus.set(true); Monkey.show(); TimerProgress.show(); @@ -1920,12 +1845,67 @@ export function afterTestStart(): void { } export function onTestRestart(): void { + $("#result").addClass("hidden"); + $("#typingTest").css("opacity", 0).removeClass("hidden"); + getInputElement().style.left = "0"; + TestConfig.show(); + Focus.set(false); + LiveSpeed.instantHide(); + LiveSpeed.reset(); + LiveBurst.instantHide(); + LiveBurst.reset(); + LiveAcc.instantHide(); + LiveAcc.reset(); + TimerProgress.instantHide(); + TimerProgress.reset(); + Monkey.instantHide(); + LayoutfluidFunboxTimer.instantHide(); + updatePremid(); + focusWords(true); + void Keymap.refresh(); + ResultWordHighlight.destroy(); + MonkeyPower.reset(); + MemoryFunboxTimer.reset(); + + if (Config.showAverage !== "off") { + void Last10Average.update().then(() => { + void ModesNotice.update(); + }); + } else { + void ModesNotice.update(); + } + + if (TestState.resultVisible) { + if (Config.randomTheme !== "off") { + void ThemeController.randomizeTheme(); + } + void XPBar.skipBreakdown(); + } + + currentTestLine = 0; + if (ActivePage.get() === "test") { + AdController.updateFooterAndVerticalAds(false); + } + AdController.destroyResult(); if (Config.compositionDisplay === "below") { CompositionDisplay.update(" "); CompositionDisplay.show(); } else { CompositionDisplay.hide(); } + void SoundController.clearAllSounds(); + cancelPendingAnimationFramesStartingWith("test-ui"); + showWords(); +} + +export function onTestFinish(): void { + Caret.hide(); + LiveSpeed.hide(); + LiveAcc.hide(); + LiveBurst.hide(); + TimerProgress.hide(); + OutOfFocus.hide(); + Monkey.hide(); } $(".pageTest #copyWordsListButton").on("click", async () => { @@ -2034,14 +2014,7 @@ $("#wordsWrapper").on("click", () => { ConfigEvent.subscribe(({ key, newValue }) => { if (key === "quickRestart") { - if (newValue === "off") { - $(".pageTest #restartTestButton").removeClass("hidden"); - } else { - $(".pageTest #restartTestButton").addClass("hidden"); - } - } - if (key === "maxLineWidth") { - updateWordsWidth(); + showHideTestRestartButton(newValue === "off"); } if (key === "timerOpacity") { updateLiveStatsOpacity(newValue); @@ -2060,4 +2033,43 @@ ConfigEvent.subscribe(({ key, newValue }) => { CompositionDisplay.hide(); } } + if ( + ["fontSize", "fontFamily", "blindMode", "hideExtraLetters"].includes( + key ?? "", + ) + ) { + void updateHintsPositionDebounced(); + } + if ((key === "theme" || key === "burstHeatmap") && TestState.resultVisible) { + void applyBurstHeatmap(); + } + if (key === "highlightMode") { + if (ActivePage.get() === "test") { + void updateWordLetters({ + input: TestInput.input.current, + wordIndex: TestState.activeWordIndex, + compositionData: CompositionState.getData(), + }); + } + } + if ( + [ + "highlightMode", + "blindMode", + "indicateTypos", + "tapeMode", + "hideExtraLetters", + "flipTestColors", + "colorfulMode", + "showAllLines", + "fontSize", + "maxLineWidth", + "tapeMargin", + ].includes(key) + ) { + updateWordWrapperClasses(); + } + if (["tapeMode", "tapeMargin"].includes(key)) { + updateLiveStatsMargin(); + } }); diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index ddc931e67..a1fe882ca 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -110,6 +110,16 @@ export function hide(): void { }); } +export function instantHide(): void { + barOpacityEl.style.opacity = "0"; + + miniEl.classList.add("hidden"); + miniEl.style.opacity = "0"; + + textEl.classList.add("hidden"); + textEl.style.opacity = "0"; +} + function getCurrentCount(): number { if (Config.mode === "custom" && CustomText.getLimitMode() === "section") { return ( From b1aa14c6b75dea6df924d9f6a8230b896fa3e793 Mon Sep 17 00:00:00 2001 From: Seif Soliman Date: Sun, 14 Dec 2025 01:10:19 +0200 Subject: [PATCH 04/11] fix(pace-caret): prevent null dereference in update() (@byseif21) (#7226) ### Description * `update()` could hit a race where settings became `null` between checks, causing a`TypeError` at runtime. * Async callbacks (setTimeout) accessed the global settings after it was cleared, leading to runaway errors. Screenshot 2025-12-12 135316 **fix** settings once (currentSettings) at the start of update() and use that for all property access and scheduling, so the loop never touches a null/stale reference. --- frontend/src/ts/test/pace-caret.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 4a3b861d7..da0b4d01c 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -130,7 +130,12 @@ export async function init(): Promise { } export async function update(expectedStepEnd: number): Promise { - if (settings === null || !TestState.isActive || TestState.resultVisible) { + const currentSettings = settings; + if ( + currentSettings === null || + !TestState.isActive || + TestState.resultVisible + ) { return; } @@ -146,8 +151,8 @@ export async function update(expectedStepEnd: number): Promise { const duration = absoluteStepEnd - now; caret.goTo({ - wordIndex: settings.currentWordIndex, - letterIndex: settings.currentLetterIndex, + wordIndex: currentSettings.currentWordIndex, + letterIndex: currentSettings.currentLetterIndex, isLanguageRightToLeft: TestState.isLanguageRightToLeft, isDirectionReversed: TestState.isDirectionReversed, animate: true, @@ -157,12 +162,14 @@ export async function update(expectedStepEnd: number): Promise { }, }); - // Normal case - schedule next step - settings.timeout = setTimeout( + currentSettings.timeout = setTimeout( () => { - update(expectedStepEnd + (settings?.spc ?? 0) * 1000).catch(() => { - settings = null; - }); + if (settings !== currentSettings) return; + update(expectedStepEnd + (currentSettings.spc ?? 0) * 1000).catch( + () => { + if (settings === currentSettings) settings = null; + }, + ); }, Math.max(0, duration), ); From 331ca1a26faeb3d78f631014b027a6b2a1ead78e Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 14 Dec 2025 00:36:27 +0100 Subject: [PATCH 05/11] build: restore vendor.css (@fehmer) (#7235) --- frontend/vite.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index aee372baf..1c3bc2a98 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -190,7 +190,7 @@ function getPlugins({ UnpluginInjectPreload({ files: [ { - outputMatch: /css\/vendor.*\.css$/, + outputMatch: /css\/.*\.css$/, attributes: { as: "style", type: "text/css", @@ -246,6 +246,10 @@ function getBuildOptions({ if (/\.(woff|woff2|eot|ttf|otf)$/.test(assetInfo.name)) { return `webfonts/[name]-[hash].${extType}`; } + if (assetInfo.name === "misc.css") { + return `${extType}/vendor.[hash][extname]`; + } + return `${extType}/[name].[hash][extname]`; }, chunkFileNames: "js/[name].[hash].js", From 38f3fc251e23f65196cdf230c71e711e3c9c9d71 Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:29:05 +0200 Subject: [PATCH 06/11] refactor: Remove JQuery from account-settings (@Leonabcd123, @fehmer) (#7219) ### Description Also modifies `swapElements` to accept `ElementWithUtils`. --------- Co-authored-by: Christian Fehmer Co-authored-by: Christian Fehmer --- frontend/src/ts/elements/account-button.ts | 10 +- .../src/ts/elements/settings/theme-picker.ts | 17 +-- frontend/src/ts/pages/account-settings.ts | 140 +++++++++--------- frontend/src/ts/utils/misc.ts | 30 ++-- 4 files changed, 90 insertions(+), 107 deletions(-) diff --git a/frontend/src/ts/elements/account-button.ts b/frontend/src/ts/elements/account-button.ts index ed29c0b75..5d394ce75 100644 --- a/frontend/src/ts/elements/account-button.ts +++ b/frontend/src/ts/elements/account-button.ts @@ -65,15 +65,11 @@ export function update(): void { accountButtonAndMenuEl .qs(".menu .items .goToProfile") ?.setAttribute("href", `/profile/${name}`); - void Misc.swapElements( - loginButtonEl.native, - accountButtonAndMenuEl.native, - 250, - ); + void Misc.swapElements(loginButtonEl, accountButtonAndMenuEl, 250); } else { void Misc.swapElements( - accountButtonAndMenuEl.native, - loginButtonEl.native, + accountButtonAndMenuEl, + loginButtonEl, 250, async () => { updateName(""); diff --git a/frontend/src/ts/elements/settings/theme-picker.ts b/frontend/src/ts/elements/settings/theme-picker.ts index 7b667bd93..38a414c98 100644 --- a/frontend/src/ts/elements/settings/theme-picker.ts +++ b/frontend/src/ts/elements/settings/theme-picker.ts @@ -13,6 +13,7 @@ import * as ActivePage from "../../states/active-page"; import { CustomThemeColors, ThemeName } from "@monkeytype/schemas/configs"; import { captureException } from "../../sentry"; import { ThemesListSorted } from "../../constants/themes"; +import { qs } from "../../utils/dom"; function updateActiveButton(): void { let activeThemeName: string = Config.theme; @@ -310,22 +311,14 @@ export function updateActiveTab(): void { if (Config.customTheme) { void Misc.swapElements( - document.querySelector( - '.pageSettings [tabContent="preset"]', - ) as HTMLElement, - document.querySelector( - '.pageSettings [tabContent="custom"]', - ) as HTMLElement, + qs('.pageSettings [tabContent="preset"]'), + qs('.pageSettings [tabContent="custom"]'), 250, ); } else { void Misc.swapElements( - document.querySelector( - '.pageSettings [tabContent="custom"]', - ) as HTMLElement, - document.querySelector( - '.pageSettings [tabContent="preset"]', - ) as HTMLElement, + qs('.pageSettings [tabContent="custom"]'), + qs('.pageSettings [tabContent="preset"]'), 250, ); } diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index 4d9ab4502..4e558d1cf 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -12,9 +12,9 @@ import * as BlockedUserTable from "../elements/account-settings/blocked-user-tab import * as Notifications from "../elements/notifications"; import { z } from "zod"; import * as AuthEvent from "../observables/auth-event"; -import { qsr } from "../utils/dom"; +import { qs, qsr, onDocumentReady } from "../utils/dom"; -const pageElement = $(".page.pageAccountSettings"); +const pageElement = qsr(".page.pageAccountSettings"); const StateSchema = z.object({ tab: z.enum([ @@ -34,9 +34,9 @@ const state: State = { }; function updateAuthenticationSections(): void { - pageElement.find(".section.passwordAuthSettings button").addClass("hidden"); - pageElement.find(".section.googleAuthSettings button").addClass("hidden"); - pageElement.find(".section.githubAuthSettings button").addClass("hidden"); + pageElement.qs(".section.passwordAuthSettings button")?.addClass("hidden"); + pageElement.qs(".section.googleAuthSettings button")?.addClass("hidden"); + pageElement.qs(".section.githubAuthSettings button")?.addClass("hidden"); const user = getAuthenticatedUser(); if (user === null) return; @@ -53,134 +53,128 @@ function updateAuthenticationSections(): void { if (passwordProvider) { pageElement - .find(".section.passwordAuthSettings #emailPasswordAuth") - .removeClass("hidden"); + .qs(".section.passwordAuthSettings #emailPasswordAuth") + ?.removeClass("hidden"); pageElement - .find(".section.passwordAuthSettings #passPasswordAuth") - .removeClass("hidden"); + .qs(".section.passwordAuthSettings #passPasswordAuth") + ?.removeClass("hidden"); if (googleProvider || githubProvider) { pageElement - .find(".section.passwordAuthSettings #removePasswordAuth") - .removeClass("hidden"); + .qs(".section.passwordAuthSettings #removePasswordAuth") + ?.removeClass("hidden"); } } else { pageElement - .find(".section.passwordAuthSettings #addPasswordAuth") - .removeClass("hidden"); + .qs(".section.passwordAuthSettings #addPasswordAuth") + ?.removeClass("hidden"); } if (googleProvider) { pageElement - .find(".section.googleAuthSettings #removeGoogleAuth") - .removeClass("hidden"); + .qs(".section.googleAuthSettings #removeGoogleAuth") + ?.removeClass("hidden"); if (passwordProvider || githubProvider) { pageElement - .find(".section.googleAuthSettings #removeGoogleAuth") - .removeClass("disabled"); + .qs(".section.googleAuthSettings #removeGoogleAuth") + ?.removeClass("disabled"); } else { pageElement - .find(".section.googleAuthSettings #removeGoogleAuth") - .addClass("disabled"); + .qs(".section.googleAuthSettings #removeGoogleAuth") + ?.addClass("disabled"); } } else { pageElement - .find(".section.googleAuthSettings #addGoogleAuth") - .removeClass("hidden"); + .qs(".section.googleAuthSettings #addGoogleAuth") + ?.removeClass("hidden"); } if (githubProvider) { pageElement - .find(".section.githubAuthSettings #removeGithubAuth") - .removeClass("hidden"); + .qs(".section.githubAuthSettings #removeGithubAuth") + ?.removeClass("hidden"); if (passwordProvider || googleProvider) { pageElement - .find(".section.githubAuthSettings #removeGithubAuth") - .removeClass("disabled"); + .qs(".section.githubAuthSettings #removeGithubAuth") + ?.removeClass("disabled"); } else { pageElement - .find(".section.githubAuthSettings #removeGithubAuth") - .addClass("disabled"); + .qs(".section.githubAuthSettings #removeGithubAuth") + ?.addClass("disabled"); } } else { pageElement - .find(".section.githubAuthSettings #addGithubAuth") - .removeClass("hidden"); + .qs(".section.githubAuthSettings #addGithubAuth") + ?.removeClass("hidden"); } } function updateIntegrationSections(): void { //no code and no discord if (!isAuthenticated()) { - pageElement.find(".section.discordIntegration").addClass("hidden"); + pageElement.qs(".section.discordIntegration")?.addClass("hidden"); } else { if (!getSnapshot()) return; - pageElement.find(".section.discordIntegration").removeClass("hidden"); + pageElement.qs(".section.discordIntegration")?.removeClass("hidden"); if (getSnapshot()?.discordId === undefined) { //show button pageElement - .find(".section.discordIntegration .buttons") - .removeClass("hidden"); - pageElement.find(".section.discordIntegration .info").addClass("hidden"); + .qs(".section.discordIntegration .buttons") + ?.removeClass("hidden"); + pageElement.qs(".section.discordIntegration .info")?.addClass("hidden"); } else { pageElement - .find(".section.discordIntegration .buttons") - .addClass("hidden"); + .qs(".section.discordIntegration .buttons") + ?.addClass("hidden"); pageElement - .find(".section.discordIntegration .info") - .removeClass("hidden"); + .qs(".section.discordIntegration .info") + ?.removeClass("hidden"); } } } function updateTabs(): void { void swapElements( - pageElement.find(".tab.active")[0] as HTMLElement, - pageElement.find(`.tab[data-tab="${state.tab}"]`)[0] as HTMLElement, + pageElement.qs(".tab.active"), + pageElement.qs(`.tab[data-tab="${state.tab}"]`), 250, async () => { // }, async () => { - pageElement.find(".tab").removeClass("active"); - pageElement.find(`.tab[data-tab="${state.tab}"]`).addClass("active"); + pageElement.qs(".tab")?.removeClass("active"); + pageElement.qs(`.tab[data-tab="${state.tab}"]`)?.addClass("active"); }, ); - pageElement.find("button").removeClass("active"); - pageElement.find(`button[data-tab="${state.tab}"]`).addClass("active"); + pageElement.qs("button")?.removeClass("active"); + pageElement.qs(`button[data-tab="${state.tab}"]`)?.addClass("active"); } function updateAccountSections(): void { + pageElement.qs(".section.optOutOfLeaderboards .optedOut")?.addClass("hidden"); pageElement - .find(".section.optOutOfLeaderboards .optedOut") - .addClass("hidden"); + .qs(".section.optOutOfLeaderboards .buttons") + ?.removeClass("hidden"); + pageElement.qs(".section.setStreakHourOffset .info")?.addClass("hidden"); pageElement - .find(".section.optOutOfLeaderboards .buttons") - .removeClass("hidden"); - pageElement.find(".section.setStreakHourOffset .info").addClass("hidden"); - pageElement - .find(".section.setStreakHourOffset .buttons") - .removeClass("hidden"); + .qs(".section.setStreakHourOffset .buttons") + ?.removeClass("hidden"); const snapshot = getSnapshot(); if (snapshot?.lbOptOut === true) { pageElement - .find(".section.optOutOfLeaderboards .optedOut") - .removeClass("hidden"); + .qs(".section.optOutOfLeaderboards .optedOut") + ?.removeClass("hidden"); pageElement - .find(".section.optOutOfLeaderboards .buttons") - .addClass("hidden"); + .qs(".section.optOutOfLeaderboards .buttons") + ?.addClass("hidden"); } if (snapshot?.streakHourOffset !== undefined) { - pageElement - .find(".section.setStreakHourOffset .info") - .removeClass("hidden"); + pageElement.qs(".section.setStreakHourOffset .info")?.removeClass("hidden"); const sign = snapshot?.streakHourOffset > 0 ? "+" : ""; pageElement - .find(".section.setStreakHourOffset .info span") - .text(sign + snapshot?.streakHourOffset); - pageElement - .find(".section.setStreakHourOffset .buttons") - .addClass("hidden"); + .qs(".section.setStreakHourOffset .info span") + ?.setText(sign + snapshot?.streakHourOffset); + pageElement.qs(".section.setStreakHourOffset .buttons")?.addClass("hidden"); } } @@ -195,15 +189,17 @@ export function updateUI(): void { page.setUrlParams(state); } -$(".page.pageAccountSettings").on("click", ".tabs button", (event) => { - state.tab = $(event.target).data("tab") as State["tab"]; +qs(".page.pageAccountSettings")?.onChild("click", ".tabs button", (event) => { + state.tab = (event.target as HTMLElement).getAttribute( + "data-tab", + ) as State["tab"]; updateTabs(); page.setUrlParams(state); }); -$( +qs( ".page.pageAccountSettings .section.discordIntegration .getLinkAndGoToOauth", -).on("click", () => { +)?.on("click", () => { Loader.show(); void Ape.users.getDiscordOAuth().then((response) => { if (response.status === 200) { @@ -217,7 +213,7 @@ $( }); }); -$(".page.pageAccountSettings #setStreakHourOffset").on("click", () => { +qs(".page.pageAccountSettings #setStreakHourOffset")?.on("click", () => { StreakHourOffsetModal.show(); }); @@ -230,7 +226,7 @@ AuthEvent.subscribe((event) => { export const page = new PageWithUrlParams({ id: "accountSettings", display: "Account Settings", - element: qsr(".page.pageAccountSettings"), + element: pageElement, path: "/account-settings", urlParamsSchema: UrlParameterSchema, afterHide: async (): Promise => { @@ -241,11 +237,11 @@ export const page = new PageWithUrlParams({ state.tab = options.urlParams.tab; } Skeleton.append("pageAccountSettings", "main"); - pageElement.find(`.tab[data-tab="${state.tab}"]`).addClass("active"); + pageElement.qs(`.tab[data-tab="${state.tab}"]`)?.addClass("active"); updateUI(); }, }); -$(() => { +onDocumentReady(() => { Skeleton.save("pageAccountSettings"); }); diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index e3e17c6a7..7ddeab6e5 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -7,6 +7,7 @@ import { Result } from "@monkeytype/schemas/results"; import { RankAndCount } from "@monkeytype/schemas/users"; import { roundTo2 } from "@monkeytype/util/numbers"; import { animate, AnimationParams } from "animejs"; +import { ElementWithUtils } from "./dom"; export function whorf(speed: number, wordlen: number): number { return Math.min( @@ -187,8 +188,8 @@ type LastIndex = { export const trailingComposeChars = /[\u02B0-\u02FF`´^¨~]+$|⎄.*$/; export async function swapElements( - el1: HTMLElement, - el2: HTMLElement, + el1: ElementWithUtils | null, + el2: ElementWithUtils | null, totalDuration: number, callback = async function (): Promise { return Promise.resolve(); @@ -203,38 +204,35 @@ export async function swapElements( totalDuration = applyReducedMotion(totalDuration); if ( - (el1.classList.contains("hidden") && !el2.classList.contains("hidden")) || - (!el1.classList.contains("hidden") && el2.classList.contains("hidden")) + (el1.hasClass("hidden") && !el2.hasClass("hidden")) || + (!el1.hasClass("hidden") && el2.hasClass("hidden")) ) { //one of them is hidden and the other is visible - if (el1.classList.contains("hidden")) { + if (el1.hasClass("hidden")) { await middleCallback(); await callback(); return false; } - el1.classList.remove("hidden"); - await promiseAnimate(el1, { + el1.show(); + await el1.promiseAnimate({ opacity: [1, 0], duration: totalDuration / 2, }); - el1.classList.add("hidden"); + el1.hide(); await middleCallback(); - el2.classList.remove("hidden"); - await promiseAnimate(el2, { + el2.show(); + await el2.promiseAnimate({ opacity: [0, 1], duration: totalDuration / 2, }); await callback(); - } else if ( - el1.classList.contains("hidden") && - el2.classList.contains("hidden") - ) { + } else if (el1.hasClass("hidden") && el2.hasClass("hidden")) { //both are hidden, only fade in the second await middleCallback(); - el2.classList.remove("hidden"); - await promiseAnimate(el2, { + el2.show(); + await el2.promiseAnimate({ opacity: [0, 1], duration: totalDuration / 2, }); From fd8001ca75637d4c9a0bb530c39dae984fb277d5 Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:32:20 +0200 Subject: [PATCH 07/11] fix(custom-text): Reset long custom text progress to start of word (@Leonabcd123) (#7213) ### Description Check if the user bailed out mid word, if so decrease `historyLength` by 1 to reset to the start of the current word instead of the start of the next word. --- frontend/src/ts/test/test-logic.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 689cc34db..0532c5212 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1084,7 +1084,16 @@ export async function finish(difficultyFailed = false): Promise { ) { // They bailed out - const historyLength = TestInput.input.getHistory()?.length; + const history = TestInput.input.getHistory(); + let historyLength = history?.length; + const wordIndex = historyLength - 1; + + const lastWordInputLength = history[wordIndex]?.length ?? 0; + + if (lastWordInputLength < TestWords.words.get(wordIndex).length) { + historyLength--; + } + const newProgress = CustomText.getCustomTextLongProgress(customTextName) + historyLength; CustomText.setCustomTextLongProgress(customTextName, newProgress); From d6484109b6d3e3eec7eaf9fb9f5387c4b16b8ee5 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 14 Dec 2025 19:00:01 +0100 Subject: [PATCH 08/11] fix(tape): stuck after restarting --- frontend/src/ts/test/test-logic.ts | 10 ++++++++-- frontend/src/ts/test/test-ui.ts | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 0532c5212..8af562e23 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -273,14 +273,21 @@ export function restart(options = {} as RestartOptions): void { ConnectionState.showOfflineBanner(); } + // TestUI.beforeTestRestart(); + + let source: "testPage" | "resultPage"; let el: HTMLElement; if (TestState.resultVisible) { //results are being displayed el = document.querySelector("#result") as HTMLElement; + source = "resultPage"; } else { //words are being displayed el = document.querySelector("#typingTest") as HTMLElement; + source = "testPage"; } + + TestState.setResultVisible(false); TestState.setTestRestarting(true); animate(el, { @@ -316,8 +323,7 @@ export function restart(options = {} as RestartOptions): void { fb.functions.restart(); } - TestUI.onTestRestart(); - TestState.setResultVisible(false); + TestUI.onTestRestart(source); const typingTestEl = document.querySelector("#typingTest") as HTMLElement; animate(typingTestEl, { diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 6ebe02254..b05e4213a 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -1844,7 +1844,7 @@ export function onTestStart(): void { TimerProgress.update(); } -export function onTestRestart(): void { +export function onTestRestart(source: "testPage" | "resultPage"): void { $("#result").addClass("hidden"); $("#typingTest").css("opacity", 0).removeClass("hidden"); getInputElement().style.left = "0"; @@ -1875,7 +1875,7 @@ export function onTestRestart(): void { void ModesNotice.update(); } - if (TestState.resultVisible) { + if (source === "resultPage") { if (Config.randomTheme !== "off") { void ThemeController.randomizeTheme(); } From 69684eddafcc15d65134caf11e09be3543bb6a2d Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 14 Dec 2025 20:17:50 +0100 Subject: [PATCH 09/11] fix(account settings): tabs not deselecting --- frontend/src/ts/pages/account-settings.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index 4e558d1cf..c4fc0c803 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -141,11 +141,11 @@ function updateTabs(): void { // }, async () => { - pageElement.qs(".tab")?.removeClass("active"); + pageElement.qsa(".tab")?.removeClass("active"); pageElement.qs(`.tab[data-tab="${state.tab}"]`)?.addClass("active"); }, ); - pageElement.qs("button")?.removeClass("active"); + pageElement.qsa("button")?.removeClass("active"); pageElement.qs(`button[data-tab="${state.tab}"]`)?.addClass("active"); } From 469a2f6332b8a63ce2b08d4f79dabf9af03263c8 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 14 Dec 2025 20:22:25 +0100 Subject: [PATCH 10/11] fix(account settings): buttons not working --- frontend/src/ts/pages/account-settings.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index c4fc0c803..0fad24100 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -243,5 +243,9 @@ export const page = new PageWithUrlParams({ }); onDocumentReady(() => { - Skeleton.save("pageAccountSettings"); + setTimeout(() => { + //band aid fix for now, we need to delay saving the skeleton + // to allow the click listeners to be registered first + Skeleton.save("pageAccountSettings"); + }, 0); }); From 64436ee2b325b197499b8931533f68b6dfd91950 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 14 Dec 2025 20:32:03 +0100 Subject: [PATCH 11/11] impr(dom utils): rename ondocumentready, add onwindowload !nuf --- frontend/src/ts/controllers/ad-controller.ts | 4 ++-- frontend/src/ts/pages/account-settings.ts | 10 +++------- frontend/src/ts/ready.ts | 4 ++-- frontend/src/ts/utils/dom.ts | 19 +++++++++++++++++-- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/frontend/src/ts/controllers/ad-controller.ts b/frontend/src/ts/controllers/ad-controller.ts index a612a81f0..ca66690dd 100644 --- a/frontend/src/ts/controllers/ad-controller.ts +++ b/frontend/src/ts/controllers/ad-controller.ts @@ -7,7 +7,7 @@ import Config from "../config"; import * as TestState from "../test/test-state"; import * as EG from "./eg-ad-controller"; import * as PW from "./pw-ad-controller"; -import { onDocumentReady, qs } from "../utils/dom"; +import { onDOMReady, qs } from "../utils/dom"; const breakpoint = 900; let widerThanBreakpoint = true; @@ -317,7 +317,7 @@ BannerEvent.subscribe(() => { updateVerticalMargin(); }); -onDocumentReady(() => { +onDOMReady(() => { updateBreakpoint(true); updateBreakpoint2(); }); diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index 0fad24100..14f3149d3 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -12,7 +12,7 @@ import * as BlockedUserTable from "../elements/account-settings/blocked-user-tab import * as Notifications from "../elements/notifications"; import { z } from "zod"; import * as AuthEvent from "../observables/auth-event"; -import { qs, qsr, onDocumentReady } from "../utils/dom"; +import { qs, qsr, onWindowLoad } from "../utils/dom"; const pageElement = qsr(".page.pageAccountSettings"); @@ -242,10 +242,6 @@ export const page = new PageWithUrlParams({ }, }); -onDocumentReady(() => { - setTimeout(() => { - //band aid fix for now, we need to delay saving the skeleton - // to allow the click listeners to be registered first - Skeleton.save("pageAccountSettings"); - }, 0); +onWindowLoad(() => { + Skeleton.save("pageAccountSettings"); }); diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/ready.ts index d0ca342bc..20f523cdf 100644 --- a/frontend/src/ts/ready.ts +++ b/frontend/src/ts/ready.ts @@ -10,9 +10,9 @@ import { getActiveFunboxesWithFunction } from "./test/funbox/list"; import { configLoadPromise } from "./config"; import { authPromise } from "./firebase"; import { animate } from "animejs"; -import { onDocumentReady, qs } from "./utils/dom"; +import { onDOMReady, qs } from "./utils/dom"; -onDocumentReady(async () => { +onDOMReady(async () => { await configLoadPromise; await authPromise; diff --git a/frontend/src/ts/utils/dom.ts b/frontend/src/ts/utils/dom.ts index 467a7eef2..7955d0378 100644 --- a/frontend/src/ts/utils/dom.ts +++ b/frontend/src/ts/utils/dom.ts @@ -52,10 +52,11 @@ export function qsr( } /** - * Execute a callback function when the document is fully loaded. + * Execute a callback function when the DOM is fully loaded. If you need to wait + * for all resources (images, stylesheets, scripts, etc.) to load, use `onWindowLoad` instead. * If the document is already loaded, the callback is executed immediately. */ -export function onDocumentReady(callback: () => void): void { +export function onDOMReady(callback: () => void): void { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", callback); } else { @@ -63,6 +64,20 @@ export function onDocumentReady(callback: () => void): void { } } +/** + * Execute a callback function when the window 'load' event fires, which occurs + * after the entire page (including all dependent resources such as images, + * stylesheets, and scripts) has fully loaded. + * If the window is already loaded, the callback is executed immediately. + */ +export function onWindowLoad(callback: () => void): void { + if (document.readyState === "complete") { + callback(); + } else { + window.addEventListener("load", callback); + } +} + /** * Creates an ElementWithUtils wrapping a newly created element. * @param tagName The tag name of the element to create.