diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md
index 0d9d88404a8d..253923e82483 100644
--- a/docs/specs/solid-monkeytype/roadmap.md
+++ b/docs/specs/solid-monkeytype/roadmap.md
@@ -110,7 +110,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec
|---|---|---|---|---|---|
| [ ] | U1 | L · PR | Single-row user collection with `getUser()` and named writes (direct writes for server-side effects, optimistic actions for user changes, incl. quote ratings); convert **all writers** (result save, xp, badges, inbox count, lb memory, streak offset, quote rate, favourite quotes, edit profile, update name, unlink Discord, Discord OAuth, auth); delete the reactive mirror; `DB.getSnapshot()` becomes a read-only shim over the row | none | Writers must move in the same PR: there's no mutable object left |
| [ ] | U2 | M · PR | Activity calendar: plain activity data in the user row; the calendar becomes a Solid component; delete the imperative test-activity modules | U1 (data shape) | Can share a PR with U1 |
-| [ ] | U3 | M · PR | Remove the snapshot-updated auth event (PSA, last-signed-out-result modal read `getUser()`); auth becomes a signal the router reads, with the user-data load as state (loading/loaded/failed) plus `reloadUserData()` replacing the event's promise; delete the auth bus | U1 | |
+| [ ] | U3 | M · PR | Remove the snapshot-updated auth event (PSA, last-signed-out-result modal read `getUser()`); PSA's `authEvent` subscription (`Psa.init`, started by boot) follows the auth signal; auth becomes a signal the router reads, with the user-data load as state (loading/loaded/failed) plus `reloadUserData()` replacing the event's promise; delete the auth bus | U1 | |
| [ ] | U4 | S · commit per area | Readers move from the `DB.getSnapshot()` shim to `getUser()`, by area; the last PR deletes the shim and the snapshot code in `db.ts` | U1 | Parallel by area |
| [ ] | U5 | M · PR | Tags, presets and custom themes get the `getUser()` pattern: plain getters backed by a module-level reactive mirror, replacing their live-query hooks (keep `gcTime: Infinity`). Rewrite the non-test `__nonReactive` callers as Solid code reading the getters: commandline tag/preset lists, the custom-themes check in config metadata, the tag PB helpers in `db.ts` (or they go with U4) | none | Test-page callers go with T6a/T6c |
| [ ] | U6 | S · commit | Delete `__nonReactive` from every module once no caller is left | U5, T6a, T6c | Pure deletion: a caller still left means a step missed it |
@@ -130,10 +130,10 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec
|---|---|---|---|---|
| [x] | L1 | M · PR | Focus mode becomes a signal; layout binds focus state; cursor hiding goes to CSS; one component owns the mouse-move exit. Done in [#8519](https://github.com/monkeytypegame/monkeytype/pull/8519): `isFocusMode`/`setFocusMode` (rAF-deferred) in states/test; `data-focused` on app, header, footer, banners; `FocusModeExit`; caret blink follows the signal; `test/focus.ts` deleted | none (before T5) |
| [x] | L2 | L · PR | Funbox component owns funbox document state (body state, stylesheet link, CRT scanline); funbox functions stop touching the DOM and become engine plugin hooks, getting input as arguments instead of importing the EventLog; display tweaks via `data-ui-*` / funbox CSS. Done in [#8522](https://github.com/monkeytypegame/monkeytype/pull/8522): `components/core/Funbox.tsx` in App derives `fb-*`/`ignore-reduced-motion` and per-funbox links from `getAppliedFunboxes()` (set by `activate()` after validation, cleared by `clear()`), crt (`crtmode`, `#scanline`, global link) from `config.funbox` unless screenshotting; selectors unchanged; body classes via the `useBodyClasses` hook (Theme's `darkMode` and DevOptionsModal's debug classes moved too). `applyGlobalCSS`/`clearGlobal` and funbox's config-event subscriber deleted; crt loses `hasCssFile` (was loaded twice). Safari ≤16.5 crt check → `getFunboxUnsupportedReason` (metadata `isBlocked`, `toggleFunbox` setter, `activate()`). `handleKeydown`/`handleSpace` get a `FunboxInput` (`current()`, `forWord(i)`) from input instead of importing `events/data`. Hook shape left for T6a | none (before T5) |
-| [ ] | L3 | M · PR | Monkey power as a component owning its canvas, positioned from the caret | T3 |
+| [ ] | L3 | M · PR | Monkey power as a component owning its canvas, positioned from the caret; replaces App's `MonkeyPower.init` on mount (L6 stopgap) | T3 |
| [ ] | L4 | M · PR | Result word highlight becomes part of words history, driven by a highlighted-range signal from the chart | none |
-| [ ] | L5 | M · PR | Screenshot-mode signal; elements hide through state; capture uses the result ref | none |
-| [ ] | L6 | M · PR | Boot cleanup: the ready module goes into App lifecycle / a boot module; PSA, merch banner and no-CSS detection become state plus components | none |
+| [ ] | L5 | M · PR | Screenshot-mode signal; elements hide through state (incl. the static `#nocss` box); capture uses the result ref | none |
+| [x] | L6 | M · PR | Boot cleanup: the ready module goes into App lifecycle / a boot module; PSA, merch banner and no-CSS detection become state plus components. Done: DOM-free `boot.ts` (called after render) awaits config then auth, sets app ready, shows merch banner, syncs server config, registers the service worker, and starts connection listeners (`initConnection`, was `onDOMReady`) and PSA (`Psa.init`, still on the auth bus until U3); App calls `MonkeyPower.init` on mount until L3 (the body transition gate went in 99ef85bb6: the first paint has the stored theme); `NoCssDetails` portals into empty mount points of the static `#nocss` box; `ready.ts` and `no-css.ts` deleted; the app has no `onDOMReady` callers left (`email-handler.html` still uses it, see CL1) | none |
| [ ] | L7 | S · commit | Remove stray DOM queries: account page, leaderboard table, captcha, challenge controller header, ad/analytics controllers. Done except the leaderboard table: #8524 rewrites its scroll-to-user and should switch to DataTable's `rowSelection.scrollToActiveRow` | none |
| [ ] | L8 | S · commit | Delete dead modules: result batches, character counter, discord avatar util, input indicator, unused input-validation exports, tag builder plus its spec | none |
| [ ] | L9 | M · PR | Replace the sorted-table utility with DataTable | none |
@@ -145,7 +145,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec
| Done | ID | Size | Step | Blocked by |
|---|---|---|---|---|
-| [ ] | CL1 | M · PR | Delete the DOM-wrapper utility library, the ref-with-utils hook and `onDOMReady` | T7, all L, U2 |
+| [ ] | CL1 | M · PR | Delete the DOM-wrapper utility library, the ref-with-utils hook and `onDOMReady` (the app has none since L6; `email-handler.html`, a separate Vite entry, still imports `qs` and `onDOMReady` from `utils/dom`, so migrate that page first) | T7, all L, U2 |
| [ ] | CL2 | M · PR | DOM-query / `innerHTML` lint ban, with the server-HTML allowlist (notifications, simple modal, version history, quote-search highlight) | CL1 |
| [ ] | CL3 | M · PR | **Announced removal of legacy theme selectors** (old ids, classes, `letter`): the single break for user custom CSS; delete remaining SCSS | T1, L11, L12 |
| [ ] | CL4 | S · commit | Update coding standards and agent docs to describe only the final patterns | CL1 |
diff --git a/frontend/__tests__/components/core/NoCssDetails.spec.tsx b/frontend/__tests__/components/core/NoCssDetails.spec.tsx
new file mode 100644
index 000000000000..9642a4bfdcbc
--- /dev/null
+++ b/frontend/__tests__/components/core/NoCssDetails.spec.tsx
@@ -0,0 +1,51 @@
+import { render } from "@solidjs/testing-library";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+
+import { NoCssDetails } from "../../../src/ts/components/core/NoCssDetails";
+
+function text(id: string): string {
+ return (document.getElementById(id)?.textContent ?? "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+describe("NoCssDetails", () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+
+
`;
+ document.head.innerHTML = ``;
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ document.head.innerHTML = "";
+ });
+
+ it("shows Ctrl outside mac", () => {
+ vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0 (X11; Linux)" });
+ render(() => );
+
+ expect(text("nocss-keys")).toBe("Ctrl + Shift + R");
+ });
+
+ it("shows Cmd on mac", () => {
+ vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0 (Macintosh)" });
+ render(() => );
+
+ expect(text("nocss-keys")).toBe("Cmd + Shift + R");
+ });
+
+ it("lists only the app stylesheets and the client version", () => {
+ render(() => );
+
+ expect(text("nocss-stylesheets")).toBe(
+ `Requested stylesheets:${location.origin}/css/style.abc.css`,
+ );
+ expect(text("nocss-js")).toMatch(/^Requested Javascript files:/);
+ expect(text("nocss-js")).toMatch(/Client version:\S+$/);
+ });
+});
diff --git a/frontend/__tests__/root/boot.spec.ts b/frontend/__tests__/root/boot.spec.ts
new file mode 100644
index 000000000000..57d9858bd6d9
--- /dev/null
+++ b/frontend/__tests__/root/boot.spec.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect, vi } from "vitest";
+
+const { config, auth } = vi.hoisted(() => ({
+ config: Promise.withResolvers(),
+ auth: Promise.withResolvers(),
+}));
+
+vi.mock("../../src/ts/config/lifecycle", () => ({
+ configLoadPromise: config.promise,
+}));
+vi.mock("../../src/ts/firebase", () => ({ authPromise: auth.promise }));
+vi.mock("../../src/ts/elements/merch-banner", () => ({
+ showIfNotClosedBefore: vi.fn(),
+}));
+vi.mock("../../src/ts/elements/psa", () => ({ init: vi.fn() }));
+vi.mock("../../src/ts/states/connection", () => ({ initConnection: vi.fn() }));
+vi.mock("../../src/ts/ape/server-configuration", () => ({
+ sync: vi.fn(async () => undefined),
+}));
+vi.mock("../../src/ts/utils/env", () => ({ isDevEnvironment: () => false }));
+
+import * as ServerConfiguration from "../../src/ts/ape/server-configuration";
+import { boot } from "../../src/ts/boot";
+import * as MerchBanner from "../../src/ts/elements/merch-banner";
+import * as Psa from "../../src/ts/elements/psa";
+import { initConnection } from "../../src/ts/states/connection";
+import { isAppReady } from "../../src/ts/states/core";
+
+describe("boot", () => {
+ it("waits for config, then auth, before the app is ready", async () => {
+ const booted = boot();
+
+ // started before awaiting, so the first auth state change shows PSAs
+ expect(initConnection).toHaveBeenCalledOnce();
+ expect(Psa.init).toHaveBeenCalledOnce();
+
+ auth.resolve(undefined);
+ await Promise.resolve();
+ expect(isAppReady()).toBe(false);
+
+ config.resolve(undefined);
+ await booted;
+
+ expect(isAppReady()).toBe(true);
+ expect(MerchBanner.showIfNotClosedBefore).toHaveBeenCalledOnce();
+ expect(ServerConfiguration.sync).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/html/warnings.html b/frontend/src/html/warnings.html
index 36840c09f75a..6a4763a7baa8 100644
--- a/frontend/src/html/warnings.html
+++ b/frontend/src/html/warnings.html
@@ -108,53 +108,7 @@
It seems like the CSS failed to load. Please try clearing your cache to
redownload the styles:
-
-
- Ctrl
-
- /
-
- Cmd
-
- +
-
- Shift
-
- +
-
- R
-
-
+
You could also try to reinstall the service worker:
@@ -172,11 +126,7 @@
If that doesn't help contact support (support@monkeytype.com or
discord.gg/monkeytype)
-
- If the website works for a bit but then this screen comes back, clear your
- cache again and then on Monkeytype open the command line (esc) and search
- for "Clear SW cache".
-
-
+
+
diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/boot.ts
similarity index 75%
rename from frontend/src/ts/ready.ts
rename to frontend/src/ts/boot.ts
index cde35b5fe3f3..d734eae721fb 100644
--- a/frontend/src/ts/ready.ts
+++ b/frontend/src/ts/boot.ts
@@ -1,13 +1,18 @@
-import * as MonkeyPower from "./elements/monkey-power";
-import * as MerchBanner from "./elements/merch-banner";
import * as ServerConfiguration from "./ape/server-configuration";
import { configLoadPromise } from "./config/lifecycle";
+import * as MerchBanner from "./elements/merch-banner";
+import * as Psa from "./elements/psa";
import { authPromise } from "./firebase";
-import { onDOMReady } from "./utils/dom";
-import { isDevEnvironment } from "./utils/env";
+import { initConnection } from "./states/connection";
import { setAppReady } from "./states/core";
+import { isDevEnvironment } from "./utils/env";
+
+/** App startup work that doesn't touch the DOM. Call once, right after render. */
+export async function boot(): Promise {
+ // before awaiting: PSAs show on the first auth state change
+ initConnection();
+ Psa.init();
-onDOMReady(async () => {
await configLoadPromise;
await authPromise;
@@ -17,8 +22,10 @@ onDOMReady(async () => {
void ServerConfiguration.sync();
- MonkeyPower.init();
+ registerServiceWorker();
+}
+function registerServiceWorker(): void {
if (isDevEnvironment()) {
void navigator.serviceWorker
.getRegistrations()
@@ -44,4 +51,4 @@ onDOMReady(async () => {
});
}
}
-});
+}
diff --git a/frontend/src/ts/components/core/App.tsx b/frontend/src/ts/components/core/App.tsx
index f6bbc3ae99d8..c1117847447f 100644
--- a/frontend/src/ts/components/core/App.tsx
+++ b/frontend/src/ts/components/core/App.tsx
@@ -3,8 +3,9 @@ import {
RouterContextProvider,
RouterProvider as RouteRenderer,
} from "@tanstack/solid-router";
-import { JSXElement } from "solid-js";
+import { JSXElement, onMount } from "solid-js";
+import * as MonkeyPower from "../../elements/monkey-power";
import { queryClient } from "../../queries";
import { router } from "../../router/app-router";
import { getGlobalOffsetTop, isAppReady } from "../../states/core";
@@ -23,9 +24,13 @@ import { BarTimerProgress } from "../pages/test/live-stats/BarTimerProgress";
import { CustomBackground } from "./CustomBackground";
import { Font } from "./Font";
import { Funbox } from "./Funbox";
+import { NoCssDetails } from "./NoCssDetails";
import { Theme } from "./Theme";
export function App(): JSXElement {
+ // until monkey power owns its canvas (L3)
+ onMount(() => MonkeyPower.init());
+
return (
@@ -66,6 +71,7 @@ export function App(): JSXElement {
+
);
}
diff --git a/frontend/src/ts/components/core/NoCssDetails.tsx b/frontend/src/ts/components/core/NoCssDetails.tsx
new file mode 100644
index 000000000000..87b0e9252e9c
--- /dev/null
+++ b/frontend/src/ts/components/core/NoCssDetails.tsx
@@ -0,0 +1,93 @@
+import { For, JSXElement, Show } from "solid-js";
+import { Portal } from "solid-js/web";
+import { envConfig } from "virtual:env-config";
+
+const keyStyle = {
+ padding: "1rem",
+ display: "inline-block",
+ "border-radius": "1rem",
+ background: "#2c2e31",
+};
+
+/**
+ * Fills the static no-CSS warning (warnings.html) with the reload shortcut for
+ * this platform and the requested stylesheets and scripts. Inline styles only:
+ * it shows when the CSS failed to load.
+ */
+export function NoCssDetails(): JSXElement {
+ const isMac = window.navigator.userAgent.toLowerCase().includes("mac");
+ const stylesheets = (
+ [...document.querySelectorAll("link[rel=stylesheet]")] as HTMLLinkElement[]
+ )
+ .map((l) => l.href)
+ .filter((l) => /\/css\/style/gi.test(l));
+ const scripts = (
+ [...document.querySelectorAll("script")] as HTMLScriptElement[]
+ )
+ .map((l) => l.src)
+ .filter((l) => /(\/js\/mon|\/js\/vendor)/gi.test(l));
+
+ return (
+ <>
+
+
+ {isMac ? "Cmd" : "Ctrl"}
+ {" "}
+ + Shift + R
+
+
+ Requested stylesheets:
+
+ {(href) => (
+ <>
+
+ {href}
+ >
+ )}
+
+
+
+ Requested Javascript files:
+
+ {(src) => (
+ <>
+
+ {src}
+ >
+ )}
+
+
+
+ Client version:
+
+ {envConfig.clientVersion}
+
+ >
+ );
+}
+
+/** Renders into an empty mount point of the static warning markup. */
+function Mount(props: {
+ at: HTMLElement | null;
+ children: JSXElement;
+}): JSXElement {
+ return (
+
+ {(el) => (
+ (wrapper.style.display = "contents")}
+ >
+ {props.children}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/ts/elements/no-css.ts b/frontend/src/ts/elements/no-css.ts
deleted file mode 100644
index c12d08cbe370..000000000000
--- a/frontend/src/ts/elements/no-css.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-// queries only static markup from index.html (#nocss, links, scripts)
-/* oxlint-disable monkeytype-rules/no-module-scope-dom-query */
-import { envConfig } from "virtual:env-config";
-import { qs } from "../utils/dom";
-
-qs("#nocss .requestedStylesheets")?.setHtml(
- `Requested stylesheets: ${(
- [...document.querySelectorAll("link[rel=stylesheet")] as HTMLAnchorElement[]
- )
- .map((l) => l.href)
- .filter((l) => /\/css\/style/gi.test(l))
- .join(" ")}`,
-);
-
-qs("#nocss .requestedJs")?.setHtml(
- `Requested Javascript files: ${(
- [...document.querySelectorAll("script")] as HTMLScriptElement[]
- )
- .map((l) => l.src)
- .filter((l) => /(\/js\/mon|\/js\/vendor)/gi.test(l))
- .join(" ")}
Client version: ${envConfig.clientVersion}`,
-);
-
-if (window.navigator.userAgent.toLowerCase().includes("mac")) {
- qs("#nocss .keys")?.setHtml(`
-
- Cmd
-
- +
-
- Shift
-
- +
-
- R
-
- `);
-} else {
- qs("#nocss .keys")?.setHtml(`
-
- Ctrl
-
- +
-
- Shift
-
- +
-
- R
-
- `);
-}
diff --git a/frontend/src/ts/elements/psa.tsx b/frontend/src/ts/elements/psa.tsx
index a86b81c8f414..34173168adc3 100644
--- a/frontend/src/ts/elements/psa.tsx
+++ b/frontend/src/ts/elements/psa.tsx
@@ -203,8 +203,11 @@ export async function show(): Promise {
});
}
-authEvent.subscribe((event) => {
- if (event.type === "authStateChanged") {
- void show();
- }
-});
+/** Shows the PSAs on every auth state change. */
+export function init(): void {
+ authEvent.subscribe((event) => {
+ if (event.type === "authStateChanged") {
+ void show();
+ }
+ });
+}
diff --git a/frontend/src/ts/index.tsx b/frontend/src/ts/index.tsx
index 404a438d18b8..64a115cc1df5 100644
--- a/frontend/src/ts/index.tsx
+++ b/frontend/src/ts/index.tsx
@@ -17,21 +17,18 @@ import { Config } from "./config/store";
import * as TestTimer from "./test/test-timer";
import { toggleSmoothedBurst } from "./states/result";
import { onAuthStateChanged } from "./auth";
-import "./elements/no-css";
-import "./states/connection";
import "./test/tts";
import { addToGlobal } from "./utils/misc";
import { fetchLatestVersion } from "./utils/version";
import * as Sentry from "./sentry";
import * as Cookies from "./cookies";
-import "./elements/psa";
import { applyEngineSettings } from "./anim";
import { qs, qsa, qsr } from "./utils/dom";
import { render } from "solid-js/web";
import { App } from "./components/core/App";
-import "./ready";
import { setVersion } from "./states/core";
import { loadFromLocalStorage } from "./config/lifecycle";
+import { boot } from "./boot";
import "./input/hotkeys";
import { showModal } from "./states/modals";
@@ -64,6 +61,7 @@ if (import.meta.env.DEV) {
// render before anything that might touch component-rendered DOM (eg. test page).
// Renders after the static markup in (index.html)
render(() => , document.body);
+void boot();
applyEngineSettings();
void loadFromLocalStorage();
diff --git a/frontend/src/ts/states/connection.ts b/frontend/src/ts/states/connection.ts
index 250e1f3d1e89..49ed0df372e8 100644
--- a/frontend/src/ts/states/connection.ts
+++ b/frontend/src/ts/states/connection.ts
@@ -1,7 +1,6 @@
import { createSignal } from "solid-js";
import { debounce } from "throttle-debounce";
import { showSuccessNotification } from "./notifications";
-import { onDOMReady } from "../utils/dom";
import { addBanner, removeBanner } from "./banners";
import { isTestActive } from "./test";
@@ -45,11 +44,11 @@ function handleConnectionChange(newState: boolean): void {
throttledHandleState();
}
-onDOMReady(() => {
+export function initConnection(): void {
window.addEventListener("online", () => handleConnectionChange(true));
window.addEventListener("offline", () => handleConnectionChange(false));
setOnline(navigator.onLine);
if (!isOnline()) {
showOfflineBanner();
}
-});
+}
diff --git a/frontend/src/ts/states/core.ts b/frontend/src/ts/states/core.ts
index 5cca4c3ecbd4..42b6b452a1c3 100644
--- a/frontend/src/ts/states/core.ts
+++ b/frontend/src/ts/states/core.ts
@@ -23,7 +23,7 @@ export const [getThemeIndicator, setThemeIndicator] = createSignal<{
});
export const [getGlobalOffsetTop, setGlobalOffsetTop] = createSignal(0);
-// #app stays hidden until the config and auth are loaded (see ready.ts)
+// #app stays hidden until the config and auth are loaded (see boot.ts)
export const [isAppReady, setAppReady] = createSignal(false);
export const [getIsScreenshotting, setIsScreenshotting] = createSignal(false);