From 812bf4da949696a11e3ee68a6b798d997959345c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 02:34:17 -0300 Subject: [PATCH 1/5] chore: start #572 From 758d9243958c5e31a40889cd7b01e099e4c0a68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 03:07:11 -0300 Subject: [PATCH 2/5] feat: design atlas (269 terms), browse, term pages, MCP search_terms (#572) --- .agents/skills/codeatlas-ui/SKILL.md | 2 +- .changeset/design-atlas-mcp.md | 5 + AGENTS.md | 2 +- packages/ui/bin/ca-ui-mcp.ts | 84 +- packages/ui/scripts/ai-entry.ts | 17 + packages/ui/scripts/atlas-markdown.ts | 184 + packages/ui/scripts/build-manifest.ts | 4 + packages/ui/tests/ai-entry.test.ts | 1 + packages/ui/tests/atlas.test.ts | 145 + packages/ui/tests/mcp-atlas.test.ts | 71 + site/src/components/atlas/AtlasBrowser.svelte | 263 + site/src/data/atlas/atlas.ts | 337 + site/src/data/atlas/terms.json | 5923 +++++++++++++++++ site/src/pages/atlas/[slug].astro | 143 + site/src/pages/atlas/index.astro | 38 + site/src/pages/llms/atlas.txt.ts | 8 + site/src/pages/llms/atlas/[slug].txt.ts | 16 + site/src/styles/site.css | 32 + 18 files changed, 7272 insertions(+), 3 deletions(-) create mode 100644 .changeset/design-atlas-mcp.md create mode 100644 packages/ui/scripts/atlas-markdown.ts create mode 100644 packages/ui/tests/atlas.test.ts create mode 100644 packages/ui/tests/mcp-atlas.test.ts create mode 100644 site/src/components/atlas/AtlasBrowser.svelte create mode 100644 site/src/data/atlas/atlas.ts create mode 100644 site/src/data/atlas/terms.json create mode 100644 site/src/pages/atlas/[slug].astro create mode 100644 site/src/pages/atlas/index.astro create mode 100644 site/src/pages/llms/atlas.txt.ts create mode 100644 site/src/pages/llms/atlas/[slug].txt.ts diff --git a/.agents/skills/codeatlas-ui/SKILL.md b/.agents/skills/codeatlas-ui/SKILL.md index 795d7655..ef367778 100644 --- a/.agents/skills/codeatlas-ui/SKILL.md +++ b/.agents/skills/codeatlas-ui/SKILL.md @@ -109,7 +109,7 @@ Icons passed to components (`icon: Settings`) are `@lucide/svelte` components. T Never guess a prop name. This skill and `API.md` track the `main` branch, which can be ahead of the `@codeatlasdev/ui` the project has installed: a prop, snippet or MCP tool listed here may not exist in the installed version. Check the installed version first (`node_modules/@codeatlasdev/ui/package.json`) and prefer what that package answers over this file. Two sources, both generated from the source types: -1. **MCP server (preferred).** `ca-ui-mcp` answers `get_component("Button")` (props, defaults, snippets, `styling`: stable `ca-*` parts, scoped variables, `data-*` attributes), `list_parts("Button")` (just the override surface: call it before writing a `.ca-*` selector), `get_export("setLocale")` (signature and summary of any function, store, constant or type: `caErrorPlugin`, `createErrorHandler`, `nativeFeel`, `primeKeyboard`, `toast`...), `list_components`, `search_tokens("safe-bottom")`, `list_surfaces()` (the `data-style` surface styles and which draw a blur) and `get_guide("theming")`. Setup: `claude mcp add codeatlas-ui -- npx -y -p @codeatlasdev/ui ca-ui-mcp`. +1. **MCP server (preferred).** `ca-ui-mcp` answers `get_component("Button")` (props, defaults, snippets, `styling`: stable `ca-*` parts, scoped variables, `data-*` attributes), `list_parts("Button")` (just the override surface: call it before writing a `.ca-*` selector), `get_export("setLocale")` (signature and summary of any function, store, constant or type: `caErrorPlugin`, `createErrorHandler`, `nativeFeel`, `primeKeyboard`, `toast`...), `list_components`, `search_tokens("safe-bottom")`, `list_surfaces()` (the `data-style` surface styles and which draw a blur) `get_guide("theming")`, and for the design atlas (269 terms; the 1-5 scales are heuristic, the sources support principles not styles) `search_terms("swiss")`, `get_term("DS001")` and `get_reference("E05")`. Setup: `claude mcp add codeatlas-ui -- npx -y -p @codeatlasdev/ui ca-ui-mcp`. 2. **[API.md](./API.md)**: the same data as one markdown file; its first lines name the version it was generated for. Offline fallback; for the installed version read `node_modules/@codeatlasdev/ui/dist/components.json` instead. Large, search it instead of reading it whole. Components first, then the `# Non-component exports` section grouped by import path. Regenerate with `bun run --cwd packages/ui manifest -- --skill` after changing a component. ## Mobile, theme, SEO diff --git a/.changeset/design-atlas-mcp.md b/.changeset/design-atlas-mcp.md new file mode 100644 index 00000000..39a29162 --- /dev/null +++ b/.changeset/design-atlas-mcp.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +`ca-ui-mcp` gains `search_terms`, `get_term` and `get_reference`: the 269-term design atlas and the verified sources behind it, offline from `dist/atlas.json`. Scores are labelled heuristic, every term carries its limits and review date, and a reference returns what it does not support and the wording that was read. diff --git a/AGENTS.md b/AGENTS.md index 7cacccad..4dfa8a64 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`) 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`, 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 99d64681..70f1bbff 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, - * 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). + * 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). */ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -15,6 +15,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import type { Recipe } from "../scripts/ai-entry.ts"; +import type { AtlasFile } from "../scripts/atlas-markdown.ts"; import type { Manifest, ManifestDemo, @@ -61,6 +62,9 @@ const recipes = existsSync(join(DIST, "recipes.json")) const demos = existsSync(join(DIST, "demos.json")) ? load("demos.json") : []; // The published facets only (a facet still under review is never searchable), same entries as api/v1/index.json. const entries = indexEntries(demos); +// Like demos: a build from the tarball has no site/ data, and the other tools must keep working. +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)."; const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] }); @@ -474,6 +478,84 @@ server.registerTool( } ); +server.registerTool( + "search_terms", + { + description: + "Search the design atlas: 269 terms for aesthetics, layouts, interaction patterns and design-system languages. Matches term, category, definition, traits and sectors (every word of the query must match). Returns one line per term with its status and the three 1-5 scales, which are heuristic editorial judgements, not study results. Use get_term for the full entry.", + inputSchema: { query: z.string().optional(), category: z.string().optional() }, + }, + ({ query, category }) => { + if (!atlas) return fail(noAtlas); + const words = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean); + const cat = category ? squash(category) : ""; + const hits = atlas.terms.filter( + (t) => (!cat || squash(t.category) === cat) && words.every((w) => t.search.includes(w)) + ); + if (!hits.length) + return fail( + `No atlas term matches "${query ?? ""}"${category ? ` in "${category}"` : ""}. Categories: ${[...new Set(atlas.terms.map((t) => t.category))].join(", ")}.` + ); + return text( + `${hits.length} of ${atlas.terms.length} terms${hits.length > 40 ? " (first 40)" : ""}:\n${hits + .slice(0, 40) + .map((t) => t.line) + .join("\n")}` + ); + } +); + +server.registerTool( + "get_term", + { + description: + "One design atlas term as markdown: definition, traits, when to use it and when not, UX risk and mitigation, evidence with what the sources do not support, the limits, the heuristic scales, our design-style recipes and a prompt for your AI. `id` is DS001 to DS269, a slug (swiss-style) or the term's name.", + inputSchema: { id: z.string() }, + }, + ({ id }) => { + if (!atlas) return fail(noAtlas); + const n = squash(id); + const hit = atlas.terms.find( + (t) => + t.id.toLowerCase() === id.trim().toLowerCase() || + squash(t.slug) === n || + squash(t.term) === n + ); + if (!hit) { + const near = atlas.terms.filter((t) => squash(t.term).includes(n)).slice(0, 8); + return fail( + `No atlas term "${id}".${near.length ? ` Did you mean: ${near.map((t) => `${t.id} ${t.term}`).join(", ")}?` : " Use search_terms."}` + ); + } + return text(hit.text); + } +); + +server.registerTool( + "get_reference", + { + description: + "One verified source behind the atlas and the concept pages (ids E01-E22, N, W, M, D): citation, kind, strength label, what it supports, what it does not support, the date it was opened and the wording read. Without `id`, lists every source.", + inputSchema: { id: z.string().optional() }, + }, + ({ id }) => { + if (!atlas) return fail(noAtlas); + if (!id) + return text( + atlas.references + .map((r) => { + const [head] = r.text.split("\n"); + return `- ${head.replace(/^# /, "")}`; + }) + .join("\n") + ); + const hit = atlas.references.find((r) => r.id.toLowerCase() === id.trim().toLowerCase()); + return hit + ? text(hit.text) + : fail(`No reference "${id}". Call get_reference without an id to list them.`); + } +); + server.registerTool( "report_issue", { diff --git a/packages/ui/scripts/ai-entry.ts b/packages/ui/scripts/ai-entry.ts index 249db2f6..e8282621 100644 --- a/packages/ui/scripts/ai-entry.ts +++ b/packages/ui/scripts/ai-entry.ts @@ -127,6 +127,23 @@ export function buildLlmsIndex(i: { heading: "Setup", links: [page(ENTRY_PAGES.install, "Installation"), page(ENTRY_PAGES.aiAgents, "AI agents")], }, + { + heading: "Design atlas", + links: [ + { + title: "Design atlas", + href: `${SITE}/llms/atlas.txt`, + description: + "269 terms for aesthetics, layouts, interaction patterns and design-system languages, by category; one file per term at /llms/atlas/.txt, or ask the MCP server (`search_terms`, `get_term`). The 1 to 5 scores are heuristic and an audience is a hypothesis", + }, + { + title: "How we use evidence", + href: `${SITE}${llmsPagePath("concepts/evidence")}`, + description: + "the five strength labels and the rules every concept and atlas entry follows: read it before you quote a claim about users", + }, + ], + }, { heading: "Machine-readable endpoints", links: [ diff --git a/packages/ui/scripts/atlas-markdown.ts b/packages/ui/scripts/atlas-markdown.ts new file mode 100644 index 00000000..3def68c5 --- /dev/null +++ b/packages/ui/scripts/atlas-markdown.ts @@ -0,0 +1,184 @@ +// The atlas as plain markdown for agents: one file per term (`/llms/atlas/.txt`), an index of all terms +// (`/llms/atlas.txt`) and the compact line the MCP `search_terms` returns. Pure functions over the dataset, so the +// site endpoints, the MCP build and the tests share one text. +import { + ATLAS_LIMITS, + ATLAS_REVIEWED, + EVIDENCE_LABEL, + guidance, + SCORE_LABEL, + SCORE_NAMES, + STANDARDIZATION_LABEL, + STATUS_LABEL, + STATUS_MEANING, + stylesFor, + TERMS, + type Term, + TREND_LABEL, + termPrompt, + termReferences, + termSlug, +} from "../../../site/src/data/atlas/atlas.ts"; +import { + REFERENCES, + type Reference, + STRENGTH_LABEL, +} from "../../../site/src/data/evidence/references.ts"; + +export const SITE = "https://ui.codeatlas.com.br"; + +/** Path of a term's markdown twin. */ +export const atlasTermPath = (t: Pick) => `/llms/atlas/${termSlug(t)}.txt`; + +/** One term: everything the page shows, with the caveats attached to the values they qualify. */ +export function buildTermMarkdown(t: Term): string { + const g = guidance(t); + const styles = stylesFor(t.id); + const refs = termReferences(t); + return [ + `# ${t.term}`, + "", + `${t.id}. ${t.category}: ${t.subcategory}. Page: ${SITE}/atlas/${termSlug(t)}/. Index: ${SITE}/llms/atlas.txt`, + "", + `${t.definition}.`, + "", + `- Status in 2026: ${STATUS_LABEL[t.status]}. ${STATUS_MEANING[t.status]}`, + `- Trend 2027 to 2030: ${TREND_LABEL[t.trend]} (an editorial projection, not a forecast).`, + `- The term: ${STANDARDIZATION_LABEL[t.standardization]}.`, + "", + "## Traits", + "", + ...t.traits.map((x) => `- ${x}`), + "", + `Typical settings: ${t.sectors.join(", ")}.`, + "", + "## When to use it, and when not", + "", + g.whenToUse, + "", + g.whenNot, + "", + `Audience (a hypothesis about motivation and task, never measured, not a statement about any group): ${t.audience}.`, + "", + "## UX risk and how to reduce it", + "", + `- Risk: ${t.risk}.`, + `- Mitigation and accessibility: ${t.mitigation}.`, + `- How to check: ${g.validate}`, + "", + "## Evidence", + "", + `${EVIDENCE_LABEL[t.evidence]}.`, + "", + "The sources support the principles that apply to this term. None shows that this style works better or is preferred.", + "", + ...refs.map( + (r) => + `- [${r.id}] ${r.authors} (${r.year}). ${r.title}. ${r.url} (${STRENGTH_LABEL[r.strength]}). Does not support: ${r.doesNotSupport}` + ), + "", + "## Limits", + "", + ...ATLAS_LIMITS.map((x) => `- ${x}`), + "", + `Last reviewed ${ATLAS_REVIEWED}.`, + "", + "## Heuristic scales", + "", + "Relative editorial judgements from 1 to 5, not results of a study.", + "", + ...SCORE_NAMES.map((n) => `- ${SCORE_LABEL[n]}: ${t.scores[n]} / 5 (heuristic)`), + "", + "## In our library", + "", + ...(styles.length + ? styles.map((s) => `- designStyle("${s.id}"): ${s.label}. ${s.description}`) + : ["No design-style recipe implements this term yet; build it from `--ca-*` tokens."]), + "", + "## Prompt for your AI", + "", + "```text", + termPrompt(t), + "```", + "", + ].join("\n"); +} + +/** One line per term: what `search_terms` returns and what the index lists. */ +export function termLine(t: Term): string { + return `${t.id} ${t.term} (${t.category}: ${t.subcategory}; ${STATUS_LABEL[t.status].toLowerCase()}; complexity ${t.scores.complexity}, expressiveness ${t.scores.expressiveness}, density ${t.scores.density}, heuristic): ${t.definition}`; +} + +/** The index of the atlas by category, linking each term's own file. */ +export function buildAtlasIndexMarkdown(terms: readonly Term[] = TERMS): string { + const cats = [...new Set(terms.map((t) => t.category))]; + return [ + "# Design atlas", + "", + `> ${terms.length} terms for aesthetics, layouts, interaction patterns and design-system languages. A map, not a verdict. Page: ${SITE}/atlas/. Index: ${SITE}/llms.txt`, + "", + "How to read it: the 1 to 5 scores are heuristic editorial judgements; an audience is a hypothesis about motivation and task, never measured; the references behind a term support underlying principles, not the style. Validate a choice with your own users.", + `Method and limits: ${SITE}/llms/concepts/evidence.txt`, + "", + "Limits:", + ...ATLAS_LIMITS.map((x) => `- ${x}`), + `Last reviewed ${ATLAS_REVIEWED}.`, + "", + ...cats.flatMap((c) => [ + `## ${c}`, + "", + ...terms + .filter((t) => t.category === c) + .map((t) => `- [${t.term}](${SITE}${atlasTermPath(t)}): ${t.definition}`), + "", + ]), + ].join("\n"); +} + +/** One reference as markdown: the source, what it supports and does not, and what was read when. */ +export function buildReferenceMarkdown(r: Reference): string { + return [ + `# [${r.id}] ${r.title}`, + "", + `${r.authors} (${r.year}). ${r.venue}. ${r.url}`, + "", + `- Kind: ${r.kind}. Strength: ${STRENGTH_LABEL[r.strength]}.`, + `- Topic: ${r.topic}.`, + `- Supports: ${r.supports}`, + `- Does not support: ${r.doesNotSupport}`, + `- Checked ${r.verified.date} (${r.verified.level === "claim-read" ? "wording read at the source" : "record only; text not read, so no claim may rest on it"}): ${r.verified.how}.`, + `- Excerpt: "${r.verified.excerpt}"`, + "", + ].join("\n"); +} + +/** What `dist/atlas.json` holds: enough for the three MCP tools to answer with no network. */ +export interface AtlasFile { + terms: { + id: string; + slug: string; + term: string; + category: string; + line: string; + text: string; + search: string; + }[]; + references: { id: string; text: string }[]; +} + +export function buildAtlasFile(terms: readonly Term[] = TERMS): AtlasFile { + return { + terms: terms.map((t) => ({ + id: t.id, + slug: termSlug(t), + term: t.term, + category: t.category, + line: termLine(t), + text: buildTermMarkdown(t), + search: [t.term, t.category, t.subcategory, t.definition, ...t.traits, ...t.sectors] + .join(" ") + .toLowerCase(), + })), + references: REFERENCES.map((r) => ({ id: r.id, text: buildReferenceMarkdown(r) })), + }; +} diff --git a/packages/ui/scripts/build-manifest.ts b/packages/ui/scripts/build-manifest.ts index 09a92a06..9e6ae812 100644 --- a/packages/ui/scripts/build-manifest.ts +++ b/packages/ui/scripts/build-manifest.ts @@ -5,6 +5,8 @@ * dist/tokens.json every `--ca-*` custom property (tokens.css + built-in presets + brand) * dist/surfaces.json every `` surface style, from materials.css (offline `list_surfaces`) * 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/) * --skill also rewrites .agents/skills/codeatlas-ui/API.md (components, then non-component exports) * * bun scripts/build-manifest.ts [--check] @@ -29,6 +31,7 @@ import { DESIGN_STYLES } from "../src/lib/theme/styles.ts"; import { ENTRY_PAGES, parseRecipes, type Recipe } from "./ai-entry.ts"; import { buildIndexFiles } from "./index-build.ts"; import { checkIdRange, checkIndexFacets, ID_PATTERN, type IndexFacets } from "./index-schema.ts"; +import { buildAtlasFile } from "./atlas-markdown.ts"; import { demoSources, mdxToMarkdown } from "./mdx-to-markdown.ts"; // CJS require keeps svelte2tsx out of Vite's transform pipeline (its sourceMappingURL points at a file it does not ship). @@ -1682,6 +1685,7 @@ if (import.meta.main) { recipes: buildRecipes(guides), }); } + if (existsSync(DEMOS_DIR)) write("atlas.json", buildAtlasFile()); const { demos, files } = catalog.length ? buildDemos(manifest, catalog) : { demos: [], files: {} }; diff --git a/packages/ui/tests/ai-entry.test.ts b/packages/ui/tests/ai-entry.test.ts index 041934be..127d7a6c 100644 --- a/packages/ui/tests/ai-entry.test.ts +++ b/packages/ui/tests/ai-entry.test.ts @@ -251,6 +251,7 @@ describe("llms.txt links the agent-readable .txt form of every page and recipe ( ...slugs.map((s) => `${SITE}${llmsPagePath(s)}`), ...recipes.map((r) => `${SITE}${llmsRecipePath(r.id)}`), `${SITE}/llms-full.txt`, + `${SITE}/llms/atlas.txt`, ]); 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 new file mode 100644 index 00000000..e04bc3aa --- /dev/null +++ b/packages/ui/tests/atlas.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "bun:test"; +import { + CATEGORIES, + checkAtlas, + facetCounts, + filterTerms, + getTerm, + guidance, + STYLE_PAGES, + styleHref, + stylesFor, + TERMS, + termPrompt, + termReferences, + termSlug, +} from "../../../site/src/data/atlas/atlas.ts"; +import { BANNED_PHRASES } from "../../../site/src/data/evidence/references.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. +// Rules of reading: site/src/content/docs/concepts/evidence.mdx. + +describe("atlas dataset", () => { + it("has the 269 terms of the taxonomy and passes every structural rule", () => { + expect(TERMS).toHaveLength(269); + expect(checkAtlas(TERMS)).toEqual([]); + }); + + it("numbers the terms DS001 to DS269 without gaps", () => { + expect(TERMS.map((t) => t.id)).toEqual( + Array.from({ length: 269 }, (_, i) => `DS${String(i + 1).padStart(3, "0")}`) + ); + }); + + it("uses every category and each of them at least once", () => { + const seen = new Set(TERMS.map((t) => t.category)); + expect([...seen].sort()).toEqual([...CATEGORIES].sort()); + }); + + it("rejects a score outside 1-5, an unknown source and a duplicate slug", () => { + const base = TERMS[0]; + const bad = checkAtlas([ + { ...base, scores: { ...base.scores, density: 6 } }, + { ...TERMS[1], refs: ["E99"] }, + { ...TERMS[2], id: "DS900", term: base.term }, + ]); + expect(bad.some((e) => e.includes("score density"))).toBe(true); + expect(bad.some((e) => e.includes("unknown reference E99"))).toBe(true); + expect(bad.some((e) => e.includes("also used by DS001"))).toBe(true); + }); + + it("never claims proof", () => { + const text = JSON.stringify(TERMS).toLowerCase(); + for (const b of BANNED_PHRASES) expect(text).not.toContain(b); + }); + + it("describes an audience by motivation or task, never by age or gender", () => { + // A term made FOR a user group (Kids UI) may name it; a style's audience may not. + const forAGroup = new Set(["DS162"]); + const group = + /\b(millennials?|gen ?z|boomers?|teen(ager)?s?|youth|young|elderly|seniors?|older|women|men|girls|boys|females?|males?|gamers)\b/i; + const hits = TERMS.filter((t) => !forAGroup.has(t.id) && group.test(t.audience)).map( + (t) => `${t.id}: ${t.audience}` + ); + expect(hits).toEqual([]); + }); + + it("keeps the exploratory-evidence label on every Future / AI term that asks for governance", () => { + const emerging = TERMS.filter((t) => t.evidence === "emerging"); + expect(emerging.length).toBeGreaterThan(30); + expect(emerging.every((t) => t.category === "Future / AI")).toBe(true); + }); +}); + +describe("atlas links", () => { + it("gives every design style recipe terms that exist", () => { + for (const s of Object.values(DESIGN_STYLES)) { + expect(s.taxonomy.length).toBeGreaterThan(0); + for (const id of s.taxonomy) expect(getTerm(id)?.id).toBe(id); + } + }); + + it("finds the recipes that implement a term", () => { + expect(stylesFor("DS001").map((s) => s.id)).toContain("swiss"); + expect(stylesFor("DS269")).toEqual([]); + }); + + it("links a design style only to a page that exists", () => { + for (const s of Object.values(DESIGN_STYLES)) { + const href = styleHref(s.id); + expect(href === null || (STYLE_PAGES.has(s.id) && href === `/styles/${s.id}/`)).toBe(true); + } + expect(styleHref("not-a-style")).toBeNull(); + }); + + 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}-/); + }); +}); + +describe("atlas facets", () => { + it("filters by category, status, a score range and text, together", () => { + const swiss = filterTerms(TERMS, { query: "swiss" }); + expect(swiss.map((t) => t.id)).toContain("DS001"); + const calm = filterTerms(TERMS, { category: "Aesthetic", density: [1, 2], complexity: [1, 2] }); + expect(calm.length).toBeGreaterThan(0); + for (const t of calm) { + expect(t.category).toBe("Aesthetic"); + expect(t.scores.density).toBeLessThanOrEqual(2); + expect(t.scores.complexity).toBeLessThanOrEqual(2); + } + expect(filterTerms(TERMS, { query: "zzzz-no-such-term" })).toEqual([]); + }); + + it("counts every term once per facet", () => { + for (const key of ["category", "status", "trend", "standardization"] as const) + expect([...facetCounts(TERMS, key).values()].reduce((a, b) => a + b, 0)).toBe(269); + }); + + it("gives every term a unique page slug", () => { + expect(new Set(TERMS.map(termSlug)).size).toBe(269); + expect(termSlug({ term: "Ma-Inspired Design" })).toBe("ma-inspired-design"); + }); +}); + +describe("term page prose", () => { + const swiss = TERMS[0]; + + it("composes guidance from the term's own fields and always asks for validation", () => { + const g = guidance(swiss); + expect(g.whenToUse).toContain(swiss.sectors[0].toLowerCase()); + expect(g.whenNot).toContain(swiss.risk.charAt(0).toLowerCase()); + expect(g.validate).toMatch(/baseline/); + }); + + it("tells the model to compare and not to claim the style performs better", () => { + const p = termPrompt(swiss); + expect(p).toContain(swiss.term); + expect(p).toContain("WCAG 2.2 AA"); + expect(p).toContain("Do not claim the style performs better"); + expect(p).toContain('designStyle("swiss")'); + expect(termPrompt(TERMS[268])).not.toContain("designStyle("); + }); +}); diff --git a/packages/ui/tests/mcp-atlas.test.ts b/packages/ui/tests/mcp-atlas.test.ts new file mode 100644 index 00000000..48e08107 --- /dev/null +++ b/packages/ui/tests/mcp-atlas.test.ts @@ -0,0 +1,71 @@ +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"; + +// search_terms, get_term and get_reference over the real stdio server: needs `bun run build` (dist/bin + dist/atlas.json). +const bin = join(import.meta.dir, "../dist/bin/ca-ui-mcp.js"); +const built = existsSync(bin) && existsSync(join(import.meta.dir, "../dist/atlas.json")); + +describe.skipIf(!built)("ca-ui-mcp design atlas", () => { + 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 (name: string, args: Record = {}) => { + const res = (await client.callTool({ name, arguments: args })) as { + isError?: boolean; + content: { text: string }[]; + }; + return { error: res.isError === true, text: res.content[0]?.text ?? "" }; + }; + + it("registers the three tools", async () => { + const tools = (await client.listTools()).tools.map((t) => t.name); + for (const t of ["search_terms", "get_term", "get_reference"]) expect(tools).toContain(t); + }); + + it("search_terms finds a term and labels the scales heuristic", async () => { + const { error, text } = await call("search_terms", { query: "swiss" }); + expect(error).toBe(false); + expect(text).toContain("DS001 Swiss Style"); + expect(text).toContain("heuristic"); + }); + + it("search_terms filters by category and reports a miss with the categories", async () => { + const only = await call("search_terms", { category: "future ai" }); + expect(only.text).toContain("Future / AI"); + expect(only.text).not.toContain("DS001 "); + const miss = await call("search_terms", { query: "zzzz-no-such-term" }); + expect(miss.error).toBe(true); + expect(miss.text).toContain("Categories:"); + }); + + it("get_term resolves id, slug and name, and keeps the caveats", async () => { + for (const id of ["DS001", "swiss-style", "Swiss Style"]) { + const { error, text } = await call("get_term", { id }); + expect(error).toBe(false); + expect(text).toContain("# Swiss Style"); + expect(text).toContain("## Limits"); + expect(text).toContain("Last reviewed "); + expect(text).toContain("Does not support:"); + } + const miss = await call("get_term", { id: "swis" }); + expect(miss.error).toBe(true); + expect(miss.text).toContain("DS001 Swiss Style"); + }); + + it("get_reference returns the claim excerpt and the check date, and lists all sources", async () => { + const one = await call("get_reference", { id: "e05" }); + expect(one.error).toBe(false); + expect(one.text).toContain("Checked 2026-"); + expect(one.text).toContain("Excerpt:"); + expect(one.text).toContain("Does not support:"); + const all = await call("get_reference"); + expect(all.text.split("\n").length).toBe(45); + expect((await call("get_reference", { id: "Z99" })).error).toBe(true); + }); +}); diff --git a/site/src/components/atlas/AtlasBrowser.svelte b/site/src/components/atlas/AtlasBrowser.svelte new file mode 100644 index 00000000..41223c1a --- /dev/null +++ b/site/src/components/atlas/AtlasBrowser.svelte @@ -0,0 +1,263 @@ + + +
+