diff --git a/.changeset/code-snippet-any-language.md b/.changeset/code-snippet-any-language.md new file mode 100644 index 00000000..7d1700fd --- /dev/null +++ b/.changeset/code-snippet-any-language.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": patch +--- + +`CodeSnippet` highlights any language shiki bundles (`markdown`, `yaml`, `diff`, ...), loading it on first use, and falls back to plain text for a name it does not know. Before, a language outside the eight preloaded ones made the highlighter throw inside the promise and the snippet stayed on its unhighlighted fallback for good. diff --git a/.changeset/code-snippet-raw-focusable.md b/.changeset/code-snippet-raw-focusable.md new file mode 100644 index 00000000..a5525b7e --- /dev/null +++ b/.changeset/code-snippet-raw-focusable.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": patch +--- + +`CodeSnippet`: a code block that scrolls sideways is keyboard-reachable. The raw fallback shown before the highlighter loads (and the highlighted block) now use `scrollFocusable`, so a long line no longer leaves a scroll region that only a mouse can reach (axe `scrollable-region-focusable`). diff --git a/.changeset/design-style-pages.md b/.changeset/design-style-pages.md new file mode 100644 index 00000000..7060674b --- /dev/null +++ b/.changeset/design-style-pages.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +Design style pages (#583): `/styles//` for `swiss`, `editorial`, `brutalism`, `sketchbook`, `glassmorphism` and `terminal`, each rendered in its own style, with the origin, the traits (type, colour, shape, depth, motion), do and don't, accessibility pitfalls with their sources, before and after on real components, the `designStyle()` recipe and a prompt. The `ca-ui-mcp` server gains `get_style` (a style as markdown, or the digest of all of them) and `llms.txt` links the digest at `/llms/styles.txt`. `designDefaults()` is exported: the stylesheet default of every knob a theme can write, so a scoped preview can reset what an outer theme set. diff --git a/AGENTS.md b/AGENTS.md index dec541b3..43a1e869 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,7 @@ Every numbered demo (`site/src/demos///.svelte`, id in `si **A11y tests**: test every state (open, error, empty, light, dark) with `await expectNoA11yViolations(container)` from `@codeatlasdev/ui/testing` (needs the optional peer `axe-core`). Inside this repo `packages/ui/tests/a11y.ts` re-exports the same `checkA11y`; never fork the axe options. -13. **Discovering the API**: never guess a prop. Use the `ca-ui-mcp` MCP server (`get_component`, `get_export`, `get_equivalent`, `list_components`, `list_parts`, `get_migration`, `search_tokens`, `list_surfaces`, `get_guide`, `list_concepts`, `get_concept`, and for the design atlas and its verified sources `search_terms`, `get_term`, `get_reference`) or `.agents/skills/codeatlas-ui/API.md`; both cover components and every non-component export (stores, `kit`, `vite`, `astro`, `actions`, `seo`, `forms`, `tokens`: signature + summary) and are generated from the code by `packages/ui/scripts/build-manifest.ts` (runs on `build`). `[ca-ui]` dev warnings (via `devWarn`, `esm-env`) flag an `IconButton` without `aria-label` and a `Dialog` without `title`. +13. **Discovering the API**: never guess a prop. Use the `ca-ui-mcp` MCP server (`get_component`, `get_export`, `get_equivalent`, `list_components`, `list_parts`, `get_migration`, `search_tokens`, `list_surfaces`, `get_guide`, `get_style`, `list_concepts`, `get_concept`, and for the design atlas and its verified sources `search_terms`, `get_term`, `get_reference`) or `.agents/skills/codeatlas-ui/API.md`; both cover components and every non-component export (stores, `kit`, `vite`, `astro`, `actions`, `seo`, `forms`, `tokens`: signature + summary) and are generated from the code by `packages/ui/scripts/build-manifest.ts` (runs on `build`). `[ca-ui]` dev warnings (via `devWarn`, `esm-env`) flag an `IconButton` without `aria-label` and a `Dialog` without `title`. 14. **Agent skills layout** (`npx skills add codeatlasdev/ui`): one folder per skill under `.agents/skills/`, named exactly like the `name:` in its `SKILL.md` (`codeatlas-ui`, `codeatlas-dx`, `codeatlas-writing`). Never put a `SKILL.md` directly in `.agents/skills/` (the CLI then treats the directory as a single skill and lists nothing else), and quote any `description:` that contains `: ` (an unquoted one is a YAML error and the CLI silently skips the skill). `packages/ui/tests/skills.test.ts` enforces this. diff --git a/packages/ui/bin/ca-ui-mcp.ts b/packages/ui/bin/ca-ui-mcp.ts index 56f405db..27fc3ea5 100755 --- a/packages/ui/bin/ca-ui-mcp.ts +++ b/packages/ui/bin/ca-ui-mcp.ts @@ -6,7 +6,7 @@ * claude mcp add codeatlas-ui -- npx -y -p @codeatlasdev/ui ca-ui-mcp * * Tools: get_recipe, list_components, get_component, get_export, get_equivalent, list_parts, search_tokens, list_surfaces, get_guide, - * list_concepts, get_concept (the Concepts pages with their claims, limits and sources), search_terms, get_term, get_reference (the design atlas and its verified sources), search_demos, get_demo, search_ui, list_facets, get_related, report_issue (drafts a sanitized GitHub issue from a library problem; it never posts anything). + * list_concepts, get_concept (the Concepts pages with their claims, limits and sources), search_terms, get_term, get_reference (the design atlas and its verified sources), search_demos, get_demo, get_style (a design style: traits, pitfalls with sources, the designStyle recipe), search_ui, list_facets, get_related, report_issue (drafts a sanitized GitHub issue from a library problem; it never posts anything). */ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -33,6 +33,7 @@ import { relatedEntries, searchIndex, } from "../scripts/index-search.ts"; +import type { StyleFile } from "../scripts/styles-markdown.ts"; import { type Equivalent, findEquivalent } from "../src/cli/equivalents.ts"; import { type MigrationFile, migrationMarkdown } from "../src/cli/migrations.ts"; import { buildReport } from "../src/cli/report.ts"; @@ -66,6 +67,9 @@ const entries = indexEntries(demos); const atlas = existsSync(join(DIST, "atlas.json")) ? load("atlas.json") : undefined; const noAtlas = "No design atlas in this build (dist/atlas.json is missing)."; const noDemos = "No demo catalog in this build (dist/demos.json is missing)."; +// Like demos: a build from the tarball has no site/ data, and the other tools must keep working. +const styles = existsSync(join(DIST, "styles.json")) ? load("styles.json") : undefined; +const noStyles = "No design styles in this build (dist/styles.json is missing)."; const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] }); const fail = (s: string) => ({ ...text(s), isError: true }); @@ -600,6 +604,26 @@ server.registerTool( } ); +server.registerTool( + "get_style", + { + description: + 'A design style of the library as markdown: where it comes from, its traits (type, colour, shape, depth, motion), do and don\'t, accessibility pitfalls with their sources and strength labels, where it fits, the `designStyle("")` ThemeOptions recipe and a prompt. `name` is the style id or title (swiss, editorial, brutalism, sketchbook, glassmorphism, terminal; case-insensitive, forgives spaces and dashes). Without `name`, returns the digest of every style. A recipe is a proposal, not a measurement, and no claim says a style performs better.', + inputSchema: { name: z.string().optional() }, + }, + ({ name }) => { + if (!styles) return fail(noStyles); + if (!name) return text(styles.index); + const n = squash(name); + const ids = Object.keys(styles.styles); + const hit = + ids.find((id) => squash(id) === n || squash(styles.names[id] ?? "") === n) ?? + ids.find((id) => squash(id).includes(n) || squash(styles.names[id] ?? "").includes(n)); + if (!hit) return fail(`No style "${name}". Styles: ${ids.join(", ")}.`); + return text(styles.styles[hit] ?? ""); + } +); + server.registerTool( "report_issue", { diff --git a/packages/ui/scripts/ai-entry.ts b/packages/ui/scripts/ai-entry.ts index 8c800149..0dfcf6b1 100644 --- a/packages/ui/scripts/ai-entry.ts +++ b/packages/ui/scripts/ai-entry.ts @@ -155,6 +155,17 @@ export function buildLlmsIndex(i: { }, ], }, + { + heading: "Design styles", + links: [ + { + title: "Design styles", + href: `${SITE}/llms/styles.txt`, + description: + 'six design-style recipes (Swiss, Editorial, Brutalism, Sketchbook, Glassmorphism, Terminal): traits, do and don\'t, accessibility pitfalls with their sources, the `designStyle("")` recipe and a prompt; one file per style at /llms/styles/.txt, or ask the MCP server (`get_style`). A recipe is a proposal, not a measurement, and no claim says a style performs better', + }, + ], + }, { heading: "Machine-readable endpoints", links: [ diff --git a/packages/ui/scripts/build-manifest.ts b/packages/ui/scripts/build-manifest.ts index bbcfa325..ca25bd81 100644 --- a/packages/ui/scripts/build-manifest.ts +++ b/packages/ui/scripts/build-manifest.ts @@ -7,6 +7,7 @@ * dist/guides.json the docs site pages as markdown (offline `get_guide`) * dist/atlas.json the design atlas as one markdown text per term + the verified references (offline * `search_terms`, `get_term`, `get_reference`); only in the monorepo (needs site/) + * dist/styles.json the design-style pages as markdown, one per style (offline `get_style`) * --skill also rewrites .agents/skills/codeatlas-ui/API.md (components, then non-component exports) * * bun scripts/build-manifest.ts [--check] @@ -33,6 +34,7 @@ import { buildAtlasFile } from "./atlas-markdown.ts"; import { buildIndexFiles } from "./index-build.ts"; import { checkIdRange, checkIndexFacets, ID_PATTERN, type IndexFacets } from "./index-schema.ts"; import { demoSources, mdxToMarkdown } from "./mdx-to-markdown.ts"; +import { buildStyleFile } from "./styles-markdown.ts"; // CJS require keeps svelte2tsx out of Vite's transform pipeline (its sourceMappingURL points at a file it does not ship). const { svelte2tsx }: typeof import("svelte2tsx") = createRequire(import.meta.url)("svelte2tsx"); @@ -1686,6 +1688,8 @@ if (import.meta.main) { }); } if (existsSync(DEMOS_DIR)) write("atlas.json", buildAtlasFile()); + // The design-style pages need site/ (their data lives in site/src/data/styles): not in a tarball build. + if (existsSync(DEMOS_DIR)) write("styles.json", buildStyleFile()); const { demos, files } = catalog.length ? buildDemos(manifest, catalog) : { demos: [], files: {} }; diff --git a/packages/ui/scripts/styles-markdown.ts b/packages/ui/scripts/styles-markdown.ts new file mode 100644 index 00000000..dc56d110 --- /dev/null +++ b/packages/ui/scripts/styles-markdown.ts @@ -0,0 +1,152 @@ +// The design-style pages as plain markdown for agents: one file per style (`/llms/styles/.txt`), a digest of all +// of them (`/llms/styles.txt`) and the file the MCP `get_style` tool serves offline (`dist/styles.json`). Pure functions +// over the same data the pages render, so the site endpoints, the MCP build and the tests share one text. +import { STRENGTH_LABEL } from "../../../site/src/data/evidence/references.ts"; +import { + recipeKnobs, + recipeSource, + STYLE_LIST, + type StyleDoc, + stylePrompt, + styleReferences, + TRAIT_AXES, +} from "../../../site/src/data/styles/styles.ts"; + +export const SITE = "https://ui.codeatlas.com.br"; + +/** Path of the digest of every style. */ +export const STYLES_INDEX_PATH = "/llms/styles.txt"; + +/** Path of a style's markdown twin. */ +export const stylePath = (id: string) => `/llms/styles/${id}.txt`; + +const AXIS_LABEL = { + type: "Type", + colour: "Colour", + shape: "Shape", + depth: "Depth", + motion: "Motion", +} as const; + +/** One style: everything the page shows, with each claim next to its source, strength and scope. */ +export function buildStyleMarkdown(s: StyleDoc): string { + return [ + `# ${s.name}`, + "", + `${s.tagline} Page: ${SITE}/styles/${s.id}/. Index: ${SITE}${STYLES_INDEX_PATH}. Recipe: \`designStyle("${s.id}")\`. Atlas terms: ${s.atlas.join(", ")}.`, + "", + "## Where it comes from", + "", + ...s.origin.flatMap((p) => [p, ""]), + "Our own account, as background. It is not a claim about users.", + "", + "## Traits", + "", + ...TRAIT_AXES.map( + (a) => + `- ${AXIS_LABEL[a]}: ${s.traits[a].summary} Tokens: ${s.traits[a].tokens.map((t) => `\`${t}\``).join(", ")}.` + ), + "", + "## Do", + "", + ...s.dos.map((d) => `- ${d}`), + "", + "## Do not", + "", + ...s.donts.map((d) => `- ${d}`), + "", + "## Accessibility pitfalls", + "", + ...s.pitfalls.flatMap((p) => [ + `### ${p.title}`, + "", + `${STRENGTH_LABEL[p.claim.strength]}${p.claim.refs.length ? ` (${p.claim.refs.join(", ")})` : ""}: ${p.claim.text}`, + ...(p.claim.scope ? ["", `Scope: ${p.claim.scope}`] : []), + "", + `What to do: ${p.fix}`, + "", + ]), + "## Where it fits", + "", + `${s.fitsWhen} (our judgement, not a measured result)`, + "", + `Avoid it when: ${s.avoidWhen}`, + "", + "Components that suit it:", + ...s.fits.map((f) => `- ${f.component} (${SITE}${f.href}): ${f.why}`), + "", + "## The ThemeOptions recipe", + "", + "```ts", + recipeSource(s.id).trimEnd(), + "```", + "", + "Every knob the recipe sets:", + "", + "```json", + recipeKnobs(s.id), + "```", + "", + "## Prompt for your AI", + "", + "```text", + stylePrompt(s.id), + "```", + "", + "## Limits", + "", + ...s.limits.map((l) => `- ${l}`), + "", + `Last reviewed ${s.reviewed}.`, + "", + "## References", + "", + ...styleReferences(s).map( + (r) => + `- [${r.id}] ${r.authors} (${r.year}). ${r.title}. ${r.url} (${STRENGTH_LABEL[r.strength]}). Supports: ${r.supports} Does not support: ${r.doesNotSupport}` + ), + "", + `How we use evidence: ${SITE}/llms/concepts/evidence.txt`, + "", + ].join("\n"); +} + +/** The digest: every style with its tagline, where it fits and where not, linking its own file. */ +export function buildStylesIndex(styles: readonly StyleDoc[] = STYLE_LIST): string { + return [ + "# Design styles", + "", + `> ${styles.length} design-style recipes of @codeatlasdev/ui, each a live page rendered in its own style. A recipe is one call: \`designStyle("")\`. Page: ${SITE}/styles/. Index: ${SITE}/llms.txt`, + "", + "How to read it: a recipe is a proposal built from the library's design knobs, not a measurement of any named visual language. Accessibility claims name their source and strength; none says a style performs better. Method: " + + `${SITE}/llms/concepts/evidence.txt`, + "", + ...styles.flatMap((s) => [ + `## ${s.name}`, + "", + `${s.tagline}`, + "", + `- Recipe: \`designStyle("${s.id}")\`. File: ${SITE}${stylePath(s.id)}. Page: ${SITE}/styles/${s.id}/.`, + `- Fits: ${s.fitsWhen}`, + `- Avoid when: ${s.avoidWhen}`, + `- Accessibility to check: ${s.pitfalls.map((p) => p.title).join("; ")}.`, + "", + ]), + ].join("\n"); +} + +/** What `dist/styles.json` holds: enough for `get_style` to answer with no network. */ +export interface StyleFile { + index: string; + styles: Record; + /** Display names by id, so `get_style` also resolves "Swiss" or "neobrutalism". */ + names: Record; +} + +export function buildStyleFile(styles: readonly StyleDoc[] = STYLE_LIST): StyleFile { + return { + index: buildStylesIndex(styles), + styles: Object.fromEntries(styles.map((s) => [s.id, buildStyleMarkdown(s)])), + names: Object.fromEntries(styles.map((s) => [s.id, s.name])), + }; +} diff --git a/packages/ui/src/lib/components/CodeSnippet/CodeSnippet.svelte b/packages/ui/src/lib/components/CodeSnippet/CodeSnippet.svelte index 0d3e24d7..ba7fa548 100644 --- a/packages/ui/src/lib/components/CodeSnippet/CodeSnippet.svelte +++ b/packages/ui/src/lib/components/CodeSnippet/CodeSnippet.svelte @@ -3,7 +3,8 @@ import { codeSnippet } from '../../i18n/groups/codeSnippet.js'; import { Copy, Check } from '@lucide/svelte'; import { copyToClipboard } from '../../utils/index.js'; - import { getHighlighter } from '../../utils/highlighter.js'; + import { highlight } from '../../utils/highlighter.js'; + import { scrollFocusable } from '../../actions/scroll-focusable.js'; const t = useMessages(codeSnippet); interface Props { @@ -22,12 +23,9 @@ $effect(() => { const lang = language; const src = code; - getHighlighter().then((hl) => { - highlightedHtml = hl.codeToHtml(src, { - lang, - themes: { light: 'github-light-high-contrast', dark: 'github-dark-high-contrast' }, - defaultColor: false, - }); + highlight(src, lang).then((html) => { + // A newer `code` or `language` may have started while this one loaded: keep the last request's result. + if (src === code && lang === language) highlightedHtml = html; }); }); @@ -57,9 +55,9 @@ {#if highlightedHtml} -
{@html highlightedHtml}
+
{@html highlightedHtml}
{:else} -
{code}
+
{code}
{/if} diff --git a/packages/ui/src/lib/components/CodeSnippet/CodeSnippetLang.svelte.test.ts b/packages/ui/src/lib/components/CodeSnippet/CodeSnippetLang.svelte.test.ts new file mode 100644 index 00000000..4a36ea41 --- /dev/null +++ b/packages/ui/src/lib/components/CodeSnippet/CodeSnippetLang.svelte.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { render } from "vitest-browser-svelte"; +import CodeSnippet from "./CodeSnippet.svelte"; + +// The docs ask for these languages (a prompt is markdown, a recipe is ts, its knobs are json). A language the +// highlighter does not know must not leave the raw fallback up forever. +describe("CodeSnippet languages", () => { + it.each(["markdown", "ts", "json", "yaml", "not-a-language"])( + "%s ends up highlighted", + async (language) => { + const screen = await render(CodeSnippet, { code: "# Title\n\nSome text", language }); + await expect + .poll(() => screen.container.querySelector("pre.shiki") !== null, { timeout: 20_000 }) + .toBe(true); + expect(screen.container.querySelector(".ca-code-snippet__pre--raw")).toBeNull(); + }, + 60_000 + ); +}); diff --git a/packages/ui/src/lib/components/CodeSnippet/CodeSnippetRaw.svelte.test.ts b/packages/ui/src/lib/components/CodeSnippet/CodeSnippetRaw.svelte.test.ts new file mode 100644 index 00000000..b463d5b7 --- /dev/null +++ b/packages/ui/src/lib/components/CodeSnippet/CodeSnippetRaw.svelte.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-svelte"; +import { checkA11y } from "../../../../tests/a11y"; +import CodeSnippet from "./CodeSnippet.svelte"; + +// The highlighter never loads: the raw `
` fallback is what the user sees (a slow network, a failed
+// chunk). It scrolls sideways on a long line, so it must be keyboard-reachable like any scroll region.
+vi.mock("../../utils/highlighter.js", () => ({
+	highlight: () => new Promise(() => {}),
+}));
+
+const LONG = `const configuration = { ${"someVeryLongPropertyName: 'a value that keeps going', ".repeat(8)} };`;
+
+describe("CodeSnippet raw fallback", () => {
+	it("a raw block that overflows is focusable and passes axe (scrollable-region-focusable)", async () => {
+		const screen = await render(CodeSnippet, {
+			code: LONG,
+			language: "typescript",
+			class: "narrow",
+		});
+		const pre = screen.container.querySelector(".ca-code-snippet__pre--raw");
+		expect(pre).not.toBeNull();
+		const el = pre as HTMLElement;
+		el.parentElement?.style.setProperty("max-width", "240px");
+		await expect.poll(() => el.scrollWidth > el.clientWidth + 1).toBe(true);
+		await expect.poll(() => el.tabIndex).toBe(0);
+		expect(await checkA11y(screen.container)).toHaveNoViolations();
+	});
+
+	it("a raw block that fits adds no tab stop", async () => {
+		const screen = await render(CodeSnippet, { code: "ls -la", language: "bash" });
+		const pre = screen.container.querySelector(".ca-code-snippet__pre--raw");
+		expect(pre?.hasAttribute("tabindex")).toBe(false);
+	});
+});
diff --git a/packages/ui/src/lib/utils/highlighter.ts b/packages/ui/src/lib/utils/highlighter.ts
index 75b0700e..0e6d7d69 100644
--- a/packages/ui/src/lib/utils/highlighter.ts
+++ b/packages/ui/src/lib/utils/highlighter.ts
@@ -14,3 +14,22 @@ export function getHighlighter(): Promise {
 	}
 	return _promise;
 }
+
+/** Both themes as `--shiki-light` / `--shiki-dark`; CSS picks one with `light-dark()` (no re-highlight on a scheme change). */
+const THEMES = { light: "github-light-high-contrast", dark: "github-dark-high-contrast" } as const;
+
+/**
+ * Highlighted HTML of `code`. A language the singleton did not preload is loaded on demand when shiki bundles
+ * it (`markdown`, `yaml`, ...); one it does not know falls back to plain text, still inside the highlighted
+ * block, so the caller never keeps showing its unhighlighted fallback.
+ */
+export async function highlight(code: string, language: string): Promise {
+	const hl = await getHighlighter();
+	let lang = language;
+	if (!hl.getLoadedLanguages().includes(lang)) {
+		const { bundledLanguages } = await import("shiki");
+		if (lang in bundledLanguages) await hl.loadLanguage(lang as keyof typeof bundledLanguages);
+		else lang = "text";
+	}
+	return hl.codeToHtml(code, { lang, themes: THEMES, defaultColor: false });
+}
diff --git a/packages/ui/tests/ai-entry.test.ts b/packages/ui/tests/ai-entry.test.ts
index 9b2f39ce..407ad017 100644
--- a/packages/ui/tests/ai-entry.test.ts
+++ b/packages/ui/tests/ai-entry.test.ts
@@ -13,6 +13,7 @@ import {
 	SITE,
 } from "../scripts/ai-entry.ts";
 import { buildGuides, buildRecipes } from "../scripts/build-manifest.ts";
+import { STYLES_INDEX_PATH } from "../scripts/styles-markdown.ts";
 
 const REPO = join(import.meta.dir, "../../..");
 const DOCS = join(REPO, "site/src/content/docs");
@@ -102,6 +103,15 @@ describe("llms.txt, the canonical entry (#384)", () => {
 		expect(out).not.toContain("## Docs: Surfaces");
 	});
 
+	it("points at the design-style digest and one file per style (#583)", () => {
+		expect(out).toContain("## Design styles");
+		expect(out).toContain(`${SITE}${STYLES_INDEX_PATH}`);
+		expect(out).toContain("`get_style`");
+		expect(out.indexOf("## Design styles")).toBeLessThan(
+			out.indexOf("## Machine-readable endpoints")
+		);
+	});
+
 	it("does not repeat an entry page in its docs group", () => {
 		expect(out.split("## Docs: Getting started")[1] ?? "").not.toContain("[Choosing]");
 		expect(out.match(/\[Choosing\]/g)).toHaveLength(1);
@@ -253,6 +263,7 @@ describe("llms.txt links the agent-readable .txt form of every page and recipe (
 			`${SITE}/llms-full.txt`,
 			`${SITE}/llms/atlas.txt`,
 			`${SITE}/llms/concepts/viewport.txt`,
+			`${SITE}${STYLES_INDEX_PATH}`,
 		]);
 		for (const m of out.matchAll(/\]\((https:\/\/[^)]*\.txt)\)/g)) {
 			const url = m[1];
diff --git a/packages/ui/tests/atlas.test.ts b/packages/ui/tests/atlas.test.ts
index e04bc3aa..be9961db 100644
--- a/packages/ui/tests/atlas.test.ts
+++ b/packages/ui/tests/atlas.test.ts
@@ -15,6 +15,7 @@ import {
 	termSlug,
 } from "../../../site/src/data/atlas/atlas.ts";
 import { BANNED_PHRASES } from "../../../site/src/data/evidence/references.ts";
+import { STYLE_PAGE_IDS } from "../../../site/src/data/styles/styles.ts";
 import { DESIGN_STYLES } from "../src/lib/theme/styles.ts";
 
 // The design atlas dataset: the schema, the labelled caveats and the links to references and design styles.
@@ -93,6 +94,15 @@ describe("atlas links", () => {
 		expect(styleHref("not-a-style")).toBeNull();
 	});
 
+	it("every design style that has a page is linked to it from the atlas", () => {
+		expect(STYLE_PAGE_IDS.length).toBeGreaterThan(0);
+		for (const id of STYLE_PAGE_IDS) {
+			expect(STYLE_PAGES.has(id)).toBe(true);
+			expect(styleHref(id)).toBe(`/styles/${id}/`);
+		}
+		expect(STYLE_PAGES.size).toBe(STYLE_PAGE_IDS.length);
+	});
+
 	it("resolves every term to full references with a read source", () => {
 		for (const t of TERMS)
 			for (const r of termReferences(t)) expect(r.verified.date).toMatch(/^\d{4}-/);
diff --git a/packages/ui/tests/mcp-style.test.ts b/packages/ui/tests/mcp-style.test.ts
new file mode 100644
index 00000000..baa7e712
--- /dev/null
+++ b/packages/ui/tests/mcp-style.test.ts
@@ -0,0 +1,55 @@
+import { afterAll, beforeAll, describe, expect, it } from "bun:test";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+
+// get_style over the real stdio server: needs `bun run build` (dist/bin + dist/styles.json).
+const bin = join(import.meta.dir, "../dist/bin/ca-ui-mcp.js");
+const built = existsSync(bin) && existsSync(join(import.meta.dir, "../dist/styles.json"));
+
+describe.skipIf(!built)("ca-ui-mcp get_style (#583)", () => {
+	const client = new Client({ name: "test", version: "0.0.0" });
+	beforeAll(async () => {
+		await client.connect(new StdioClientTransport({ command: "node", args: [bin] }));
+	});
+	afterAll(() => client.close());
+
+	const call = async (args: Record = {}) => {
+		const res = (await client.callTool({ name: "get_style", arguments: args })) as {
+			isError?: boolean;
+			content: { text: string }[];
+		};
+		return { error: res.isError === true, text: res.content[0]?.text ?? "" };
+	};
+
+	it("is registered", async () => {
+		expect((await client.listTools()).tools.map((t) => t.name)).toContain("get_style");
+	});
+
+	it("without a name returns the digest of all six styles", async () => {
+		const { error, text } = await call();
+		expect(error).toBe(false);
+		for (const id of ["swiss", "editorial", "brutalism", "sketchbook", "glassmorphism", "terminal"])
+			expect(text).toContain(`designStyle("${id}")`);
+	});
+
+	it("returns a style by id or by title, forgiving case and separators", async () => {
+		for (const name of ["brutalism", "Brutalism", "BRUTALISM"]) {
+			const { error, text } = await call({ name });
+			expect(error).toBe(false);
+			expect(text).toContain("# Brutalism");
+			expect(text).toContain('designStyle("brutalism")');
+			expect(text).toContain("## Accessibility pitfalls");
+			expect(text).toContain("Scope:");
+			expect(text).toContain("## Limits");
+		}
+	});
+
+	it("answers an unknown style with an error that lists the ids", async () => {
+		const { error, text } = await call({ name: "zzz-nope" });
+		expect(error).toBe(true);
+		expect(text).toContain("swiss");
+		expect(text).toContain("terminal");
+	});
+});
diff --git a/packages/ui/tests/style-island.test.ts b/packages/ui/tests/style-island.test.ts
new file mode 100644
index 00000000..51bf3f5d
--- /dev/null
+++ b/packages/ui/tests/style-island.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "bun:test";
+import { STYLE_PAGE_IDS } from "../../../site/src/data/styles/styles.ts";
+import {
+	AFTER,
+	afterCSS,
+	BEFORE,
+	beforeCSS,
+	scopeCSS,
+} from "../../../site/src/lib/style-island.ts";
+import { defineTheme, designDefaults, themeCSS } from "../src/lib/theme/define.ts";
+import { designStyle } from "../src/lib/theme/styles.ts";
+
+// A style page shows a recipe on a subtree while the site around it wears its own theme (sketchbook),
+// so the recipe must never leak to :root and the "before" side must hold the package defaults.
+describe("style island CSS (#583)", () => {
+	it("never writes a recipe on :root or html: the page around it keeps its theme", () => {
+		for (const id of STYLE_PAGE_IDS) {
+			const css = afterCSS(id);
+			expect(css).not.toMatch(/(^|\n):root[\s,{.]/);
+			expect(css).not.toMatch(/(^|\n)html[\s,{.[]/);
+			expect(css).not.toContain('[data-theme-scope="light"] {');
+		}
+	});
+
+	it("starts from the package defaults, then the recipe, so nothing of the page leaks in", () => {
+		const css = afterCSS("brutalism");
+		const defaults = css.indexOf(`.${AFTER}{`);
+		const recipe = css.indexOf(`.${AFTER} {`);
+		expect(defaults).toBeGreaterThanOrEqual(0);
+		expect(recipe).toBeGreaterThan(defaults);
+		// Every default knob is reset, and the recipe's own value comes after it.
+		for (const name of Object.keys(designDefaults())) expect(css).toContain(`${name}:`);
+		expect(css.slice(recipe)).toContain("--ca-border-width: 3px;");
+	});
+
+	it("keeps the dark block of a recipe inside the island", () => {
+		const css = afterCSS("glassmorphism");
+		expect(css).toContain(`.${AFTER}[data-theme-scope="dark"] {`);
+		expect(css).toContain("--ca-glass-rim: oklch(1 0 0 / 0.32);");
+	});
+
+	it("the before side is the package default and beats the recipe wrapped around it", () => {
+		const css = beforeCSS();
+		expect(css.startsWith(`.${AFTER} .${BEFORE}[data-theme-scope]{`)).toBe(true);
+		for (const [name, value] of Object.entries(designDefaults()))
+			expect(css).toContain(`${name}:${value};`);
+		expect(css).toContain("--ca-page-mesh:none;");
+	});
+
+	it("scopeCSS rewrites exactly the two selectors themeCSS emits for the root", () => {
+		const css = themeCSS(defineTheme(designStyle("glassmorphism")));
+		const scoped = scopeCSS(css, "x");
+		expect(scoped).not.toContain(":root");
+		expect(scoped).toContain(".x {");
+		expect(scoped).toContain('.x[data-theme-scope="dark"] {');
+	});
+});
diff --git a/packages/ui/tests/styles.test.ts b/packages/ui/tests/styles.test.ts
new file mode 100644
index 00000000..73f6fbae
--- /dev/null
+++ b/packages/ui/tests/styles.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from "bun:test";
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { BANNED_PHRASES, getReference } from "../../../site/src/data/evidence/references.ts";
+import {
+	checkStyles,
+	isStylePage,
+	recipeKnobs,
+	recipeSource,
+	STYLE_LIST,
+	STYLE_PAGE_IDS,
+	STYLES,
+	styleHref,
+	stylePrompt,
+	styleReferences,
+} from "../../../site/src/data/styles/styles.ts";
+import {
+	buildStyleFile,
+	buildStyleMarkdown,
+	buildStylesIndex,
+} from "../scripts/styles-markdown.ts";
+import { DESIGN_STYLES } from "../src/lib/theme/styles.ts";
+
+const SITE = join(import.meta.dir, "../../../site");
+
+describe("style pages data (#583)", () => {
+	it("passes every structural and evidence rule", () => {
+		expect(checkStyles()).toEqual([]);
+	});
+
+	it("covers the six requested styles, each a real designStyle recipe", () => {
+		const wanted: string[] = [
+			"brutalism",
+			"editorial",
+			"glassmorphism",
+			"sketchbook",
+			"swiss",
+			"terminal",
+		];
+		const have: string[] = [...STYLE_PAGE_IDS];
+		expect(have.sort()).toEqual(wanted.sort());
+		for (const id of STYLE_PAGE_IDS) expect(DESIGN_STYLES[id].id).toBe(id);
+	});
+
+	it("styleHref returns a URL only for a style that has a page", () => {
+		expect(styleHref("swiss")).toBe("/styles/swiss/");
+		expect(styleHref("neumorphism")).toBeNull();
+		expect(isStylePage("terminal")).toBe(true);
+		expect(isStylePage("liquid")).toBe(false);
+	});
+
+	it("rejects a claim that rests on a source nobody read, or a heuristic with a source", () => {
+		const swiss = STYLES.swiss;
+		// Every record of the dataset is `claim-read` now: make E02 the source nobody read, then put it back.
+		const e02 = getReference("E02");
+		if (!e02) throw new Error("E02 is missing from the references");
+		const was = e02.verified.level;
+		e02.verified.level = "identity-only";
+		let bad: string[];
+		try {
+			bad = runBad();
+		} finally {
+			e02.verified.level = was;
+		}
+		function runBad() {
+			return checkStyles({
+				swiss: {
+					...swiss,
+					pitfalls: [
+						{ ...swiss.pitfalls[0], claim: { ...swiss.pitfalls[0].claim, refs: ["E02"] } },
+						{
+							...swiss.pitfalls[0],
+							title: "h",
+							claim: { strength: "heuristic", refs: ["W04"], scope: "", text: "x" },
+						},
+						{
+							...swiss.pitfalls[0],
+							title: "u",
+							claim: { ...swiss.pitfalls[0].claim, refs: ["E99"] },
+						},
+					],
+				},
+			});
+		}
+		expect(bad.some((e) => e.includes("was not read"))).toBe(true);
+		expect(bad.some((e) => e.includes("a heuristic has no sources"))).toBe(true);
+		expect(bad.some((e) => e.includes("unknown reference E99"))).toBe(true);
+	});
+
+	it("never presents opinion as proof, and names no outside source of inspiration", () => {
+		const text = JSON.stringify(STYLES).toLowerCase();
+		const hits = BANNED_PHRASES.filter((b) => text.includes(b));
+		expect(hits).toEqual([]);
+		// Our history is our own writing: no person, product or publication is named.
+		for (const name of [
+			"dribbble",
+			"behance",
+			"awwwards",
+			"figma",
+			"tailwind",
+			"shadcn",
+			"neubrutalism.com",
+			"wikipedia",
+		])
+			expect(text).not.toContain(name);
+	});
+
+	it("every cited reference exists and was read", () => {
+		for (const s of STYLE_LIST)
+			for (const r of styleReferences(s)) {
+				expect(getReference(r.id)?.id).toBe(r.id);
+				expect(r.verified.level).toBe("claim-read");
+			}
+	});
+
+	it("every fit and docs link points at a page that exists", () => {
+		for (const s of STYLE_LIST)
+			for (const f of s.fits) {
+				const file = join(SITE, "src/content", `${f.href.replace(/\/$/, "")}.mdx`);
+				expect(existsSync(file), `${s.id}: ${f.href}`).toBe(true);
+			}
+	});
+
+	it("the recipe text is exactly what designStyle takes", () => {
+		for (const id of STYLE_PAGE_IDS) {
+			expect(recipeSource(id)).toContain(`designStyle("${id}")`);
+			expect(JSON.parse(recipeKnobs(id))).toEqual(
+				JSON.parse(JSON.stringify(DESIGN_STYLES[id].theme))
+			);
+			expect(stylePrompt(id)).toContain(`designStyle("${id}")`);
+		}
+	});
+});
+
+describe("style pages as markdown for agents (#583)", () => {
+	it("one file per style carries the recipe, the pitfalls with their scope and the limits", () => {
+		for (const s of STYLE_LIST) {
+			const md = buildStyleMarkdown(s);
+			expect(md).toContain(`# ${s.name}`);
+			expect(md).toContain(recipeSource(s.id).trim());
+			for (const p of s.pitfalls) {
+				expect(md).toContain(p.title);
+				if (p.claim.scope) expect(md).toContain(p.claim.scope);
+			}
+			for (const l of s.limits) expect(md).toContain(l);
+			expect(md).toContain(`https://ui.codeatlas.com.br/styles/${s.id}/`);
+		}
+	});
+
+	it("the index lists every style with a link to its file", () => {
+		const idx = buildStylesIndex();
+		for (const s of STYLE_LIST) {
+			expect(idx).toContain(s.name);
+			expect(idx).toContain(`/llms/styles/${s.id}.txt`);
+		}
+	});
+
+	it("the MCP file serves get_style by id and by name", () => {
+		const file = buildStyleFile();
+		expect(Object.keys(file.styles).sort()).toEqual([...STYLE_PAGE_IDS].sort());
+		expect(file.styles.swiss).toContain("# Swiss");
+		expect(file.index).toContain("brutalism");
+	});
+});
+
+describe("the site wires the pages in (#583)", () => {
+	const read = (p: string) => readFileSync(join(SITE, p), "utf8");
+
+	it("adds Styles to the header sections", () => {
+		expect(read("src/lib/nav.ts")).toContain('href: "/styles/"');
+	});
+
+	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");
+		// 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/site/src/components/styles/ApplyStyle.svelte b/site/src/components/styles/ApplyStyle.svelte
new file mode 100644
index 00000000..215f6942
--- /dev/null
+++ b/site/src/components/styles/ApplyStyle.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+ {#if active} + + {:else} + + {/if} +

+ {#if active} + This browser now wears {name}. Reset puts the site back as it ships. + {:else if current} + This browser wears another style from this site. Applying replaces it. + {:else} + Try it on every page of this site, only in this browser. Nothing is published or changed for anyone else. + {/if} +

+
+
+ + diff --git a/site/src/components/styles/Fits.svelte b/site/src/components/styles/Fits.svelte new file mode 100644 index 00000000..08deccce --- /dev/null +++ b/site/src/components/styles/Fits.svelte @@ -0,0 +1,116 @@ + + +
+ {#each names as name (name)} +
+

{name}

+
+ {#if name === 'Button'} +
+ + + + +
+ {:else if name === 'Card'} + +

A card is a surface with a title and room to breathe.

+ +
+ {:else if name === 'Badge'} +
+ New + Active + Pending + Overdue +
+ {:else if name === 'Alert'} + The export has two empty columns. + {:else if name === 'Callout'} + A callout reads as a note set beside the text. + {:else if name === 'Heading'} + A headline that reads the display face + {:else if name === 'Highlight'} +

Emphasis belongs on the words that matter, once or twice a page.

+ {:else if name === 'Kbd'} +

Press Ctrl + K to search, Esc to close.

+ {:else if name === 'Breadcrumb'} + + {:else if name === 'Tabs'} + + + Overview + Activity + Settings + +

Overview content.

+

Activity content.

+

Settings content.

+
+ {:else if name === 'Accordion'} + + The recipe, the components and the prompt. + Yes: override any knob on top of the recipe. + + {:else if name === 'TextField'} + + {:else if name === 'CodeSnippet'} + + {:else if name === 'SimpleTable'} + + + ServiceRegionLatency + + apieu-142 ms + workerus-2118 ms + searcheu-167 ms + + + + {/if} +
+
+ {/each} +
+ + diff --git a/site/src/components/styles/Showcase.svelte b/site/src/components/styles/Showcase.svelte new file mode 100644 index 00000000..895dadd1 --- /dev/null +++ b/site/src/components/styles/Showcase.svelte @@ -0,0 +1,192 @@ + + + + {@html ``} + + +
+
+ +

Same components, same markup. Only the tokens change.

+
+ +
+ {#each sides as side (side.key)} +
+
{side.label}
+
+ {#if showcases.includes('actions')} +
+
+ + + + +
+
+ Active + Pending + Overdue +
+
+ {/if} + + {#if showcases.includes('card')} +
+ +

Revenue grew in three of four regions. The fourth is waiting on a late import.

+ +
+ + +
+
+
+ {/if} + + {#if showcases.includes('form')} +
+ + + + + +
+ {/if} + + {#if showcases.includes('feedback')} +
+ Your trial ends in 3 days. + Check the card and try again. +
+ {/if} + + {#if showcases.includes('table')} +
+ + + NameRoleStatus + + {#each rows as r (r.name)} + {r.name}{r.role}{r.status} + {/each} + + + +
+ {/if} +
+
+ {/each} +
+
+ + diff --git a/site/src/components/styles/StyleView.svelte b/site/src/components/styles/StyleView.svelte new file mode 100644 index 00000000..29b1e235 --- /dev/null +++ b/site/src/components/styles/StyleView.svelte @@ -0,0 +1,51 @@ + + + + {@html ``} + + + +
+
+ +
+ {@render children()} +
+
+ + diff --git a/site/src/content/docs/getting-started/ai-agents.mdx b/site/src/content/docs/getting-started/ai-agents.mdx index 022d57d4..7df86856 100644 --- a/site/src/content/docs/getting-started/ai-agents.mdx +++ b/site/src/content/docs/getting-started/ai-agents.mdx @@ -52,6 +52,7 @@ Every page is also linked with `")` recipe and a prompt. Without a name it returns the digest of every style. The same text is at [/llms/styles.txt](/llms/styles.txt); the pages are at [/styles/](/styles/). | | `report_issue` | Drafts a sanitized GitHub issue when the library gets in the agent's way: markdown body, the redactions applied, a prefilled form link and a `gh issue create` command. It never posts. See [Reporting problems](/docs/getting-started/reporting/). | Prop names are never guessed: an agent asks the server and gets the types the compiler sees. diff --git a/site/src/data/atlas/atlas.ts b/site/src/data/atlas/atlas.ts index afa1e958..974cba28 100644 --- a/site/src/data/atlas/atlas.ts +++ b/site/src/data/atlas/atlas.ts @@ -4,6 +4,7 @@ // site/src/content/docs/concepts/evidence.mdx. import { DESIGN_STYLES } from "../../../../packages/ui/src/lib/theme/styles.ts"; import { getReference, type Reference } from "../evidence/references.ts"; +import { STYLE_PAGE_IDS } from "../styles/styles.ts"; import data from "./terms.json"; export const CATEGORIES = [ @@ -182,7 +183,7 @@ export function termReferences(t: Term): Reference[] { // ── Our design styles, as live examples ─────────────────────────────────────── /** Pages that exist for a design style. A style gets a link only when its page is listed here. */ -export const STYLE_PAGES: ReadonlySet = new Set(); +export const STYLE_PAGES: ReadonlySet = new Set(STYLE_PAGE_IDS); /** `/styles//` for a design style that has a page; `null` otherwise, so a term never links to a 404. */ export const styleHref = (styleId: string): string | null => diff --git a/site/src/data/styles/styles.ts b/site/src/data/styles/styles.ts new file mode 100644 index 00000000..8f1fd88b --- /dev/null +++ b/site/src/data/styles/styles.ts @@ -0,0 +1,858 @@ +// The design-style pages (`/styles//`): one record per design style. Plain data and pure functions, +// shared by the Astro pages, the llms digest, the MCP build and the tests, so the text a person reads and +// the text an agent gets are the same. Every claim about users names a verified reference of +// `site/src/data/evidence/references.json`, a strength label and its scope; the rules are on +// /docs/concepts/evidence/. The history is our own writing and names no person, product or publication. +import { DESIGN_STYLES, type DesignStyleId } from "../../../../packages/ui/src/lib/theme/styles.ts"; +import { getReference, type Reference, type Strength } from "../evidence/references.ts"; + +/** The styles that have a page, in the order of the index. The atlas links a style only when it is listed here. */ +export const STYLE_PAGE_IDS = [ + "swiss", + "editorial", + "brutalism", + "sketchbook", + "glassmorphism", + "terminal", +] as const satisfies readonly DesignStyleId[]; +export type StylePageId = (typeof STYLE_PAGE_IDS)[number]; + +export const isStylePage = (id: string): id is StylePageId => + (STYLE_PAGE_IDS as readonly string[]).includes(id); + +/** `/styles//` for a style that has a page, `null` otherwise (same contract as the atlas `styleHref`). */ +export const styleHref = (id: string): string | null => (isStylePage(id) ? `/styles/${id}/` : null); + +export const TRAIT_AXES = ["type", "colour", "shape", "depth", "motion"] as const; +export type TraitAxis = (typeof TRAIT_AXES)[number]; + +/** A claim about users or about a standard, with its source, strength and scope (same fields as ``). */ +export interface StyleClaim { + strength: Strength; + /** Reference ids of the references dataset. Empty only for `heuristic`. */ + refs: string[]; + /** Who or what was studied, or which criterion; why it does not generalize. Empty only for `heuristic`. */ + scope: string; + text: string; +} + +export interface Pitfall { + /** What goes wrong, in one sentence. */ + title: string; + /** What to do about it; the recipe and the components already do it when a token can. */ + fix: string; + claim: StyleClaim; +} + +/** A component shown twice: with the style and with the plain package default. */ +export type ShowcaseId = "actions" | "form" | "card" | "feedback" | "table"; + +export interface StyleDoc { + id: StylePageId; + /** Page `

` and nav label. */ + name: string; + /** One line for cards, meta description and the llms digest. */ + tagline: string; + /** Atlas terms the recipe implements (DESIGN_STYLES taxonomy), by id. */ + atlas: string[]; + /** Our own account of where the style comes from and what it reacted against: no names. */ + origin: string[]; + /** One entry per axis; `tokens` are the `--ca-*` tokens the recipe sets for it. */ + traits: Record; + dos: string[]; + donts: string[]; + pitfalls: Pitfall[]; + /** Components that suit the style, with the one-line reason; each links to its docs page. */ + fits: { component: string; href: string; why: string }[]; + /** Where it tends to fit and where it does not: a judgement, labelled as such on the page. */ + fitsWhen: string; + avoidWhen: string; + /** Showcases to render before/after, in order. */ + showcases: ShowcaseId[]; + /** `elevation` of the cards in the showcase: a glass recipe shows its blur at 3 (the regular tier), the rest at the default. */ + cardElevation?: 0 | 1 | 2 | 3 | 4 | 5; + reviewed: string; + limits: string[]; +} + +const REVIEWED = "2026-10-25"; + +const standardLimits = [ + "The recipe is a proposal built from the library's design knobs. It is not a measurement of any named visual language, and a real product in that language will differ.", + "The sources behind the claims on this page cover contrast, text spacing, target size, motion and the way appeal is judged. None of them shows that this style performs better or worse than another.", + "Before and after toggles show the same components with different tokens. They do not show how a style reads on your content, your brand colour or your users' devices.", +]; + +export const STYLES: Record = { + swiss: { + id: "swiss", + name: "Swiss", + tagline: "A grid, hairlines, square corners and no shadows: order and legibility first.", + atlas: DESIGN_STYLES.swiss.taxonomy, + origin: [ + "This style comes from mid-century print, where designers in a few European cities decided that a page should be built from a grid and a small, strict set of type sizes instead of from decoration. Everything was set flush on a column, hierarchy came from size and weight rather than from ornament, and white space was treated as a working part of the layout.", + "It travelled to screens because screens are grids too. Tables, forms, dashboards and documentation reward a layout where every element lines up, where one sans-serif family does all the work and where a thin rule is enough to separate two things. It reads as neutral, which is its strength in tools and its weakness in anything that needs a personality.", + ], + traits: { + type: { + summary: + "One neutral sans-serif family for body and display. Headings are bold with slightly tight tracking; small labels are uppercase with open tracking, so a label never competes with the heading above it.", + tokens: [ + "--ca-font-sans", + "--ca-font-display", + "--ca-font-weight-heading", + "--ca-heading-tracking", + "--ca-label-case", + "--ca-label-tracking", + ], + }, + colour: { + summary: + "Mostly paper and ink. The brand colour is a single accent used for the one thing you want noticed; surfaces stay neutral so the grid, not the palette, carries the structure.", + tokens: [ + "--ca-brand-fill", + "--ca-surface-base", + "--ca-surface-raised", + "--ca-outline-default", + ], + }, + shape: { + summary: + "Square corners everywhere and a visible one-pixel rule around surfaces (the outlined material). Controls are rectangles; nothing is a pill.", + tokens: ["--ca-radius-scale", "--ca-border-width", "--ca-border-width-strong"], + }, + depth: { + summary: + "None. Separation comes from rules and spacing, not from shadow, blur or tint, so a card and the page differ by a line, not by a lift.", + tokens: ["--ca-shadow-sm", "--ca-shadow-md", "--ca-mat-edge"], + }, + motion: { + summary: + "Short and plain. Transitions run at about 70% of the default duration with the standard curve; nothing bounces and nothing moves unless the state changed.", + tokens: ["--ca-motion-scale", "--ca-ease-standard"], + }, + }, + dos: [ + "Align everything to one column grid and one spacing scale, and let the gaps between groups be larger than the gaps inside a group.", + "Use size and weight for hierarchy before you reach for colour.", + "Keep one accent colour and spend it on the primary action of each screen.", + "Use a rule, not a shadow, to separate a surface from the page.", + ], + donts: [ + "Do not add gradients, glows or rounded pills to soften it; the style is the absence of them.", + "Do not centre long text. Set it flush left on the grid.", + "Do not use more than two weights of the family on one screen.", + "Do not mistake plain for finished: neutral surfaces still need a clear focus state and states for hover, pressed and disabled.", + ], + pitfalls: [ + { + title: "Hairlines can fall below the contrast a control needs", + fix: "Keep control outlines on the stronger outline token (`--ca-outline-control`), not on the subtle divider token. The preset contrast test measures 3:1 for it in every built-in preset.", + claim: { + strength: "practice", + refs: ["W04"], + scope: + "WCAG 2.2 criterion 1.4.11: meaningful visual cues, such as the boundary of an input, need 3:1 against the background. The criterion sets no minimum line width, so a thin line is allowed when its contrast is enough.", + text: "A field that is only a pale one-pixel outline can fail the non-text contrast requirement even when the page looks tidy.", + }, + }, + { + title: "Tight, uppercase labels get hard to read when a user widens the spacing", + fix: "Do not rely on a fixed box height for a label. Let the line grow when text spacing is overridden, which the components do by using min-height rather than height.", + claim: { + strength: "practice", + refs: ["W03"], + scope: + "WCAG 2.2 criterion 1.4.12: no loss of content or function when a user sets line height to 1.5, letter spacing to 0.12 and word spacing to 0.16 times the font size. It constrains what breaks, not what the page must use.", + text: "Open uppercase tracking is safe by itself; clipping a label inside a fixed-height box is what breaks when a user applies their own spacing.", + }, + }, + ], + fits: [ + { + component: "SimpleTable", + href: "/docs/display/table/", + why: "Rules and a column grid are what a dense table needs.", + }, + { + component: "TextField", + href: "/docs/inputs/text-field/", + why: "A rectangular outlined field reads clearly as an input.", + }, + { + component: "Tabs", + href: "/docs/navigation/tabs/", + why: "Line tabs on a hairline suit a grid layout.", + }, + { + component: "Breadcrumb", + href: "/docs/navigation/breadcrumb/", + why: "Plain, aligned navigation text.", + }, + ], + fitsWhen: + "Tools, documentation, admin screens, reports and anything dense where people scan and compare. It tends to fit teams that want the interface to disappear behind the content.", + avoidWhen: + "A product that has to be remembered for its look, a children's or entertainment audience, or a screen where one hero moment has to carry emotion.", + showcases: ["actions", "form", "table", "feedback"], + reviewed: REVIEWED, + limits: standardLimits, + }, + + editorial: { + id: "editorial", + name: "Editorial", + tagline: "A serif display face, a long line and plenty of space: reading comes first.", + atlas: DESIGN_STYLES.editorial.taxonomy, + origin: [ + "Print journalism and long-form publishing solved a problem that a lot of software now has: how to hold a person's attention on a lot of text for more than a few seconds. The answers were a clear typographic hierarchy, a measure short enough to follow, generous margins, hairline rules between sections and a display face with character set against a quiet text face.", + "On screen the same moves make a product feel curated instead of busy. The cost is that a page built for reading can hide its actions: when everything is quiet, the one button that matters can disappear, so the style asks for deliberate emphasis where it counts.", + ], + traits: { + type: { + summary: + "A serif for display and headings, a clean text face for the body, a slightly larger scale, a line height near 1.7 and a measure capped at about 65 characters.", + tokens: [ + "--ca-font-display", + "--ca-font-scale", + "--ca-line-height-body", + "--ca-measure", + "--ca-label-case", + "--ca-label-tracking", + ], + }, + colour: { + summary: + "Restrained and warm-neutral. Ink on paper with one accent; large areas stay close to the page colour so the text carries the page.", + tokens: [ + "--ca-brand-fill", + "--ca-surface-base", + "--ca-text-primary", + "--ca-text-secondary", + ], + }, + shape: { + summary: + "Nearly square corners (a fifth of the default radius) and hairline dividers. Surfaces are flat panels, not floating cards.", + tokens: ["--ca-radius-scale", "--ca-border-width"], + }, + depth: { + summary: + "None. No shadows and no blur; the flat material separates with a hairline. Rhythm comes from spacing and rules.", + tokens: ["--ca-shadow-md", "--ca-mat-edge"], + }, + motion: { + summary: + "Calm and a little slower than default (about 130%): a fade or a short slide that never competes with the text.", + tokens: ["--ca-motion-scale", "--ca-ease-standard"], + }, + }, + dos: [ + "Cap the line length of running text; wider is not more comfortable.", + "Pair one expressive display face with a plain text face, and keep to two families.", + "Use space and a hairline, not a box, to group related content.", + "Make the primary action visibly an action: a filled button, not a text link.", + ], + donts: [ + "Do not use a display serif for small interface text such as table cells, badges or captions.", + "Do not hide navigation to keep the page quiet; people should see where the routes are.", + "Do not set long text in all caps; reserve uppercase for short labels.", + "Do not let a hero image push the first paragraph off the first screen without a reason.", + ], + pitfalls: [ + { + title: "Airy spacing and a wide display scale can break at large text sizes", + fix: "Use relative units for sizes and test at 200% text size. Containers should grow with the text instead of clipping it.", + claim: { + strength: "practice", + refs: ["W02"], + scope: + "WCAG 2.2 criterion 1.4.4: text can be resized up to 200 percent without assistive technology and without loss of content or function. It says nothing about which typeface or size to start from.", + text: "A page that looks balanced at 100% can overlap or clip when a reader enlarges the text.", + }, + }, + { + title: "A quiet page can hide the one action that matters", + fix: "Give the primary action the brand fill and a clear label; keep secondary actions as outlined or text buttons.", + claim: { + strength: "heuristic", + refs: [], + scope: "", + text: "When every element is understated, nothing signals what to do next. We have seen this on our own pages, but we have no study to point to, so test it with your users.", + }, + }, + ], + fits: [ + { + component: "Heading", + href: "/docs/display/heading/", + why: "The display size reads the serif display family.", + }, + { + component: "Card", + href: "/docs/surfaces/card/", + why: "A flat card with a hairline is a column, not a tile.", + }, + { + component: "Highlight", + href: "/docs/display/highlight/", + why: "A hand-set emphasis inside running text.", + }, + { + component: "Accordion", + href: "/docs/surfaces/accordion/", + why: "Progressive disclosure for long pages.", + }, + ], + fitsWhen: + "Content-led products: publications, documentation, case studies, portfolios, reports and pricing or product pages that tell a story before they sell.", + avoidWhen: + "Dense operational tools, small-screen data entry, or anywhere a user scans for a value rather than reads.", + showcases: ["card", "actions", "form", "feedback"], + reviewed: REVIEWED, + limits: standardLimits, + }, + + brutalism: { + id: "brutalism", + name: "Brutalism", + tagline: "Thick strokes, hard offset shadows, flat fills and loud type.", + atlas: DESIGN_STYLES.brutalism.taxonomy, + origin: [ + "The name was borrowed from architecture, where raw, exposed structure was left visible instead of being finished over. On the web it began as a reaction to interfaces that had become uniformly smooth: rounded, soft, gradient-filled and nearly interchangeable. Early sites in this spirit used default fonts, visible grids and harsh contrast on purpose.", + "The version used in product interfaces today is gentler and more systematic. It keeps the visible structure (heavy outlines, flat saturated fills, a shadow that is a solid offset block rather than a blur) and drops the hostility. It is loud on purpose, which makes it memorable and also tiring: it works as an accent for a brand moment far better than as the surface of a long working session.", + ], + traits: { + type: { + summary: + "Heavy, uppercase headings at weight 900 in a system sans-serif, tight tracking, bold labels. Type is a graphic element, not a quiet carrier.", + tokens: [ + "--ca-font-weight-heading", + "--ca-heading-case", + "--ca-heading-tracking", + "--ca-label-case", + "--ca-label-tracking", + ], + }, + colour: { + summary: + "Flat, saturated fills against a black stroke. No gradients, no tints. The brand colour is used at full strength, which is why contrast has to be checked per preset.", + tokens: ["--ca-brand-fill", "--ca-brand-contrast", "--ca-border-color"], + }, + shape: { + summary: + "Square corners and a three to four pixel outline on every surface and control. Icons are drawn with a heavier stroke to match.", + tokens: [ + "--ca-radius-scale", + "--ca-border-width", + "--ca-border-width-strong", + "--ca-icon-stroke", + ], + }, + depth: { + summary: + "A hard offset shadow: a solid copy of the border colour pushed down and to the right, with no blur. Bigger surfaces cast a bigger offset.", + tokens: ["--ca-shadow-sm", "--ca-shadow-md", "--ca-shadow-lg", "--ca-mat-regular-shadow"], + }, + motion: { + summary: + "Fast and stepped: half the default duration with a stepped curve, so a state change snaps instead of gliding.", + tokens: ["--ca-motion-scale", "--ca-ease-standard"], + }, + }, + dos: [ + "Keep one outline weight and one shadow offset per elevation, and repeat them everywhere.", + "Use the loud treatment on a few things and leave the rest plain, so the loud parts still read as loud.", + "Check the text colour on every saturated fill; the brand colour has to carry its label.", + "Keep real hierarchy and real navigation under the shouting: order, spacing and clear labels.", + ], + donts: [ + "Do not use it for the whole of a long form or a dense table; the heavy lines add up to noise.", + "Do not use the shadow offset as the only way to show a pressed or selected state.", + "Do not combine it with blur, gradients or soft tints; that is a different style.", + "Do not set long paragraphs in uppercase or at heading weight.", + ], + pitfalls: [ + { + title: "Saturated fills with white or black text can miss the contrast ratio", + fix: "Take the label colour from `--ca-brand-contrast`, which the preset derives from the fill, and run the preset contrast check when you change the brand colour.", + claim: { + strength: "practice", + refs: ["E07"], + scope: + "WCAG 2.2 Quick Reference, contrast criteria: text needs at least 4.5:1 (3:1 for large text) against its background at level AA. It states what content must do, not what users prefer.", + text: "A bright yellow or pure green fill looks right in a mock-up and can fail the text contrast requirement with a white label.", + }, + }, + { + title: "Constant strong stimulation is tiring in long sessions", + fix: "Confine the style to marketing surfaces, empty states and accents; use a calmer recipe for the working screens of the same product.", + claim: { + strength: "heuristic", + refs: [], + scope: "", + text: "Heavy outlines on every control raise the visual weight of the whole screen. We have not found a source that measures fatigue for this style, so treat it as a judgement and test it with the people who use the product all day.", + }, + }, + { + title: "A hard shadow can be the only cue for a pressed button", + fix: "Also change the fill or the label on press and keep a visible focus ring; the components do both.", + claim: { + strength: "practice", + refs: ["W04"], + scope: + "WCAG 2.2 criterion 1.4.11: a visual cue that is needed to identify a control or its state needs 3:1 against its surroundings. A shifted shadow is a cue only if it meets that.", + text: "State changes carried only by an offset that moves a few pixels are easy to miss, and are not a reliable signal on their own.", + }, + }, + ], + fits: [ + { + component: "Button", + href: "/docs/inputs/button/", + why: "A flat fill, a heavy stroke and a hard shadow define the style.", + }, + { + component: "Card", + href: "/docs/surfaces/card/", + why: "A thick-bordered tile with an offset shadow.", + }, + { component: "Badge", href: "/docs/display/badge/", why: "Small saturated labels." }, + { + component: "Alert", + href: "/docs/display/alert/", + why: "A bold banner that cannot be missed.", + }, + ], + fitsWhen: + "Creator tools, campaigns, portfolios, onboarding and landing moments where being remembered matters more than long-session comfort.", + avoidWhen: + "Dense data entry, finance and health screens, and any product used for hours a day.", + showcases: ["actions", "card", "feedback", "form"], + reviewed: REVIEWED, + limits: standardLimits, + }, + + sketchbook: { + id: "sketchbook", + name: "Sketchbook", + tagline: "A journal on grid paper: serif display, square outlined cards, hand-drawn marks.", + atlas: DESIGN_STYLES.sketchbook.taxonomy, + origin: [ + "Before software, products were worked out on paper: graph-ruled pads, pencil, a margin note with an arrow, a circled word. A sketchbook look borrows that working surface on purpose. It says that the thing is in progress and made by hand, which lowers the stakes of reading it and suits tools for people who make things.", + "This is the language of the documentation you are reading. We chose it because it carries two moods at once: the structure of a printed grid and the warmth of marks made with a pen. It sits between the rigour of a plain grid style and the shouting of a heavy-stroke style, with outlined cards that cast a hard offset shadow and a serif for headings.", + ], + traits: { + type: { + summary: + "A serif display face over a plain body face, semibold headings, uppercase labels with open tracking, and a generous line height near 1.65. Captions are italic.", + tokens: [ + "--ca-font-display", + "--ca-font-weight-heading", + "--ca-label-case", + "--ca-label-tracking", + "--ca-line-height-body", + ], + }, + colour: { + summary: + "Paper and pencil: a quiet surface, graphite text, and the brand colour for marks and the active state. The page grid is drawn in a subtle outline colour so it stays texture.", + tokens: ["--ca-brand-main", "--ca-outline-subtle", "--ca-mark-color", "--ca-page-mesh"], + }, + shape: { + summary: + "Square corners with a two to three pixel outline. Hand-drawn underlines and margin notes (`.ca-mark-underline`, `.ca-mark-note`) add the pen.", + tokens: ["--ca-radius-scale", "--ca-border-width", "--ca-border-width-strong"], + }, + depth: { + summary: + "A hard offset shadow, like a card laid on a desk. No blur. The paper itself is flat grid lines.", + tokens: ["--ca-shadow-md", "--ca-mat-regular-shadow"], + }, + motion: { + summary: + "Short and plain at about 60% of the default, with the standard curve: things appear, they do not perform.", + tokens: ["--ca-motion-scale", "--ca-ease-standard"], + }, + }, + dos: [ + "Keep the grid faint; it is texture behind the content, not a pattern in front of it.", + "Use the hand-drawn marks sparingly, once or twice a page, on the words that matter.", + "Let captions and notes be italic and secondary so the body stays plain.", + "Pair it with a light and a dark scheme and check the grid ink in both.", + ], + donts: [ + "Do not draw a mark on every heading; a mark only means something when it is rare.", + "Do not put text over the grid lines at a size where they cross the letters.", + "Do not use a handwriting font for body text. The warmth comes from marks and layout, not from illegible type.", + "Do not add blur or gradients; they fight the paper.", + ], + pitfalls: [ + { + title: "A busy background pattern can reduce how readable text over it is", + fix: "Keep the paper pattern at low contrast (it uses `--ca-outline-subtle` at about half strength) and keep text on a raised, opaque surface rather than directly on the grid.", + claim: { + strength: "practice", + refs: ["E07"], + scope: + "WCAG 2.2 Quick Reference, contrast criteria: text needs 4.5:1 against its background. A background pattern counts as part of the background, so the lines under text are measured too.", + text: "Grid or dotted paper behind body text is a background the text contrast has to survive.", + }, + }, + { + title: "Decorative marks must not carry meaning", + fix: "Marks are drawn with CSS masks and are not announced; any emphasis they give must also be in the words (bold, a label, an order), and in forced-colors mode they fall back to the system text colour.", + claim: { + strength: "practice", + refs: ["E07"], + scope: + "WCAG 2.2 Quick Reference: information conveyed only through a visual device needs a text or programmatic equivalent. It says what must be available, not how a mark is perceived.", + text: "A hand-drawn circle around a word tells a sighted reader that it matters; a screen reader hears nothing, so the importance has to be stated in text as well.", + }, + }, + ], + fits: [ + { + component: "Highlight", + href: "/docs/display/highlight/", + why: "A hand-drawn emphasis on the words that matter.", + }, + { + component: "Card", + href: "/docs/surfaces/card/", + why: "An outlined card with a hard shadow is a page of the book.", + }, + { + component: "Callout", + href: "/docs/docs-kit/docs/", + why: "A margin note with a clear tone.", + }, + { + component: "CodeSnippet", + href: "/docs/display/code-snippet/", + why: "A boxed block that sits well on paper.", + }, + ], + fitsWhen: + "Developer tools, documentation, learning products, portfolios and products for people who make things, where warmth and structure should coexist.", + avoidWhen: + "Formal institutions, finance, or any context where a hand-made look would read as unserious.", + showcases: ["card", "actions", "feedback", "form"], + reviewed: REVIEWED, + limits: standardLimits, + }, + + glassmorphism: { + id: "glassmorphism", + name: "Glassmorphism", + tagline: + "Translucent, blurred layers over a colourful backdrop, with thin rims and soft shadows.", + atlas: DESIGN_STYLES.glassmorphism.taxonomy, + origin: [ + "Operating systems began putting frosted, translucent panels over their backgrounds once graphics hardware could blur what sits behind a layer in real time. The idea is about depth: a control layer floats above the content layer, and you can see that the content continues underneath. When it spread to the web it became a look in its own right, usually over a gradient or a photo.", + "It is expressive and it is fragile. The text on a glass panel sits on whatever happens to be behind it, so legibility depends on the backdrop, on the blur and on the tint all at once. It works best as a layer for a few controls and overlays, not as the surface of everything.", + ], + traits: { + type: { + summary: + "Nothing special: the style leaves type alone. Weight and size matter more than usual because the background is busy; use the regular text weight at body size or larger.", + tokens: ["--ca-font-sans", "--ca-font-weight-heading"], + }, + colour: { + summary: + "A colourful or photographic backdrop (the page mesh) with the panel tinted by the surface colour. The glass rim is a one-pixel highlight, bright in light mode and fainter in dark.", + tokens: ["--ca-glass-rim", "--ca-glass-tint", "--ca-page-mesh", "--ca-brand-main"], + }, + shape: { + summary: + "Generously rounded corners (about 1.4 times the default scale) and a large control radius, so panels read as soft sheets.", + tokens: ["--ca-radius-scale", "--ca-radius-control"], + }, + depth: { + summary: + "The whole point: a stronger backdrop blur with extra saturation, three thicknesses (thin, regular, thick), a soft shadow and a little grain so large blurred areas do not band.", + tokens: [ + "--ca-blur-thin", + "--ca-blur-regular", + "--ca-blur-thick", + "--ca-noise-opacity", + "--ca-glass-shadow", + ], + }, + motion: { + summary: "Smooth and emphasized at the default speed: panels ease in rather than snap.", + tokens: ["--ca-motion-scale", "--ca-ease-standard"], + }, + }, + dos: [ + "Put text on a tinted, thick-enough layer, not on a thin one over a busy picture.", + "Use blur for a layer that floats above content (a toolbar, a sheet, a menu) and keep the content layer opaque.", + "Test over the lightest and the darkest part of the backdrop, in both schemes.", + "Provide the opaque fallback: with transparency reduced the panel should become a solid surface.", + ], + donts: [ + "Do not stack glass on glass; blur compounds and the text loses its ground.", + "Do not use it for dense data tables or long forms.", + "Do not rely on a faint rim as the only edge of a control.", + "Do not animate large blurred areas continuously; it costs frames on modest devices.", + ], + pitfalls: [ + { + title: + "Text contrast depends on what is behind the panel, so it changes as content scrolls", + fix: "Raise the tint of the layer that holds text (use the regular or thick tier for body text), and test the worst backdrop. Use an opaque base behind long text.", + claim: { + strength: "practice", + refs: ["E07"], + scope: + "WCAG 2.2 Quick Reference, contrast criteria: text needs 4.5:1 against its background at level AA. A translucent panel's effective background is whatever shows through it.", + text: "A panel that passes over a dark part of a gradient can fail over a bright part of the same gradient.", + }, + }, + { + title: "A faint rim may not be enough to identify a control", + fix: "Give inputs and buttons a stronger outline token or a fill, not only the highlight rim.", + claim: { + strength: "practice", + refs: ["W04"], + scope: + "WCAG 2.2 criterion 1.4.11: visual information needed to identify a control needs 3:1 against adjacent colours.", + text: "A white highlight on a light blurred panel is a cue that is easy to lose.", + }, + }, + { + title: + "Platform guidance treats glass as a separate control layer, not as a surface for content", + fix: "Use glass for navigation and controls that float over content, and keep the content itself on a solid layer.", + claim: { + strength: "practice", + refs: ["E12"], + scope: + "A platform owner's design guidance for its own system material. It is guidance, not a study, and it does not show better outcomes for users.", + text: "The guidance describes the material as a functional layer for controls and navigation that floats above content, which is the use this recipe supports best.", + }, + }, + ], + fits: [ + { + component: "Card", + href: "/docs/surfaces/card/", + why: "The panel itself: `elevation` 2, 3 and 4 are the thin, regular and thick glass tiers.", + }, + { + component: "Tabs", + href: "/docs/navigation/tabs/", + why: "A pill tab list is a glass control over content.", + }, + { component: "Badge", href: "/docs/display/badge/", why: "Small tinted status pills." }, + { + component: "Alert", + href: "/docs/display/alert/", + why: "A tinted banner that still reads over the backdrop.", + }, + ], + fitsWhen: + "Media, dashboards over a hero visual, landing pages and overlays: a few floating layers over rich content.", + avoidWhen: + "Dense tables, long text, low-end devices, or any screen where legibility cannot be guaranteed over the backdrop.", + showcases: ["card", "actions", "feedback", "form"], + cardElevation: 3, + reviewed: REVIEWED, + limits: [ + ...standardLimits, + "Blur is rendered by the browser and its cost depends on the device. We have not measured it for this page, so check the frame rate on the lowest-end device you support.", + ], + }, + + terminal: { + id: "terminal", + name: "Terminal", + tagline: "Monospace everywhere, square cells, outlined panels and instant motion.", + atlas: DESIGN_STYLES.terminal.taxonomy, + origin: [ + "Before graphical interfaces, people worked in text: a grid of fixed-width characters, a prompt, and output that scrolled by. Programmers still do much of their work there, and the look has become a shorthand for tools that are exact, fast and built for people who read code.", + "As an interface style it uses one monospace family for everything, tight spacing, square outlined panels and no easing. It packs a lot of information into a small area and rewards people who already read that way. It is a poor first impression for someone who does not, so it asks for obvious graphical navigation next to the text.", + ], + traits: { + type: { + summary: + "One monospace family for body, display and code, a slightly smaller scale, bold headings with no tracking and uppercase labels. Columns line up because every character has the same width.", + tokens: [ + "--ca-font-sans", + "--ca-font-display", + "--ca-font-mono", + "--ca-font-scale", + "--ca-label-case", + ], + }, + colour: { + summary: + "Dark by default: a near-black surface, bright text and one accent used like a phosphor. The recipe defaults to the dark scheme; the light scheme is still supported.", + tokens: ["--ca-brand-fill", "--ca-surface-base", "--ca-text-primary"], + }, + shape: { + summary: + "Square corners and one-pixel outlines on every panel, like cells in a grid. Compact density keeps rows tight.", + tokens: ["--ca-radius-scale", "--ca-density", "--ca-border-width"], + }, + depth: { + summary: "None. Panels are outlined rectangles; there is no shadow, blur or tint.", + tokens: ["--ca-shadow-md", "--ca-mat-edge"], + }, + motion: { + summary: + "None by design: the motion scale is zero and the curve is stepped, so state changes are instant. This also makes the recipe safe for people who reduce motion.", + tokens: ["--ca-motion-scale", "--ca-ease-standard"], + }, + }, + dos: [ + "Use the monospace family for data, identifiers and code, where alignment helps comparison.", + "Keep a visible, high-contrast focus ring; keyboard users are this style's main audience.", + "Offer plain graphical navigation (a sidebar or tabs) beside any command-style input.", + "Keep rows compact but keep touch targets large enough on touch devices.", + ], + donts: [ + "Do not use a monospace face for long paragraphs of prose; the uneven rhythm slows reading.", + "Do not imitate a blinking cursor everywhere; motion with no meaning is noise.", + "Do not use green-on-black as the only palette without checking contrast and colour-blind legibility.", + "Do not hide features behind typed commands with no visible alternative.", + ], + pitfalls: [ + { + title: "Compact density can push targets below the minimum pointer size", + fix: "Keep the interactive area at least 24 by 24 CSS pixels (the components keep a larger hit area than their visible box) and give touch layouts the comfortable density.", + claim: { + strength: "practice", + refs: ["W06"], + scope: + "WCAG 2.2 criterion 2.5.8 (Level AA): pointer targets are at least 24 by 24 CSS pixels unless spacing, equivalence, inline, user-agent or essential exceptions apply. 44 pixels is a larger recommendation, not this criterion.", + text: "Tight rows look right in a terminal-style list and can make the controls inside them too small to hit.", + }, + }, + { + title: "Removing all motion is safe, but do not remove state feedback with it", + fix: "Keep a clear change in fill, outline or label for hover, pressed and selected so the instant transition still shows what changed.", + claim: { + strength: "practice", + refs: ["M06", "E09"], + scope: + "MDN describes how to detect a reduced-motion preference. WCAG 2.2 criterion 2.3.3 (Level AAA) says non-essential motion triggered by interaction can trigger vestibular disorders and should be possible to turn off. It makes no claim about how common this is.", + text: "A style with no motion already honours a reduced-motion setting, so the remaining risk is a state that changes with no visible signal.", + }, + }, + ], + fits: [ + { + component: "CodeSnippet", + href: "/docs/display/code-snippet/", + why: "Boxed monospace output.", + }, + { component: "SimpleTable", href: "/docs/display/table/", why: "Compact, aligned rows." }, + { + component: "TextField", + href: "/docs/inputs/text-field/", + why: "A square, outlined, monospace field.", + }, + { component: "Kbd", href: "/docs/display/kbd/", why: "Keyboard hints in the same face." }, + ], + fitsWhen: + "Developer tools, logs, consoles, infrastructure dashboards and documentation for engineers.", + avoidWhen: + "Consumer onboarding, content for general audiences, or anywhere people are not used to reading fixed-width text.", + showcases: ["table", "form", "actions", "feedback"], + reviewed: REVIEWED, + limits: standardLimits, + }, +}; + +export const STYLE_LIST: readonly StyleDoc[] = STYLE_PAGE_IDS.map((id) => STYLES[id]); + +// ── Recipe, as copyable text ────────────────────────────────────────────────── + +/** The `ThemeOptions` recipe, as the code a person pastes into `src/lib/theme.ts`. Always the `designStyle(id)` call. */ +export function recipeSource(id: StylePageId, withBrand = false): string { + const args = withBrand + ? `"${id}", {\n\tpresets: [{ id: "acme", color: "#ff5c00" }],\n\tdefaultPreset: "acme",\n}` + : `"${id}"`; + return `import { designStyle, type ThemeOptions } from "@codeatlasdev/ui";\n\nexport const theme: ThemeOptions = designStyle(${args});\n`; +} + +/** The resolved options of a recipe, printed as a plain object, so the reader sees every knob the style sets. */ +export function recipeKnobs(id: StylePageId): string { + const theme = DESIGN_STYLES[id].theme; + return JSON.stringify(theme, null, "\t"); +} + +// ── Prompt ──────────────────────────────────────────────────────────────────── + +/** A paste-in instruction that carries the style, its limits and the accessibility rules into a coding agent. */ +export function stylePrompt(id: StylePageId): string { + const s = STYLES[id]; + return [ + `Build this interface in the "${s.name}" style with @codeatlasdev/ui: ${s.tagline.replace(/\.$/, "")}.`, + "", + `1. In src/lib/theme.ts export \`theme = designStyle("${id}")\` (import it from "@codeatlasdev/ui", or from "@codeatlasdev/ui/vite" in a file shared with vite.config). Add your brand as a preset on top: \`designStyle("${id}", { presets: [{ id: "brand", color: "#…" }], defaultPreset: "brand" })\`.`, + "2. Use the library components as they are. Change the look only through ThemeOptions or `--ca-*` tokens: no `!important`, no overrides of `.ca-*` classes, no hard-coded colours.", + `3. The style's traits: type: ${s.traits.type.summary} Shape: ${s.traits.shape.summary} Depth: ${s.traits.depth.summary} Motion: ${s.traits.motion.summary}`, + `4. Do: ${s.dos.join(" ")}`, + `5. Do not: ${s.donts.join(" ")}`, + `6. Accessibility, which this style puts at risk: ${s.pitfalls.map((p) => `${p.title}. ${p.fix}`).join(" ")}`, + `7. Fits: ${s.fitsWhen} Avoid it when: ${s.avoidWhen}`, + "8. Keep content, hierarchy and behaviour identical to a plain baseline so the two can be compared. Do not claim the style performs better; validate it with your users.", + `Docs: https://ui.codeatlas.com.br${styleHref(id)}`, + ].join("\n"); +} + +// ── References and validation ───────────────────────────────────────────────── + +/** Every reference a style page cites, in first-use order, without duplicates. */ +export function styleReferences(s: StyleDoc): Reference[] { + const ids = [...new Set(s.pitfalls.flatMap((p) => p.claim.refs))]; + return ids.map((id) => { + const r = getReference(id); + if (!r) throw new Error(`${s.id}: unknown reference ${id}`); + return r; + }); +} + +/** Error messages for every problem in the style records; empty means valid. Used by the unit test. */ +export function checkStyles(styles: Record = STYLES): string[] { + const errors: string[] = []; + for (const [key, s] of Object.entries(styles)) { + const at = `style ${key}`; + if (s.id !== key) errors.push(`${at}: id "${s.id}" does not match its key`); + if (!(s.id in DESIGN_STYLES)) errors.push(`${at}: not a designStyle id`); + if (s.origin.length < 2) errors.push(`${at}: origin needs at least two paragraphs`); + for (const axis of TRAIT_AXES) { + const t = s.traits[axis]; + if (!t?.summary.trim()) errors.push(`${at}: trait ${axis} is empty`); + if (!t?.tokens.length) errors.push(`${at}: trait ${axis} names no token`); + } + if (s.dos.length < 3 || s.donts.length < 3) + errors.push(`${at}: needs at least 3 dos and 3 don'ts`); + if (!s.pitfalls.length) errors.push(`${at}: no accessibility pitfall`); + if (!s.fits.length) errors.push(`${at}: no fitting component`); + if (!s.limits.length) errors.push(`${at}: no limits`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(s.reviewed)) errors.push(`${at}: reviewed is not YYYY-MM-DD`); + for (const p of s.pitfalls) { + const c = p.claim; + const w = `${at}: pitfall "${p.title}"`; + if (!p.fix.trim() || !c.text.trim()) errors.push(`${w}: fix or claim text is empty`); + if (c.strength === "heuristic") { + if (c.refs.length) errors.push(`${w}: a heuristic has no sources`); + } else { + if (!c.refs.length || !c.scope.trim()) errors.push(`${w}: needs refs and scope`); + for (const id of c.refs) { + const r = getReference(id); + if (!r) errors.push(`${w}: unknown reference ${id}`); + else if (r.verified.level !== "claim-read") + errors.push(`${w}: reference ${id} was not read, no claim may rest on it`); + } + } + } + for (const id of s.atlas) + if (!DESIGN_STYLES[s.id].taxonomy.includes(id)) + errors.push(`${at}: atlas ${id} is not in the recipe taxonomy`); + } + return errors; +} diff --git a/site/src/lib/nav.ts b/site/src/lib/nav.ts index 97109f89..a9dcafff 100644 --- a/site/src/lib/nav.ts +++ b/site/src/lib/nav.ts @@ -21,6 +21,7 @@ export type Group = (typeof GROUPS)[number]; export const SECTIONS: DocsSection[] = [ { id: "docs", label: "Docs", href: "/docs/" }, { id: "gallery", label: "Gallery", href: "/gallery/" }, + { id: "styles", label: "Styles", href: "/styles/" }, { id: "templates", label: "Templates", href: "/templates/" }, { id: "theme", label: "Theme editor", href: "/theme/" }, { id: "changelog", label: "Changelog", href: "/changelog/" }, diff --git a/site/src/lib/style-island.ts b/site/src/lib/style-island.ts new file mode 100644 index 00000000..1974b024 --- /dev/null +++ b/site/src/lib/style-island.ts @@ -0,0 +1,69 @@ +// ─── A design style, scoped to one element ───────────────────────────────────────────────────────── +// `themeCSS` writes a recipe on `:root` (and `[data-theme-scope]`). A style page needs the recipe on a +// subtree while the page around it keeps wearing the site's own theme, and a "before" subtree that +// wears the package default even though the site itself is a recipe (sketchbook). +// +// `designTokens` leaves a knob out when it equals the package default, so a recipe on its own would +// inherit whatever the page set. Every island therefore starts from `designDefaults()` and the recipe +// is layered on top: what you see is what an app with that `theme.ts` gets. Shadows and materials +// need no reset: the island carries `data-theme-scope` and `data-style`, so `tokens.css` and +// `materials.css` re-resolve them on the island itself. The one exception is the paper pattern, which +// this site overrides in `src/theme.ts` and the package default leaves empty. + +// Relative imports of the package's pure theme modules (no Svelte): this file is also loaded by the +// unit tests, where the package root (which imports .svelte components) cannot be evaluated. +import { + defineTheme, + designDefaults, + themeCSS, +} from "../../../packages/ui/src/lib/theme/define.ts"; +import { type DesignStyleId, designStyle } from "../../../packages/ui/src/lib/theme/styles.ts"; + +/** Class that scopes the CSS of the "after" island. The "before" island uses `BEFORE`. */ +export const AFTER = "sx-after"; +export const BEFORE = "sx-before"; + +/** + * Declarations of the package defaults on `selector`. The "before" island sits inside the page's own + * recipe island, so it is scoped under the page island with an attribute (`.sx-after .sx-before[data-theme-scope]`, three + * classes' worth of specificity beats the recipe's `.sx-after[data-theme-scope="dark"]`) and wins whatever the head order. + */ +const reset = (selector: string) => + `${selector}{${Object.entries({ ...designDefaults(), "--ca-page-mesh": "none" }) + .map(([k, v]) => `${k}:${v};`) + .join("")}}`; + +/** + * Rewrite the `:root` / `[data-theme-scope]` selectors `themeCSS` emits to `.scope`, so the recipe + * stays inside the island. Brand presets are keyed on `[data-preset]`, which works on any element. + */ +export function scopeCSS(css: string, scope: string): string { + return css + .replace(':root,\n[data-theme-scope="light"] {', `.${scope} {`) + .replace( + ':root.dark,\n:root [data-theme-scope="dark"] {', + `.${scope}[data-theme-scope="dark"] {` + ); +} + +/** + * `base.css` draws the glass mesh on `html[data-style="glass"]` through `body::before`, both outside an + * island, so a glass island has nothing to blur. This is the same recipe (brand-tinted radial gradients, + * stronger in dark), on the island itself: blur needs something colourful behind it to read. + */ +const MESH = { + light: `radial-gradient(ellipse 80% 60% at 0% 0%, color-mix(in oklab, var(--ca-brand-main) 22%, transparent) 0%, transparent 60%), radial-gradient(ellipse 60% 50% at 100% 100%, oklch(0.68 0.14 235 / 0.18) 0%, transparent 55%), radial-gradient(ellipse 50% 40% at 50% 50%, oklch(0.60 0.13 150 / 0.12) 0%, transparent 50%)`, + dark: `radial-gradient(ellipse 80% 60% at 0% 0%, color-mix(in oklab, var(--ca-brand-main) 42%, transparent) 0%, transparent 60%), radial-gradient(ellipse 60% 50% at 100% 100%, oklch(0.40 0.14 235 / 0.30) 0%, transparent 55%), radial-gradient(ellipse 50% 40% at 50% 50%, oklch(0.35 0.10 150 / 0.20) 0%, transparent 50%)`, +}; + +/** The mesh a glass or frosted island paints behind its content; empty for every other material. */ +export const meshCSS = (scope: string) => + `.${scope}[data-style="glass"]{--ca-page-mesh:${MESH.light};}\n.${scope}[data-style="glass"][data-theme-scope="dark"]{--ca-page-mesh:${MESH.dark};}`; + +/** CSS for the "before" island: the package default, with nothing of the page's own theme left. */ +export const beforeCSS = () => reset(`.${AFTER} .${BEFORE}[data-theme-scope]`); + +/** CSS for the "after" island: the package default, then the recipe, scoped. */ +export function afterCSS(id: DesignStyleId): string { + return `${reset(`.${AFTER}`)}\n${scopeCSS(themeCSS(defineTheme(designStyle(id))), AFTER)}\n${meshCSS(AFTER)}`; +} diff --git a/site/src/lib/visitor-theme.ts b/site/src/lib/visitor-theme.ts new file mode 100644 index 00000000..637924d6 --- /dev/null +++ b/site/src/lib/visitor-theme.ts @@ -0,0 +1,109 @@ +// ─── "Apply to site": a design style worn by this browser only ──────────────────────────────────── +// The recipe is compiled to CSS once and stored under the visitor-only key the Theme Studio uses +// (`ca-site-studio`). A tiny inline script in the layout (`StudioBoot.astro`) replays it before the +// first paint. Nothing here touches the published theme, the build output or the package defaults: +// remove the key and the site is exactly what ships. The visitor's own scheme and preset are never +// changed; only the surface material and the recipe's tokens are. +// +// The record is the same one the Studio writes (`v`, `css`, `preset`, `style`, `code`, `prev`), so +// "Reset" on either page clears what the other applied. + +import { + colorScheme, + DESIGN_STYLES, + type DesignStyleId, + defineTheme, + designStyle, + type SurfaceStyle, + themeCSS, +} from "@codeatlasdev/ui"; + +/** The visitor-only storage key. The boot script in `StudioBoot.astro` reads the same one. */ +export const SITE_KEY = "ca-site-studio"; +export const STYLE_ID = "ca-site-studio"; + +/** What is persisted. `css` is the compiled theme, so the boot script needs no library code. */ +export interface SiteTheme { + v: 1; + css: string; + /** `data-preset` to show: a preset id the page already knows. */ + preset: string; + /** `data-style` (surface material). */ + style: string; + /** A string the writer understands to reopen what is applied. A style page writes `style:`. */ + code: string; + /** What the visitor had before applying, restored by `resetSite`. */ + prev: { preset: string; style: string }; +} + +/** Compile a recipe to the CSS the whole page wears: what an app that uses `designStyle(id)` ships. */ +export function recipeCSS(id: DesignStyleId): string { + return themeCSS(defineTheme(designStyle(id))); +} + +export function readApplied(): SiteTheme | null { + try { + const v = JSON.parse(localStorage.getItem(SITE_KEY) ?? "null") as SiteTheme | null; + return v && v.v === 1 && typeof v.css === "string" ? v : null; + } catch { + return null; + } +} + +/** The style id a style page applied, when the stored record came from one. */ +export function appliedStyle(): DesignStyleId | null { + const code = readApplied()?.code ?? ""; + const id = code.startsWith("style:") ? code.slice(6) : ""; + return id in DESIGN_STYLES ? (id as DesignStyleId) : null; +} + +function writeStyle(css: string) { + let el = document.getElementById(STYLE_ID); + if (!css) return el?.remove(); + if (!el) { + el = document.createElement("style"); + el.id = STYLE_ID; + } + el.textContent = css; + // Last in the head: the site's own theme and component styles share these selectors. + document.head.appendChild(el); +} + +/** Make the whole site wear the recipe `id`, for this browser only. Returns the stored record. */ +export function applyStyleToSite(id: DesignStyleId): SiteTheme { + const css = recipeCSS(id); + const style = (DESIGN_STYLES[id].theme.defaultStyle ?? "solid") as SurfaceStyle; + const previous = readApplied(); + const applied: SiteTheme = { + v: 1, + css, + preset: colorScheme.preset, + style, + code: `style:${id}`, + prev: previous?.prev ?? { preset: colorScheme.preset, style: colorScheme.style }, + }; + try { + localStorage.setItem(SITE_KEY, JSON.stringify(applied)); + } catch { + /* private mode / quota: the theme still shows for this page */ + } + writeStyle(css); + colorScheme.setStyle(style); + return applied; +} + +/** Back to what the site ships: drops the stored theme and restores the visitor's own choices. */ +export function resetSite(): void { + const applied = readApplied(); + try { + localStorage.removeItem(SITE_KEY); + } catch { + /* nothing stored */ + } + writeStyle(""); + if (applied) { + colorScheme.setStyle(applied.prev.style as SurfaceStyle); + if (colorScheme.presets.includes(applied.prev.preset)) + colorScheme.setPreset(applied.prev.preset); + } +} diff --git a/site/src/pages/llms/styles.txt.ts b/site/src/pages/llms/styles.txt.ts new file mode 100644 index 00000000..2a55936d --- /dev/null +++ b/site/src/pages/llms/styles.txt.ts @@ -0,0 +1,8 @@ +import { buildStylesIndex } from "../../../../packages/ui/scripts/styles-markdown"; + +/** `/llms/styles.txt`: the digest of every design-style page, each linking its own file. */ +export function GET() { + return new Response(buildStylesIndex(), { + headers: { "content-type": "text/plain; charset=utf-8" }, + }); +} diff --git a/site/src/pages/llms/styles/[id].txt.ts b/site/src/pages/llms/styles/[id].txt.ts new file mode 100644 index 00000000..8ab059cb --- /dev/null +++ b/site/src/pages/llms/styles/[id].txt.ts @@ -0,0 +1,13 @@ +import type { GetStaticPaths } from "astro"; +import { STYLE_LIST, type StyleDoc } from "~/data/styles/styles"; +import { buildStyleMarkdown } from "../../../../../packages/ui/scripts/styles-markdown"; + +/** `/llms/styles/.txt`: one design-style page as markdown, with each claim's source, strength and scope. */ +export const getStaticPaths = (() => + STYLE_LIST.map((doc) => ({ params: { id: doc.id }, props: { doc } }))) satisfies GetStaticPaths; + +export function GET({ props }: { props: { doc: StyleDoc } }) { + return new Response(buildStyleMarkdown(props.doc), { + headers: { "content-type": "text/plain; charset=utf-8" }, + }); +} diff --git a/site/src/pages/styles/[id].astro b/site/src/pages/styles/[id].astro new file mode 100644 index 00000000..f42bfda0 --- /dev/null +++ b/site/src/pages/styles/[id].astro @@ -0,0 +1,192 @@ +--- +import { CodeSnippet } from "@codeatlasdev/ui/code"; +import type { GetStaticPaths } from "astro"; +import Claim from "~/components/docs/Claim.astro"; +import Limits from "~/components/docs/Limits.astro"; +import Refs from "~/components/docs/Refs.astro"; +import SiteFooter from "~/components/site/SiteFooter.astro"; +import SiteHeader from "~/components/site/SiteHeader.svelte"; +import ApplyStyle from "~/components/styles/ApplyStyle.svelte"; +import Fits from "~/components/styles/Fits.svelte"; +import Showcase from "~/components/styles/Showcase.svelte"; +import StyleView from "~/components/styles/StyleView.svelte"; +import { + recipeKnobs, + recipeSource, + STYLE_LIST, + type StyleDoc, + stylePrompt, + styleHref, + styleReferences, + TRAIT_AXES, +} from "~/data/styles/styles"; +import Base from "~/layouts/Base.astro"; +import { SECTIONS } from "~/lib/nav"; +import { DESIGN_STYLES } from "@codeatlasdev/ui"; + +export const getStaticPaths = (() => + STYLE_LIST.map((doc) => ({ params: { id: doc.id }, props: { doc } }))) satisfies GetStaticPaths; + +const { doc } = Astro.props as { doc: StyleDoc }; +const recipe = DESIGN_STYLES[doc.id].theme; +const material = recipe.defaultStyle ?? "solid"; +const scheme = recipe.defaultScheme === "dark" ? "dark" : "light"; +const refs = styleReferences(doc); +const AXIS_LABEL = { type: "Type", colour: "Colour", shape: "Shape", depth: "Depth", motion: "Motion" } as const; +const others = STYLE_LIST.filter((s) => s.id !== doc.id); +--- + + + +
+
+

← Design styles

+

{doc.name}

+

{doc.tagline}

+

+ Rendered in designStyle("{doc.id}").{" "} + Atlas terms: {doc.atlas.map((a, i) => (<>{i > 0 && ", "}{a}))}. +

+
+ +
+

Where it comes from

+ {doc.origin.map((p) =>

{p}

)} +

Written by us, in our own words, as background. It is not a claim about users.

+
+ +
+

Traits

+
+ {TRAIT_AXES.map((axis) => ( +
+
{AXIS_LABEL[axis]}
+
+

{doc.traits[axis].summary}

+

{doc.traits[axis].tokens.map((t, i) => (<>{i > 0 && " "}{t}))}

+
+
+ ))} +
+
+ +
+

Before and after, on real components

+

+ The same components twice: with the package default and with this style. Use the toggle to flip between them or put them side by side. +

+ +
+ +
+

Do and don't

+
+
+

Do

+
    {doc.dos.map((d) =>
  • {d}
  • )}
+
+
+

Don't

+
    {doc.donts.map((d) =>
  • {d}
  • )}
+
+
+

These are our own guidance for using the recipe well. The accessibility items below carry their sources.

+
+ +
+

Accessibility pitfalls

+

Each item names what goes wrong, what the library already does about it and what is left to you.

+ {doc.pitfalls.map((p) => ( + <> +

{p.title}

+ +

{p.claim.text}

+
+

What to do. {p.fix}

+ + ))} +
+ +
+

Components that fit

+

{doc.fitsWhen}

+

Avoid it when: {doc.avoidWhen}

+

Where it fits is our judgement from using the recipe, not a measured result.

+ + f.component)} /> +

Overlays such as Dialog and Tooltip render in the page body, outside this style's subtree, so they are not shown here. Apply the style to the whole site to see them wear it.

+
+ +
+

The ThemeOptions recipe

+

One call in src/lib/theme.ts. Everything below is what the recipe sets; override any of it on top.

+ +

With your own brand colour on top:

+ +
+ Every knob this recipe sets + +
+

Try it on this site

+ +
+ +
+

Prompt for your AI

+

Paste this into a coding agent. It carries the recipe, the limits and the accessibility rules.

+ +
+ + +
    {doc.limits.map((l) =>
  • {l}
  • )}
+
+ + r.id).join(", ")} /> + + +
+
+ + + + diff --git a/site/src/pages/styles/index.astro b/site/src/pages/styles/index.astro new file mode 100644 index 00000000..6ffe9b12 --- /dev/null +++ b/site/src/pages/styles/index.astro @@ -0,0 +1,51 @@ +--- +import { CardGrid, LinkCard } from "@codeatlasdev/ui"; +import SiteFooter from "~/components/site/SiteFooter.astro"; +import SiteHeader from "~/components/site/SiteHeader.svelte"; +import { STYLE_LIST, styleHref } from "~/data/styles/styles"; +import Base from "~/layouts/Base.astro"; +import { SECTIONS } from "~/lib/nav"; +--- + + +
+
+

Design styles

+

One theme object, six languages

+

+ Every page below is rendered in the style it describes: open it and the type, corners, depth and motion you see are + designStyle("…") applied to that page. Each one shows the same components before and after, the recipe to copy, what goes wrong for accessibility and a prompt for your coding agent. +

+
+ + {STYLE_LIST.map((s) => ( + {s.tagline} + ))} + +
+

How to read these pages

+
    +
  • The recipes are proposals built from the library's design knobs, not measurements of any named visual language.
  • +
  • Claims about accessibility cite the standard or source they rest on, with a strength label. None of them says a style performs better than another. How we use evidence.
  • +
  • Want the broader map? The design atlas lists the terms these recipes implement.
  • +
  • Agents: /llms/styles.txt is the digest, and the MCP server has get_style.
  • +
+
+
+ + + + diff --git a/site/tests/header.spec.ts b/site/tests/header.spec.ts index fee1b555..882d51ed 100644 --- a/site/tests/header.spec.ts +++ b/site/tests/header.spec.ts @@ -70,6 +70,7 @@ for (const [name, width, height] of [ expect(ref.nav.map((n) => n.text)).toEqual([ "Docs", "Gallery", + "Styles", "Templates", "Theme editor", "Changelog", @@ -99,6 +100,7 @@ test("the current section is underlined on its own page and nowhere else", async ["/docs/", "Docs"], ["/changelog/", "Changelog"], ["/gallery/", "Gallery"], + ["/styles/", "Styles"], ]; for (const [route, label] of current) { await gotoReady(page, route); diff --git a/site/tests/styles.spec.ts b/site/tests/styles.spec.ts new file mode 100644 index 00000000..63be358d --- /dev/null +++ b/site/tests/styles.spec.ts @@ -0,0 +1,171 @@ +import type { Page } from "@playwright/test"; +import { expect, expectNoViolations, gotoReady, makeAxe, settleDom, test } from "./fixtures"; + +const SITE_KEY = "ca-site-studio"; +const IDS = ["swiss", "editorial", "brutalism", "sketchbook", "glassmorphism", "terminal"] as const; + +const px = (page: Page, sel: string, prop: string) => + page + .locator(sel) + .first() + .evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), prop); + +test.describe("design styles (#583)", () => { + test("/styles/ lists the six styles and each link opens its page", async ({ page }) => { + await gotoReady(page, "/styles/"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + for (const id of IDS) + await expect(page.locator(`main a[href="/styles/${id}/"]`)).toHaveCount(1); + await page.locator('main a[href="/styles/brutalism/"]').click(); + await expect(page).toHaveURL(/\/styles\/brutalism\/$/); + await expect(page.getByRole("heading", { level: 1, name: "Brutalism" })).toBeVisible(); + }); + + test("the page is rendered in its style while the site header keeps the site's own", async ({ + page, + }) => { + await gotoReady(page, "/styles/brutalism/"); + const frame = ".sx-frame"; + // Brutalism: 3px strokes, square corners, hard shadow, uppercase 900 headings, in the page... + expect(await px(page, frame, "--ca-border-width")).toBe("3px"); + expect(await px(page, frame, "--ca-radius-scale")).toBe("0"); + expect(await px(page, `${frame} h1`, "text-transform")).toBe("uppercase"); + expect(await px(page, `${frame} h1`, "font-weight")).toBe("900"); + // ...and the header above it still wears the sketchbook knobs of the site (2px strokes). + expect(await px(page, "header.ca-docs-header", "--ca-border-width")).toBe("2px"); + }); + + for (const id of IDS) { + test(`/styles/${id}/ has every section, an h1 and one main`, async ({ page }) => { + await gotoReady(page, `/styles/${id}/`); + await expect(page.locator("main#main")).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1); + for (const h of [ + "Where it comes from", + "Traits", + "Before and after, on real components", + "Do and don't", + "Accessibility pitfalls", + "Components that fit", + "The ThemeOptions recipe", + "Prompt for your AI", + "Limits", + "References", + ]) + await expect(page.getByRole("heading", { level: 2, name: h })).toBeVisible(); + await expect(page.getByText(`designStyle("${id}")`).first()).toBeVisible(); + }); + } + + test("before and after change real computed styles on the same components", async ({ page }) => { + await gotoReady(page, "/styles/brutalism/"); + const after = '[data-testid="sc-after"]'; + const before = '[data-testid="sc-before"]'; + await expect(page.locator(after)).toBeVisible(); + await expect(page.locator(before)).toHaveCount(0); + + const afterBtn = `${after} button:has-text("Save")`; + const afterRadius = await px(page, afterBtn, "border-top-left-radius"); + const afterWidth = await px(page, after, "--ca-border-width"); + expect(afterWidth).toBe("3px"); + expect(afterRadius).toBe("0px"); + + await page.getByRole("radio", { name: "Side by side" }).click(); + await expect(page.locator(before)).toBeVisible(); + await expect(page.locator(after)).toBeVisible(); + const beforeBtn = `${before} button:has-text("Save")`; + // The package default inside a brutalist page: 1px strokes, rounded controls. + expect(await px(page, before, "--ca-border-width")).toBe("1px"); + expect(await px(page, beforeBtn, "border-top-left-radius")).not.toBe("0px"); + // The same markup on both sides. + expect(await page.locator(`${before} button`).count()).toBe( + await page.locator(`${after} button`).count() + ); + }); + + test("Before shows the package default and the toggle is keyboard operable", async ({ page }) => { + await gotoReady(page, "/styles/swiss/"); + await page.getByRole("radio", { name: "Before", exact: true }).focus(); + await page.keyboard.press("Space"); + await expect(page.locator('[data-testid="sc-before"]')).toBeVisible(); + await expect(page.locator('[data-testid="sc-after"]')).toHaveCount(0); + expect(await px(page, '[data-testid="sc-before"]', "--ca-radius-scale")).toBe("0.8"); + }); + + test("the recipe is copyable and is exactly designStyle(id)", async ({ page }) => { + await gotoReady(page, "/styles/terminal/"); + const recipe = page.locator("main").getByText('designStyle("terminal")').first(); + await expect(recipe).toBeVisible(); + await expect(page.getByText("import { designStyle, type ThemeOptions }").first()).toBeVisible(); + }); + + test("Apply to this site writes only the visitor-only key and Reset removes it", async ({ + page, + }) => { + await gotoReady(page, "/styles/editorial/"); + const ls = (k: string) => page.evaluate((key) => localStorage.getItem(key), k); + expect(await ls(SITE_KEY)).toBeNull(); + const published = await page.evaluate(() => + document.documentElement.getAttribute("data-style") + ); + + await page.getByRole("button", { name: "Apply to this site" }).click(); + const stored = JSON.parse((await ls(SITE_KEY)) ?? "null"); + expect(stored.v).toBe(1); + expect(stored.code).toBe("style:editorial"); + expect(stored.css).toContain("--ca-font-scale: 1.06;"); + // The whole page now wears it, and the visitor's own scheme was not touched. + expect(await px(page, "html", "--ca-font-scale")).toBe("1.06"); + expect(await ls("ca-scheme")).not.toContain("editorial"); + + // It survives a reload before first paint (the inline boot script replays it). + await page.reload(); + expect(await px(page, "html", "--ca-font-scale")).toBe("1.06"); + + await gotoReady(page, "/styles/editorial/"); + await page.getByRole("button", { name: "Reset the site" }).click(); + expect(await ls(SITE_KEY)).toBeNull(); + expect(await px(page, "html", "--ca-font-scale")).not.toBe("1.06"); + expect(await page.evaluate(() => document.documentElement.getAttribute("data-style"))).toBe( + published + ); + }); + + test("the published default is never changed by applying", async ({ page, request }) => { + const before = await (await request.get("/styles/swiss/")).text(); + await gotoReady(page, "/styles/swiss/"); + await page.getByRole("button", { name: "Apply to this site" }).click(); + const after = await (await request.get("/styles/swiss/")).text(); + expect(after).toBe(before); + }); + + test("the agent twins exist: digest, one file per style", async ({ request }) => { + const digest = await request.get("/llms/styles.txt"); + expect(digest.ok()).toBe(true); + const text = await digest.text(); + for (const id of IDS) { + expect(text).toContain(`/llms/styles/${id}.txt`); + const one = await request.get(`/llms/styles/${id}.txt`); + expect(one.ok()).toBe(true); + expect(await one.text()).toContain(`designStyle("${id}")`); + } + const llms = await (await request.get("/llms.txt")).text(); + expect(llms).toContain("/llms/styles.txt"); + }); + + for (const id of IDS) { + test(`/styles/${id}/ has no axe violations in light and dark`, async ({ page }, testInfo) => { + await gotoReady(page, `/styles/${id}/`); + // CodeSnippet shows a raw
 until shiki resolves; axe must see the final highlighted block.
+			await page.waitForFunction(
+				() => !document.querySelector(".ca-code-snippet__pre--raw:not(details:not([open]) *)")
+			);
+			for (const scheme of ["Light", "Dark"]) {
+				await page.getByRole("radio", { name: scheme, exact: true }).click();
+				// The scheme change transitions colors: axe must read the final ones, not a frame in flight.
+				await settleDom(page);
+				await expectNoViolations(await makeAxe(page).analyze(), testInfo, `${id}-${scheme}`);
+			}
+		});
+	}
+});