diff --git a/.agents/skills/codeatlas-ui/API.md b/.agents/skills/codeatlas-ui/API.md index 59a28c46..5e556ee9 100644 --- a/.agents/skills/codeatlas-ui/API.md +++ b/.agents/skills/codeatlas-ui/API.md @@ -5800,14 +5800,24 @@ const scrollFocusable: Action> ## @codeatlasdev/ui/astro +### astroHead + +`import { astroHead } from "@codeatlasdev/ui/astro"` + +Markup for the start of ``: font preloads, the theme ``; + const event = Object.assign(new Event("astro:before-swap"), { newDocument: next }); + document.dispatchEvent(event); + const styles = next.head.querySelectorAll(`#${KEY}`); + expect(styles).toHaveLength(1); + expect(styles[0]?.textContent).toBe(record.css); + expect(next.documentElement.getAttribute("data-preset")).toBe("tokyo"); + expect(next.documentElement.getAttribute("data-style")).toBe("flat"); + }); +}); diff --git a/packages/ui/tests/styles.test.ts b/packages/ui/tests/styles.test.ts index 73f6fbae..170ec45e 100644 --- a/packages/ui/tests/styles.test.ts +++ b/packages/ui/tests/styles.test.ts @@ -173,7 +173,7 @@ describe("the site wires the pages in (#583)", () => { it("applies to the site through the Studio's visitor-only key, never the published theme", () => { const src = read("src/lib/visitor-theme.ts"); expect(src).toContain('SITE_KEY = "ca-site-studio"'); - expect(read("src/components/site/StudioBoot.astro")).toContain("ca-site-studio"); + expect(read("scripts/theme-boot.ts")).toContain('SITE_KEY = "ca-site-studio"'); // Nothing writes the build's own theme or the shared scheme/preset keys directly. expect(src).not.toMatch(/writeFile|ca-theme-def|"ca-preset"|"ca-scheme"/); }); diff --git a/packages/ui/tests/theme-island.test.ts b/packages/ui/tests/theme-island.test.ts index 89e0f6c5..e8559e80 100644 --- a/packages/ui/tests/theme-island.test.ts +++ b/packages/ui/tests/theme-island.test.ts @@ -248,19 +248,24 @@ describe("caUI view-transition swap with islands (#352)", () => { async function swapTo(next: string) { const { caUI } = await import("../src/lib/astro.js"); - let script = ""; + let markup = ""; const hooks = caUI().hooks as unknown as { "astro:config:setup": (o: { - injectScript: (stage: string, code: string) => void; - updateConfig: () => void; + addMiddleware: (m: unknown) => void; + updateConfig: (c: { vite: { plugins: { load: (id: string) => string | null }[] } }) => void; }) => void; }; hooks["astro:config:setup"]({ - injectScript: (_stage, code) => { - script = code; + addMiddleware: () => {}, + updateConfig: ({ vite }) => { + const exported = vite.plugins[0]?.load("\0virtual:codeatlas-ui/head") ?? ""; + markup = JSON.parse(exported.replace(/^export default |;$/g, "")); }, - updateConfig: () => {}, }); + // The page's own head scripts, as the browser runs them in order. + const script = [...markup.matchAll(/]*>([\s\S]*?)<\/script>/g)] + .map((m) => m[1]) + .join("\n"); window.matchMedia = (() => ({ matches: false, addEventListener: () => {}, diff --git a/packages/ui/tests/theme-no-flash.e2e.ts b/packages/ui/tests/theme-no-flash.e2e.ts new file mode 100644 index 00000000..0baa6444 --- /dev/null +++ b/packages/ui/tests/theme-no-flash.e2e.ts @@ -0,0 +1,412 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { build } from "astro"; +import type { Server } from "bun"; +import { type Browser, type BrowserContext, chromium, type Page } from "playwright"; +import { caThemeHandle } from "../src/lib/kit/theme-handle.ts"; +import { + THEME_SCRIPT, + type ThemeOptions, + themeHead, + themeScript, +} from "../src/lib/theme-script.ts"; + +// The zero-flash guarantee of the theme engine, for every way a consumer ships it, measured in a real +// browser on a slow device and a slow link (6x CPU, Slow 3G). A frame is "painted" once the browser +// reported first-paint; from then on EVERY frame must already wear the target theme: its brand token, +// its surface (the canvas colour), its `dark` class and `data-preset`/`data-style`. +// +// Every document here carries a large INLINE stylesheet (~100 KB) before ``, the shape that exposed the +// site bug (a theme script placed after the bundler's CSS ran only after that download, and the page +// painted the wrong theme in between). + +const PKG = join(import.meta.dir, ".."); +const ASTRO_ENTRY = join(PKG, "src/lib/astro.ts"); +const THEME: ThemeOptions = { + presets: [{ id: "brand", color: "#e8590c" }], + defaultPreset: "brand", + defaultScheme: "dark", +}; +const THEME_OPTIONS = JSON.stringify(THEME); + +/** 6x CPU and Slow 3G (400 kbit/s, 400 ms RTT). */ +const SLOW = { + latency: 400, + downloadThroughput: (400 * 1024) / 8, + uploadThroughput: (400 * 1024) / 8, +}; + +/** ~100 KB of rules the page needs before it can paint, so a late script is visible. */ +const BIG_CSS = Array.from( + { length: 1500 }, + (_, i) => `.x${i}{margin:${i % 7}px;padding:${i % 5}px}` +).join(""); +const TOKENS_CSS = `html{background:var(--ca-surface-base,#fff);color:var(--ca-text-primary,#000)}`; + +interface Frame { + brand: string; + canvas: string; + dark: boolean; + preset: string | null; + style: string | null; +} + +interface Recorder { + __f: Frame[]; + __cls: number; + __painted: boolean; +} +interface ShiftEntry extends PerformanceEntry { + value: number; + hadRecentInput: boolean; +} + +let browser: Browser; +let root = ""; +let astroDist = ""; +const servers: Server[] = []; + +/** + * A document whose bundled CSS is INLINED in the HTML (what Astro does with `inlineStylesheets: "always"`, and + * what a bundler's critical-CSS step does). The browser parses inline CSS as the bytes arrive and may paint + * part-way, so a theme script placed after it runs late and a frame of the wrong theme is painted. A + * `` would not show this: it blocks rendering until it has loaded. + */ +const doc = (head: string, body = "

hello

") => + `t${head}${body}`; + +function serve(handler: (path: string) => string | undefined) { + const s = Bun.serve({ + port: 0, + fetch(req) { + const p = new URL(req.url).pathname; + const html = handler(p); + return html === undefined + ? new Response("nf", { status: 404 }) + : new Response(html, { headers: { "content-type": "text/html" } }); + }, + }); + servers.push(s); + return `http://localhost:${s.port}`; +} + +/** What `caThemeHandle` does to a SvelteKit response, with the real handle. */ +async function kitHtml(html: string, opts = THEME): Promise { + let out = ""; + const handle = caThemeHandle(opts) as unknown as (a: { + event: unknown; + resolve: ( + e: unknown, + o: { transformPageChunk: (c: { html: string; done: boolean }) => string } + ) => unknown; + }) => unknown; + await handle({ + event: {}, + resolve: (_e, o) => { + out = o.transformPageChunk({ html, done: true }); + return new Response(); + }, + }); + return out; +} + +async function context(opts: { storage?: Record; scheme?: "dark" | "light" } = {}) { + const ctx = await browser.newContext({ colorScheme: opts.scheme ?? "dark" }); + if (opts.storage) + await ctx.addInitScript((kv: Record) => { + if (sessionStorage.getItem("__seeded")) return; + sessionStorage.setItem("__seeded", "1"); + for (const [k, v] of Object.entries(kv)) localStorage.setItem(k, v); + }, opts.storage); + return ctx; +} + +async function recorded(ctx: BrowserContext): Promise { + const page = await ctx.newPage(); + await page.addInitScript(() => { + const w: Window & Partial = window; + w.__f = []; + w.__cls = 0; + w.__painted = false; + new PerformanceObserver((l) => { + for (const e of l.getEntries()) if (e.name === "first-paint") w.__painted = true; + }).observe({ type: "paint", buffered: true }); + new PerformanceObserver((l) => { + for (const e of l.getEntries() as ShiftEntry[]) + if (!e.hadRecentInput) w.__cls = (w.__cls ?? 0) + e.value; + }).observe({ type: "layout-shift", buffered: true }); + const tick = () => { + if (w.__painted && w.__f) { + const h = document.documentElement; + const cs = getComputedStyle(h); + w.__f.push({ + brand: cs.getPropertyValue("--ca-brand-main").trim(), + canvas: cs.backgroundColor, + dark: h.classList.contains("dark"), + preset: h.getAttribute("data-preset"), + style: h.getAttribute("data-style"), + }); + } + if ((w.__f?.length ?? 0) < 800) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + const cdp = await ctx.newCDPSession(page); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 6 }); + await cdp.send("Network.enable"); + await cdp.send("Network.emulateNetworkConditions", { offline: false, ...SLOW }); + return page; +} + +const frames = (p: Page): Promise => + p.evaluate(() => { + const w: Window & Partial = window; + return w.__f ?? []; + }); +const cls = (p: Page): Promise => + p.evaluate(() => { + const w: Window & Partial = window; + return w.__cls ?? 0; + }); + +/** The distinct looks in paint order: a failure reads as the sequence the visitor saw. */ +const looks = (f: Frame[]) => + f + .map((x) => `${x.preset}/${x.style}/${x.dark ? "dark" : "light"} ${x.brand} ${x.canvas}`) + .filter((v, i, a) => v !== a[i - 1]); + +/** Loads `url`, waits for `load`, and returns the distinct looks that were painted. */ +async function firstFrames(ctx: BrowserContext, url: string) { + const p = await recorded(ctx); + await p.goto(url, { waitUntil: "load" }); + await p.waitForTimeout(300); + const f = await frames(p); + expect(f.length, "the recorder saw painted frames").toBeGreaterThan(0); + return { page: p, frames: f, looks: looks(f) }; +} + +beforeAll(async () => { + browser = await chromium.launch(); + root = mkdtempSync(join(PKG, ".astro-fixture-")); + mkdirSync(join(root, "src/pages"), { recursive: true }); + writeFileSync( + join(root, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; +import { caUI } from ${JSON.stringify(ASTRO_ENTRY)}; +export default defineConfig({ integrations: [caUI(${THEME_OPTIONS})], devToolbar: { enabled: false }, build: { inlineStylesheets: "always" } }); +` + ); + const bigRules = Array.from({ length: 1500 }, (_, i) => `.x${i}{margin:${i % 7}px}`).join(""); + const page = (title: string, href: string) => `--- +import { ClientRouter } from "astro:transitions"; +--- + + + + ${title} + + + +

${title}

go + +`; + writeFileSync(join(root, "src/pages/index.astro"), page("A", "/b/")); + writeFileSync(join(root, "src/pages/b.astro"), page("B", "/")); + await build({ root, logLevel: "silent" }); + astroDist = join(root, "dist"); +}, 180_000); + +afterAll(async () => { + await browser?.close(); + for (const s of servers) s.stop(true); + if (root) rmSync(root, { recursive: true, force: true }); +}); + +describe("zero flash of the wrong theme, every consumer path (6x CPU, Slow 3G)", () => { + it("SvelteKit: caThemeHandle (SSR/prerender) paints the stored theme from the first frame", async () => { + const html = await kitHtml(doc("")); + const base = serve(() => html); + const ctx = await context({ + storage: { "ca-scheme": '"light"', "ca-style": '"flat"' }, + scheme: "dark", + }); + const r = await firstFrames(ctx, `${base}/`); + expect(r.looks).toHaveLength(1); + expect(r.looks[0]).toContain("brand/flat/light"); + expect(await cls(r.page)).toBe(0); + await ctx.close(); + }, 90_000); + + it("SPA with no SSR: caThemePlugin's head in index.html paints the stored theme from the first frame", async () => { + const base = serve(() => doc(themeHead(THEME))); + const ctx = await context({ storage: { "ca-scheme": '"light"' } }); + const r = await firstFrames(ctx, `${base}/`); + expect(r.looks).toHaveLength(1); + expect(r.looks[0]).toContain("brand/solid/light"); + await ctx.close(); + }, 90_000); + + it("system scheme follows the OS on the first frame, dark and light", async () => { + const base = serve(() => doc(themeHead(THEME))); + for (const os of ["dark", "light"] as const) { + const ctx = await context({ storage: { "ca-scheme": '"system"' }, scheme: os }); + const r = await firstFrames(ctx, `${base}/`); + expect(r.looks, `OS ${os}`).toHaveLength(1); + expect(r.looks[0]).toContain(`/${os} `); + await ctx.close(); + } + }, 90_000); + + it("persisted runtime tokens (colorScheme.setTokens) are on the first frame", async () => { + const base = serve(() => doc(themeHead(THEME))); + const ctx = await context({ + storage: { "ca-runtime-tokens": JSON.stringify({ "--ca-brand-main": "rgb(1, 2, 3)" }) }, + }); + const r = await firstFrames(ctx, `${base}/`); + expect(r.looks).toHaveLength(1); + expect(r.looks[0]).toContain("rgb(1, 2, 3)"); + await ctx.close(); + }, 90_000); + + it("old, corrupt or blocked storage falls back to the default silently, on the first frame", async () => { + const base = serve(() => doc(themeHead(THEME))); + const ctx = await context({ + storage: { + "ca-scheme": "{not json", + "ca-preset": '"amber-from-v1"', + "ca-style": '"neon"', + "ca-runtime-tokens": "[[[", + }, + }); + const r = await firstFrames(ctx, `${base}/`); + expect(r.looks).toHaveLength(1); + expect(r.looks[0]).toContain("brand/"); + expect(r.looks[0]).toContain("/dark "); + await ctx.close(); + + // Storage that throws on access (private mode, cookies off): the default theme still paints. + const blocked = await browser.newContext({ colorScheme: "dark" }); + await blocked.addInitScript(() => { + Object.defineProperty(window, "localStorage", { + get() { + throw new DOMException("denied", "SecurityError"); + }, + }); + }); + const b = await firstFrames(blocked, `${base}/`); + expect(b.looks).toHaveLength(1); + expect(b.looks[0]).toContain("brand/solid/dark"); + await blocked.close(); + }, 90_000); + + it("the init script is synchronous, tiny and CSP-nonce friendly", () => { + const head = themeHead(THEME); + expect(head).not.toMatch(/\b(async|defer)\b|type="module"/); + // Nothing in it needs `eval` or an inline event handler, so a nonce/hash on the one tag is enough. + expect(THEME_SCRIPT).not.toMatch(/\beval\(|new Function|onload=|javascript:/); + // Same body whatever the options: the scripts differ only by their JSON data. + expect(themeScript(THEME).length).toBeLessThan(2200); + }); + + it("Astro caUI + : the first frame and every navigated frame wear the theme", async () => { + const s = Bun.serve({ + port: 0, + async fetch(req) { + let p = new URL(req.url).pathname; + if (p.endsWith("/")) p += "index.html"; + const f = Bun.file(join(astroDist, p)); + return (await f.exists()) ? new Response(f) : new Response("nf", { status: 404 }); + }, + }); + servers.push(s); + const url = `http://localhost:${s.port}`; + const ctx = await context({ storage: { "ca-scheme": '"light"', "ca-style": '"flat"' } }); + const r = await firstFrames(ctx, `${url}/`); + expect(r.looks).toHaveLength(1); + expect(r.looks[0]).toContain("brand/flat/light"); + + // A client-side navigation swaps and : no frame of it may drop the theme. + await r.page.evaluate(() => { + const w: Window & Partial = window; + w.__f = []; + }); + await r.page.click("#go"); + await r.page.waitForFunction(() => document.title === "B"); + await r.page.waitForTimeout(300); + const after = looks(await frames(r.page)); + expect(after).toHaveLength(1); + expect(after[0]).toContain("brand/flat/light"); + await ctx.close(); + }, 120_000); + + it("the order is what protects the first frame: the same built Astro page with its theme scripts late paints the wrong theme", async () => { + const html = await Bun.file(join(astroDist, "index.html")).text(); + const scripts = [...html.matchAll(/]*\bsrc=)[^>]*>[\s\S]*?<\/script>/g)] + .map((m) => m[0]) + .filter((m) => /__caTheme|__caThemePersist/.test(m)); + expect(scripts.length, "caUI emits its scripts").toBeGreaterThan(0); + const late = scripts + .reduce((h, m) => h.replace(m, ""), html) + .replace("", `${scripts.join("")}`); + const storage = { "ca-scheme": '"light"', "ca-style": '"flat"' }; + const early = await context({ storage }); + const ok = await firstFrames(early, `${serve(() => html)}/`); + await early.close(); + const lateCtx = await context({ storage }); + const bad = await firstFrames(lateCtx, `${serve(() => late)}/`); + await lateCtx.close(); + expect(ok.looks, "as built").toHaveLength(1); + expect(bad.looks.length, `late scripts: ${bad.looks.join(" ; ")}`).toBeGreaterThan(1); + }, 120_000); + + it("a runtime switch changes preset, style and scheme in one frame, never a mixed state", async () => { + const base = serve(() => doc(themeHead(THEME))); + const ctx = await browser.newContext({ colorScheme: "dark" }); + const p = await ctx.newPage(); + await p.goto(`${base}/`, { waitUntil: "load" }); + const seen = await p.evaluate(async () => { + const h = document.documentElement; + const snap = () => + `${h.getAttribute("data-preset")}/${h.getAttribute("data-style")}/${h.classList.contains("dark")}`; + const frames: string[] = []; + let go = true; + const tick = () => { + frames.push(snap()); + if (go) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + const frame = () => { + const { promise, resolve } = Promise.withResolvers(); + requestAnimationFrame(() => resolve()); + return promise; + }; + await frame(); + // What `colorScheme.setPreset/setStyle/setPreference` do together: one synchronous write. + h.setAttribute("data-preset", "ocean"); + h.setAttribute("data-style", "flat"); + h.classList.toggle("dark", false); + await frame(); + await frame(); + go = false; + return [...new Set(frames)]; + }); + expect(seen).toEqual(["brand/solid/true", "ocean/flat/false"]); + await ctx.close(); + }, 60_000); + + it("no transition plays on load: transitions are off until the first frames are painted", async () => { + const base = serve(() => + doc( + `${themeHead(THEME)}` + ) + ); + const ctx = await context(); + const p = await ctx.newPage(); + await p.goto(`${base}/`, { waitUntil: "commit" }); + await p.waitForLoadState("load"); + await p.waitForFunction(() => !document.documentElement.classList.contains("ca-init")); + expect(await p.evaluate(() => document.getAnimations().length)).toBe(0); + await ctx.close(); + }, 60_000); +}); diff --git a/packages/ui/tests/theme-order.test.ts b/packages/ui/tests/theme-order.test.ts new file mode 100644 index 00000000..fb73db4f --- /dev/null +++ b/packages/ui/tests/theme-order.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { build } from "astro"; +import { THEME_BOOT_HEAD } from "../../../site/scripts/theme-boot.ts"; +import { headFirst } from "../src/lib/head-first.ts"; +import { caThemeHandle } from "../src/lib/kit/theme-handle.ts"; +import { type ThemeOptions, themeHead } from "../src/lib/theme-script.ts"; + +// The first paint wears the right theme only if every theme script has RUN before the browser parses +// any CSS: a script after a stylesheet waits for it, and a slow link paints the wrong theme in between. +// This is the deterministic half of that guarantee (no browser, no timing): in the BUILT HTML of every +// consumer path (Kit, Vite, Astro), each theme script comes before the first `${head}

x

`; + +describe("helpers", () => { + it("orderProblems names a script placed after a stylesheet, and passes the right order", () => { + const late = ``; + expect(orderProblems(late, ["__caTheme"])).toEqual([ + expect.stringContaining("__caTheme: script at"), + ]); + const linked = ``; + expect(orderProblems(linked, ["__caTheme"])).toHaveLength(1); + const ok = ``; + expect(orderProblems(ok, ["__caTheme"])).toEqual([]); + expect(orderProblems(ok, ["nope"])).toEqual(["nope: no script in "]); + }); + + it("headFirst inserts after `` with or without attributes, once", () => { + expect(headFirst("t", "")).toBe( + "t" + ); + expect(headFirst('', "<b>")).toBe( + '<html><head data-x="1"><b><title>' + ); + expect(headFirst("<html><head><b>", "<b>")).toBe("<html><head><b>"); + }); +}); + +describe("SvelteKit: caThemeHandle puts the theme before every stylesheet", () => { + it("on a document with inline and linked CSS", async () => { + const handle = caThemeHandle(THEME) as unknown as (i: unknown) => Promise<Response>; + const res = await handle({ + event: { locals: {}, route: { id: "/" } }, + resolve: ( + _e: unknown, + o: { transformPageChunk: (c: { html: string; done: boolean }) => string } + ) => new Response(o.transformPageChunk({ html: doc(""), done: true })), + }); + expect(orderProblems(await res.text(), SCRIPTS(false))).toEqual([]); + }); +}); + +describe("plain Vite: caThemePlugin's head is before every stylesheet", () => { + it("on an index.html with inline and linked CSS", () => { + const html = headFirst(doc(""), themeHead(THEME)); + expect(orderProblems(html, SCRIPTS(false))).toEqual([]); + }); +}); + +describe("Astro: caUI puts the theme before every stylesheet, bundled or inline", () => { + let root = ""; + const dist = () => join(root, "dist"); + beforeAll(async () => { + root = mkdtempSync(join(PKG, ".astro-order-")); + mkdirSync(join(root, "src/pages"), { recursive: true }); + writeFileSync( + join(root, "astro.config.mjs"), + `import { defineConfig } from "astro/config"; +import { caUI } from ${JSON.stringify(join(PKG, "src/lib/astro.ts"))}; +export default defineConfig({ integrations: [caUI(${JSON.stringify(THEME)}, { head: ${JSON.stringify(THEME_BOOT_HEAD)} })], devToolbar: { enabled: false }, build: { inlineStylesheets: "always" } }); +` + ); + writeFileSync( + join(root, "src/pages/index.astro"), + `---\nimport { ClientRouter } from "astro:transitions";\n---\n<html lang="en"><head><meta charset="utf-8" /><title>A

A

\n` + ); + await build({ root, logLevel: "silent" }); + }, 120_000); + afterAll(() => root && rmSync(root, { recursive: true, force: true })); + + it("the built page has every theme script ahead of its inlined CSS", () => { + const html = readFileSync(join(dist(), "index.html"), "utf8"); + expect(firstCss(headOf(html))).toBeGreaterThan(0); + expect(orderProblems(html, SCRIPTS(true))).toEqual([]); + }); +}); diff --git a/packages/ui/tests/theme-script.test.ts b/packages/ui/tests/theme-script.test.ts index f028d4dd..ab60cbae 100644 --- a/packages/ui/tests/theme-script.test.ts +++ b/packages/ui/tests/theme-script.test.ts @@ -9,6 +9,8 @@ afterAll(() => GlobalRegistrator.unregister()); const acme: ThemeOptions = { defaultPreset: "acme", presets: [{ id: "acme", color: "#2563EB" }] }; const html = document.documentElement; +/** The rule runtime tokens are written as: `:root` repeated, so it beats every preset block (see theme-script.ts). */ +const RUNTIME_RULE = (decls: string) => `:root:root:root{${decls}}`; let dark = false; const listeners: Array<() => void> = []; @@ -77,7 +79,68 @@ describe("theme init script", () => { ); run(); expect(document.getElementById("ca-runtime-tokens")?.textContent).toBe( - ":root{--ca-brand-main:#ff0000;}" + RUNTIME_RULE("--ca-brand-main:#ff0000;") + ); + }); + + it("emits a rule that out-ranks a brand preset's dark block (.dark[data-preset], 0,2,0)", () => { + localStorage.setItem("ca-runtime-tokens", JSON.stringify({ "--ca-brand-main": "#ff0000" })); + run(); + const rule = document.getElementById("ca-runtime-tokens")?.textContent ?? ""; + const selector = rule.slice(0, rule.indexOf("{")); + // Specificity of a compound of `:root` (pseudo-class, 0,1,0 each): needs strictly more than 0,2,0. + expect(selector.split(":root").length - 1).toBeGreaterThanOrEqual(3); + }); +}); + +describe("theme init script robustness", () => { + const blockStorage = () => { + const real = Object.getOwnPropertyDescriptor(window, "localStorage"); + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { + throw new DOMException("denied", "SecurityError"); + }, + }); + return () => { + if (real) Object.defineProperty(window, "localStorage", real); + }; + }; + + it("still paints the default theme when storage is blocked (private mode, cookies off)", () => { + const restore = blockStorage(); + try { + run({ ...acme, defaultScheme: "dark", defaultStyle: "flat" }); + } finally { + restore(); + } + expect(html.getAttribute("data-preset")).toBe("acme"); + expect(html.getAttribute("data-style")).toBe("flat"); + expect(html.classList.contains("dark")).toBe(true); + }); + + it("still paints the stored theme when removing a corrupt entry throws", () => { + localStorage.setItem("ca-preset", '"gone"'); + localStorage.setItem("ca-style", '"solid"'); + const remove = Storage.prototype.removeItem; + Storage.prototype.removeItem = () => { + throw new DOMException("quota", "QuotaExceededError"); + }; + try { + run(); + } finally { + Storage.prototype.removeItem = remove; + } + expect(html.getAttribute("data-preset")).toBe("acme"); + expect(html.getAttribute("data-style")).toBe("solid"); + }); + + it("replays runtime tokens even when an earlier stored value is corrupt", () => { + localStorage.setItem("ca-scheme", "{not json"); + localStorage.setItem("ca-runtime-tokens", JSON.stringify({ "--ca-brand-main": "#ff0000" })); + run(); + expect(document.getElementById("ca-runtime-tokens")?.textContent).toBe( + RUNTIME_RULE("--ca-brand-main:#ff0000;") ); }); }); diff --git a/scripts/test-changed.ts b/scripts/test-changed.ts index 37a395d2..a4604221 100755 --- a/scripts/test-changed.ts +++ b/scripts/test-changed.ts @@ -66,10 +66,15 @@ const VISUAL_ROUTES = new Set([ "/theme/", ]); -/** A site spec that is not the route harness: it runs itself when it changed. */ +/** + * A site spec that is not the route harness: it runs itself when it changed. `no-flash.spec.ts` is not one of + * them: it paints real pages at 6x CPU on Slow 3G (~90 s for its cases), so it runs in `test:e2e` at release. + * What guards the same guarantee at land is `packages/ui/tests/theme-order.test.ts`, a unit test (2 s) that + * reads the built HTML and fails when a theme script comes after a stylesheet. + */ const ownSpec = (f: string) => /^site\/tests\/[^/]+\.spec\.ts$/.test(f) && - !/\/(routes|routes\.axe|dist-html|header)\.spec\.ts$/.test(f); + !/\/(routes|routes\.axe|dist-html|header|no-flash)\.spec\.ts$/.test(f); const land = process.argv.includes("--land"); const git = (...args: string[]) => diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 543891d2..1105e5d7 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -6,13 +6,14 @@ import { defineConfig } from "astro/config"; import { changelogPosts } from "./scripts/changelog-integration.ts"; import { distScope } from "./scripts/scope-integration.ts"; import { ogCards } from "./scripts/og-integration.ts"; +import { THEME_BOOT_HEAD } from "./scripts/theme-boot.ts"; import { theme } from "./src/theme.ts"; export default defineConfig({ site: "https://ui.codeatlas.com.br", output: "static", trailingSlash: "always", - integrations: [svelte(), mdx(), caUI(theme), sitemap(), ogCards(), changelogPosts(), distScope()], + integrations: [svelte(), mdx(), caUI(theme, { head: THEME_BOOT_HEAD }), sitemap(), ogCards(), changelogPosts(), distScope()], markdown: { shikiConfig: { themes: { light: "github-light-high-contrast", dark: "github-dark-high-contrast" }, diff --git a/site/scripts/theme-boot.ts b/site/scripts/theme-boot.ts new file mode 100644 index 00000000..68994b66 --- /dev/null +++ b/site/scripts/theme-boot.ts @@ -0,0 +1,47 @@ +// Head boot script: the visitor's own theme, replayed before the first paint. +// +// Why it goes through `caUI(theme, { head })` and not a ``; diff --git a/site/src/components/site/StudioBoot.astro b/site/src/components/site/StudioBoot.astro deleted file mode 100644 index dad2618b..00000000 --- a/site/src/components/site/StudioBoot.astro +++ /dev/null @@ -1,26 +0,0 @@ ---- -// Replays a theme the visitor applied from /theme/ ("Apply to site"). It only reads this -// browser's localStorage key; with no key it does nothing, so every build and every first visit -// renders sketchbook exactly as published. The CSS string was compiled by the studio when the -// visitor applied it, so no library code runs here. ---- - diff --git a/site/src/components/theme/ThemeStudio.svelte b/site/src/components/theme/ThemeStudio.svelte index 50d8bb4e..9bbb97aa 100644 --- a/site/src/components/theme/ThemeStudio.svelte +++ b/site/src/components/theme/ThemeStudio.svelte @@ -17,6 +17,7 @@ clone, commit, createHistory, DEFAULT_STATE, decodeState, encodeState, redo, sanitizeState, type History, type StudioState, undo, } from './studio-state'; + import { islandCSS } from './studio-output'; interface Props { /** The site's own default preset id: a custom brand rides on it for "apply to site". */ @@ -25,18 +26,43 @@ let { sitePreset }: Props = $props(); const EDITOR_KEY = 'ca-studio-editor'; + /** Same prefix `scripts/theme-boot.ts` replays before first paint: compiled preview CSS by share code. */ + const ISLAND_CACHE = 'ca-studio-island:'; - // SSR and the first client render use the default; the URL or storage replaces it on mount. - let hist = $state(createHistory(clone(DEFAULT_STATE))); + /** The state the URL or storage holds, read in the browser only; the server and a bare visit start from the default. */ + function startState(): StudioState | null { + if (typeof location === 'undefined') return null; + const fromUrl = /[#&]t=(v\d+\.[\w-]+)/.exec(location.hash)?.[1]; + const shared = fromUrl ? decodeState(fromUrl) : null; + if (shared) return shared; + try { + const raw = localStorage.getItem(EDITOR_KEY); + return raw ? sanitizeState(JSON.parse(raw)) : null; + } catch { + return null; // corrupt or private mode: fall back to the default + } + } + + // Hydration starts from the state the page is already wearing: the first client render is the + // shared theme, never the default one followed by a swap in `onMount`. + let hist = $state(createHistory(startState() ?? clone(DEFAULT_STATE))); const theme = $derived(hist.present); let ready = $state(false); - // Hydration-safe: false (stacked layout) until measured. + // Content of the side columns, which only exists at the wide layout: false until measured (the grid itself is CSS). let wide = $state(false); let studioEl = $state(); - // Vertical space above the studio (site header, banners): measured, never hardcoded, so the - // columns fill exactly the rest of the viewport and the page itself never scrolls on desktop. - let top = $state(0); + // Vertical space above the studio (site header, banners), so the columns fill exactly the rest of the + // viewport and the page itself never scrolls on desktop. `null` until measured: the stylesheet's own + // default (the docs header plus the content padding above the first block) is what the server and the + // first client frame use, so the measurement only refines it and nothing moves when it lands. + let top = $state(null); + /** + * The side panels live in the columns until a narrow screen is measured, then in the sheets. The + * condition is true on the server and on the first client render (`ready` is false), so hydration + * adopts the server's nodes instead of tearing them down and building new ones. + */ + const showColumns = $derived(!ready || wide); let controlsOpen = $state(false); let codeOpen = $state(false); @@ -100,7 +126,7 @@ onMount(() => { const mq = matchMedia('(min-width: 900px)'); const measure = () => { - if (studioEl) top = Math.max(0, studioEl.getBoundingClientRect().top + scrollY); + if (studioEl) top = Math.max(0, Math.round(studioEl.getBoundingClientRect().top + scrollY)); }; const sync = () => { wide = mq.matches; @@ -110,18 +136,9 @@ mq.addEventListener('change', sync); addEventListener('resize', measure); - const fromUrl = /[#&]t=(v\d+\.[\w-]+)/.exec(location.hash)?.[1]; - let start: StudioState | null = fromUrl ? decodeState(fromUrl) : null; - if (!start) { - try { - const raw = localStorage.getItem(EDITOR_KEY); - if (raw) start = sanitizeState(JSON.parse(raw)); - } catch { - /* corrupt or private mode: fall back to the default */ - } - } - if (start) hist = createHistory(start); ready = true; + // The preview now wears the real state: lift the boot script's hold on it. + document.documentElement.removeAttribute('data-ts-code'); const onKey = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return; @@ -152,9 +169,23 @@ const code = encodeState(theme); const hash = code === encodeState(DEFAULT_STATE) ? '' : `#t=${code}`; writeHash(hash); + cacheIsland(code, hash !== ''); }); }); + /** Keep the compiled preview CSS of the open share code, so the next visit paints it from the first frame. Only the open one is kept. */ + function cacheIsland(code: string, shared: boolean) { + try { + for (let i = localStorage.length - 1; i >= 0; i--) { + const k = localStorage.key(i); + if (k?.startsWith(ISLAND_CACHE) && k !== ISLAND_CACHE + code) localStorage.removeItem(k); + } + if (shared) localStorage.setItem(ISLAND_CACHE + code, islandCSS(theme)); + } catch { + /* quota or private mode */ + } + } + function writeHash(hash: string) { const url = location.pathname + location.search + hash; if (url !== location.pathname + location.search + location.hash) window.history.replaceState(null, '', url); @@ -179,12 +210,12 @@ {#snippet toolbar()} {/snippet} @@ -244,22 +275,24 @@ {/snippet} -
+ +
{@render toolbar()} - {#if wide} -
-
-
{@render left()}
-
-
-
{@render stage()}
-
-
-
{@render code()}
-
-
- {:else} - {@render stage()} +
+
+
{#if showColumns}{@render left()}{/if}
+
+
+
{@render stage()}
+
+
+
{#if showColumns}{@render code()}{/if}
+
+
+ {#if ready && !wide} {@render left()} {@render code()} {/if} @@ -274,7 +307,11 @@ /* Desktop: the studio is exactly the viewport below the site header, toolbar on top, columns filling the rest and scrolling on their own. */ @media (min-width: 900px) { - .ts-studio { block-size: calc(var(--ca-vh) - var(--ts-top, 0px)); } + .ts-studio { + block-size: calc( + var(--ca-vh) - var(--ts-top, calc(var(--ca-docs-header-h) + var(--ca-safe-top) + var(--ca-border-width) + var(--ca-space-8))) + ); + } } .ts-toolbar { display: flex; @@ -285,11 +322,20 @@ border-block-end: var(--ca-border-width) solid var(--ca-outline-subtle); } .ts-spacer { flex: 1; } + .ts-narrow { display: contents; } .ts-wide { display: grid; flex: 1; min-block-size: 0; - grid-template-columns: minmax(20rem, 24rem) minmax(0, 1fr) minmax(20rem, 26rem); + grid-template-columns: minmax(0, 1fr); + } + .ts-col--left, .ts-col--right { display: none; } + .ts-col--stage { border-inline-end: 0; } + @media (min-width: 900px) { + .ts-wide { grid-template-columns: minmax(20rem, 24rem) minmax(0, 1fr) minmax(20rem, 26rem); } + .ts-col--left, .ts-col--right { display: flex; } + .ts-col--stage { border-inline-end: var(--ca-border-width) solid var(--ca-outline-subtle); } + .ts-narrow { display: none; } } .ts-col { display: flex; @@ -298,7 +344,7 @@ min-height: 0; border-inline-end: var(--ca-border-width) solid var(--ca-outline-subtle); } - .ts-col:last-child { border-inline-end: 0; } + .ts-col--right { border-inline-end: 0; } .ts-scroll { flex: 1; min-width: 0; min-height: 0; overflow: hidden auto; padding: var(--ca-space-3); } .ts-stage { display: flex; flex-direction: column; gap: var(--ca-space-3); min-width: 0; padding: var(--ca-space-3); } .ts-stagebar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ca-space-2) var(--ca-space-3); } diff --git a/site/src/components/theme/apply-site.ts b/site/src/components/theme/apply-site.ts index 01820217..1f92750c 100644 --- a/site/src/components/theme/apply-site.ts +++ b/site/src/components/theme/apply-site.ts @@ -1,13 +1,13 @@ // ─── "Apply to site": the visitor's own theme, for this browser only ───────── // The edited theme is compiled to CSS once, stored under the site's own key and replayed by a tiny -// inline script (`StudioBoot.astro`) before first paint. Nothing here touches the published theme, +// inline head script (`scripts/theme-boot.ts`) before first paint. Nothing here touches the published theme, // the build output or the package defaults: clear the key and the site is sketchbook again. import { colorScheme } from "@codeatlasdev/ui"; import { siteCSS } from "./studio-output"; import { encodeState, type StudioState } from "./studio-state"; -/** The site's own storage key. The boot script in `StudioBoot.astro` reads the same one. */ +/** The site's own storage key. The boot script in `scripts/theme-boot.ts` reads the same one. */ export const SITE_KEY = "ca-site-studio"; export const STYLE_ID = "ca-site-studio"; diff --git a/site/src/components/theme/preview/PreviewStage.svelte b/site/src/components/theme/preview/PreviewStage.svelte index a5b70d9b..4f3ece28 100644 --- a/site/src/components/theme/preview/PreviewStage.svelte +++ b/site/src/components/theme/preview/PreviewStage.svelte @@ -50,9 +50,9 @@ }); - - {@html ``} - + +{@html ``}
@@ -119,6 +119,11 @@ margin-inline: auto; } + /* A share link whose compiled preview CSS this browser has not seen yet (`scripts/theme-boot.ts` + sets `data-ts-code` and replays the cache): the server-rendered preview is the default theme, so + it stays invisible, space reserved, until the studio hydrates with the shared state. */ + :global(html[data-ts-code]:not(:has(#ca-island-boot))) .ts-island { visibility: hidden; } + .ts-island { container-type: inline-size; container-name: ts-island; diff --git a/site/src/content/docs/getting-started/installation.mdx b/site/src/content/docs/getting-started/installation.mdx index 41a4230d..f49fa6ad 100644 --- a/site/src/content/docs/getting-started/installation.mdx +++ b/site/src/content/docs/getting-started/installation.mdx @@ -265,7 +265,7 @@ See [SEO](/docs/getting-started/seo/) and [PWA](/docs/getting-started/pwa/). ## Astro -Use the integration together with `@astrojs/svelte`. `caUI` injects the theme into the `` of every page and handles the SSR bundling. `bunx @codeatlasdev/ui init` sets this up for an Astro project: +Use the integration together with `@astrojs/svelte`. `caUI` puts the theme (font preloads, `