From 91a7a069c9f3052e7533a4d9192b51ba366d7cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Thu, 15 Oct 2026 00:16:05 -0300 Subject: [PATCH 1/4] chore: start #123 From a3c9fc566e6f577d6fcc279dc58ef89a37b0c596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Thu, 15 Oct 2026 00:21:48 -0300 Subject: [PATCH 2/4] test(docs): guard documented props and imports against the manifest --- packages/ui/tests/docs-accuracy.test.ts | 330 ++++++++++++++++++ site/src/content/docs/charts/bar.mdx | 2 +- site/src/content/docs/charts/gauge.mdx | 2 +- site/src/content/docs/charts/heatmap.mdx | 2 +- site/src/content/docs/charts/index.mdx | 2 +- site/src/content/docs/charts/line.mdx | 2 +- site/src/content/docs/charts/pie.mdx | 2 +- site/src/content/docs/charts/radar.mdx | 2 +- site/src/content/docs/charts/scatter.mdx | 2 +- site/src/content/docs/charts/sparklines.mdx | 2 +- site/src/content/docs/display/table.mdx | 2 +- site/src/content/docs/docs-kit/docs.mdx | 2 +- site/src/content/docs/feedback/toast.mdx | 2 +- site/src/content/docs/inputs/date-picker.mdx | 2 +- site/src/content/docs/inputs/masked-field.mdx | 6 +- site/src/content/docs/inputs/radio.mdx | 2 +- site/src/content/docs/inputs/use-form.mdx | 4 +- site/src/content/docs/layout/app-shell.mdx | 2 +- site/src/content/docs/layout/form-page.mdx | 2 +- .../content/docs/layout/layout-primitives.mdx | 2 +- site/src/content/docs/navigation/actions.mdx | 2 +- site/src/content/docs/navigation/app-bar.mdx | 2 +- 22 files changed, 354 insertions(+), 24 deletions(-) create mode 100644 packages/ui/tests/docs-accuracy.test.ts diff --git a/packages/ui/tests/docs-accuracy.test.ts b/packages/ui/tests/docs-accuracy.test.ts new file mode 100644 index 00000000..c4a0b5ac --- /dev/null +++ b/packages/ui/tests/docs-accuracy.test.ts @@ -0,0 +1,330 @@ +/** + * Guards the docs site against drifting from the package. Everything is checked against the + * generated manifest (the same data the MCP server and API.md serve), never against a copy: + * + * - every `` row names a prop (or snippet) the component really has, and a + * documented default equals the one in the source; + * - every `@codeatlasdev/ui*` import in a code fence resolves to a real export of that entry; + * - every capitalised component used in a `svelte` fence passes only props it declares; + * - every exported component appears in the docs (a short allowlist of internals is explicit). + */ +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import ts from "typescript"; +import { buildManifest, type ManifestComponent } from "../scripts/build-manifest.ts"; + +const DOCS = join(import.meta.dir, "../../../site/src/content/docs"); +const manifest = buildManifest(); +const components = new Map(manifest.components.map((c) => [c.name, c])); + +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((f) => { + const p = join(dir, f); + return statSync(p).isDirectory() ? walk(p) : p.endsWith(".mdx") ? [p] : []; + }); +const pages = walk(DOCS).map((file) => ({ + file, + rel: relative(DOCS, file), + src: readFileSync(file, "utf8"), +})); + +// ── blocks ─────────────────────────────────────────────────────────── + +interface PropsBlock { + page: string; + title: string; + /** Components the table describes: `for="A, B"`, else the component named by the title, else the page. */ + targets: string[]; + rows: { name: string; default?: string }[]; +} + +/** Native attributes a component forwards through `...rest`, which the manifest lists only as `extends`. */ +const INHERITED: Record = { Image: ["srcset", "sizes"] }; + +/** Props the document element carries for free; documenting them on a component page is fine. */ +const NATIVE = /^(aria-[a-z]+|data-[a-z-]+|id|class|style|role|tabindex|title|lang|dir|hidden)$/; + +function propsBlocks(): PropsBlock[] { + const out: PropsBlock[] = []; + for (const p of pages) { + for (const m of p.src.matchAll(//g)) { + const attrs = m[1]; + const title = attrs.match(/title="([^"]*)"/)?.[1] ?? "Props"; + const forAttr = attrs.match(/for="([^"]*)"/)?.[1]; + // A row may name several props at once ("p, px, py", "leading / trailing"): one entry per prop. + const rows = [...m[2].matchAll(/\{\s*name:\s*"((?:[^"\\]|\\.)*)"([^}]*)\}/g)].flatMap((r) => { + const dflt = r[2].match(/default:\s*"((?:[^"\\]|\\.)*)"/)?.[1]?.replace(/\\"/g, '"'); + const names = r[1].split(/\s*[,/]\s*/).filter(Boolean); + return names.map((name) => ({ name, default: names.length === 1 ? dflt : undefined })); + }); + const pageName = p.rel + .replace(/^.*\//, "") + .replace(/\.mdx$/, "") + .replace(/(^|-)(\w)/g, (_, __, c: string) => c.toUpperCase()); + const titled = title + .replace(/\s+props$/i, "") + .replace(/\(.*$/, "") + .trim(); + const targets = forAttr + ? forAttr.split(",").map((s) => s.trim()) + : components.has(titled) + ? [titled] + : [pageName]; + out.push({ page: p.rel, title, targets, rows }); + } + } + return out; +} + +const blocks = propsBlocks(); + +/** Props a component owns plus the ones its `extends` clause adds from a known type (`Pick`, another component). */ +function owned(c: ManifestComponent, seen = new Set()): Map { + const out = new Map( + [...c.props, ...c.snippets].map((prop) => [prop.name, prop]) + ); + for (const n of INHERITED[c.name] ?? []) out.set(n, {}); + if (seen.has(c.name)) return out; + seen.add(c.name); + for (const m of c.extends?.matchAll(/Pick]+)>/g) ?? []) + for (const n of m[1].matchAll(/'(\w+)'/g)) out.set(n[1], {}); + for (const m of c.extends?.matchAll(/ComponentProps/g) ?? []) { + const base = components.get(m[1]); + if (base) for (const [k, v] of owned(base, seen)) out.set(k, v); + } + return out; +} + +describe("docs tables", () => { + it("finds the tables (the parser is not silently empty)", () => { + expect(blocks.length).toBeGreaterThan(40); + }); + + it('targets only exported components (use for="A, B" or for="none" when the title does not name one)', () => { + const bad: string[] = []; + for (const b of blocks) + for (const t of b.targets) + if (t !== "none" && !components.has(t)) bad.push(`${b.page} [${b.title}]: ${t}`); + expect(bad).toEqual([]); + }); + + it("documents only props the component has", () => { + const bad: string[] = []; + for (const b of blocks) { + const targets = b.targets.filter((t) => t !== "none"); + if (!targets.length) continue; + const comps = targets + .map((t) => components.get(t)) + .filter((c): c is ManifestComponent => !!c); + for (const row of b.rows) { + // "PhoneField.value" names the component it belongs to. + const [only, prop] = row.name.includes(".") ? row.name.split(".") : [undefined, row.name]; + if (NATIVE.test(prop)) continue; + const scope = only ? comps.filter((c) => c.name === only) : comps; + if (!scope.some((c) => owned(c).has(prop))) + bad.push(`${b.page} [${b.title}] ${targets.join("/")}: ${row.name}`); + } + } + expect(bad).toEqual([]); + }); + + it("documents the default the source declares", () => { + const norm = (s: string) => s.replace(/\s+/g, "").replace(/"/g, "'"); + const bad: string[] = []; + for (const b of blocks) { + const targets = b.targets.filter((t) => t !== "none"); + if (targets.length !== 1) continue; + const c = components.get(targets[0]); + if (!c) continue; + for (const row of b.rows) { + const prop = owned(c).get(row.name); + if (!prop || row.default === undefined || prop.default === undefined) continue; + // Identifiers and function expressions (`DEFAULT_ZONES`, `(_item, i) => i`) cannot be compared as text. + if ( + !/^(['"`].*['"`]|-?[\d.]+|true|false|null|undefined|\[.*\]|\{.*\})$/s.test(prop.default) + ) + continue; + if (norm(row.default) !== norm(prop.default)) + bad.push(`${b.page} ${c.name}.${row.name}: docs ${row.default}, source ${prop.default}`); + } + } + expect(bad).toEqual([]); + }); +}); + +// ── code fences ────────────────────────────────────────────────────────────── + +interface Fence { + page: string; + lang: string; + code: string; +} + +function fences(): Fence[] { + const out: Fence[] = []; + for (const p of pages) + for (const m of p.src.matchAll(/^[ \t]*```(\w+)[^\n]*\n([\s\S]*?)^[ \t]*```/gm)) + out.push({ page: p.rel, lang: m[1], code: m[2] }); + return out; +} + +const allFences = fences(); + +const LIB = join(import.meta.dir, "../src/lib"); +const ENTRY_FILES: Record = { + "@codeatlasdev/ui": join(LIB, "index.ts"), + "@codeatlasdev/ui/charts": join(LIB, "charts/index.ts"), + "@codeatlasdev/ui/table": join(LIB, "components/DataTable/index.ts"), + "@codeatlasdev/ui/code": join(LIB, "components/CodeSnippet/index.ts"), + "@codeatlasdev/ui/forms": join(LIB, "forms/index.ts"), +}; + +/** Names each entry point really exports (components and every other runtime/type export). */ +function exportsByEntry(): Map> { + const by = new Map>(); + const add = (entry: string, name: string) => { + if (!by.has(entry)) by.set(entry, new Set()); + by.get(entry)?.add(name); + }; + for (const c of manifest.components) add(c.import, c.name); + for (const e of manifest.exports) add(e.import, e.name); + // Names an entry re-exports from a dependency (`CalendarDate`): the manifest only follows own declarations. + for (const [entry, file] of Object.entries(ENTRY_FILES)) { + const sf = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.ESNext, true); + for (const st of sf.statements) + if (ts.isExportDeclaration(st) && st.exportClause && ts.isNamedExports(st.exportClause)) + for (const el of st.exportClause.elements) add(entry, el.name.text); + } + return by; +} +const exportsOf = exportsByEntry(); + +/** Entries whose exports are not in the manifest (assets, subpath files, bin). */ +const NON_API_ENTRY = /\/(styles|presets|testing|i18n|primitives)(\/|$)|\.css$|\.json$/; + +describe("docs code samples", () => { + it("finds the fences (the parser is not silently empty)", () => { + expect(allFences.length).toBeGreaterThan(100); + }); + + it("imports only names that the entry point exports", () => { + const bad: string[] = []; + for (const f of allFences) { + if (!["svelte", "ts", "js", "astro", "tsx", "mdx"].includes(f.lang)) continue; + for (const m of f.code.matchAll( + /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+["'](@codeatlasdev\/ui[^"']*)["']/g + )) { + m[1] = m[1].replace(/\/\/[^\n]*/g, ""); + const entry = m[2]; + if (NON_API_ENTRY.test(entry)) continue; + const known = exportsOf.get(entry); + if (!known) { + bad.push(`${f.page}: unknown entry ${entry}`); + continue; + } + for (const raw of m[1].split(",")) { + const name = raw + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/)[0] + .trim(); + if (name && !known.has(name)) bad.push(`${f.page}: ${name} is not exported by ${entry}`); + } + } + } + expect(bad).toEqual([]); + }); + + it("passes only declared props to components in svelte samples", () => { + const bad: string[] = []; + for (const f of allFences) { + if (f.lang !== "svelte") continue; + // Attribute names of each `` opening tag, brace-aware so `{…}` values with `>` do not end it. + for (const tag of f.code.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g)) { + const c = components.get(tag[1]); + if (!c) continue; + const attrs = readAttributes(f.code, (tag.index ?? 0) + tag[0].length); + const known = owned(c); + for (const a of attrs) { + if ( + NATIVE.test(a) || + (/^(on[a-z]+|bind:.+|use:.+|class:.+|style:.+|transition:.+)$/.test(a) && c.extends) + ) + continue; + const base = a.replace(/^bind:/, ""); + if (!known.has(base) && !known.has(a)) bad.push(`${f.page}: <${c.name} ${a}>`); + } + } + } + expect(bad).toEqual([]); + }); +}); + +/** Attribute names of the tag whose name just ended at `from`. Skips string and `{…}` values. */ +function readAttributes(src: string, from: number): string[] { + const names: string[] = []; + let i = from; + while (i < src.length) { + const ch = src[i]; + if (ch === ">" || (ch === "/" && src[i + 1] === ">")) break; + if (/\s/.test(ch)) { + i++; + continue; + } + if (ch === "{") { + // `{...rest}` / `{shorthand}`: shorthand names a prop, spread does not. + const end = matching(src, i); + const inner = src.slice(i + 1, end).trim(); + if (/^[A-Za-z_$][\w$]*$/.test(inner)) names.push(inner); + i = end + 1; + continue; + } + const m = src.slice(i).match(/^[A-Za-z_:@][\w:.\-@]*/); + if (!m) { + i++; + continue; + } + names.push(m[0]); + i += m[0].length; + if (src[i] === "=") { + i++; + if (src[i] === '"' || src[i] === "'") i = src.indexOf(src[i], i + 1) + 1; + else if (src[i] === "{") i = matching(src, i) + 1; + } + } + return names; +} + +/** Index of the `}` that closes the `{` at `open`, skipping strings and template literals. */ +function matching(src: string, open: number): number { + let depth = 0; + for (let i = open; i < src.length; i++) { + const ch = src[i]; + if (ch === '"' || ch === "'" || ch === "`") { + for (i++; i < src.length && src[i] !== ch; i++) if (src[i] === "\\") i++; + } else if (ch === "{") depth++; + else if (ch === "}" && --depth === 0) return i; + } + return src.length; +} + +// ── coverage ───────────────────────────────────────────────────────────────── + +/** Exported components with no page of their own and no mention worth making (internals of a documented parent). */ +const UNDOCUMENTED_OK = new Set([]); + +describe("docs coverage", () => { + it("mentions every exported component", () => { + const all = pages.map((p) => p.src).join("\n"); + const missing = manifest.components + .map((c) => c.name) + .filter((n) => !UNDOCUMENTED_OK.has(n) && !new RegExp(`\\b${n}\\b`).test(all)); + expect(missing).toEqual([]); + }); + + it("has no stale allowlist entries", () => { + const names = new Set(manifest.components.map((c) => c.name)); + expect([...UNDOCUMENTED_OK].filter((n) => !names.has(n))).toEqual([]); + }); +}); diff --git a/site/src/content/docs/charts/bar.mdx b/site/src/content/docs/charts/bar.mdx index ba8139b0..c471a5f4 100644 --- a/site/src/content/docs/charts/bar.mdx +++ b/site/src/content/docs/charts/bar.mdx @@ -30,7 +30,7 @@ Multiple series render grouped by default. Set `stacked` to stack them into one - string", description: "Formats value-axis ticks and tooltip values." }, diff --git a/site/src/content/docs/charts/gauge.mdx b/site/src/content/docs/charts/gauge.mdx index 0a1fff07..cbf75b2f 100644 --- a/site/src/content/docs/charts/gauge.mdx +++ b/site/src/content/docs/charts/gauge.mdx @@ -31,7 +31,7 @@ The default gauge colors its arc in three zones (error, warning, success). Overr - - - diff --git a/site/src/content/docs/charts/line.mdx b/site/src/content/docs/charts/line.mdx index 498c2a96..4609ac76 100644 --- a/site/src/content/docs/charts/line.mdx +++ b/site/src/content/docs/charts/line.mdx @@ -32,7 +32,7 @@ import StreamSrc from "~/demos/charts/line/Stream.svelte?raw"; - string", description: "Formats y-axis ticks and tooltip values." }, diff --git a/site/src/content/docs/charts/pie.mdx b/site/src/content/docs/charts/pie.mdx index 7431384e..f448b291 100644 --- a/site/src/content/docs/charts/pie.mdx +++ b/site/src/content/docs/charts/pie.mdx @@ -28,7 +28,7 @@ import AnimatedSrc from "~/demos/charts/pie/Animated.svelte?raw"; - - - - - - - string", description: "Shows a toast and returns its id. Variant defaults to 'default'." }, { name: "toast.success(message, duration?)", type: "(message: string, duration?: number) => string", description: "Success toast." }, { name: "toast.error(message, duration?)", type: "(message: string, duration?: number) => string", description: "Error toast." }, diff --git a/site/src/content/docs/inputs/date-picker.mdx b/site/src/content/docs/inputs/date-picker.mdx index e5db57e7..bf2de862 100644 --- a/site/src/content/docs/inputs/date-picker.mdx +++ b/site/src/content/docs/inputs/date-picker.mdx @@ -36,7 +36,7 @@ An `error` is rendered with `role="alert"` and sets `aria-invalid` and `aria-des - - string", description: "Required. Pure formatter applied while typing, for example maskCPF." }, { name: "validate", type: "(value: string) => string | undefined", description: "Returns an error message on blur for non-empty values, or undefined when valid." }, { name: "inputmode", type: "HTMLInputAttributes['inputmode']", default: "auto", description: "Defaults to 'numeric' for digit-only masks and 'text' otherwise." }, @@ -56,7 +56,7 @@ Documents try these values: CPF `123.456.789-09` is valid and `111.111.111-11` i { name: "onblur", type: "(e: FocusEvent) => void", description: "Called when the input loses focus." }, ]} /> - - string", description: "Document masks. maskCPFCNPJ switches after 11 digits." }, { name: "maskDateBR / maskRG / maskCEP / maskPhoneBR", type: "(raw: string) => string", description: "Date (dd/mm/yyyy), RG, CEP and Brazilian phone masks." }, { name: "isValidCPF / isValidCNPJ / isValidDateBR", type: "(value: string) => boolean", description: "Validators for use in validate." }, diff --git a/site/src/content/docs/inputs/radio.mdx b/site/src/content/docs/inputs/radio.mdx index f5de4ba3..8ba4740f 100644 --- a/site/src/content/docs/inputs/radio.mdx +++ b/site/src/content/docs/inputs/radio.mdx @@ -27,7 +27,7 @@ Arrow keys move focus and selection inside the group, Tab leaves it. The group h - -", description: "Field names and their initial string values." }, { name: "options.schema", type: "ValidationSchema", description: "A function (data) => Partial>, or a Zod or Valibot schema." }, { name: "options.arrays", type: "{ [name]: Item[] }", description: "Initial rows for repeatable field groups, exposed as form.arrays." }, ]} /> -", description: "Reactive state of every field." }, { name: "arrays", type: "{ [name]: { items; add(item); remove(index); move(from, to) } }", description: "Repeatable row groups declared with options.arrays." }, { name: "dirty", type: "boolean", description: "True when any value differs from its initial value." }, diff --git a/site/src/content/docs/layout/app-shell.mdx b/site/src/content/docs/layout/app-shell.mdx index 992aea22..d1afcc08 100644 --- a/site/src/content/docs/layout/app-shell.mdx +++ b/site/src/content/docs/layout/app-shell.mdx @@ -31,7 +31,7 @@ The shell never imports a router. You pass the current `pathname` and handle `on - void", description: "Called with the destination path when the rail, panel or bottom navigation is used." }, diff --git a/site/src/content/docs/layout/form-page.mdx b/site/src/content/docs/layout/form-page.mdx index e825fc45..ce4806c9 100644 --- a/site/src/content/docs/layout/form-page.mdx +++ b/site/src/content/docs/layout/form-page.mdx @@ -32,7 +32,7 @@ AppForm fills its parent, so give the parent a height. It replaces the removed F - - - void>", description: "Calls the handler when a click lands outside the element." }, { name: "focusTrap", type: "Action", description: "Traps Tab focus inside the element while the parameter is true." }, { name: "autofocus", type: "Action", description: "Focuses the element on mount without scrolling. On touch devices it only focuses when primeKeyboard() ran in the opening gesture; pass { touch: true } to focus anyway." }, diff --git a/site/src/content/docs/navigation/app-bar.mdx b/site/src/content/docs/navigation/app-bar.mdx index eb1175fb..37282f0b 100644 --- a/site/src/content/docs/navigation/app-bar.mdx +++ b/site/src/content/docs/navigation/app-bar.mdx @@ -27,7 +27,7 @@ Renders a `
`. Icon-only buttons inside it need an `aria-label`. Inside [ - Date: Thu, 15 Oct 2026 01:02:01 -0300 Subject: [PATCH 3/4] docs: accuracy review of every page against 3.0, new pages, sample checks --- .changeset/filtergroup-label-contrast.md | 5 + .changeset/kpicard-on-card.md | 5 + .gitignore | 3 + biome.json | 1 + packages/ui/scripts/check-docs-samples.ts | 184 ++++++++++++++++++ .../components/FilterGroup/FilterGroup.svelte | 2 +- .../src/lib/components/KpiCard/KpiCard.svelte | 6 +- .../components/KpiCard/KpiCard.svelte.test.ts | 16 +- .../ui/src/lib/components/Sheet/Sheet.svelte | 2 +- packages/ui/tests/contrast.test.ts | 26 +++ packages/ui/tests/docs-accuracy.test.ts | 64 ++++++ packages/ui/tests/docs-samples.test.ts | 38 ++++ site/src/content/docs/charts/gauge.mdx | 4 +- site/src/content/docs/charts/index.mdx | 2 +- site/src/content/docs/display/alert.mdx | 2 +- site/src/content/docs/display/badge.mdx | 4 +- site/src/content/docs/display/chip.mdx | 4 +- site/src/content/docs/display/data-table.mdx | 72 ++++++- site/src/content/docs/display/divider.mdx | 4 +- .../content/docs/display/external-link.mdx | 2 +- site/src/content/docs/display/image.mdx | 8 +- site/src/content/docs/display/kbd.mdx | 27 +++ site/src/content/docs/display/live-list.mdx | 2 +- .../docs/display/notification-bell.mdx | 6 +- site/src/content/docs/display/pagination.mdx | 6 +- site/src/content/docs/display/scroll-area.mdx | 4 +- site/src/content/docs/display/table.mdx | 10 +- site/src/content/docs/display/timeline.mdx | 2 +- site/src/content/docs/display/tooltip.mdx | 4 +- site/src/content/docs/docs-kit/docs.mdx | 12 +- .../content/docs/feedback/alert-dialog.mdx | 6 +- site/src/content/docs/feedback/dialog.mdx | 21 +- site/src/content/docs/feedback/drawer.mdx | 4 +- site/src/content/docs/feedback/lightbox.mdx | 32 +++ site/src/content/docs/feedback/progress.mdx | 8 +- site/src/content/docs/feedback/sheet.mdx | 8 +- site/src/content/docs/feedback/skeleton.mdx | 4 +- site/src/content/docs/feedback/spinner.mdx | 2 +- site/src/content/docs/feedback/toast.mdx | 15 +- .../src/content/docs/foundation/materials.mdx | 10 +- site/src/content/docs/foundation/shadows.mdx | 2 +- .../content/docs/foundation/typography.mdx | 4 +- .../docs/getting-started/accessibility.mdx | 6 +- .../docs/getting-started/ai-agents.mdx | 24 +-- .../src/content/docs/getting-started/i18n.mdx | 4 +- .../docs/getting-started/installation.mdx | 79 ++++++-- .../content/docs/getting-started/mobile.mdx | 24 +-- .../docs/getting-started/performance.mdx | 8 +- site/src/content/docs/getting-started/pwa.mdx | 13 +- site/src/content/docs/getting-started/seo.mdx | 21 +- .../content/docs/getting-started/theming.mdx | 27 ++- site/src/content/docs/index.mdx | 18 +- site/src/content/docs/inputs/button.mdx | 3 +- site/src/content/docs/inputs/combobox.mdx | 4 +- site/src/content/docs/inputs/date-picker.mdx | 2 +- site/src/content/docs/inputs/export-menu.mdx | 6 +- site/src/content/docs/inputs/filter-group.mdx | 36 ++++ site/src/content/docs/inputs/radio.mdx | 11 +- site/src/content/docs/inputs/select.mdx | 4 +- site/src/content/docs/inputs/setting-item.mdx | 33 ++++ site/src/content/docs/inputs/text-area.mdx | 14 +- site/src/content/docs/inputs/toggle-group.mdx | 10 +- site/src/content/docs/layout/app-shell.mdx | 42 +++- site/src/content/docs/layout/auth-layout.mdx | 50 +++++ site/src/content/docs/layout/error-page.mdx | 32 +++ site/src/content/docs/layout/page-shell.mdx | 6 +- .../content/docs/layout/sticky-actions.mdx | 4 +- site/src/content/docs/navigation/actions.mdx | 15 +- .../content/docs/navigation/breadcrumb.mdx | 4 +- .../docs/navigation/command-palette.mdx | 4 +- site/src/content/docs/navigation/drawer.mdx | 4 +- site/src/content/docs/navigation/menu.mdx | 22 ++- site/src/content/docs/navigation/popover.mdx | 4 +- site/src/content/docs/navigation/stepper.mdx | 4 +- site/src/content/docs/navigation/tabs.mdx | 17 +- site/src/content/docs/surfaces/accordion.mdx | 4 +- site/src/content/docs/surfaces/card.mdx | 4 +- site/src/content/docs/surfaces/glass-card.mdx | 8 + .../content/docs/surfaces/glass-surface.mdx | 10 + .../content/docs/surfaces/liquid-glass.mdx | 32 +++ site/src/content/docs/surfaces/surface.mdx | 12 +- site/src/demos/display/kbd/Basic.svelte | 5 + site/src/demos/feedback/dialog/Info.svelte | 2 +- site/src/demos/feedback/lightbox/Basic.svelte | 13 ++ .../demos/foundation/materials/Tiers.svelte | 6 +- .../foundation/typography/Families.svelte | 5 + .../demos/inputs/filter-group/Basic.svelte | 13 ++ site/src/demos/inputs/radio/Cards.svelte | 13 ++ .../demos/inputs/setting-item/Basic.svelte | 13 ++ .../demos/layout/error-page/Statuses.svelte | 5 + .../demos/surfaces/accordion/Single.svelte | 8 +- site/src/demos/surfaces/card/Accents.svelte | 4 +- .../demos/surfaces/liquid-glass/Basic.svelte | 17 ++ 93 files changed, 1136 insertions(+), 236 deletions(-) create mode 100644 .changeset/filtergroup-label-contrast.md create mode 100644 .changeset/kpicard-on-card.md create mode 100644 packages/ui/scripts/check-docs-samples.ts create mode 100644 packages/ui/tests/docs-samples.test.ts create mode 100644 site/src/content/docs/display/kbd.mdx create mode 100644 site/src/content/docs/feedback/lightbox.mdx create mode 100644 site/src/content/docs/inputs/filter-group.mdx create mode 100644 site/src/content/docs/inputs/setting-item.mdx create mode 100644 site/src/content/docs/layout/auth-layout.mdx create mode 100644 site/src/content/docs/layout/error-page.mdx create mode 100644 site/src/content/docs/surfaces/liquid-glass.mdx create mode 100644 site/src/demos/display/kbd/Basic.svelte create mode 100644 site/src/demos/feedback/lightbox/Basic.svelte create mode 100644 site/src/demos/inputs/filter-group/Basic.svelte create mode 100644 site/src/demos/inputs/radio/Cards.svelte create mode 100644 site/src/demos/inputs/setting-item/Basic.svelte create mode 100644 site/src/demos/layout/error-page/Statuses.svelte create mode 100644 site/src/demos/surfaces/liquid-glass/Basic.svelte diff --git a/.changeset/filtergroup-label-contrast.md b/.changeset/filtergroup-label-contrast.md new file mode 100644 index 00000000..2610b2dc --- /dev/null +++ b/.changeset/filtergroup-label-contrast.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": patch +--- + +`FilterGroup` draws its label with `--ca-text-secondary` instead of `--ca-text-hint`. The hint role can be set low by a custom preset, and the label failed the 4.5:1 axe check on a raised surface in dark mode. diff --git a/.changeset/kpicard-on-card.md b/.changeset/kpicard-on-card.md new file mode 100644 index 00000000..40665fdb --- /dev/null +++ b/.changeset/kpicard-on-card.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": patch +--- + +`KpiCard` is built on `Card` instead of the deprecated `GlassCard`, so rendering it no longer logs the `[ca-ui]` deprecation warning in dev. The look is unchanged (elevation 3). diff --git a/.gitignore b/.gitignore index 59b8e9e8..400f9c42 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ site/src/generated/ # wrangler local state .wrangler/ + +# docs sample type-check output +/site/.cache/ diff --git a/biome.json b/biome.json index 1220d8e2..e0b222bd 100644 --- a/biome.json +++ b/biome.json @@ -37,6 +37,7 @@ "!**/.turbo", "!**/.svelte-kit", "!**/.astro", + "!**/.cache", "!**/__screenshots__", "!**/.vitest", "!**/test-results", diff --git a/packages/ui/scripts/check-docs-samples.ts b/packages/ui/scripts/check-docs-samples.ts new file mode 100644 index 00000000..6b27bd70 --- /dev/null +++ b/packages/ui/scripts/check-docs-samples.ts @@ -0,0 +1,184 @@ +/** + * Type-checks the `svelte` code fences of the docs site with svelte-check, against the built package + * types (`dist`), exactly as a consumer app would compile them. + * + * A fence is checked when it imports from `@codeatlasdev/ui*`: those are the ones that claim API + * usage. Docs samples are often fragments, so the extractor makes them compilable without hiding + * real mistakes: + * - `…` and `/* … *\/` placeholders are removed; + * - names the sample uses but never declares (`users`, `save`, `page`, a `bind:` target) become + * ambient `any` declarations: a docs sample may assume app state; + * - a bare markup fence (no `\n${code}`; + return decl ? code.replace(tag[0], `${tag[0]}\n${decl}`) : code; +} + +export function extractSamples(): Sample[] { + rmSync(OUT, { recursive: true, force: true }); + mkdirSync(OUT, { recursive: true }); + const samples: Sample[] = []; + for (const mdx of walk(DOCS)) { + const src = readFileSync(mdx, "utf8"); + const page = relative(DOCS, mdx); + let index = 0; + for (const m of src.matchAll(/^([ \t]*)```svelte[^\n]*\n([\s\S]*?)^[ \t]*```/gm)) { + const indent = m[1].length; + const body = m[2] + .split("\n") + .map((l) => l.slice(Math.min(indent, l.length - l.trimStart().length))) + .join("\n"); + const code = normalize(body); + if (code === null) continue; + const line = src.slice(0, m.index).split("\n").length; + const file = `${page.replace(/\.mdx$/, "").replace(/[/.]/g, "_")}_${index}.svelte`; + writeFileSync(join(OUT, file), code); + samples.push({ page, index: index++, line, file }); + } + } + writeFileSync( + join(OUT, "tsconfig.json"), + JSON.stringify({ extends: "../../tsconfig.json", include: ["./*.svelte"] }) + ); + return samples; +} + +export interface SampleError { + page: string; + line: number; + message: string; +} + +/** Runs svelte-check over the extracted samples; returns one entry per error, mapped back to its docs page. */ +export async function checkDocsSamples( + extra: Record = {} +): Promise<{ checked: number; errors: SampleError[] }> { + const samples = extractSamples(); + // Extra fences (tests feed known-bad ones to prove the check can fail), reported under their own name. + for (const [name, body] of Object.entries(extra)) { + const code = normalize(body); + if (code === null) continue; + const file = `extra_${name}.svelte`; + writeFileSync(join(OUT, file), code); + samples.push({ page: name, index: 0, line: 0, file }); + } + const proc = Bun.spawn( + [ + SVELTE_CHECK, + "--tsconfig", + join(OUT, "tsconfig.json"), + "--workspace", + OUT, + "--output", + "machine", + ], + { cwd: join(REPO, "site"), stdout: "pipe", stderr: "pipe" } + ); + const [out, err] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + await proc.exited; + if (!/COMPLETED/.test(out)) throw new Error(`svelte-check did not complete:\n${out}${err}`); + const byFile = new Map(samples.map((s) => [s.file, s])); + const errors: SampleError[] = []; + for (const m of out.matchAll(/^\d+ ERROR "([^"]+)" (\d+):\d+ "((?:[^"\\]|\\.)*)"/gm)) { + const sample = byFile.get(m[1]); + errors.push({ + page: sample?.page ?? m[1], + line: (sample?.line ?? 0) + Number(m[2]), + message: (JSON.parse(`"${m[3]}"`) as string) + .replace(/\n/g, " ") + .split("https://svelte.dev")[0] + .trim(), + }); + } + return { checked: samples.length, errors }; +} + +if (import.meta.main) { + const { checked, errors } = await checkDocsSamples(); + for (const e of errors) console.log(`${e.page}:${e.line} ${e.message}`); + console.log(`${checked} samples checked, ${errors.length} errors`); + process.exit(errors.length ? 1 : 0); +} diff --git a/packages/ui/src/lib/components/FilterGroup/FilterGroup.svelte b/packages/ui/src/lib/components/FilterGroup/FilterGroup.svelte index 82875ab9..dd643560 100644 --- a/packages/ui/src/lib/components/FilterGroup/FilterGroup.svelte +++ b/packages/ui/src/lib/components/FilterGroup/FilterGroup.svelte @@ -74,7 +74,7 @@ .ca-filter-group__label { font-size: var(--ca-font-size-micro); font-weight: var(--ca-font-weight-semibold); - color: var(--ca-text-hint); + color: var(--ca-text-secondary); text-transform: uppercase; letter-spacing: var(--ca-overline-tracking); padding: 0 var(--ca-space-1-5) 0 var(--ca-space-1); diff --git a/packages/ui/src/lib/components/KpiCard/KpiCard.svelte b/packages/ui/src/lib/components/KpiCard/KpiCard.svelte index 72a793c6..370b38cb 100644 --- a/packages/ui/src/lib/components/KpiCard/KpiCard.svelte +++ b/packages/ui/src/lib/components/KpiCard/KpiCard.svelte @@ -1,7 +1,7 @@ - +

{title}

@@ -50,7 +50,7 @@ {/if}
- + diff --git a/site/src/demos/inputs/filter-group/Basic.svelte b/site/src/demos/inputs/filter-group/Basic.svelte new file mode 100644 index 00000000..600a42b4 --- /dev/null +++ b/site/src/demos/inputs/filter-group/Basic.svelte @@ -0,0 +1,13 @@ + + + +

Selected: {role}

diff --git a/site/src/demos/inputs/radio/Cards.svelte b/site/src/demos/inputs/radio/Cards.svelte new file mode 100644 index 00000000..015565a9 --- /dev/null +++ b/site/src/demos/inputs/radio/Cards.svelte @@ -0,0 +1,13 @@ + + + diff --git a/site/src/demos/inputs/setting-item/Basic.svelte b/site/src/demos/inputs/setting-item/Basic.svelte new file mode 100644 index 00000000..ceccbc9d --- /dev/null +++ b/site/src/demos/inputs/setting-item/Basic.svelte @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/site/src/demos/layout/error-page/Statuses.svelte b/site/src/demos/layout/error-page/Statuses.svelte new file mode 100644 index 00000000..e3aaebbc --- /dev/null +++ b/site/src/demos/layout/error-page/Statuses.svelte @@ -0,0 +1,5 @@ + + + diff --git a/site/src/demos/surfaces/accordion/Single.svelte b/site/src/demos/surfaces/accordion/Single.svelte index 54d12c0c..57a6a68a 100644 --- a/site/src/demos/surfaces/accordion/Single.svelte +++ b/site/src/demos/surfaces/accordion/Single.svelte @@ -5,14 +5,14 @@
- Liquid Glass is a design language that applies real-time backdrop blur, refraction and - tinting to UI surfaces, creating depth without heavy visual weight. + Liquid Glass is the opt-in look of the kit: backdrop blur, refraction and tinting on + surfaces. The default theme is solid. All modern evergreen browsers. Older environments receive a fallback without the blur effect. - - Yes. Both components use isolation: isolate to keep stacking contexts correct. + + Yes. A Card is a Surface, so any Surface can sit inside it.
diff --git a/site/src/demos/surfaces/card/Accents.svelte b/site/src/demos/surfaces/card/Accents.svelte index 62f6a929..8074bd10 100644 --- a/site/src/demos/surfaces/card/Accents.svelte +++ b/site/src/demos/surfaces/card/Accents.svelte @@ -5,11 +5,11 @@

Tinted

-

Brand gradient overlay on the surface.

+

Brand tint over the fill.

Glowing

-

Backlit halo behind the surface.

+

Brand halo behind the surface.

diff --git a/site/src/demos/surfaces/liquid-glass/Basic.svelte b/site/src/demos/surfaces/liquid-glass/Basic.svelte new file mode 100644 index 00000000..2c0ccabb --- /dev/null +++ b/site/src/demos/surfaces/liquid-glass/Basic.svelte @@ -0,0 +1,17 @@ + + +
+ +

Refraction layer behind this text.

+
+ + From 4c08f23e108f2afe12474963063c646aed49cd54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Thu, 15 Oct 2026 01:25:03 -0300 Subject: [PATCH 4/4] docs: SwatchGroup page --- site/src/content/docs/inputs/swatch-group.mdx | 43 +++++++++++++++++++ .../demos/inputs/swatch-group/Custom.svelte | 34 +++++++++++++++ .../demos/inputs/swatch-group/Presets.svelte | 34 +++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 site/src/content/docs/inputs/swatch-group.mdx create mode 100644 site/src/demos/inputs/swatch-group/Custom.svelte create mode 100644 site/src/demos/inputs/swatch-group/Presets.svelte diff --git a/site/src/content/docs/inputs/swatch-group.mdx b/site/src/content/docs/inputs/swatch-group.mdx new file mode 100644 index 00000000..335ad8bc --- /dev/null +++ b/site/src/content/docs/inputs/swatch-group.mdx @@ -0,0 +1,43 @@ +--- +title: SwatchGroup +description: Single-choice color picker made of round swatches. Preset swatches follow the theme; custom ones take any CSS color. +group: Inputs +--- +import Demo from "~/components/docs/Demo.astro"; +import Props from "~/components/docs/Props.astro"; +import Presets from "~/demos/inputs/swatch-group/Presets.svelte"; +import PresetsSrc from "~/demos/inputs/swatch-group/Presets.svelte?raw"; +import Custom from "~/demos/inputs/swatch-group/Custom.svelte"; +import CustomSrc from "~/demos/inputs/swatch-group/Custom.svelte?raw"; + +```svelte + + + +``` + +Each item paints from the theme preset named by its `value`, so the swatch always matches the palette. Give an item a `color` to paint it with any CSS color instead; its `value` is then free-form. Leave `value` empty for "none selected", for example while a custom color is active. The selected swatch shows a check mark. + +On touch screens (`pointer: coarse`) each swatch grows to the 44px touch target, so neighbouring hit areas never overlap. + +**Accessibility.** The group is a `radiogroup` and every swatch a `radio` with `aria-checked`. The group has no visible heading, so pass `aria-label`; each swatch is named by its `label`, falling back to `value`, so give custom colors a readable `label` ("Blue", not "#2563eb"). Arrow keys move focus between enabled swatches and Tab leaves the group. The selected state does not rely on color alone: it adds a check mark and a ring, and both survive forced-colors mode. + + + + + void", description: "Called when the user picks a different swatch." }, +]} /> diff --git a/site/src/demos/inputs/swatch-group/Custom.svelte b/site/src/demos/inputs/swatch-group/Custom.svelte new file mode 100644 index 00000000..ba00d4c5 --- /dev/null +++ b/site/src/demos/inputs/swatch-group/Custom.svelte @@ -0,0 +1,34 @@ + + +
+ +

value: {color}

+
+ + diff --git a/site/src/demos/inputs/swatch-group/Presets.svelte b/site/src/demos/inputs/swatch-group/Presets.svelte new file mode 100644 index 00000000..be77e28e --- /dev/null +++ b/site/src/demos/inputs/swatch-group/Presets.svelte @@ -0,0 +1,34 @@ + + +
+ +

value: {preset}

+
+ +