From 233461f1413cd5bfa4e109979318b84d0bc58fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 13:33:34 -0300 Subject: [PATCH 1/7] chore: start #618 From a1e5c8210eaf61958bc15f3dc4a992a147c0e78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 14:58:54 -0300 Subject: [PATCH 2/7] fix: theme flashes: first paint wears the target theme on every path (#618) Visitor theme and share URL now apply from a head-inline integration ahead of the bundled CSS, the studio hydrates from the URL state, setTokens beats a preset's dark block, the init script survives blocked storage, and a zero-flash matrix runs in the land gate when theme files change. --- .changeset/no-theme-flash-blocked-storage.md | 7 + packages/ui/scripts/size-limit.ts | 34 ++ packages/ui/size-limit.json | 3 +- .../lib/stores/color-scheme.svelte.test.ts | 28 ++ .../ui/src/lib/stores/color-scheme.svelte.ts | 9 +- packages/ui/src/lib/theme-script.ts | 17 +- packages/ui/tests/site-theme-boot.test.ts | 105 +++++ packages/ui/tests/styles.test.ts | 2 +- packages/ui/tests/theme-no-flash.e2e.ts | 387 ++++++++++++++++++ packages/ui/tests/theme-script.test.ts | 65 ++- scripts/test-changed.ts | 22 +- site/astro.config.mjs | 3 +- site/scripts/theme-boot.ts | 56 +++ site/src/components/site/StudioBoot.astro | 26 -- site/src/components/theme/ThemeStudio.svelte | 126 ++++-- site/src/components/theme/apply-site.ts | 4 +- .../theme/preview/PreviewStage.svelte | 11 +- .../content/docs/getting-started/theming.mdx | 2 + site/src/layouts/Base.astro | 2 - site/src/lib/visitor-theme.ts | 4 +- site/tests/no-flash.spec.ts | 227 ++++++++++ 21 files changed, 1053 insertions(+), 87 deletions(-) create mode 100644 .changeset/no-theme-flash-blocked-storage.md create mode 100644 packages/ui/tests/site-theme-boot.test.ts create mode 100644 packages/ui/tests/theme-no-flash.e2e.ts create mode 100644 site/scripts/theme-boot.ts delete mode 100644 site/src/components/site/StudioBoot.astro create mode 100644 site/tests/no-flash.spec.ts diff --git a/.changeset/no-theme-flash-blocked-storage.md b/.changeset/no-theme-flash-blocked-storage.md new file mode 100644 index 00000000..1fe953b1 --- /dev/null +++ b/.changeset/no-theme-flash-blocked-storage.md @@ -0,0 +1,7 @@ +--- +"@codeatlasdev/ui": patch +--- + +The theme init script no longer fails with the page. When `localStorage` throws on access (private mode, blocked cookies, a sandboxed iframe) it used to abort before setting `data-preset`, `data-style` and the `dark` class, so the page painted unthemed and then switched once the store hydrated. It now falls back to the theme's defaults on the first frame, and a failing `removeItem` while discarding a corrupt value no longer stops the rest of the script. A size-limit entry pins the emitted script (under 1 kB gzip). + +`colorScheme.setTokens` now actually applies in dark mode with a brand preset. Runtime tokens were written as `:root{…}` (specificity 0,1,0) and lost to the preset's `.dark[data-preset]` block (0,2,0) whatever the source order, so the stored tokens replayed by the init script, and the ones the store wrote, silently did nothing. Both now use `:root:root:root{…}`. diff --git a/packages/ui/scripts/size-limit.ts b/packages/ui/scripts/size-limit.ts index 6b82fd91..2a09b1d8 100644 --- a/packages/ui/scripts/size-limit.ts +++ b/packages/ui/scripts/size-limit.ts @@ -8,6 +8,7 @@ // The ratchet only moves down: --update never raises a limit, raise one by hand in a PR that explains why. import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { gzipSync } from "node:zlib"; import { dist, type Entry, foreignStrings, measure } from "./size-lib.js"; const root = join(import.meta.dir, ".."); @@ -71,6 +72,39 @@ const next: Record = { ...limits }; const kb = (n: number) => `${(n / 1024).toFixed(2)} kB`; let failed = 0; +/** + * Inline scripts ship as text inside every HTML page, ahead of the first paint, so their cost is paid + * on the critical path of every navigation: raw bytes of the real emitted script, gzip as the metric. + */ +const INLINE = [ + { + name: "inline: theme init script (default theme)", + requires: "theme-script.js", + script: async () => (await import(join(dist, "theme-script.js"))).themeScript() as string, + }, +]; + +for (const e of INLINE) { + if (!existsSync(join(dist, e.requires))) { + console.log(`skip ${e.name} (not built)`); + continue; + } + const size = gzipSync(await e.script(), { level: 9 }).length; + const limit = limits[e.name]; + if (limit === undefined && mode === "check") { + console.error(`FAIL ${e.name}: no limit recorded; run \`bun run size:update\``); + failed++; + continue; + } + const target = Math.ceil(size * HEADROOM); + if (mode === "init" || limit === undefined) next[e.name] = target; + else if (mode === "update") next[e.name] = Math.min(limit, target); + const shown = next[e.name] ?? limit ?? target; + const over = size > shown; + if (over) failed++; + console.log(`${over ? "FAIL " : "ok "} ${e.name}: ${size} B gzip (limit ${shown} B)`); +} + for (const e of ENTRIES) { if (e.requires && !existsSync(join(dist, e.requires))) { console.log(`skip ${e.name} (not built)`); diff --git a/packages/ui/size-limit.json b/packages/ui/size-limit.json index b609b026..8ac54deb 100644 --- a/packages/ui/size-limit.json +++ b/packages/ui/size-limit.json @@ -7,5 +7,6 @@ "./seo": 32190, "./map": 34977, "./sound": 31700, - "./sound (own code, Svelte external)": 4096 + "./sound (own code, Svelte external)": 4096, + "inline: theme init script (default theme)": 1000 } diff --git a/packages/ui/src/lib/stores/color-scheme.svelte.test.ts b/packages/ui/src/lib/stores/color-scheme.svelte.test.ts index 294ed9c6..2b63263f 100644 --- a/packages/ui/src/lib/stores/color-scheme.svelte.test.ts +++ b/packages/ui/src/lib/stores/color-scheme.svelte.test.ts @@ -98,4 +98,32 @@ describe("colorScheme store", () => { expect(html.classList.contains("dark")).toBe(false); expect(html.getAttribute("data-preset")).toBe("crimson"); }); + + it("setTokens wins over a preset's dark block, in dark and in light", () => { + // What a brand preset emits: a light block on [data-preset] and a dark one on .dark[data-preset] (0,2,0). + const preset = document.createElement("style"); + preset.textContent = + '[data-preset="tokens-test"]{--ca-brand-main:rgb(10,10,10)}.dark[data-preset="tokens-test"]{--ca-brand-main:rgb(20,20,20)}'; + document.head.appendChild(preset); + const brand = () => getComputedStyle(html).getPropertyValue("--ca-brand-main").trim(); + try { + html.setAttribute("data-preset", "tokens-test"); + colorScheme.setPreference("dark"); + html.setAttribute("data-preset", "tokens-test"); + expect(brand()).toBe("rgb(20,20,20)"); + + colorScheme.setTokens({ "--ca-brand-main": "rgb(1,2,3)" }); + expect(brand()).toBe("rgb(1,2,3)"); + + colorScheme.setPreference("light"); + html.setAttribute("data-preset", "tokens-test"); + expect(brand()).toBe("rgb(1,2,3)"); + + colorScheme.setTokens({}); + expect(brand()).toBe("rgb(10,10,10)"); + } finally { + colorScheme.setTokens({}); + preset.remove(); + } + }); }); diff --git a/packages/ui/src/lib/stores/color-scheme.svelte.ts b/packages/ui/src/lib/stores/color-scheme.svelte.ts index dd61dc92..b79c3909 100644 --- a/packages/ui/src/lib/stores/color-scheme.svelte.ts +++ b/packages/ui/src/lib/stores/color-scheme.svelte.ts @@ -16,6 +16,13 @@ import { scheduleChromeSync, startChromeSync } from "./chrome-sync.js"; export type { ColorScheme, SurfaceStyle }; const hasDOM = typeof document !== "undefined"; + +/** + * Selector of the runtime-tokens rule. `:root` repeated raises the specificity to 0,3,0, above a brand + * preset's dark block (`.dark[data-preset]`, 0,2,0), so the tokens win in both schemes without + * `!important`. Keep this in step with the replay in `theme-script.ts`. + */ +const RUNTIME_TOKENS_SELECTOR = ":root:root:root"; const runtime = (): ThemeRuntime | undefined => (hasDOM ? window.__caTheme : undefined); function resolveScheme(pref: ColorScheme): "light" | "dark" { @@ -113,7 +120,7 @@ function createColorSchemeStore() { el.id = "ca-runtime-tokens"; document.head.appendChild(el); } - el.textContent = `:root{${Object.entries(clean) + el.textContent = `${RUNTIME_TOKENS_SELECTOR}{${Object.entries(clean) .map(([k, v]) => `${k}:${v};`) .join("")}}`; } diff --git a/packages/ui/src/lib/theme-script.ts b/packages/ui/src/lib/theme-script.ts index e5cddbd1..e25a7c6e 100644 --- a/packages/ui/src/lib/theme-script.ts +++ b/packages/ui/src/lib/theme-script.ts @@ -33,7 +33,10 @@ export interface ThemeRuntime { * style before any CSS/JS loads, so the page never flashes the wrong theme. * Stored values that are not valid for this theme are discarded. It also sets * ``, follows the OS scheme while `system` is active - * (until the colorScheme store takes over) and replays runtime tokens. + * (until the colorScheme store takes over) and replays runtime tokens. Those are written as + * `:root:root:root{…}` (specificity 0,3,0): a brand preset's dark block is `.dark[data-preset]` + * (0,2,0), so a plain `:root{…}` lost to it whatever the source order, and the tokens silently did + * nothing in dark mode. Keep this in step with `RUNTIME_TOKENS_SELECTOR` in color-scheme.svelte.ts. * * Storage keys must stay in sync with color-scheme.svelte.ts. */ @@ -46,10 +49,12 @@ export function themeScript(options: ThemeOptions | ResolvedTheme = {}): string ), }; return /* js */ `(function(){try{ -var w=window,h=document.documentElement,ls=localStorage,R=${JSON.stringify(rt)}; +var w=window,h=document.documentElement,ls=null,R=${JSON.stringify(rt)}; +try{ls=w.localStorage}catch(e){} w.__caTheme=R; -function g(k,ok,d){var r=ls.getItem(k),v;try{v=JSON.parse(r)}catch(e){} - if(ok.indexOf(v)<0){if(r!==null)ls.removeItem(k);return d}return v} +function rd(k){try{return ls.getItem(k)}catch(e){return null}} +function g(k,ok,d){var r=rd(k),v;try{v=JSON.parse(r)}catch(e){} + if(ok.indexOf(v)<0){if(r!==null)try{ls.removeItem(k)}catch(e){}return d}return v} var scheme=g('ca-scheme',${JSON.stringify(["light", "dark", "system"])},${JSON.stringify(t.defaultScheme)}); var preset=g('ca-preset',R.known,${JSON.stringify(t.defaultPreset)}); var style=g('ca-style',${JSON.stringify(STYLES)},${JSON.stringify(t.defaultStyle)}); @@ -69,9 +74,9 @@ function a(){var d=scheme==='system'?m.matches:scheme==='dark'; e.content=R.colors[preset][d?1:0]} a(); if(scheme==='system')m.addEventListener('change',function(){if(!h.hasAttribute('data-ca-live'))a()}); -try{var x=JSON.parse(ls.getItem('ca-runtime-tokens')),c=''; +try{var x=JSON.parse(rd('ca-runtime-tokens')),c=''; for(var k in x)if(/^--ca-[\\w-]+$/.test(k)&&!/[{}<;]/.test(x[k]))c+=k+':'+x[k]+';'; - if(c){var s=document.createElement('style');s.id='ca-runtime-tokens';s.textContent=':root{'+c+'}';document.head.appendChild(s)} + if(c){var s=document.createElement('style');s.id='ca-runtime-tokens';s.textContent=':root:root:root{'+c+'}';document.head.appendChild(s)} }catch(e){} requestAnimationFrame(function(){requestAnimationFrame(function(){h.classList.remove('ca-init')})}); }catch(e){}})();`; diff --git a/packages/ui/tests/site-theme-boot.test.ts b/packages/ui/tests/site-theme-boot.test.ts new file mode 100644 index 00000000..c4f5650b --- /dev/null +++ b/packages/ui/tests/site-theme-boot.test.ts @@ -0,0 +1,105 @@ +import { afterAll, beforeEach, describe, expect, it } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { THEME_BOOT } from "../../../site/scripts/theme-boot.ts"; + +// The site's head boot script (`site/scripts/theme-boot.ts`): runs before the page's CSS, so it is the +// difference between the visitor's theme on the first frame and a flash of the published one. + +GlobalRegistrator.register(); +afterAll(() => GlobalRegistrator.unregister()); + +const html = document.documentElement; +const KEY = "ca-site-studio"; +const record = { v: 1, css: ":root{--ca-brand-main:#e8590c}", preset: "tokyo", style: "flat" }; + +function boot(hash = "", path = "/docs/") { + // happy-dom ignores history.replaceState on about:blank; navigating by href sets pathname and hash. + window.location.href = `http://localhost:3000${path}${hash}`; + new Function(THEME_BOOT)(); +} + +beforeEach(() => { + localStorage.clear(); + document.head.innerHTML = ""; + for (const a of [...html.attributes]) html.removeAttribute(a.name); + (window as unknown as { __caSiteBoot?: number }).__caSiteBoot = undefined; +}); + +describe("site theme boot", () => { + it("applies the stored visitor theme synchronously: style tag and data attributes", () => { + localStorage.setItem(KEY, JSON.stringify(record)); + boot(); + expect(document.getElementById(KEY)?.textContent).toBe(record.css); + expect(html.getAttribute("data-preset")).toBe("tokyo"); + expect(html.getAttribute("data-style")).toBe("flat"); + }); + + it("does nothing without a record, and ignores a corrupt or foreign one", () => { + boot(); + expect(document.getElementById(KEY)).toBeNull(); + for (const bad of [ + "{not json", + JSON.stringify({ v: 2, css: "x" }), + JSON.stringify({ v: 1, css: "" }), + ]) { + localStorage.setItem(KEY, bad); + boot(); + expect(document.getElementById(KEY), bad).toBeNull(); + } + }); + + it("never writes an attribute value that is not an identifier", () => { + localStorage.setItem(KEY, JSON.stringify({ ...record, preset: '"> 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/theming.mdx b/site/src/content/docs/getting-started/theming.mdx index b997a8bb..35232a38 100644 --- a/site/src/content/docs/getting-started/theming.mdx +++ b/site/src/content/docs/getting-started/theming.mdx @@ -261,6 +261,8 @@ All choices persist in `localStorage` (`ca-scheme`, `ca-preset`, `ca-style`). St `setTokens` writes your `--ca-*` variables to a single `${body}`; function serve(handler: (path: string) => string | undefined) { const s = Bun.serve({ port: 0, fetch(req) { const p = new URL(req.url).pathname; - if (p === "/big.css") - return new Response(`${TOKENS_CSS}${BIG_CSS}`, { headers: { "content-type": "text/css" } }); const html = handler(p); return html === undefined ? new Response("nf", { status: 404 }) @@ -189,7 +197,7 @@ 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: 9000 }, (_, i) => `.x${i}{margin:${i % 7}px}`).join(""); + 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"; --- @@ -292,14 +300,11 @@ describe("zero flash of the wrong theme, every consumer path (6x CPU, Slow 3G)", await blocked.close(); }, 90_000); - it("the init script is synchronous, first in , tiny and CSP-nonce friendly", async () => { + 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"/); - expect(head.indexOf('`; + 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([]); + }); +}); + +describe("the built site: every page", () => { + const pages: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) walk(full); + else if (name === "index.html") pages.push(full); + } + }; + if (existsSync(SITE_DIST)) walk(SITE_DIST); + + it.skipIf(pages.length === 0)("carries the theme scripts before the first stylesheet", () => { + const bad: string[] = []; + for (const file of pages) { + const html = readFileSync(file, "utf8"); + // Redirect stubs and standalone embeds have no theme head of their own. + if (html.includes('http-equiv="refresh"')) continue; + const problems = orderProblems(html, SCRIPTS(true)); + if (problems.length) bad.push(`${file.slice(SITE_DIST.length)}: ${problems.join("; ")}`); + } + expect(bad.slice(0, 10)).toEqual([]); + }); +}); diff --git a/scripts/test-changed.ts b/scripts/test-changed.ts index 1995f656..37a395d2 100755 --- a/scripts/test-changed.ts +++ b/scripts/test-changed.ts @@ -71,14 +71,6 @@ const ownSpec = (f: string) => /^site\/tests\/[^/]+\.spec\.ts$/.test(f) && !/\/(routes|routes\.axe|dist-html|header)\.spec\.ts$/.test(f); -/** - * Files whose change can put the wrong theme on the first frame: the init script, the Kit and Astro - * integrations, the theme engine, the store, and the site's own boot script. They run the zero-flash - * matrix (`packages/ui/tests/theme-no-flash.e2e.ts`: real browsers at 6x CPU on Slow 3G) in the land gate. - */ -const THEME_PATHS = - /^(packages\/ui\/src\/lib\/(theme-script|astro|vite-plugin|theme\/|kit\/theme-handle|stores\/color-scheme)|packages\/ui\/tests\/theme-no-flash\.e2e\.ts$|site\/scripts\/theme-boot\.ts$|site\/tests\/no-flash\.spec\.ts$)/; - const land = process.argv.includes("--land"); const git = (...args: string[]) => spawnSync("git", args, { cwd: ROOT, encoding: "utf8" }).stdout.trim().split("\n").filter(Boolean); @@ -120,14 +112,7 @@ if (!changed.length && !land) { const sel = select(changed); const sharedFile = changed.find(isSharedComponent); -const themeChanged = changed.some((f) => THEME_PATHS.test(f)); -// The site's zero-flash spec also runs when anything on the theme path changed, not only when it was edited. -const specs = [ - ...new Set([ - ...changed.filter(ownSpec), - ...(themeChanged && land ? ["site/tests/no-flash.spec.ts"] : []), - ]), -]; +const specs = changed.filter(ownSpec); const routes = sel.all ? [] : [...new Set([...sel.routes, ...(land ? CANARIES : [])])].sort(); const sweepAll = sel.all || (land && sharedFile !== undefined); const siteRun = sweepAll || routes.length > 0 || specs.length > 0; @@ -279,19 +264,8 @@ const building = builtRoutes ? `, ${builtRoutes.length} routes, beside the tests` : ", beside the tests" ) : undefined; -// Started with the site build and awaited after the package tests: it needs only the built package, so it runs -// beside them instead of adding its ~60 s to the end of the run. -const zeroFlash = - land && themeChanged - ? step( - "theme zero-flash matrix", - () => runAsync(["bun", "test", "./packages/ui/tests/theme-no-flash.e2e.ts"]), - ", beside the tests" - ) - : undefined; await packageTests(); await building; -await zeroFlash; if (!shared && vrtTests.length) await step("visual (components)", () => diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 34d9c97f..1105e5d7 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -6,14 +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 { themeBoot } from "./scripts/theme-boot.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), themeBoot(), 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 index 504d84ff..68994b66 100644 --- a/site/scripts/theme-boot.ts +++ b/site/scripts/theme-boot.ts @@ -1,10 +1,11 @@ -// Astro integration: the visitor's own theme, replayed before the first paint. +// Head boot script: the visitor's own theme, replayed before the first paint. // -// Why an integration and not a ``; 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, `