Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions .agents/skills/codeatlas-ui/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -5800,14 +5800,24 @@ const scrollFocusable: Action<HTMLElement, undefined, Record<never, any>>

## @codeatlasdev/ui/astro

### astroHead

`import { astroHead } from "@codeatlasdev/ui/astro"`

Markup for the start of `<head>`: font preloads, the theme `<style>`, the init script and the `<ClientRouter />` persistence script. The same pieces `caThemeHandle` emits for SvelteKit, plus the persistence script.

```ts
function astroHead(options?: ThemeOptions): string
```

### caUI

`import { caUI } from "@codeatlasdev/ui/astro"`

Astro integration: injects the theme (preset CSS, fonts, init script) into every page `<head>` before first paint and bundles the ESM-only dependencies for SSR. Pass the same `ThemeOptions` as `defineTheme`.
Astro integration: puts the theme (preset CSS, fonts, init script) FIRST in every page `<head>`, ahead of every stylesheet, so the first paint wears the right theme, and bundles the ESM-only dependencies for SSR. Pass the same `ThemeOptions` as `defineTheme`.

```ts
function caUI(options?: ThemeOptions): AstroIntegration
function caUI(options?: ThemeOptions, options?: CaUIAstroOptions): AstroIntegration
```

### DESIGN_STYLES
Expand Down Expand Up @@ -5844,13 +5854,20 @@ Types:

| Type | Declaration | Notes |
|---|---|---|
| `CaUIAstroOptions` | `interface CaUIAstroOptions { head?: string; }` | `caUI(theme, { head })`: extra markup (a site's own boot script) placed right after the theme markup, still ahead of every stylesheet. One middleware, one order, no second integration to race. |
| `DesignStyleId` | `type DesignStyleId = \| "default" \| "liquid" \| "swiss" \| "brutalism" \| "neumorphism" \| "glassmorphism" \| "material" \| "fluent" \| "apple" \| "bento" \| "claymorphism" \| "y2k" \| "editorial" \| "terminal" \| "sketchbook";` | |
| `Paper` | `type Paper = "mesh" \| "plain" \| "grid" \| "dots" \| "lines";` | Page background pattern. mesh = brand glow gradients (drawn by the glass and frosted materials); the rest are flat paper. |
| `PresetDefinition` | `interface PresetDefinition { id: string; color: `#${string}`; label?: string; colors?: { light?: SchemeColors; dark?: SchemeColors }; tokens?: PresetTokenGroups; sizes?: PresetSizes; }` | A preset generated from one sRGB hex color (e.g. the logo color). |
| `PresetSizes` | `interface PresetSizes { inputFontSize?: string; }` | |
| `SchemeColors` | `interface SchemeColors { brand?: `#${string}`; brandFill?: ColorValue; brandFillHover?: ColorValue; brandContrast?: ColorValue; background?: ColorValue; sunken?: ColorValue; surface?: ColorValue; overlay?: ColorValue; text?: ColorValue; textSecondary?: ColorValue; textHint?: ColorValue; textDisabled?: ColorValue; borderSubtle?: ColorValue; border?: ColorValue; borderStrong?: ColorValue; success?: ColorValue; warning?: ColorValue; info?: ColorValue; error?: ColorValue; }` | Hand-picked colors for one scheme of a preset, by role. Every role is optional: unset roles keep the generated value. Use it to port an existing palette (a terminal or editor theme) without touching raw `--ca-*` tokens. A role is any CSS color (`#rrggbb`, `oklch(1 0 0 / 0.62)`, `rgb()`, `color-mix()`) or `{ color, alpha }`; a translucent role follows the surface behind it, and the build measures text roles after compositing it (see `presetContrastIssues`). |
| `ThemeOptions` | `type ThemeOptions = Partial<ThemeDefinition>;` | Options for themeHead / caThemeHandle / caThemePlugin. |

#### CaUIAstroOptions

| Field | Type | Notes |
|---|---|---|
| `head?` | `string` | |

#### PresetDefinition

| Field | Type | Notes |
Expand Down
5 changes: 5 additions & 0 deletions .changeset/astro-theme-first-in-head.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codeatlasdev/ui": patch
---

`caUI` (Astro) now puts the theme first in `<head>`, ahead of every stylesheet. It used `injectScript("head-inline")`, which Astro renders AFTER every bundled `<style>`: with `inlineStylesheets: "always"` the init script sat behind hundreds of KB of CSS and ran only once the browser had parsed it, so a slow link could paint the wrong theme first. A middleware now rewrites the rendered page, at build time and in SSR and dev alike, so the order holds whatever the layout or the bundler does. Font preloads move ahead of the CSS too. `caUI(theme, { head })` takes extra first-paint markup (a boot script) that lands right after the theme. `caThemeHandle` and `caThemePlugin` already emitted at the start of `<head>`.
7 changes: 7 additions & 0 deletions .changeset/no-theme-flash-blocked-storage.md
Original file line number Diff line number Diff line change
@@ -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{…}`.
34 changes: 34 additions & 0 deletions packages/ui/scripts/size-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand Down Expand Up @@ -71,6 +72,39 @@ const next: Record<string, number> = { ...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)`);
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
14 changes: 14 additions & 0 deletions packages/ui/src/lib/astro-middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Astro middleware registered by `caUI` (see astro.ts): puts the integration's head markup FIRST in
// `<head>` of every HTML page, after Astro has rendered it. `injectScript("head-inline")` cannot: Astro
// renders every bundled `<style>` (hundreds of KB with `inlineStylesheets: "always"`) before it, so the
// theme script would run only after that CSS was parsed, and a slow link paints the wrong theme in
// between. Runs for prerendered pages at build time and for SSR/dev requests alike.
import head from "virtual:codeatlas-ui/head";
import { headFirst } from "./head-first.js";

/** @type {import("astro").MiddlewareHandler} */
export const onRequest = async (_context, next) => {
const response = await next();
if (!response.headers.get("content-type")?.includes("text/html")) return response;
return new Response(headFirst(await response.text(), head), response);
};
88 changes: 61 additions & 27 deletions packages/ui/src/lib/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,26 @@
* import { caUI } from "@codeatlasdev/ui/astro";
* export default defineConfig({ integrations: [svelte(), caUI(theme)] });
*
* - Paints the theme before first frame: brand preset CSS + init script go into an
* inline `<head>` script on every page (same output as caThemeHandle for SvelteKit).
* - Paints the theme before first frame: brand preset CSS + init script are the FIRST children of
* `<head>` on every page, ahead of every bundled stylesheet (same output as caThemeHandle for SvelteKit).
* Astro renders its own `<style>` tags before `injectScript("head-inline")` scripts, so this is done
* by a middleware that rewrites the rendered page (`astro-middleware.js`).
* - Keeps the theme across `<ClientRouter />` (View Transitions) navigations.
* - Bundles the ESM-only/extensionless deps for SSR (tanstack table, shiki).
*
* Import `@codeatlasdev/ui/styles/global.css` once in your base layout.
*/
import { fileURLToPath } from "node:url";
import type { AstroIntegration } from "astro";
import { warnPresetContrast } from "./theme/contrast.js";
import { defineTheme, fontPreloads, SSR_NO_EXTERNAL, themeCSS } from "./theme/define.js";
import { type ThemeOptions, themeScript } from "./theme-script.js";
import { defineTheme, SSR_NO_EXTERNAL } from "./theme/define.js";
import { type ThemeOptions, themeHead } from "./theme-script.js";

/**
* `<ClientRouter />` (View Transitions) replaces `<head>` and the `<html>` attributes with the next
* document's, which only holds the head scripts, not what they created. Before each swap, copy the
* live theme (style tags, theme-color meta, `<html>` theme attributes) onto the incoming document so
* the swap keeps them. Registered once: head-inline scripts do not re-run on navigation.
* the swap keeps them. Registered once per page load.
*/
const THEME_PERSIST_SCRIPT = /* js */ `(function(){
if(window.__caThemePersist)return;window.__caThemePersist=1;
Expand All @@ -47,30 +50,61 @@ r.classList.toggle('ca-init',h.classList.contains('ca-init'));
if(v===null)r.removeAttribute(a);else r.setAttribute(a,v)})
})})();`;

/** Astro integration: injects the theme (preset CSS, fonts, init script) into every page `<head>` before first paint and bundles the ESM-only dependencies for SSR. Pass the same `ThemeOptions` as `defineTheme`. */
export function caUI(options: ThemeOptions = {}): AstroIntegration {
const t = defineTheme(options);
warnPresetContrast(t, "caUI");
const css = themeCSS(t);
// head-inline content is emitted verbatim inside <script>; the CSS rides along as a string.
const style = css
? `(function(){var s=document.createElement('style');s.id='ca-theme';s.textContent=${JSON.stringify(css).replace(/</g, "\\u003c")};document.head.appendChild(s)})();`
: "";
// A script cannot ship <link> tags verbatim: append them as markup. They start the font fetch
// before CSS parsing ends, but the preload scanner never sees them. For the fastest path put the
// same <link> in your layout head.
const preloads = fontPreloads(t);
const preload = preloads
? `document.head.insertAdjacentHTML('afterbegin',${JSON.stringify(preloads).replace(/</g, "\\u003c")});`
: "";
/** Virtual module the middleware imports: the markup to put first in `<head>`. */
const HEAD_MODULE = "virtual:codeatlas-ui/head";

/**
* Markup for the start of `<head>`: font preloads, the theme `<style>`, the init script and the
* `<ClientRouter />` persistence script. The same pieces `caThemeHandle` emits for SvelteKit, plus
* the persistence script.
*/
export function astroHead(options: ThemeOptions = {}): string {
return `${themeHead(options)}<script id="ca-theme-persist">${THEME_PERSIST_SCRIPT}</script>`;
}

/**
* `caUI(theme, { head })`: extra markup (a site's own boot script) placed right after the theme
* markup, still ahead of every stylesheet. One middleware, one order, no second integration to race.
*/
export interface CaUIAstroOptions {
head?: string;
}

/** Registers `markup` to be inserted first in `<head>` of every page, ahead of any bundled CSS. */
function headFirstHooks(markup: string) {
return ({
addMiddleware,
updateConfig,
}: Parameters<AstroIntegration["hooks"]["astro:config:setup"] & {}>[0]) => {
updateConfig({
vite: {
plugins: [
{
name: "@codeatlasdev/ui:head",
resolveId: (id) => (id === HEAD_MODULE ? `\0${HEAD_MODULE}` : null),
load: (id) =>
id === `\0${HEAD_MODULE}` ? `export default ${JSON.stringify(markup)};` : null,
},
],
ssr: { noExternal: SSR_NO_EXTERNAL },
},
});
addMiddleware({
order: "pre",
entrypoint: fileURLToPath(new URL("./astro-middleware.js", import.meta.url)),
});
};
}

/** Astro integration: puts the theme (preset CSS, fonts, init script) FIRST in every page `<head>`, ahead of every stylesheet, so the first paint wears the right theme, and bundles the ESM-only dependencies for SSR. Pass the same `ThemeOptions` as `defineTheme`. */
export function caUI(
options: ThemeOptions = {},
{ head = "" }: CaUIAstroOptions = {}
): AstroIntegration {
warnPresetContrast(defineTheme(options), "caUI");
return {
name: "@codeatlasdev/ui",
hooks: {
"astro:config:setup": ({ injectScript, updateConfig }) => {
injectScript("head-inline", preload + style + themeScript(t) + THEME_PERSIST_SCRIPT);
updateConfig({ vite: { ssr: { noExternal: SSR_NO_EXTERNAL } } });
},
},
hooks: { "astro:config:setup": headFirstHooks(astroHead(options) + head) },
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/lib/head-first.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** Inserts `markup` right after the opening `<head>` tag (with or without attributes). Idempotent. */
export function headFirst(html: string, markup: string): string {
if (!markup || html.includes(markup)) return html;
return html.replace(/<head(\s[^>]*)?>/i, (open) => `${open}${markup}`);
}
28 changes: 28 additions & 0 deletions packages/ui/src/lib/stores/color-scheme.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
9 changes: 8 additions & 1 deletion packages/ui/src/lib/stores/color-scheme.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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("")}}`;
}
Expand Down
17 changes: 11 additions & 6 deletions packages/ui/src/lib/theme-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<meta name="theme-color">`, 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.
*/
Expand All @@ -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)});
Expand All @@ -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){}})();`;
Expand Down
Loading