From 6bbc1d3ab896c9a5bac4d386db2990474cf8ba0a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 19:39:22 +0000 Subject: [PATCH 01/22] chore(deps): bump tmp from 0.2.5 to 0.2.7 Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.5 to 0.2.7. - [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md) - [Commits](https://github.com/raszi/node-tmp/compare/v0.2.5...v0.2.7) --- updated-dependencies: - dependency-name: tmp dependency-version: 0.2.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index dd9f38aaa0..a8ac6ab428 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46065,9 +46065,9 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { From ca33ea2dc3de746630ef2650854afa040c11df14 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Thu, 28 May 2026 22:08:28 -0600 Subject: [PATCH 02/22] feat(#3960): add Markdown bundle generator output --- docs/package.json | 4 +- docs/src/scripts/content-generators/index.ts | 23 +- .../outputs/md-bundle.test.ts | 355 ++++++ .../content-generators/outputs/md-bundle.ts | 1082 +++++++++++++++++ .../content-generators/verify-bundle.test.ts | 103 ++ .../content-generators/verify-bundle.ts | 193 +++ 6 files changed, 1757 insertions(+), 3 deletions(-) create mode 100644 docs/src/scripts/content-generators/outputs/md-bundle.test.ts create mode 100644 docs/src/scripts/content-generators/outputs/md-bundle.ts create mode 100644 docs/src/scripts/content-generators/verify-bundle.test.ts create mode 100644 docs/src/scripts/content-generators/verify-bundle.ts diff --git a/docs/package.json b/docs/package.json index 01203da05f..200ba59b04 100644 --- a/docs/package.json +++ b/docs/package.json @@ -10,7 +10,9 @@ "extract-api": "npx tsx src/scripts/extract-api.ts --all", "preview": "astro preview", "generate-previews": "npx tsx src/scripts/generate-preview-images.ts", - "astro": "astro" + "astro": "astro", + "test": "npx tsx --test 'src/scripts/**/*.test.ts'", + "verify-bundle": "npx tsx src/scripts/content-generators/verify-bundle.ts" }, "dependencies": { "@abgov/design-tokens-v2": "npm:@abgov/design-tokens@^2.8.0", diff --git a/docs/src/scripts/content-generators/index.ts b/docs/src/scripts/content-generators/index.ts index be26838afb..ddb1a736eb 100644 --- a/docs/src/scripts/content-generators/index.ts +++ b/docs/src/scripts/content-generators/index.ts @@ -9,6 +9,8 @@ import { loadFrameworkIdentifiers } from "./loaders/framework-identifiers"; import { addComponentAliases, addExampleAliases } from "./transforms/aliases"; import { linkGuidanceToComponents } from "./transforms/link-guidance"; import { writeMcpJson } from "./outputs/mcp-json"; +import { writeMdBundle } from "./outputs/md-bundle"; +import { verifyBundle } from "./verify-bundle"; import { runChecks } from "./checks"; import { renderFindings } from "./checks/render"; import type { AnyRecord, ComponentRecord } from "./types"; @@ -67,13 +69,30 @@ function main(): void { if (errorCount > 0) { process.stderr.write( `[content-generators] skipped output because of ${errorCount} ${errorCount === 1 ? "error" : "errors"}. ` + - `Existing files in docs/generated/mcp/ are unchanged.\n`, + `Existing files in docs/generated/ are unchanged.\n`, ); process.exit(1); } // 5. Output. const result = writeMcpJson(records); + // Markdown bundle: one self-contained set per framework. + const mdResult = writeMdBundle(records); + + // Validate the generated bundle. A regression here (leaked code placeholder, + // raw HTML outside fences, gutted code block, broken table, lost frontmatter, + // or drift from the MCP component set) fails the run instead of shipping. + const violations = verifyBundle(); + if (violations.length > 0) { + process.stderr.write( + `[content-generators] md bundle failed validation (${violations.length} ${violations.length === 1 ? "issue" : "issues"}):\n`, + ); + for (const v of violations) { + process.stderr.write(` ${v.file}${v.line ? `:${v.line}` : ""} — ${v.message}\n`); + } + process.exit(1); + } + const elapsed = Math.round(performance.now() - start); // 6. Report. @@ -87,7 +106,7 @@ function main(): void { const examplesWithProductType = examples.filter((e) => e.productType).length; process.stdout.write( - `[content-generators] wrote ${result.written} records in ${elapsed}ms\n` + + `[content-generators] wrote ${result.written} records + ${mdResult.written} md files in ${elapsed}ms\n` + ` components: ${components.length} (${withFw} with fw ids, ${withApi} with api, ${withAliases} with aliases, ${withGuidance} with linked guidance)\n` + ` examples: ${examples.length} (${examplesWithAliases} with aliases, ${examplesWithProductType} with productType)\n` + ` guidance: ${guidance.length}\n` + diff --git a/docs/src/scripts/content-generators/outputs/md-bundle.test.ts b/docs/src/scripts/content-generators/outputs/md-bundle.test.ts new file mode 100644 index 0000000000..bb3d31990e --- /dev/null +++ b/docs/src/scripts/content-generators/outputs/md-bundle.test.ts @@ -0,0 +1,355 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mdxBodyProse, + renderComponent, + renderExample, + sizeNote, + extractVariantLinks, + cell, + yamlScalar, + type FrameworkTarget, +} from "./md-bundle"; +import type { ComponentRecord, ExampleRecord, GuidanceRecord } from "../types"; + +const REACT_TARGET: FrameworkTarget = { + id: "react", + apiKey: "react", + label: "React", + frontmatterKey: "react", + identifierKey: "reactClassName", + sources: [{ file: "react.tsx", lang: "tsx" }], + variantSourceAttr: "reactSourceUrl", +}; + +const WC_TARGET: FrameworkTarget = { + id: "web-components", + apiKey: "webComponents", + label: "Web component", + frontmatterKey: "webComponent", + identifierKey: "webComponentTag", + sources: [{ file: "web-components.html", lang: "html" }], +}; + +// Regression: the MDX cleanup pipeline must not strip content from inside code +// samples. Without care,
 blocks get fenced and then the ESM-import
+// strip and capitalised-JSX strip run over the fenced text, gutting real code
+// (e.g. a setup snippet collapsing to `render( , )`).
+
+test("mdxBodyProse keeps imports and JSX inside a 
 code sample", () => {
+  const input = [
+    "
{`// main.tsx",
+    'import { GoabThemeProvider } from "@abgov/react-components";',
+    "render(",
+    "  ,",
+    ");`}
", + ].join("\n"); + + const out = mdxBodyProse(input); + + assert.match(out, /```/, "expected a fenced code block"); + assert.match( + out, + /import \{ GoabThemeProvider \} from "@abgov\/react-components";/, + "import line was stripped from inside the code block", + ); + assert.match( + out, + /<\/GoabThemeProvider>/, + "JSX was stripped from inside the code block", + ); +}); + +// A code sample written as a raw Markdown fence (rather than
) must
+// survive the same way. The import strip and capitalised-JSX strip run over the
+// body, so without masking they reach inside the fence and gut the sample.
+test("mdxBodyProse keeps imports and JSX inside a raw Markdown code fence", () => {
+  const input = [
+    "Set it up like this:",
+    "",
+    "```jsx",
+    'import { GoabIconButton } from "@abgov/react-components";',
+    "",
+    '',
+    "```",
+    "",
+    "Done.",
+  ].join("\n");
+
+  const out = mdxBodyProse(input);
+
+  assert.match(
+    out,
+    /import \{ GoabIconButton \} from "@abgov\/react-components";/,
+    "import line was stripped from inside the fenced block",
+  );
+  assert.match(
+    out,
+    //,
+    "self-closing JSX was stripped from inside the fenced block",
+  );
+});
+
+test("mdxBodyProse renders inline {`...`} as clean inline code", () => {
+  const input = "Use {`var(--goa-color-text)`} for theme-aware values.";
+
+  const out = mdxBodyProse(input);
+
+  assert.match(out, /`var\(--goa-color-text\)`/, "expected clean inline code");
+  assert.doesNotMatch(out, /\{`/, "raw MDX template-literal braces leaked");
+});
+
+// goa-text headings: the site authors headings with , sometimes with
+// an explicit as="hN", sometimes only a size. They must not flatten to prose.
+test("mdxBodyProse maps goa-text headings to Markdown levels", () => {
+  assert.equal(
+    mdxBodyProse('Section').trim(),
+    "## Section",
+  );
+  assert.equal(
+    mdxBodyProse('Sub label').trim(),
+    "### Sub label",
+    "heading-xs (no as=) should be a level-3 sub-heading",
+  );
+  assert.equal(
+    mdxBodyProse('Deep').trim(),
+    "#### Deep",
+    "explicit as= is authoritative",
+  );
+});
+
+test("mdxBodyProse drops the heading-xl page title (the H1 comes from frontmatter)", () => {
+  assert.equal(
+    mdxBodyProse('Page Title').trim(),
+    "",
+  );
+});
+
+test("mdxBodyProse leaves body-size goa-text as prose, not a heading", () => {
+  const out = mdxBodyProse('Just prose.').trim();
+  assert.equal(out, "Just prose.");
+  assert.doesNotMatch(out, /^#/, "body text must not become a heading");
+});
+
+// Entities: decode named and numeric HTML entities, not just a handful, or the
+// rest leak into prose as raw `&...;`.
+test("mdxBodyProse decodes named and numeric HTML entities", () => {
+  assert.equal(
+    mdxBodyProse("False positives – a problem").trim(),
+    "False positives – a problem",
+  );
+  assert.equal(mdxBodyProse("reach me at john@example.com").trim(), "reach me at john@example.com");
+  assert.equal(mdxBodyProse("more — detail … end").trim(), "more — detail … end");
+  // & still decoded, and not double-decoded
+  assert.equal(mdxBodyProse("Tom & Jerry").trim(), "Tom & Jerry");
+});
+
+// Callout: renders as a single clean blockquote carrying its heading, with no
+// doubled "> >" markers and no leftover tags.
+test("mdxBodyProse renders goa-callout as a clean blockquote with its heading", () => {
+  const input =
+    'Be careful here.';
+  const out = mdxBodyProse(input);
+  assert.match(out, /> \*\*Heads up\*\*/, "callout heading should surface");
+  assert.match(out, /> Be careful here\./, "callout body should be quoted");
+  assert.doesNotMatch(out, /> >/, "no doubled blockquote markers");
+  assert.doesNotMatch(out, /goa-text/, "no leftover tags");
+});
+
+// Badge: the inline badge must not leave a stray double space on the heading
+// it trails.
+test("mdxBodyProse collapses the stray double space a goa-badge leaves", () => {
+  const out = mdxBodyProse(
+    'Standard migration ',
+  ).trim();
+  assert.equal(out, "### Standard migration *Recommended default*");
+});
+
+// Paragraphs: 

content is left-aligned, not carried through with the +// HTML-source indentation it was authored with. +test("mdxBodyProse left-aligns

prose (no stray source indentation)", () => { + const out = mdxBodyProse("

\n Line one wraps\n onto line two.\n

").trim(); + assert.doesNotMatch(out, /^[ \t]+\S/m, "paragraph lines must not stay indented"); + assert.match(out, /Line one wraps/); +}); + +// Examples cross-link to their related examples, closing the example-to-example +// loop the MCP carries. +test("renderExample links related examples for example-to-example navigation", () => { + const ex: ExampleRecord = { + id: "add-a-filter-chip", + collection: "examples", + title: "Add a filter chip", + body: "", + size: "interaction", + tags: [], + components: [], + relatedExamples: ["filter-data-in-a-table"], + aliases: [], + status: "published", + }; + const titles = new Map([["filter-data-in-a-table", "Filter data in a table"]]); + const out = renderExample(ex, REACT_TARGET, new Map(), titles); + assert.match(out, /## Related examples/); + assert.match(out, /- \[Filter data in a table\]\(\.\/filter-data-in-a-table\.md\)/); +}); + +// The index states the set's size, so a consumer can weigh the context cost +// before loading. +test("sizeNote reports file count and an approximate token estimate", () => { + const note = sizeNote(75, 240000); + assert.match(note, /75 files/); + assert.match(note, /60,000 tokens/); +}); + +// Web components has no source attribute, but the file exists next to +// react.tsx, so derive the WC link from reactSourceUrl (verified on disk). +const ERR_401 = + "https://github.com/GovAlta/ui-components/blob/dev/docs/src/content/examples/error-pages/401/react.tsx"; + +test("extractVariantLinks derives WC variant URLs from reactSourceUrl", () => { + const body = `## Restricted access (401)\n`; + assert.deepEqual(extractVariantLinks(body, WC_TARGET), [ + "- Restricted access (401): " + + "https://github.com/GovAlta/ui-components/blob/dev/docs/src/content/examples/error-pages/401/web-components.html", + ]); +}); + +test("extractVariantLinks emits no WC link when the web-components source is absent", () => { + const body = + '## X\n'; + assert.deepEqual(extractVariantLinks(body, WC_TARGET), []); +}); + +// Masking must survive code nested inside a capitalised-JSX wrapper. The JSX +// strip would otherwise delete the wrapper AND the placeholder inside it, +// losing the code (the same code-loss hazard, in a nested form). +test("mdxBodyProse preserves inline code masked inside a capitalised-JSX wrapper", () => { + const out = mdxBodyProse("Wrap: see {``} here done."); + assert.match(out, /``/, "inline code in a capitalised wrapper must survive"); +}); + +test("mdxBodyProse preserves a code block masked inside a capitalised-JSX wrapper", () => { + const out = mdxBodyProse("
const x = 1;
"); + assert.match(out, /```[\s\S]*const x = 1;[\s\S]*```/, "fenced code in a wrapper must survive"); +}); + +// Code scanning: escape the escape character first, so a backslash in the input +// can't break the table-cell or YAML-scalar escaping. +test("cell escapes backslashes before pipes", () => { + assert.equal(cell("a\\b|c"), "a\\\\b\\|c"); +}); + +test("yamlScalar escapes backslashes (valid double-quoted YAML)", () => { + assert.equal(yamlScalar('a\\b"c'), '"a\\\\b\\"c"'); +}); + +// A value whose first character is a YAML indicator (@, *, &, ?, !, %, |, >, `, +// or a leading "- ") must be quoted, or a parser reads it as an alias, tag, +// block scalar, or sequence item rather than plain text. +test("yamlScalar quotes values that begin with a YAML indicator character", () => { + assert.equal(yamlScalar("@abgov/foo"), '"@abgov/foo"'); + assert.equal(yamlScalar("*bold"), '"*bold"'); + assert.equal(yamlScalar("- experimental"), '"- experimental"'); +}); + +test("mdxBodyProse table cells escape backslashes and pipes", () => { + const out = mdxBodyProse("
H
a\\b|c
"); + assert.match(out, /a\\\\b\\\|c/); +}); + +// --- renderComponent --------------------------------------------------------- + +function componentFixture(overrides: Partial = {}): ComponentRecord { + return { + id: "button", + collection: "components", + body: "", + name: "Button", + status: "published", + category: "Actions", + tags: [], + aliases: [], + relatedComponents: [], + ...overrides, + }; +} + +function guidanceFixture( + overrides: Partial & { id: string }, +): GuidanceRecord { + return { + collection: "guidance", + body: "", + type: "do", + description: "", + topic: "content", + tags: [], + relatedProps: [], + status: "published", + ...overrides, + }; +} + +test("renderComponent renders props and events tables for the target framework", () => { + const c = componentFixture({ + api: { + frameworks: { + react: { + props: [ + { + name: "type", + type: "GoabButtonType", + required: true, + description: "Sets the button style.", + }, + ], + events: [ + { name: "onClick", type: "(e: Event) => void", description: "Fires on click." }, + ], + }, + }, + }, + }); + const out = renderComponent(c, REACT_TARGET, new Map(), [], new Map()); + assert.match(out, /## Properties/); + assert.match( + out, + /\| `type` \(required\) \| `GoabButtonType` \|.*\| Sets the button style\. \|/, + "required prop should render in the props table with backticked name and type", + ); + assert.match(out, /### Events/); + assert.match(out, /\| `onClick` \|/, "event should render as a table row"); + assert.match(out, /Fires on click\./); +}); + +test("renderComponent splits guidance into usage and accessibility by topic", () => { + const guidanceById = new Map([ + [ + "g-usage", + guidanceFixture({ id: "g-usage", type: "do", description: "Use a clear, action-led label.", topic: "content" }), + ], + [ + "g-a11y", + guidanceFixture({ id: "g-a11y", type: "dont", description: "Don't rely on colour alone.", topic: "screen-readers" }), + ], + ]); + const c = componentFixture({ relatedGuidance: ["g-usage", "g-a11y"] }); + const out = renderComponent(c, REACT_TARGET, guidanceById, [], new Map()); + + assert.match(out, /## Usage guidelines/); + assert.match(out, /## Accessibility/); + const [beforeA11y, afterA11y] = out.split("## Accessibility"); + assert.match(beforeA11y, /Use a clear, action-led label\./, "usage guidance belongs above Accessibility"); + assert.match(afterA11y, /Don't rely on colour alone\./, "screen-reader guidance belongs under Accessibility"); + assert.doesNotMatch(beforeA11y, /Don't rely on colour alone\./, "a11y guidance must not leak into usage"); +}); + +test("renderComponent shows a deprecation banner pointing to related components", () => { + const c = componentFixture({ status: "deprecated", relatedComponents: ["new-thing"] }); + const out = renderComponent(c, REACT_TARGET, new Map(), [], new Map([["new-thing", "New Thing"]])); + assert.match( + out, + /> \*\*Deprecated\.\*\* This component is no longer recommended for new work\. See Related components for current options\./, + ); +}); diff --git a/docs/src/scripts/content-generators/outputs/md-bundle.ts b/docs/src/scripts/content-generators/outputs/md-bundle.ts new file mode 100644 index 0000000000..43bc842ac6 --- /dev/null +++ b/docs/src/scripts/content-generators/outputs/md-bundle.ts @@ -0,0 +1,1082 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; +import type { + AnyRecord, + ComponentRecord, + ExampleRecord, + FoundationRecord, + GetStartedRecord, + GuidanceRecord, + ProductTypeRecord, +} from "../types"; + +// Markdown bundle output target. +// +// Same source and same transformed records as the MCP JSON output, but a +// different distribution: standalone Markdown for AI tools that don't speak +// MCP. The MCP resolves related guidance on demand and knows the caller's +// framework from the query; a static file can't, so this output denormalizes +// (guidance inlined as text) and splits by framework (one self-contained set +// per framework, so a consumer loads only what they use). + +export interface FrameworkTarget { + /** Folder name and frontmatter `framework` value. */ + id: string; + /** Key into `api.frameworks`. */ + apiKey: string; + /** Human label used in headings. */ + label: string; + /** Frontmatter key for this framework's component identifier. */ + frontmatterKey: string; + /** ComponentRecord field holding the identifier (e.g. `GoabButton`). */ + identifierKey: "reactClassName" | "angularSelector" | "webComponentTag"; + /** Example source files to embed, in render order, with fence languages. */ + sources: { file: string; lang: string }[]; + /** + * Attribute on `` that holds this framework's per-variant + * source URL, for page-scale examples whose source lives in variant + * subfolders rather than sibling files. Omit for frameworks PreviewContainer + * doesn't carry a source URL for. + */ + variantSourceAttr?: string; +} + +const FRAMEWORKS: FrameworkTarget[] = [ + { + id: "react", + apiKey: "react", + label: "React", + frontmatterKey: "react", + identifierKey: "reactClassName", + sources: [{ file: "react.tsx", lang: "tsx" }], + variantSourceAttr: "reactSourceUrl", + }, + { + id: "angular", + apiKey: "angular", + label: "Angular", + frontmatterKey: "angular", + identifierKey: "angularSelector", + sources: [ + { file: "angular.html", lang: "html" }, + { file: "angular.ts", lang: "typescript" }, + ], + variantSourceAttr: "angularSourceUrl", + }, + { + id: "web-components", + apiKey: "webComponents", + label: "Web component", + frontmatterKey: "webComponent", + identifierKey: "webComponentTag", + sources: [{ file: "web-components.html", lang: "html" }], + // PreviewContainer has no webComponentsSourceUrl attribute, so the WC + // bundle skips variant links rather than emit relative-only previews. + }, +]; + +// Mirrors ACCESSIBILITY_TOPICS in docs/src/lib/content-queries.ts (which drives +// the docs site's categorizeGuidance). The schema's topic enum lives in +// docs/src/content/config.ts. If that enum gains an accessibility topic, +// update this set so the bundle categorizes it the same way the site does. +const ACCESSIBILITY_TOPICS = new Set([ + "accessibility", + "screen-readers", + "keyboard", + "focus", +]); + +// Shape of the untyped `api` blob we navigate. Only the fields we render. +interface ApiItem { + name: string; + type?: string; + default?: unknown; + description?: string; + required?: boolean; +} +interface FrameworkApi { + props?: ApiItem[]; + events?: ApiItem[]; + slots?: ApiItem[]; +} +interface ApiBlob { + frameworks?: Record; +} + +/** Generate the Markdown bundle: one self-contained set per framework. */ +export function writeMdBundle(records: AnyRecord[]): { written: number } { + const targets = FRAMEWORKS; + + // Include everything, like the MCP: hidden is a website-nav concept, not a + // knowledge filter. Status (incl. deprecated) rides along in frontmatter so a + // consumer sees it, same as the MCP exposes it as a filterable field. + const components = records + .filter((r): r is ComponentRecord => r.collection === "components") + .sort((a, b) => cmp(a.id, b.id)); + const examples = records + .filter((r): r is ExampleRecord => r.collection === "examples") + .sort((a, b) => cmp(a.id, b.id)); + const foundations = records + .filter((r): r is FoundationRecord => r.collection === "foundations") + .sort((a, b) => cmp(a.id, b.id)); + const getStarted = records + .filter((r): r is GetStartedRecord => r.collection === "get-started") + .sort((a, b) => cmp(a.id, b.id)); + const productTypes = records + .filter((r): r is ProductTypeRecord => r.collection === "productTypes") + .sort((a, b) => cmp(a.id, b.id)); + const guidanceById = new Map( + records + .filter((r): r is GuidanceRecord => r.collection === "guidance") + .map((g) => [g.id, g]), + ); + + const componentNameById = new Map(components.map((c) => [c.id, c.name])); + const exampleTitleById = new Map(examples.map((e) => [e.id, e.title])); + + // Which examples reference each component (mirrors getExamplesForComponent). + const examplesByComponent = new Map(); + for (const ex of examples) { + for (const cid of ex.components) { + const list = examplesByComponent.get(cid) ?? []; + list.push(ex); + examplesByComponent.set(cid, list); + } + } + + // Clear the whole bundle before writing. The orchestrator's checks gate in + // index.ts prevents reaching this point on validation errors, so a clean run + // rebuilds from scratch with no orphans. A mid-write I/O failure would leave + // a partial bundle, but the render functions are pure, so realistic failure + // modes happen before this point, not during write. + fs.rmSync(paths.output.mdBundle, { recursive: true, force: true }); + + let written = 0; + for (const target of targets) { + const root = path.join(paths.output.mdBundle, target.id); + let chars = 0; + let fileCount = 0; + // Write a file and tally its size, so the index can report the set's weight. + const emit = (file: string, md: string): void => { + writeFile(file, md); + chars += md.length; + fileCount++; + written++; + }; + + for (const c of components) { + emit( + path.join(root, "components", filename(c.id)), + renderComponent( + c, + target, + guidanceById, + examplesByComponent.get(c.id) ?? [], + componentNameById, + ), + ); + } + for (const ex of examples) { + emit( + path.join(root, "examples", filename(ex.id)), + renderExample(ex, target, componentNameById, exampleTitleById), + ); + } + for (const f of foundations) { + emit(path.join(root, "foundations", filename(f.id)), renderFoundation(f)); + } + for (const gs of getStarted) { + emit(path.join(root, "get-started", filename(gs.id)), renderGetStarted(gs)); + } + for (const pt of productTypes) { + emit( + path.join(root, "product-types", filename(pt.id)), + renderProductType(pt, componentNameById), + ); + } + // Index last; it reports the set size, counting itself (hence fileCount + 1). + emit( + path.join(root, "index.md"), + renderIndex(target, components, foundations, examples, getStarted, productTypes, { + files: fileCount + 1, + chars, + }), + ); + } + + return { written }; +} + +// --- Component --------------------------------------------------------------- + +export function renderComponent( + c: ComponentRecord, + target: FrameworkTarget, + guidanceById: Map, + examples: ExampleRecord[], + componentNameById: Map, +): string { + const identifier = c[target.identifierKey]; + const fm: [string, unknown][] = [ + ["id", c.id], + ["name", c.name], + ["framework", target.id], + ["status", c.status], + ["category", c.category], + ["tags", c.tags], + ]; + if (c.subcomponent) fm.push(["subcomponent", true]); + if (identifier) fm.push([target.frontmatterKey, identifier]); + if (c.figmaUrl) fm.push(["figma", c.figmaUrl]); + + const out: string[] = [frontmatter(fm), `# ${c.name}`]; + if (c.description) out.push(c.description); + + // Surface deprecation in the prose, not just frontmatter: a tool reading the + // body top-to-bottom should see it. There's no replacedBy field in the source, + // so point to Related components rather than naming a replacement. + if (c.status === "deprecated") { + const alt = c.relatedComponents?.length + ? " See Related components for current options." + : ""; + out.push( + `> **Deprecated.** This component is no longer recommended for new work.${alt}`, + ); + } + + // Properties + const api = (c.api as ApiBlob | undefined)?.frameworks?.[target.apiKey]; + const propsTable = api?.props?.length ? apiTable(api.props, true) : ""; + const eventsTable = api?.events?.length ? apiTable(api.events, false) : ""; + const slotsTable = api?.slots?.length ? apiTable(api.slots, false) : ""; + if (propsTable || eventsTable || slotsTable) { + out.push("## Properties"); + if (propsTable) out.push(propsTable); + if (eventsTable) out.push("### Events", eventsTable); + if (slotsTable) out.push("### Slots", slotsTable); + } else { + out.push("## Properties", "_No extracted API for this component._"); + } + + // Examples (pointers to files in this same bundle) + if (examples.length) { + const lines = [...examples] + .sort((a, b) => cmp(a.id, b.id)) + .map((ex) => { + const uses = ex.components + .map((id) => componentNameById.get(id) ?? id) + .join(", "); + const usesText = uses ? ` Uses: ${uses}.` : ""; + return `- **${ex.title}** (${ex.size}).${usesText} See \`../examples/${filename(ex.id)}\`.`; + }); + out.push("## Examples", lines.join("\n")); + } + + // Guidance, inlined as text and split usage / accessibility (mirrors the site) + const guidance = (c.relatedGuidance ?? []) + .map((id) => guidanceById.get(id)) + .filter((g): g is GuidanceRecord => Boolean(g)); + const usage = guidance.filter((g) => !ACCESSIBILITY_TOPICS.has(g.topic)); + const accessibility = guidance.filter((g) => ACCESSIBILITY_TOPICS.has(g.topic)); + + const usageSection = guidanceByTopic(usage); + if (usageSection) out.push("## Usage guidelines", usageSection); + const a11ySection = guidanceByTopic(accessibility); + if (a11ySection) { + out.push( + "## Accessibility", + "All GoA Design System components are built to meet WCAG 2.2 AA. These notes add component-specific context.", + a11ySection, + ); + } + + // Related components + if (c.relatedComponents?.length) { + const lines = c.relatedComponents.map((id) => { + const name = componentNameById.get(id) ?? id; + return `- ${name} (\`./${filename(id)}\`)`; + }); + out.push("## Related components", lines.join("\n")); + } + + return out.join("\n\n"); +} + +// Render a list of guidance grouped by topic, in a deterministic topic order. +function guidanceByTopic(guidance: GuidanceRecord[]): string { + if (!guidance.length) return ""; + const byTopic = new Map(); + for (const g of guidance) { + const list = byTopic.get(g.topic) ?? []; + list.push(g); + byTopic.set(g.topic, list); + } + const topics = [...byTopic.keys()].sort(); + const blocks: string[] = []; + for (const topic of topics) { + const items = byTopic + .get(topic)! + .sort((a, b) => cmp(a.id, b.id)) + .map((g) => { + let line = `- **${guidanceLabel(g.type)}:** ${inline(g.description)}`; + const prose = guidanceBodyProse(g.body); + if (prose) { + // Indent the prose so it stays attached to the bullet; nested lists + // (e.g. an "overview" atom whose content lives in the body) carry over. + line += "\n" + prose.split("\n").map((l) => (l ? ` ${l}` : l)).join("\n"); + } + return line; + }); + blocks.push(`### ${titleCase(topic)}\n\n${items.join("\n")}`); + } + return blocks.join("\n\n"); +} + +function guidanceLabel(type: string): string { + if (type === "do") return "Do"; + if (type === "dont") return "Don't"; + return titleCase(type); +} + +// Guidance bodies carry a with web-component markup (a website visual) +// and sometimes extra prose. The description holds the rule; the body sometimes +// holds the substance (e.g. an "overview" atom). Drop the Preview, keep prose. +function guidanceBodyProse(body: string): string { + return body + .replace(//g, "") + .replace(/]*\/>/g, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +// --- Example ----------------------------------------------------------------- + +export function renderExample( + ex: ExampleRecord, + target: FrameworkTarget, + componentNameById: Map, + exampleTitleById: Map, +): string { + const fm: [string, unknown][] = [ + ["id", ex.id], + ["title", ex.title], + ["framework", target.id], + ["size", ex.size], + ["status", ex.status], + ["tags", ex.tags], + ["components", ex.components], + ]; + const out: string[] = [frontmatter(fm), `# ${ex.title}`]; + + // Lift per-variant source URLs out of blocks BEFORE we + // strip MDX from the body. Otherwise page examples (e.g. error-pages) lose + // their only pointer to source, which lives in variant subfolders rather + // than alongside the example's index.mdx. + const variantLinks = extractVariantLinks(ex.body, target); + + const cleanBody = mdxBodyProse(ex.body); + if (cleanBody) out.push(cleanBody); + + if (ex.components.length) { + const uses = ex.components.map((id) => componentNameById.get(id) ?? id).join(", "); + out.push(`**Components used:** ${uses}`); + } + + // Embed this framework's source where it exists on disk. + const folder = path.join(paths.content.examples, ex.id); + const codeBlocks: string[] = []; + for (const src of target.sources) { + const file = path.join(folder, src.file); + if (fs.existsSync(file)) { + const code = fs.readFileSync(file, "utf8").trimEnd(); + codeBlocks.push("```" + src.lang + "\n" + code + "\n```"); + } + } + if (codeBlocks.length) { + out.push(`## Code (${target.label})`, codeBlocks.join("\n\n")); + } + + // External links: top-level URLs on the record plus any variant URLs lifted + // from the body's blocks above. + const links: string[] = []; + if (ex.previewUrl) links.push(`- Live preview: ${ex.previewUrl}`); + if (target.id === "react" && ex.reactSourceUrl) + links.push(`- Source: ${ex.reactSourceUrl}`); + if (target.id === "angular" && ex.angularSourceUrl) + links.push(`- Source: ${ex.angularSourceUrl}`); + if (ex.sourceUrl) links.push(`- Source: ${ex.sourceUrl}`); + if (ex.stackblitzUrl) links.push(`- StackBlitz: ${ex.stackblitzUrl}`); + links.push(...variantLinks); + + if (!codeBlocks.length && !links.length) { + out.push("_Source for this pattern lives on the docs site._"); + } else if (links.length) { + out.push("## Links", links.join("\n")); + } + + // Related examples: example-to-example navigation the MCP carries. Components + // already link to their examples; this closes the loop between examples. + if (ex.relatedExamples.length) { + const lines = ex.relatedExamples.map((id) => { + const title = exampleTitleById.get(id) ?? id; + return `- [${title}](./${filename(id)})`; + }); + out.push("## Related examples", lines.join("\n")); + } + + return out.join("\n\n"); +} + +// Pull per-variant source URLs out of blocks in an MDX body, +// labelled by the nearest preceding heading. React/Angular read their own source +// attribute; Web Components has none, but its source sits next to react.tsx, so +// its URL is derived (and verified on disk) rather than left as a dead end. +export function extractVariantLinks(body: string, target: FrameworkTarget): string[] { + const blocks = [...body.matchAll(//g)]; + const lines: string[] = []; + for (const m of blocks) { + const url = variantSourceUrl(m[0], target); + if (!url) continue; + const before = body.slice(0, m.index ?? 0); + const headings = [...before.matchAll(/^#{2,4}\s+(.+)$/gm)]; + const label = headings.length ? headings[headings.length - 1][1].trim() : "Variant"; + lines.push(`- ${label}: ${url}`); + } + return lines; +} + +// The per-variant source URL for one . React/Angular each carry +// their own attribute. Web Components has no attribute, so derive its URL from +// reactSourceUrl (.../react.tsx -> .../web-components.html), but only when that +// file actually exists, so a missing variant fails safe to no link, not a 404. +function variantSourceUrl(block: string, target: FrameworkTarget): string | undefined { + if (target.variantSourceAttr) { + return block.match(new RegExp(`${target.variantSourceAttr}="([^"]+)"`))?.[1]; + } + if (target.id !== "web-components") return undefined; + const reactUrl = block.match(/reactSourceUrl="([^"]+)"/)?.[1]; + const rel = reactUrl?.match(/\/docs\/src\/content\/examples\/(.+)\/react\.tsx$/)?.[1]; + if (!reactUrl || !rel) return undefined; + if (!fs.existsSync(path.join(paths.content.examples, rel, "web-components.html"))) { + return undefined; + } + return reactUrl.replace(/\/react\.tsx$/, "/web-components.html"); +} + +// --- Foundation -------------------------------------------------------------- + +function renderFoundation(f: FoundationRecord): string { + const fm: [string, unknown][] = [ + ["id", f.id], + ["title", f.title], + ["category", f.category], + ["status", f.status], + ["tags", f.tags], + ]; + const out = [frontmatter(fm), `# ${f.title}`]; + if (f.description) out.push(f.description); + const body = mdxBodyProse(f.body); + if (body) out.push(body); + return out.join("\n\n"); +} + +// --- Get-started ------------------------------------------------------------- + +function renderGetStarted(g: GetStartedRecord): string { + const fm: [string, unknown][] = [ + ["id", g.id], + ["title", g.title], + ["section", g.section], + ["order", g.order], + ["status", g.status], + ]; + const out = [frontmatter(fm), `# ${g.title}`]; + if (g.description) out.push(g.description); + const body = mdxBodyProse(g.body); + if (body) out.push(body); + return out.join("\n\n"); +} + +// --- Product type ------------------------------------------------------------ + +function renderProductType( + pt: ProductTypeRecord, + componentNameById: Map, +): string { + const fm: [string, unknown][] = [ + ["id", pt.id], + ["title", pt.title], + ["status", pt.status], + ["tags", pt.tags], + ]; + const out = [frontmatter(fm), `# ${pt.title}`]; + if (pt.summary) out.push(mdxBodyProse(pt.summary)); + const body = mdxBodyProse(pt.body); + if (body) out.push(body); + if (pt.components.length) { + const uses = pt.components.map((id) => componentNameById.get(id) ?? id).join(", "); + out.push(`**Components used:** ${uses}`); + } + const links: string[] = []; + if (pt.demoUrl) links.push(`- Demo: ${pt.demoUrl}`); + if (pt.sourceUrl) links.push(`- Source: ${pt.sourceUrl}`); + if (links.length) out.push("## Links", links.join("\n")); + return out.join("\n\n"); +} + +// Convert an MDX/HTML body to clean Markdown. Two record types (get-started +// and product-types) are authored as HTML-heavy MDX, so the body carries +//

headings,
{`...`}
code blocks, lowercase +// wrappers around prose, and styled
/ layout wrappers. This +// pipeline rewrites the structural pieces, unwraps prose wrappers, and drops +// layout-only chrome. Order matters: code blocks first (so their content isn't +// touched by later rules), then block-level rewrites, then inline. +export function mdxBodyProse(body: string): string { + // Mask every code region to an opaque placeholder BEFORE any HTML/MDX + // transform runs, then restore them verbatim at the very end. Without this, + // the import strip and the capitalised-JSX strip below run over code that has + // already been turned into a fence and gut real samples (for example, a + // setup snippet collapsing to "render( , )"). SENTINEL can't occur in source and + // is inert to every regex below, so the placeholder survives untouched. Block + // code is padded with blank lines so its fence restores cleanly; inline code + // stays inline. + const codeBlocks: string[] = []; + const SENTINEL = "@@CODEMASK@@"; + const maskBlock = (rendered: string): string => { + codeBlocks.push(rendered); + return `\n\n${SENTINEL}${codeBlocks.length - 1}${SENTINEL}\n\n`; + }; + const maskInline = (rendered: string): string => { + codeBlocks.push(rendered); + return `${SENTINEL}${codeBlocks.length - 1}${SENTINEL}`; + }; + + let s = body; + + // Raw Markdown code fences authored directly in the body (```lang ... ```). + // Masked verbatim, info string and all, so the import strip and capitalised-JSX + // strip below cannot reach inside them. Without this, an import or capitalised + // tag inside a fenced sample would be deleted, silently corrupting the code. + s = s.replace( + /^[ \t]{0,3}(`|~)\1{2,}[^\n]*\n[\s\S]*?\n[ \t]{0,3}\1{3,}[ \t]*$/gm, + (m) => maskBlock(m), + ); + + //
{`...`}
— MDX template literal, kept raw. + s = s.replace( + /
\s*]*>\{`([\s\S]*?)`\}<\/code>\s*<\/pre>/g,
+    (_, code) => maskBlock("```\n" + code.trim() + "\n```"),
+  );
+  // 
...
— plain HTML, entity-decoded. + s = s.replace( + /
\s*]*>([\s\S]*?)<\/code>\s*<\/pre>/g,
+    (_, code) => maskBlock("```\n" + decodeEntities(code).trim() + "\n```"),
+  );
+  // Inline {`...`} — MDX template literal, kept raw.
+  s = s.replace(
+    /]*>\{`([\s\S]*?)`\}<\/code>/g,
+    (_, code) => maskInline("`" + code.trim() + "`"),
+  );
+  // Inline ... — plain, entity-decoded.
+  s = s.replace(
+    /]*>([\s\S]*?)<\/code>/g,
+    (_, code) => maskInline("`" + decodeEntities(code).trim() + "`"),
+  );
+
+  // ESM imports/exports (only when they have a `from "..."` clause, so prose
+  // that happens to start with "import" doesn't get stripped). Safe now that
+  // the code samples above are masked.
+  s = s.replace(/^(import|export)\s+[^\n]*?\bfrom\s+["'][^"']+["'][^\n]*$/gm, "");
+
+  // Capitalised JSX components (PreviewContainer, GoabTemporaryNotificationCtrl,
+  // etc.) are dropped, but any masked code placeholder nested inside one is kept:
+  // a code sample wrapped in such a component must survive like code anywhere
+  // else (otherwise the JSX strip would drop a code sample wrapped in one).
+  s = s.replace(/<([A-Z][A-Za-z0-9]*)\b[\s\S]*?<\/\1>/g, (m) => {
+    const kept = m.match(new RegExp(`${SENTINEL}\\d+${SENTINEL}`, "g"));
+    return kept ? `\n\n${kept.join("\n\n")}\n\n` : "";
+  });
+  s = s.replace(/<[A-Z][A-Za-z0-9]*\b[^>]*?\/>/g, "");
+
+  //  handled early so downstream wrappers (e.g. ) see the alt
+  // text rather than empty content. Keep the alt as an italic descriptor so an
+  // AI knows what the visual carried; drop the image element either way.
+  s = s.replace(/]*?\balt="([^"]+)"[^>]*?\/?>/g, "*[Image: $1]*");
+  s = s.replace(/]*?\/?>/g, "");
+
+  // ... -> #### X + content. Then  unwraps.
+  s = applyUntilStable(s, (t) =>
+    t.replace(
+      /]*\sheading="([^"]+)"[^>]*>([\s\S]*?)<\/goa-tab>/g,
+      (_, heading, content) => `\n#### ${heading}\n\n${content.trim()}\n`,
+    ),
+  );
+  s = s.replace(/]*>([\s\S]*?)<\/goa-tabs>/g, "$1");
+
+  //  -> horizontal rule. Handles paired-empty and self-closing.
+  s = s.replace(/]*?\/?>(?:<\/goa-divider>)?/g, "\n---\n");
+
+  // goa-callout is handled near the end (after its inner content is Markdown),
+  // so we prefix "> " onto clean lines instead of onto leftover tags.
+
+  // label -> **label**. The button's onclick URL is
+  // already surfaced via demoUrl / Links elsewhere on the record.
+  s = s.replace(
+    /]*>([\s\S]*?)<\/goa-button>/g,
+    (_, label) => `**${label.trim()}**`,
+  );
+
+  //  -> Markdown link. Source uses an MDX expression with withBase
+  // (the URL can be backtick-, single-, or double-quoted inside the call) or a
+  // plain string href. Done before goa-text unwrap so links inside 
+  // bodies survive.
+  s = s.replace(
+    /]*>([\s\S]*?)<\/a>/g,
+    (_, url, text) => `[${text.trim()}](${url})`,
+  );
+  s = s.replace(
+    /]*>([\s\S]*?)<\/a>/g,
+    (_, url, text) => `[${text.trim()}](${url})`,
+  );
+
+  // Layout/inline goa-* wrappers we haven't handled yet.  wraps an
+  // HTML ,  is a styled box: both unwrap to inner.
+  //  is a small inline label with its text in a `content` attribute.
+  s = applyUntilStable(s, (t) =>
+    t.replace(/]*>([\s\S]*?)<\/goa-table>/g, "$1"),
+  );
+  s = applyUntilStable(s, (t) =>
+    t.replace(/]*>([\s\S]*?)<\/goa-container>/g, "$1"),
+  );
+  s = s.replace(
+    /]*?\bcontent="([^"]+)"[^>]*?\/?>(?:<\/goa-badge>)?/g,
+    (_, content) => ` *${content}*`,
+  );
+
+  // : the site authors headings with this tag, so route by attribute
+  // (heading -> Markdown heading, body size -> prose) instead of flattening all
+  // of them to prose. Trim each line so accidental HTML-source indentation (the
+  // bodies are often multi-line and indented) doesn't survive into the Markdown.
+  s = applyUntilStable(s, (t) =>
+    t.replace(/]*)>([\s\S]*?)<\/goa-text>/g, (_, attrs, c) =>
+      goaText(attrs, trimLines(c)),
+    ),
+  );
+  s = applyUntilStable(s, (t) =>
+    t.replace(/]*>([\s\S]*?)<\/goa-link>/g, (_, c) => trimLines(c)),
+  );
+
+  // Headings: Title -> "##.. Title"
+  s = s.replace(/]*>([\s\S]*?)<\/h\1>/g, (_, level, content) => {
+    return "#".repeat(Number(level)) + " " + content.trim();
+  });
+
+  // 
/
/
-> newline. + s = s.replace(//gi, "\n"); + + // Inline formatting. (Inline is masked up top with the other code.) + s = s.replace(/]*>([\s\S]*?)<\/strong>/g, "**$1**"); + s = s.replace(/]*>([\s\S]*?)<\/em>/g, "*$1*"); + + // HTML tables -> Markdown tables. Cells get any remaining raw tags stripped + // defensively; pipes and newlines inside cells are escaped/normalised. + s = s.replace(/]*>([\s\S]*?)<\/table>/g, (_, t) => htmlTableToMd(t)); + + //
    /
      /
    1. -> Markdown bullets. Unwrap list wrappers first, then + // convert
    2. items iteratively: nested
    3. matches the inner pair first, + // then the outer becomes innermost on the next pass. Both list types render + // as bullets (ordered semantics are rare here and an AI consumer reads + // either correctly). + s = applyUntilStable(s, (t) => t.replace(/]*>([\s\S]*?)<\/ul>/g, "$1")); + s = applyUntilStable(s, (t) => t.replace(/]*>([\s\S]*?)<\/ol>/g, "$1")); + s = applyUntilStable(s, (t) => + t.replace(/]*>([\s\S]*?)<\/li>/g, (_, item) => "- " + item.trim() + "\n"), + ); + + //

      -> content + blank line; -> inner content. + s = applyUntilStable(s, (t) => + t.replace(/]*>([\s\S]*?)<\/p>/g, (_, c) => stripLeadingPerLine(c) + "\n"), + ); + s = applyUntilStable(s, (t) => t.replace(/]*>([\s\S]*?)<\/span>/g, "$1")); + + //

      : drop empty/whitespace-only shells (layout chrome with no content), + // then unwrap the rest. The unwrap strips per-line leading whitespace from + // the inner content: the wrapper was carrying layout meaning, and HTML-source + // indentation inside it would otherwise survive as content. (4+ leading + // spaces in Markdown is an indented code block, which is wrong intent here.) + // Run repeatedly because removing inner divs can make outer divs empty. + s = applyUntilStable(s, (t) => t.replace(/]*>\s*<\/div>/g, "")); + s = applyUntilStable(s, (t) => + t.replace(/]*>([\s\S]*?)<\/div>/g, (_, c) => stripLeadingPerLine(c)), + ); + + // ... -> blockquote. Runs here, late, + // so the inner content is already Markdown and we prefix "> " onto clean lines + // (running it early doubled "> >" once goa-text later unwrapped). The heading + // attribute becomes a bold first line. Trailing blank line stops prose after + // the callout being pulled in by CommonMark lazy continuation. + s = applyUntilStable(s, (t) => + t.replace( + /]*)>([\s\S]*?)<\/goa-callout>/g, + (_, attrs, content) => { + const heading = attrs.match(/\bheading="([^"]+)"/); + const lines = content + .trim() + .split("\n") + .map((l: string) => l.trim()); + if (heading) lines.unshift(`**${heading[1]}**`, ""); + const quoted = lines.map((l: string) => (l ? `> ${l}` : ">")).join("\n"); + return "\n\n" + quoted + "\n\n"; + }, + ), + ); + + s = decodeEntities(s); + + // Normalise: blank out whitespace-only lines, collapse internal runs of 2+ + // spaces to one (e.g. the stray space a goa-badge leaves), and collapse runs + // of blank lines. The space-collapse requires a non-space on both sides, so it + // leaves leading indentation (list nesting) and trailing hard breaks alone, + // and code is still masked at this point so it is untouched. + s = s + .replace(/^[ \t]+$/gm, "") + .replace(/(\S) {2,}(?=\S)/g, "$1 ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + // Restore masked code regions verbatim, after all transforms have run. + const restore = new RegExp(`${SENTINEL}(\\d+)${SENTINEL}`, "g"); + return s.replace(restore, (_, i) => codeBlocks[Number(i)]); +} + +// Run a transform until it reaches a fixed point or hits the iteration cap. +// Used for paired tags so nested same-tag pairs unwrap layer by layer. +function applyUntilStable(s: string, fn: (s: string) => string, max = 10): string { + for (let i = 0; i < max; i++) { + const next = fn(s); + if (next === s) return s; + s = next; + } + return s; +} + +// Named entities the source actually uses, plus the common typographic ones. +// `amp` is deliberately absent here so it can be decoded last (below), which +// stops a literal "&ndash;" from collapsing into "–". +const NAMED_ENTITIES: Record = { + apos: "'", + quot: '"', + lt: "<", + gt: ">", + ndash: "–", + mdash: "—", + hellip: "…", + nbsp: " ", + copy: "©", + reg: "®", + trade: "™", + deg: "°", + times: "×", + lsquo: "‘", + rsquo: "’", + ldquo: "“", + rdquo: "”", +}; + +function decodeEntities(s: string): string { + // Named entities (unknown ones are left untouched, not mangled). + s = s.replace(/&([a-z]+);/gi, (m, name) => { + const key = name.toLowerCase(); + return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, key) + ? NAMED_ENTITIES[key] + : m; + }); + // Numeric: decimal &#NN; and hex &#xHH;. + s = s.replace(/&#(\d+);/g, (_, n) => safeCodePoint(Number(n))); + s = s.replace(/&#x([0-9a-f]+);/gi, (_, h) => safeCodePoint(parseInt(h, 16))); + // & last, so it doesn't enable a second decode pass on its neighbours. + return s.replace(/&/g, "&"); +} + +function safeCodePoint(cp: number): string { + try { + return String.fromCodePoint(cp); + } catch { + return ""; + } +} + +// Trim leading/trailing whitespace on each line and on the whole string. +function trimLines(s: string): string { + return s + .split("\n") + .map((l) => l.trim()) + .join("\n") + .trim(); +} + +// Render a element. The site authors headings with goa-text, so an +// explicit `as="hN"` wins; otherwise the size maps by role. heading-m sits at +// h2 (it is always authored with as="h2"); the smaller heading-s/heading-xs are +// h3 sub-headings (they never co-occur, so no level is lost, and the source +// nests real h4s beneath heading-xs). heading-xl is the page title in hero +// styling and is dropped, because the file's H1 already carries the title. +// Body sizes and anything unrecognised unwrap to plain prose. +function goaText(attrs: string, content: string): string { + const level = goaTextHeadingLevel(attrs); + if (level === null) return content; // body / non-heading -> prose + if (level === 0) return ""; // heading-xl page title -> dropped + return `\n\n${"#".repeat(level)} ${content.trim()}\n\n`; +} + +function goaTextHeadingLevel(attrs: string): number | null { + const asMatch = attrs.match(/\bas="h([1-6])"/); + if (asMatch) return Number(asMatch[1]); + const sizeMatch = attrs.match(/\bsize="(heading-[a-z]+)"/); + if (!sizeMatch) return null; + switch (sizeMatch[1]) { + case "heading-xl": + return 0; // page title; dropped (frontmatter H1 carries it) + case "heading-l": + case "heading-m": + return 2; + default: + return 3; // heading-s, heading-xs, any future smaller heading size + } +} + +// Strip per-line leading whitespace, preserving line breaks and trailing +// content. Used when unwrapping layout containers whose HTML-source indentation +// shouldn't survive as content. +function stripLeadingPerLine(s: string): string { + return s.split("\n").map((l) => l.replace(/^[ \t]+/, "")).join("\n"); +} + +// Convert an HTML
body (already stripped of the outer
tag) into +// a Markdown table. Detects the header row by whether the first uses
; +// strips any remaining HTML inside cells defensively so a stray inline tag +// can't shred the table layout. +function htmlTableToMd(content: string): string { + const trMatches = [...content.matchAll(/]*>([\s\S]*?)<\/tr>/g)]; + if (!trMatches.length) return ""; + const rowOf = (tr: string) => + [...tr.matchAll(/]*>([\s\S]*?)<\/t[hd]>/g)].map((m) => + applyUntilStable(m[1], (t) => t.replace(/<[^>]+>/g, "")) + .replace(/\\/g, "\\\\") + .replace(/\|/g, "\\|") + .replace(/\s+/g, " ") + .trim(), + ); + const rows = trMatches.map((m) => rowOf(m[1])); + if (!rows.length || !rows[0].length) return ""; + + const firstRowIsHeader = / "---").join(" | ") + " |", + ...body.map((r) => "| " + r.join(" | ") + " |"), + ]; + return "\n" + lines.join("\n") + "\n"; +} + +// --- Index ------------------------------------------------------------------- + +function renderIndex( + target: FrameworkTarget, + components: ComponentRecord[], + foundations: FoundationRecord[], + examples: ExampleRecord[], + getStarted: GetStartedRecord[], + productTypes: ProductTypeRecord[], + stats: { files: number; chars: number }, +): string { + const out: string[] = []; + out.push(`# GoA Design System knowledge bundle (${target.label})`); + out.push( + "This bundle gives an AI tool the knowledge to build interfaces with the Government of Alberta Design System without an MCP connection. It is generated from the design system's documentation source, so it stays in step with the components themselves.", + ); + out.push( + `This is the **${target.label}** set: component properties and example code are ${target.label}-specific. Sets for the other frameworks exist alongside this one.`, + ); + out.push(sizeNote(stats.files, stats.chars)); + out.push( + "## Advisory, not prescriptive\n\nThe design system provides strong defaults and guidance, not rigid rules. Treat the usage guidelines as \"consider this,\" and expect teams to mix design system components with their own where a service needs it.", + ); + out.push( + "## How to use this bundle\n\n" + + "- Building with a component? Open its file in `components/`. It carries the properties, usage and accessibility guidance, and links to relevant examples.\n" + + "- Want a worked pattern? Open the linked file in `examples/`. It includes runnable source.\n" + + "- Need cross-cutting principles (accessibility, responsiveness, anti-patterns)? See `foundations/`.\n" + + "- Setting up or migrating? See `get-started/`. For product-level patterns, see `product-types/`.", + ); + + // Components grouped by category + const byCategory = new Map(); + for (const c of components) { + const list = byCategory.get(c.category) ?? []; + list.push(c); + byCategory.set(c.category, list); + } + const catBlocks: string[] = []; + for (const category of [...byCategory.keys()].sort()) { + const items = byCategory + .get(category)! + .sort((a, b) => cmp(a.name, b.name)) + .map((c) => { + const marks: string[] = []; + if (c.subcomponent) marks.push("subcomponent"); + if (c.status === "deprecated") marks.push("deprecated"); + const tag = marks.length ? ` (${marks.join(", ")})` : ""; + const desc = c.description ? ` — ${c.description}` : ""; + return `- [${c.name}](components/${filename(c.id)})${tag}${desc}`; + }); + catBlocks.push(`### ${titleCase(category)}\n\n${items.join("\n")}`); + } + out.push(`## Components (${components.length})`, catBlocks.join("\n\n")); + + const foundationItems = foundations + .map((f) => `- [${f.title}](foundations/${filename(f.id)})`) + .join("\n"); + out.push(`## Foundations`, foundationItems); + + if (getStarted.length) { + // Group by section, preserve declared order within section. + const bySection = new Map(); + for (const g of getStarted) { + const list = bySection.get(g.section) ?? []; + list.push(g); + bySection.set(g.section, list); + } + const sectionBlocks: string[] = []; + for (const section of [...bySection.keys()].sort()) { + const items = bySection + .get(section)! + .sort((a, b) => a.order - b.order || cmp(a.id, b.id)) + .map((g) => `- [${g.title}](get-started/${filename(g.id)})`); + sectionBlocks.push(`### ${titleCase(section)}\n\n${items.join("\n")}`); + } + out.push(`## Get started (${getStarted.length})`, sectionBlocks.join("\n\n")); + } + + if (productTypes.length) { + const items = productTypes.map((pt) => { + const summary = pt.summary ? inline(mdxBodyProse(pt.summary)) : ""; + return `- [${pt.title}](product-types/${filename(pt.id)})${summary ? ` — ${summary}` : ""}`; + }); + out.push(`## Product types (${productTypes.length})`, items.join("\n")); + } + + out.push( + `## Examples (${examples.length})`, + "Worked patterns live in `examples/`, each linked from the components it uses.", + ); + + return out.join("\n\n"); +} + +// --- Helpers ----------------------------------------------------------------- + +// Code-unit string comparison. Deterministic across Node/ICU versions so the +// freshness check that depends on byte-identical reruns doesn't have to absorb +// a locale-collation wobble. +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +// Flatten nested ids (e.g. "designers/x") into a filesystem-safe filename, the +// same way mcp-json does. The canonical id lives in the file's frontmatter. +function filename(id: string): string { + return id.replace(/\//g, "__") + ".md"; +} + +function writeFile(file: string, content: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content.trimEnd() + "\n", "utf8"); +} + +// Render an API item array (props, events, or slots) as a Markdown table. +function apiTable(items: ApiItem[], withDefault: boolean): string { + const header = withDefault + ? "| Name | Type | Default | Description |\n| --- | --- | --- | --- |" + : "| Name | Type | Description |\n| --- | --- | --- |"; + const rows = items.map((it) => { + const name = it.required ? `\`${it.name}\` (required)` : `\`${it.name}\``; + const type = it.type ? `\`${cell(it.type)}\`` : ""; + const desc = cell(it.description ?? ""); + if (!withDefault) return `| ${name} | ${type} | ${desc} |`; + const def = + it.default === null || it.default === undefined || it.default === "" + ? "" + : `\`${cell(String(it.default))}\``; + return `| ${name} | ${type} | ${def} | ${desc} |`; + }); + return [header, ...rows].join("\n"); +} + +// Make a string safe inside a Markdown table cell: no newlines, escape +// backslashes (first, so they can't break the pipe escape) and pipes. +export function cell(s: string): string { + return s.replace(/\r?\n/g, " ").replace(/\\/g, "\\\\").replace(/\|/g, "\\|").trim(); +} + +// Collapse a guidance description to a single line (it renders in a bullet). +function inline(s: string): string { + return s.replace(/\r?\n/g, " ").trim(); +} + +// One-line size note for a set's index, so a consumer can weigh the context cost +// before loading. Tokens are a rough chars/4 estimate; the thousands separator +// is applied by hand to stay deterministic across locales and ICU versions. +export function sizeNote(files: number, chars: number): string { + const tokens = Math.round(chars / 4); + const grouped = tokens.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return `This set is ${files} files, roughly ${grouped} tokens. Load the index plus the files you need, not the whole set.`; +} + +function titleCase(slug: string): string { + return slug + .split(/[-\s]/) + .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)) + .join(" "); +} + +// Minimal YAML frontmatter. Quotes string values only when needed; emits real +// booleans and numbers unquoted so they round-trip as the right type. +function frontmatter(pairs: [string, unknown][]): string { + const lines = ["---"]; + for (const [key, value] of pairs) { + if (Array.isArray(value)) { + lines.push(`${key}: [${value.map((v) => yamlScalar(String(v))).join(", ")}]`); + } else if (typeof value === "boolean" || typeof value === "number") { + lines.push(`${key}: ${value}`); + } else { + lines.push(`${key}: ${yamlScalar(String(value))}`); + } + } + lines.push("---"); + return lines.join("\n"); +} + +export function yamlScalar(s: string): string { + // Quote when the value could be misread as YAML structure or type-coerced + // to something that isn't a string. The numeric and keyword cases (e.g. tags + // like 401 / true / no / null) would otherwise round-trip to a non-string, + // and a leading indicator character (- @ ` * & ? ! % | >) would be read as a + // sequence item, alias, tag, or block scalar rather than plain text. + if ( + s === "" || + /[:#\[\]{},"']/.test(s) || + /^[-@`*&?!%|>]/.test(s) || + /^\s|\s$/.test(s) || + /^-?\d+(\.\d+)?$/.test(s) || + /^(true|false|yes|no|on|off|null|~)$/i.test(s) + ) { + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + } + return s; +} diff --git a/docs/src/scripts/content-generators/verify-bundle.test.ts b/docs/src/scripts/content-generators/verify-bundle.test.ts new file mode 100644 index 0000000000..0dbd761dfb --- /dev/null +++ b/docs/src/scripts/content-generators/verify-bundle.test.ts @@ -0,0 +1,103 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { verifyBundle } from "./verify-bundle"; + +function fixture(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "verify-bundle-")); +} + +// A verifier that has only ever returned [] proves nothing. These plant known +// violations and confirm they are caught — the net actually has holes-detection. +test("verifyBundle catches a raw tag leaking outside code fences", () => { + const dir = fixture(); + fs.mkdirSync(path.join(dir, "react", "components"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "react", "components", "bad.md"), + "---\nid: bad\n---\n\n# Bad\n\n
leaked
\n", + ); + const violations = verifyBundle(dir, path.join(dir, "no-mcp")); + assert.ok( + violations.some((v) => /raw tag/.test(v.message)), + "should flag the raw
outside a code fence", + ); +}); + +test("verifyBundle catches an unrestored code placeholder", () => { + const dir = fixture(); + fs.mkdirSync(path.join(dir, "react"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "react", "x.md"), + "---\nid: x\n---\n\n@@CODEMASK@@0@@CODEMASK@@\n", + ); + const violations = verifyBundle(dir, path.join(dir, "no-mcp")); + assert.ok(violations.some((v) => /placeholder/.test(v.message))); +}); + +test("verifyBundle passes a clean minimal bundle", () => { + const dir = fixture(); + fs.mkdirSync(path.join(dir, "react", "components"), { recursive: true }); + fs.writeFileSync(path.join(dir, "react", "index.md"), "# Index\n"); + fs.writeFileSync( + path.join(dir, "react", "components", "ok.md"), + "---\nid: ok\n---\n\n# OK\n\nClean prose.\n", + ); + assert.deepEqual(verifyBundle(dir, path.join(dir, "no-mcp")), []); +}); + +test("verifyBundle flags a component-set mismatch in any framework, not just react", () => { + const dir = fixture(); + const mcp = path.join(dir, "mcp"); + fs.mkdirSync(path.join(mcp, "components"), { recursive: true }); + fs.writeFileSync(path.join(mcp, "components", "a.json"), "{}"); + fs.writeFileSync(path.join(mcp, "components", "b.json"), "{}"); + // react and web-components match the MCP; angular is missing a component. + for (const fw of ["react", "web-components"]) { + fs.mkdirSync(path.join(dir, fw, "components"), { recursive: true }); + fs.writeFileSync(path.join(dir, fw, "components", "a.md"), "---\nid: a\n---\n\n# A\n"); + fs.writeFileSync(path.join(dir, fw, "components", "b.md"), "---\nid: b\n---\n\n# B\n"); + } + fs.mkdirSync(path.join(dir, "angular", "components"), { recursive: true }); + fs.writeFileSync(path.join(dir, "angular", "components", "a.md"), "---\nid: a\n---\n\n# A\n"); + + const violations = verifyBundle(dir, mcp); + assert.ok( + violations.some((v) => /angular/.test(v.file)), + "should flag the angular set diverging from the MCP", + ); +}); + +test("verifyBundle flags frontmatter that is present but not valid YAML", () => { + const dir = fixture(); + fs.mkdirSync(path.join(dir, "react", "components"), { recursive: true }); + // Frontmatter is shaped (open/close ---) but the value has an unterminated + // quote, so a real parser rejects it. Shape-checking alone would miss this. + fs.writeFileSync( + path.join(dir, "react", "components", "bad.md"), + '---\nname: "oops\n---\n\n# Bad\n\nClean prose.\n', + ); + const violations = verifyBundle(dir, path.join(dir, "no-mcp")); + assert.ok( + violations.some((v) => /YAML/.test(v.message)), + "should flag invalid YAML frontmatter, not just confirm it is present", + ); +}); + +test("verifyBundle flags an unterminated code fence", () => { + const dir = fixture(); + fs.mkdirSync(path.join(dir, "react", "components"), { recursive: true }); + // An opening fence with no close: the code mask needs a closing fence, so the + // contents would be stripped, and the scanner would otherwise treat the rest + // of the file as code and silently stop checking it. + fs.writeFileSync( + path.join(dir, "react", "components", "stray.md"), + "---\nid: stray\n---\n\n# Stray\n\n```js\nconst x = 1;\n", + ); + const violations = verifyBundle(dir, path.join(dir, "no-mcp")); + assert.ok( + violations.some((v) => /unterminated/.test(v.message)), + "should flag the unterminated fence rather than silently stop checking", + ); +}); diff --git a/docs/src/scripts/content-generators/verify-bundle.ts b/docs/src/scripts/content-generators/verify-bundle.ts new file mode 100644 index 0000000000..5376c8b410 --- /dev/null +++ b/docs/src/scripts/content-generators/verify-bundle.ts @@ -0,0 +1,193 @@ +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import { parse as parseYaml } from "yaml"; +import { paths } from "./config"; + +// `parseYaml` is a strict parser: it throws on malformed input. The loaders' +// own frontmatter parser is deliberately lenient (it skips bad lines so content +// authoring doesn't break), which makes it the wrong tool for validation, so a +// strict parser is used here to confirm emitted frontmatter is real YAML. + +// Post-render validator for the generated Markdown bundle. It encodes the +// constraints the bundle must hold, so a regression is caught rather than +// shipped silently: no leaked code placeholders, no raw HTML/MDX outside code +// fences, no gutted (empty) code blocks, column-consistent tables, present +// frontmatter, and component-set parity with the MCP output. +// +// Pure: `verifyBundle()` returns the violations. The CLI entry at the bottom +// prints them and exits non-zero, and `verifyBundle` is exported so the +// generator can call it after writing output. + +export interface Violation { + file: string; + line?: number; + message: string; +} + +const SENTINEL = "@@CODEMASK@@"; +const TAG = + /<\/?(goa-[a-z]+|[A-Z][A-Za-z0-9]+|div|span|p|h[1-6]|ul|ol|li|table|thead|tbody|tr|td|th|br|img|pre)\b/; +const FENCE = /^\s{0,3}(```|~~~)/; + +function walkMarkdown(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkMarkdown(full)); + else if (entry.name.endsWith(".md")) out.push(full); + } + return out.sort(); +} + +// Real (unescaped) column count for a Markdown table row. +function columnCount(row: string): number { + return row.replace(/\\\|/g, "").split("|").length - 2; +} + +function checkFile(file: string, rel: string): Violation[] { + const v: Violation[] = []; + const text = fs.readFileSync(file, "utf8"); + const lines = text.split("\n"); + + if (text.includes(SENTINEL)) { + v.push({ file: rel, message: "code placeholder was not restored" }); + } + if (text.includes("`{`")) { + v.push({ file: rel, message: "mangled inline template-literal code (`{`)" }); + } + + // Single fence-aware pass: raw-tag leakage outside fences + empty code fences. + let inFence = false; + let marker = ""; + let fenceContent = 0; + let fenceStart = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const fm = FENCE.exec(line); + if (fm) { + if (!inFence) { + inFence = true; + marker = fm[1]; + fenceContent = 0; + fenceStart = i + 1; + } else if (line.trim().startsWith(marker)) { + if (fenceContent === 0) { + v.push({ file: rel, line: fenceStart, message: "empty code fence (code may have been stripped)" }); + } + inFence = false; + marker = ""; + } + continue; + } + if (inFence) { + if (line.trim()) fenceContent++; + continue; + } + // Ignore inline-backticked tag names like `
` — they are legitimate. + if (TAG.test(line.replace(/`[^`]*`/g, ""))) { + v.push({ file: rel, line: i + 1, message: `raw tag outside code fence: ${line.trim().slice(0, 60)}` }); + } + } + + // A fence that never closed: the scan above then treats everything after it as + // code and checks none of it, and the generator's code mask (which needs a + // closing fence) would have stripped the contents. Fail rather than ship. + if (inFence) { + v.push({ file: rel, line: fenceStart, message: "unterminated code fence" }); + } + + // Table column consistency (escaped-pipe aware), skipping fenced regions. + inFence = false; + for (let i = 0; i < lines.length; i++) { + if (FENCE.test(lines[i])) { + inFence = !inFence; + continue; + } + if (inFence) continue; + const isRow = /^\s*\|.*\|\s*$/.test(lines[i]); + const nextIsDivider = i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1]); + if (isRow && nextIsDivider) { + const header = columnCount(lines[i]); + let j = i + 2; + while (j < lines.length && /^\s*\|.*\|\s*$/.test(lines[j])) { + if (columnCount(lines[j]) !== header) { + v.push({ file: rel, line: j + 1, message: `table column mismatch (header has ${header})` }); + } + j++; + } + } + } + + // Frontmatter present, closed, and valid YAML. Index docs intentionally carry + // none. Shape alone is not enough: a present-but-malformed block (bad quoting, + // a leading "- ", a duplicate key) parses nowhere downstream, so it must fail + // here rather than ship. + if (!rel.endsWith("index.md")) { + const fm = /^---\n([\s\S]*?)\n---\n/.exec(text); + if (!fm) { + v.push({ file: rel, message: "missing or unclosed frontmatter" }); + } else { + try { + parseYaml(fm[1], { uniqueKeys: true }); + } catch (e) { + const msg = e instanceof Error ? e.message.split("\n")[0] : String(e); + v.push({ file: rel, message: `invalid YAML frontmatter: ${msg}` }); + } + } + } + + return v; +} + +function sortedIds(dir: string, ext: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((n) => n.endsWith(ext)) + .map((n) => n.slice(0, -ext.length)) + .sort(); +} + +export function verifyBundle( + bundleDir: string = paths.output.mdBundle, + mcpDir: string = paths.output.mcp, +): Violation[] { + const violations: Violation[] = []; + for (const file of walkMarkdown(bundleDir)) { + violations.push(...checkFile(file, path.relative(bundleDir, file))); + } + + // Component-set parity with the MCP output (the bundle's stated goal). Every + // framework set must carry the same components the MCP does, so an AI sees the + // same set whichever framework it loads. The framework dirs mirror the + // generator's fixed set. + const mcp = sortedIds(path.join(mcpDir, "components"), ".json"); + if (mcp.length) { + for (const framework of ["react", "angular", "web-components"]) { + const bundled = sortedIds(path.join(bundleDir, framework, "components"), ".md"); + if (mcp.join(",") !== bundled.join(",")) { + violations.push({ + file: `(${framework} component set)`, + message: `differs from MCP (mcp ${mcp.length}, bundle ${bundled.length})`, + }); + } + } + } + + return violations; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const violations = verifyBundle(); + if (violations.length === 0) { + process.stdout.write("[verify-bundle] OK: no violations\n"); + process.exit(0); + } + process.stderr.write(`[verify-bundle] ${violations.length} violation(s):\n`); + for (const v of violations) { + process.stderr.write(` ${v.file}${v.line ? `:${v.line}` : ""} — ${v.message}\n`); + } + process.exit(1); +} From 1a91813e67338ba22f10ee5a7f74addb353bca38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:16:39 +0000 Subject: [PATCH 03/22] chore(deps-dev): bump @vitest/browser from 4.1.2 to 4.1.6 Bumps [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser) from 4.1.2 to 4.1.6. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/browser) --- updated-dependencies: - dependency-name: "@vitest/browser" dependency-version: 4.1.6 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package-lock.json | 131 +++++++++++++++++++++++++++++++++++++++++++--- package.json | 2 +- 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0980668e8..4834a12cf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ "@typescript-eslint/utils": "7.18.0", "@vitejs/plugin-react": "4.7.0", "@vitejs/plugin-react-swc": "3.11.0", - "@vitest/browser": "4.1.2", + "@vitest/browser": "4.1.6", "@vitest/browser-playwright": "4.1.2", "@vitest/coverage-v8": "4.1.2", "@vitest/ui": "4.1.2", @@ -17214,15 +17214,15 @@ } }, "node_modules/@vitest/browser": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.2.tgz", - "integrity": "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.6.tgz", + "integrity": "sha512-ynsspTubXGSpa58JFJ24xIQt4z4A25epSbugEyaTmmrV1//Wec9EgE/LtoaC6yxUrXi5P7erGHRrkdZIHaVQuA==", "dev": true, "license": "MIT", "dependencies": { "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/mocker": "4.1.6", + "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", @@ -17233,7 +17233,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.2" + "vitest": "4.1.6" } }, "node_modules/@vitest/browser-playwright": { @@ -17260,6 +17260,123 @@ } } }, + "node_modules/@vitest/browser-playwright/node_modules/@vitest/browser": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.2.tgz", + "integrity": "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.2" + } + }, + "node_modules/@vitest/browser-playwright/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/pretty-format": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/spy": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/utils": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/browser/node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/package.json b/package.json index 45a1bc635b..5102f536e0 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@typescript-eslint/utils": "7.18.0", "@vitejs/plugin-react": "4.7.0", "@vitejs/plugin-react-swc": "3.11.0", - "@vitest/browser": "4.1.2", + "@vitest/browser": "4.1.6", "@vitest/browser-playwright": "4.1.2", "@vitest/coverage-v8": "4.1.2", "@vitest/ui": "4.1.2", From bd47dd6bb0ebeb8ee597c5b3680c53e658a28550 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Wed, 3 Jun 2026 10:12:53 -0600 Subject: [PATCH 04/22] fix(#4001): scope PreviewLayout's full-viewport reset to its own pages --- docs/src/layouts/BaseLayout.astro | 12 ++++++++++++ docs/src/layouts/PreviewLayout.astro | 15 +++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/src/layouts/BaseLayout.astro b/docs/src/layouts/BaseLayout.astro index 0f9905158d..aaf0b4fe02 100644 --- a/docs/src/layouts/BaseLayout.astro +++ b/docs/src/layouts/BaseLayout.astro @@ -107,12 +107,24 @@ const ogUrl = new URL(withBase(Astro.url.pathname), Astro.site).href; html { color-scheme: light; + /* Match the page's grey wrapper so the scroll overflow / overscroll + area (visible on elastic scroll) blends instead of showing white. + Same token as .layout-wrapper, so it matches in light/dark/forced-dark. */ + background: var(--goa-color-greyscale-50); } html[data-theme="dark"] { color-scheme: dark; } + /* Below 624px the layouts flip the page wrapper to white, so match the + overscroll area there too instead of leaving it grey. */ + @media (max-width: 623px) { + html { + background: var(--goa-color-greyscale-white); + } + } + body { font: var(--goa-typography-body-m); color: var(--goa-color-text-default); diff --git a/docs/src/layouts/PreviewLayout.astro b/docs/src/layouts/PreviewLayout.astro index 112c4a3468..f6b2a5f064 100644 --- a/docs/src/layouts/PreviewLayout.astro +++ b/docs/src/layouts/PreviewLayout.astro @@ -48,7 +48,7 @@ const resolvedBackUrl = withBase(backUrl); - +
@@ -103,14 +103,11 @@ const resolvedBackUrl = withBase(backUrl); diff --git a/docs/src/lib/content-queries.ts b/docs/src/lib/content-queries.ts index c3a8ac8fdf..ff25b4dcc0 100644 --- a/docs/src/lib/content-queries.ts +++ b/docs/src/lib/content-queries.ts @@ -45,11 +45,22 @@ export interface SubComponentApi { frameworks: FrameworkApiMap; } +export type StaticMethodParam = Omit; + +export interface StaticMethod { + name: string; + signature: string; + returnType: string; + description: string; + params: StaticMethodParam[]; +} + export interface ComponentApi { componentSlug: string; extractedFrom: string; frameworks: FrameworkApiMap; subComponents?: SubComponentApi[]; + staticMethods?: StaticMethod[]; } // Load all component API JSON files at build time using Vite glob import diff --git a/docs/src/pages/components/[slug].astro b/docs/src/pages/components/[slug].astro index 3c3b3641dc..7a236e4179 100644 --- a/docs/src/pages/components/[slug].astro +++ b/docs/src/pages/components/[slug].astro @@ -4,6 +4,7 @@ import { getCollection, render } from 'astro:content'; import ComponentPageLayout from '../../layouts/ComponentPageLayout.astro'; import Breadcrumbs from '../../components/Breadcrumbs.astro'; import PropsTable from '../../components/PropsTable.astro'; +import StaticMethods from '../../components/StaticMethods.astro'; import GuidanceGrid from '../../components/GuidanceGrid.astro'; import ExampleDisplay from '../../components/ExampleDisplay.astro'; import ConfigurationPreview from '../../components/ConfigurationPreview'; @@ -113,6 +114,9 @@ const v1DocsUrl = `https://v1.design.alberta.ca/components/${slug}`; {api ? ( <> + {api.staticMethods && api.staticMethods.length > 0 && ( + + )} {api.subComponents && api.subComponents.length > 0 && ( api.subComponents.map((sub) => (
diff --git a/docs/src/scripts/extract-api.ts b/docs/src/scripts/extract-api.ts index f239f2bb06..bb6824b885 100644 --- a/docs/src/scripts/extract-api.ts +++ b/docs/src/scripts/extract-api.ts @@ -37,6 +37,23 @@ const DOCS_COMPONENT_CONTENT_PATH = path.join( "docs/src/content/components", ); +// Components that expose an imperative helper API (e.g. TemporaryNotification.show) +// are documented from their controller source rather than from element props. Each +// entry points at that controller file; the parsing lives in the "Static helper +// methods" section below. +interface StaticMethodSource { + source: string; // path to the controller source file, relative to the workspace root + namespace: string; // exported const whose object members are the public methods +} + +const STATIC_METHOD_SOURCES: Record = { + "temporary-notification": { + source: + "libs/common/src/lib/temporary-notification-controller/temporary-notification-controller.ts", + namespace: "TemporaryNotification", + }, +}; + // ============================================================================= // Output Types (matching content model spec) // ============================================================================= @@ -86,6 +103,23 @@ interface ExtractedComponentAPI { slots: ExtractedSlot[]; }; }; + staticMethods?: ExtractedStaticMethod[]; +} + +interface ExtractedStaticMethodParam { + name: string; + type: string; + values?: string[]; // Allowed values for union types + required: boolean; + description: string; +} + +interface ExtractedStaticMethod { + name: string; // e.g. "show" + signature: string; // e.g. "TemporaryNotification.show(message, options)" + returnType: string; + description: string; + params: ExtractedStaticMethodParam[]; } // ============================================================================= @@ -2076,6 +2110,237 @@ function findSvelteDeclaringTag(tag: string): string | undefined { return undefined; } +// ============================================================================= +// Static helper methods (imperative APIs that are not element props) +// ============================================================================= + +// A few components expose an imperative helper (e.g. TemporaryNotification.show) +// rather than, or alongside, element props. Everything rendered in the docs is +// parsed from the controller source so it cannot drift: the method list comes +// from the exported namespace object, signatures, params, and return types from +// the function declarations, descriptions from their JSDoc (@param tags for +// positional params), and option fields from the options type, where members +// tagged @internal are omitted. + +// Resolve a same-file union type alias (e.g. GoabTemporaryNotificationType) to its +// string-literal members, so a field typed as that alias can show its allowed values. +function parseTypeAliasUnionValues( + typeName: string, + sourceFile: ts.SourceFile, +): string[] | undefined { + for (const statement of sourceFile.statements) { + if (!ts.isTypeAliasDeclaration(statement) || statement.name.text !== typeName) continue; + if (!ts.isUnionTypeNode(statement.type)) return undefined; + const values = statement.type.types + .map((member) => + ts.isLiteralTypeNode(member) && ts.isStringLiteral(member.literal) + ? member.literal.text + : null, + ) + .filter((value): value is string => value !== null); + return values.length > 0 ? values : undefined; + } + return undefined; +} + +// Read the property signatures of a `type X = { ... }` object-literal alias. +function findTypeLiteralMembers( + typeName: string, + sourceFile: ts.SourceFile, +): Map { + const members = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isTypeAliasDeclaration(statement) || statement.name.text !== typeName) continue; + if (!ts.isTypeLiteralNode(statement.type)) break; + for (const member of statement.type.members) { + if (ts.isPropertySignature(member) && member.name && ts.isIdentifier(member.name)) { + members.set(member.name.text, member); + } + } + break; + } + return members; +} + +interface NodeJSDocInfo { + description: string; + internal: boolean; + paramDocs: Map; +} + +// Read a node's JSDoc via the AST: the description text, whether the node is +// tagged @internal, and the text of each @param tag keyed by param name. +function readNodeJSDoc(node: ts.Node): NodeJSDocInfo { + const collapse = (text: string | undefined): string => (text ?? "").replace(/\s+/g, " ").trim(); + + let description = ""; + let internal = false; + const paramDocs = new Map(); + + for (const doc of ts.getJSDocCommentsAndTags(node)) { + if (ts.isJSDoc(doc)) { + const text = collapse(ts.getTextOfJSDocComment(doc.comment)); + if (text) description = description ? `${description} ${text}` : text; + } + } + for (const tag of ts.getJSDocTags(node)) { + if (tag.tagName.text === "internal") internal = true; + if (ts.isJSDocParameterTag(tag) && ts.isIdentifier(tag.name)) { + paramDocs.set(tag.name.text, collapse(ts.getTextOfJSDocComment(tag.comment))); + } + } + return { description, internal, paramDocs }; +} + +// The public method list (and its docs order) is the exported namespace object, +// e.g. `export const TemporaryNotification = { show, dismiss, setProgress }`. +function findNamespaceMethodNames(namespace: string, sourceFile: ts.SourceFile): string[] { + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.name.text !== namespace) continue; + if (!declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer)) { + return []; + } + return declaration.initializer.properties + .map((property) => + (ts.isShorthandPropertyAssignment(property) || ts.isPropertyAssignment(property)) && + ts.isIdentifier(property.name) + ? property.name.text + : null, + ) + .filter((name): name is string => name !== null); + } + } + return []; +} + +// Expand an options-bag parameter (typed `Partial` or `X`, where X is an +// object-literal type alias in the same file) into one row per public field, +// in the field declaration order. Fields tagged @internal are omitted. +function expandOptionsBagParam( + paramName: string, + param: ts.ParameterDeclaration, + sourceFile: ts.SourceFile, +): ExtractedStaticMethodParam[] | undefined { + const typeNode = param.type; + if (!typeNode || !ts.isTypeReferenceNode(typeNode)) return undefined; + + let aliasName = typeNode.typeName.getText(sourceFile); + let isPartial = false; + if (aliasName === "Partial" && typeNode.typeArguments?.length === 1) { + const inner = typeNode.typeArguments[0]; + if (!ts.isTypeReferenceNode(inner)) return undefined; + aliasName = inner.typeName.getText(sourceFile); + isPartial = true; + } + + const members = findTypeLiteralMembers(aliasName, sourceFile); + if (members.size === 0) return undefined; + + // Every field is optional when the bag is a Partial, and also when the bag + // parameter itself can be omitted. + const bagOptional = Boolean(param.questionToken || param.initializer); + + const optionParams: ExtractedStaticMethodParam[] = []; + for (const [field, member] of members) { + const doc = readNodeJSDoc(member); + if (doc.internal) continue; + const rawType = cleanType(member.type?.getText(sourceFile)?.trim() || "unknown"); + const values = parseTypeAliasUnionValues(rawType, sourceFile); + optionParams.push({ + name: `${paramName}.${field}`, + type: rawType, + ...(values ? { values } : {}), + required: isPartial || bagOptional ? false : !member.questionToken, + description: doc.description, + }); + } + return optionParams; +} + +function extractStaticMethods(componentName: string): ExtractedStaticMethod[] { + const config = STATIC_METHOD_SOURCES[componentName]; + if (!config) return []; + + const sourcePath = path.join(WORKSPACE_ROOT, config.source); + if (!fs.existsSync(sourcePath)) { + console.warn(` static methods: source not found at ${config.source}`); + return []; + } + + const content = fs.readFileSync(sourcePath, "utf-8"); + const sourceFile = createTsSourceFile(sourcePath, content); + + const methodNames = findNamespaceMethodNames(config.namespace, sourceFile); + if (methodNames.length === 0) { + console.warn(` static methods: no exported object named ${config.namespace} in ${config.source}`); + return []; + } + + const declaredFunctions = new Map(); + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name) { + declaredFunctions.set(statement.name.text, statement); + } + } + + const methods: ExtractedStaticMethod[] = []; + for (const methodName of methodNames) { + const fn = declaredFunctions.get(methodName); + if (!fn) { + console.warn( + ` static methods: ${config.namespace}.${methodName} has no function declaration`, + ); + continue; + } + + const doc = readNodeJSDoc(fn); + // An @internal tag hides a method from the docs even when the namespace object exports it. + if (doc.internal) continue; + + const params: ExtractedStaticMethodParam[] = []; + const signatureArgs: string[] = []; + + for (const param of fn.parameters) { + if (!ts.isIdentifier(param.name)) continue; + const paramName = param.name.text; + signatureArgs.push(paramName); + + const optionParams = expandOptionsBagParam(paramName, param, sourceFile); + if (optionParams) { + params.push(...optionParams); + continue; + } + + const rawType = cleanType(param.type?.getText(sourceFile)?.trim() || "unknown"); + const values = parseTypeAliasUnionValues(rawType, sourceFile); + const paramDescription = doc.paramDocs.get(paramName); + if (paramDescription === undefined) { + console.warn( + ` static methods: ${config.namespace}.${methodName} param "${paramName}" has no @param JSDoc`, + ); + } + params.push({ + name: paramName, + type: rawType, + ...(values ? { values } : {}), + required: !param.questionToken && !param.initializer, + description: paramDescription ?? "", + }); + } + + methods.push({ + name: methodName, + signature: `${config.namespace}.${methodName}(${signatureArgs.join(", ")})`, + returnType: fn.type ? cleanType(fn.type.getText(sourceFile).trim()) : "void", + description: doc.description, + params, + }); + } + return methods; +} + // ============================================================================= // Main Extraction // ============================================================================= @@ -2265,6 +2530,8 @@ function extractComponentAPI(componentName: string): ExtractedComponentAPI | nul // Relative path from workspace root const relativePath = path.relative(WORKSPACE_ROOT, svelteFilePath); + const staticMethods = extractStaticMethods(componentName); + return { componentSlug: toKebabCase(componentName), extractedFrom: relativePath, @@ -2285,6 +2552,7 @@ function extractComponentAPI(componentName: string): ExtractedComponentAPI | nul slots: webComponentSlots, }, }, + ...(staticMethods.length > 0 ? { staticMethods } : {}), }; } diff --git a/libs/common/src/lib/temporary-notification-controller/temporary-notification-controller.ts b/libs/common/src/lib/temporary-notification-controller/temporary-notification-controller.ts index 6d393a230d..f51dcdeaa2 100644 --- a/libs/common/src/lib/temporary-notification-controller/temporary-notification-controller.ts +++ b/libs/common/src/lib/temporary-notification-controller/temporary-notification-controller.ts @@ -8,42 +8,83 @@ export type GoabTemporaryNotificationType = | "progress"; export type GoabNotificationOptions = { + /** + * The type of notification, which determines its styling and icon. Use + * "indeterminate" to show an animated progress bar while work of unknown + * length runs, or "progress" to show a progress bar you update with + * setProgress(). Defaults to "basic". + */ type: GoabTemporaryNotificationType; - uuid: string; - cancelUUID?: string; + /** + * How long the notification stays before it auto-dismisses: "short" (about + * 3 seconds), "medium" (about 4 seconds), "long" (about 6 seconds), or a + * number of milliseconds. Only "basic", "success", and "failure" + * notifications auto-dismiss (default "short"). "indeterminate" and + * "progress" notifications have no default duration and stay until you + * dismiss them. + */ duration?: "long" | "medium" | "short" | number; + /** + * Text for an action button. When set, the notification shows a button the + * user can select. + */ actionText?: string; + /** Function to run when the action button is selected. */ action?: () => void; + /** UUID of an existing notification to cancel when this one is shown. */ + cancelUUID?: string; + /** @internal Assigned by show(); not a public option. */ + uuid: string; + /** @internal Managed by the notification controller. */ visible: boolean; }; const TypesRequiringDuration: GoabTemporaryNotificationType[] = ["basic", "success", "failure"]; -function show(message: string, opts?: Partial): string { +/** + * Displays a temporary notification from your component. Returns the + * notification's UUID, which you can use to dismiss it or update its progress. + * @param message The message to display in the notification. + * @param options Settings for the notification's type, duration, and action. + * @returns The UUID of the notification. + */ +function show(message: string, options?: Partial): string { const uuid = crypto.randomUUID(); - opts = { uuid, type: "basic", ...(opts || {}) }; + options = { uuid, type: "basic", ...(options || {}) }; // set default duration for certain notification types - if (!opts.duration && opts.type && TypesRequiringDuration.includes(opts.type)) { - opts.duration = "short"; + if (!options.duration && options.type && TypesRequiringDuration.includes(options.type)) { + options.duration = "short"; } relay( document.body, "goa:temp-notification", - { message: message, ...opts }, + { message: message, ...options }, { bubbles: true }, ); return uuid; } +/** + * Hides a notification, using the UUID that show() returns. + * @param uuid The UUID of the notification to dismiss. This is the value that + * show() returns. + */ function dismiss(uuid: string) { relay( document.body, "goa:temp-notification:dismiss", uuid, { bubbles: true } ); } +/** + * Updates the progress shown on a progress notification, using the UUID that + * show() returns. + * @param uuid The UUID of the progress notification to update. This is the + * value that show() returns. + * @param progress The progress to display, from 0 to 100. + */ function setProgress(uuid: string, progress: number) { relay( document.body, From aa875e88ebf57b5d340670a2ff0f969a06028a02 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Wed, 10 Jun 2026 20:57:23 -0600 Subject: [PATCH 18/22] chore(#4038): add CI gate to keep generated docs files in sync --- .github/workflows/pull-request.yml | 63 ++++++++++++++++++++++++++++++ docs/src/scripts/extract-api.ts | 6 +++ 2 files changed, 69 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 969da32107..7fab730e4c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -55,3 +55,66 @@ jobs: if [ -d "./dist" ]; then npm run test:pr fi + + docs-freshness: + name: Docs Freshness + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + env: + # The generators don't need browsers; skip the Playwright download in npm ci. + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + cache-dependency-path: | + package-lock.json + docs/package-lock.json + + - name: Install npm 11 + run: npm install -g npm@11 + + - run: npm ci + + - name: Check committed docs generated files match source + shell: bash + run: | + # Regenerate the docs outputs from source. npm ci ran the root postinstall + # (npm install --prefix docs), so tsx and the docs deps are available here. + ( cd docs && npm run extract-api && npm run build:search-index ) + + # The committed generated files are the single source of truth for the docs + # build, so fail if regeneration changed a tracked file (drift) or produced a + # new untracked one (a component whose JSON was never committed). This mirrors + # the .githooks/pre-commit check so local and CI tell the same story. + outputs=(docs/generated/component-apis docs/public/search-index.json) + stale=0 + if ! git diff --quiet -- "${outputs[@]}"; then stale=1; fi + if [ -n "$(git ls-files --others --exclude-standard -- "${outputs[@]}")" ]; then stale=1; fi + + if [ "$stale" -ne 0 ]; then + echo "::error::Generated docs files are out of date." + echo "The committed files do not match what the generators produce from source." + echo "" + echo "To fix, from the repo root run:" + echo " npm install" + echo " cd docs && npm run extract-api && npm run build:search-index" + echo "then commit the updated files under:" + echo " docs/generated/component-apis/ docs/public/search-index.json" + echo "" + echo "Note: extracted fields (types, JSDoc descriptions) come from the component" + echo "source and are overwritten on regeneration, so don't hand-edit these files." + echo "" + git status --short -- "${outputs[@]}" + git diff --stat -- "${outputs[@]}" + exit 1 + fi + + echo "Generated docs files are up to date." diff --git a/docs/src/scripts/extract-api.ts b/docs/src/scripts/extract-api.ts index f239f2bb06..48fc4d2668 100644 --- a/docs/src/scripts/extract-api.ts +++ b/docs/src/scripts/extract-api.ts @@ -2411,6 +2411,12 @@ function main() { console.log("\n" + "═".repeat(50)); console.log(`Complete: ${successCount} succeeded, ${failCount} failed`); console.log(`Output: ${OUTPUT_PATH}\n`); + + // Exit non-zero if any component failed to extract. Without this the process + // exits 0 on partial failure, leaving the failed component's committed JSON + // untouched, which the docs freshness check (and the pre-commit hook) would + // then see as a clean, in-sync diff while extraction is actually broken. + process.exitCode = failCount > 0 ? 1 : 0; } main(); From d69903fcefcb0628f47de4edf53767621050654a Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Thu, 11 Jun 2026 13:25:08 -0600 Subject: [PATCH 19/22] chore(#4038): show full git diff when docs freshness check fails The check listed only changed file names on failure. It now prints the full git diff (intent-to-add first so new files show too), so you can see exactly what changed, including whitespace or line-ending differences that make a file look identical but diff as changed. --- .github/workflows/pull-request.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 7fab730e4c..46f8ffdaca 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -113,7 +113,9 @@ jobs: echo "source and are overwritten on regeneration, so don't hand-edit these files." echo "" git status --short -- "${outputs[@]}" + git add --intent-to-add -- "${outputs[@]}" git diff --stat -- "${outputs[@]}" + git diff -- "${outputs[@]}" exit 1 fi From 6aa32587db1e332f91c00d036304a280f07681f1 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Fri, 5 Jun 2026 09:25:41 -0600 Subject: [PATCH 20/22] feat(#4022): add skills/ folder (using-goa-design-system, content-design) --- skills/README.md | 39 + skills/content-design/README.md | 50 + skills/content-design/SKILL.md | 131 +++ .../content-design-succinct-alternatives.md | 870 ++++++++++++++++++ skills/using-goa-design-system/README.md | 62 ++ skills/using-goa-design-system/SKILL.md | 85 ++ skills/using-goa-design-system/taxonomy.md | 112 +++ 7 files changed, 1349 insertions(+) create mode 100644 skills/README.md create mode 100644 skills/content-design/README.md create mode 100644 skills/content-design/SKILL.md create mode 100644 skills/content-design/content-design-succinct-alternatives.md create mode 100644 skills/using-goa-design-system/README.md create mode 100644 skills/using-goa-design-system/SKILL.md create mode 100644 skills/using-goa-design-system/taxonomy.md diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000000..3020b622cb --- /dev/null +++ b/skills/README.md @@ -0,0 +1,39 @@ +# GoA Design System skills + +AI skills for building Government of Alberta services with the design system. Each one is a `SKILL.md` folder following the [Agent Skills open standard](https://agentskills.io), so it works across Claude Code, Cursor, GitHub Copilot, and other tools that read the format. + +A skill is plain-Markdown guidance your AI loads on its own when the work matches. It rides on top of the GoA design system MCP, which supplies the live component facts. + +## Skills + +| Skill | What it does | +|---|---| +| [`using-goa-design-system`](./using-goa-design-system/) | Turns "I'm building X for users" into the right product type, templates, and components. The navigator. | +| [`content-design`](./content-design/) | Designs user-facing words for their reader, a citizen or a worker. The writer. | + +Each skill folder has a `SKILL.md` (the instructions your AI reads) and a `README.md` (what it is, for humans). + +## Install + +Add a skill, pointed at this repo: + +``` +npx skills add GovAlta/ui-components --skill using-goa-design-system +npx skills add GovAlta/ui-components --skill content-design +``` + +It loads on its own when your work matches. Pair it with the GoA design system MCP for the live component facts. + +## Stay current + +The skills track this repo's `dev` branch, so you can keep them up to date: + +``` +npx skills update +``` + +To update automatically, add `npx skills update -g -y` to a SessionStart hook in your AI tool and they refresh every session. An update only lands when a skill's own files change, so everyday repo activity won't churn what you have. + +## Contributing + +Edit skills here; consuming teams pull from this folder. Skill updates reach teams on the skill's own cadence, independent of component releases. diff --git a/skills/content-design/README.md b/skills/content-design/README.md new file mode 100644 index 0000000000..e7597801bc --- /dev/null +++ b/skills/content-design/README.md @@ -0,0 +1,50 @@ +# content-design + +The skill that designs user-facing service words for the specific person reading them: a citizen or a worker. It names the reader first, because the same message is two different designs for the two of them, not one lightly reworded. + +## In a sentence + +For anyone writing with the design system: when you're working on any words in a service (a button label, an error, a status message, guidance, an empty state), tell your AI who reads it, a citizen or a worker, and it shapes the wording to fit that reader, then explains each change. + +Under the hood: it is the prose counterpart to the design system's User Types foundation. The foundation owns the interaction layer (components, density, layout); this skill owns the words. Both are grounded in the same citizen-vs-worker model. + +## What it does + +1. Names the reader: citizen or worker, the service task and lifecycle stage, and the surface (a button, an error, an empty state). It asks if the audience is unclear, it does not guess, because a wrong audience invalidates everything downstream. +2. Applies the six "busy reader" principles (Rogers & Lasky-Fink, *Writing for Busy Readers*), dialed to that reader. The principles hold for everyone; their settings change. A citizen gets plain language, low density, a calm tone, and one obvious action. A worker gets domain vocabulary, density, efficiency, and an action-first shape. +3. Holds three things non-negotiable for both readers (not dials): accessibility (WCAG 2.2 AA), government voice, and no citizen PII (roles, never people). +4. Emits the content-designed text first, paste-ready, then a short rationale that maps each change to its principle and its dial. + +## The master switch: citizen or worker + +Naming the reader is the master switch, because "good" means opposite things to each: + +- **Citizen:** accessing a service from outside government, maybe once in their life. They need clarity and confidence. Plain language, around grade 9, minimal density, calm and reassuring, one step at a time. +- **Worker:** internal staff using a tool every day; it is their job and they know it. They need a power tool. Density is fine, domain vocabulary is precision not jargon, efficient and direct. +- **Frequency can outrank role.** The mode is the reader's relationship to the task, not their title. A contractor filing daily permits is a citizen who wants worker-terse content; a caseworker handling a once-in-a-career exception wants citizen-style guidance. + +The clearest illustration: one service status, said two ways. To a citizen it *adds* orientation and *drops* the legal program name ("We're reviewing your application. You don't need to do anything."). To a worker it *removes* orientation and *keeps* the working vocabulary ("Stage 3, awaiting medical eligibility, 6 days in queue. Next: review docs, set eligibility."). Opposite moves, same source state. + +## What makes it distinctive + +- Reader-first, not draft-first. It refuses to reword until it knows who reads the copy, because citizen and worker writing pull in opposite directions. +- Dials, not rules. The principles are constant; their settings change per reader. Accessibility, voice, and no-PII are the only true constants. +- Self-contained. Unlike a component navigator, it barely needs live lookups: the principles and a plain-wording lookup are bundled in the skill. It works for any service content, whatever components are used. +- Shows its work. It outputs the designed text, then maps each change to a principle and a dial, so the reasoning is reviewable. + +## Using it + +Install it, pointed at our repo: + +``` +npx skills add GovAlta/ui-components --skill content-design +``` + +Then tell your AI who the copy is for and what surface it lives on, and the skill loads on its own when the work matches. Run `npx skills update` to pull the latest. + +## Good to know + +- It is the prose counterpart to the User Types foundation (served through the MCP). The foundation owns the interaction layer; this skill owns the words. When one moves, check the other. +- The bundled `content-design-succinct-alternatives.md` is a roughly 860-entry "wordy phrase to plain wording" lookup. Use it reader-awarely: most entries cut padding and help everyone, but some swap a formal word for an everyday one, which suits a citizen and can wrongly strip a worker's precise vocabulary. Never blanket-replace. +- No component dependency. This skill is about words, not UI, so it stands on its own regardless of which components a service uses. +- Best hardened by evaluation: run it on a few real pieces of content with a fresh agent and refine where the output misses, rather than trusting it on first write. diff --git a/skills/content-design/SKILL.md b/skills/content-design/SKILL.md new file mode 100644 index 0000000000..552c2b4dad --- /dev/null +++ b/skills/content-design/SKILL.md @@ -0,0 +1,131 @@ +--- +name: content-design +description: "Designs user-facing service content (service names, descriptions, guidance, microcopy, button labels, status messages, errors, empty states) for its specific reader: a citizen (public, plain language, grade-9) or a worker (internal, dense, domain-expert). Use when writing or editing any citizen-facing or worker-facing copy for a Government of Alberta service, or when checking whether existing copy fits the reader it is actually for." +--- + +# Designing content for its reader: citizen or worker + +Given a piece of content (or a brief to write one), design it for whoever reads it. **Name the reader first.** It is the master switch: the same state said to a citizen and to a worker is two different designs, not one lightly reworded, because "good" means opposite things to them. + +## When to use it + +- Writing or editing any user-facing words in a Government of Alberta service: service names, descriptions, guidance, button and link labels, status messages, empty states, errors, confirmations, notifications. +- Stress-testing existing copy against its actual reader. The two recurring failures: citizen copy written at a policy reading level, and worker copy padded with hand-holding that slows an expert down. + +Not for your team's own internal docs in your own voice (those follow your team's voice guide), though the worker profile is a fair lens for any fluent internal audience. + +## Step 1 — Name the reader (first, every time) + +Fix three things before writing a word: + +1. **Audience: citizen or worker?** Grounded in the design system's own user-type model (the **User Types** foundation, also served through the MCP): + - **Citizen** — accessing a service from outside government, maybe once in their life, once a year, or rarely. They didn't choose to be here; they need something. The experience should feel like a clear, considered path: slow on purpose, one step at a time, hard to make a mistake. Plain language, ~grade 9, minimal density. Values clarity over speed, confidence over efficiency. + - **Worker** — internal staff using a tool to deliver or administer a service from inside, every day; it's their job and they know it. The experience should feel like a power tool: fast, dense, information-rich, built for repetition and scale. Density fine, efficiency-focused, trusts expertise. Splits into an intake worker (deciding on a submission) and an ongoing case worker ("what needs me on this file"). + - **Frequency can outrank role.** The mode is the reader's relationship to the task, not their title. A contractor filing daily permits is a citizen but wants worker-terse content; a caseworker handling a once-in-a-career exception wants citizen-style guidance. When the two disagree, frequency usually wins for content too. + - **Can't tell? Ask. Do not guess.** A wrong audience invalidates everything downstream. +2. **Service task and lifecycle stage.** A service is a verb; *apply*, *check status*, and *report a change* read differently. The same stage often has a citizen-facing and a worker-facing track, said two different ways. +3. **Surface and moment** — a button, page intro, error, empty state, confirmation, notification. The moment sets the job the words do. + +## Step 2 — Apply the busy-reader base, dialed to the reader + +The six principles (Rogers & Lasky-Fink, *Writing for Busy Readers*) hold for everyone: enough formatting, design for navigation, less is more, make reading easy, tell readers why they should care, make responding easy. + +What changes is their settings. **"Make reading easy" and "use enough" are defined relative to the reader's expertise and frequency.** + +| Dimension | Citizen (public, occasional, novice) | Worker (internal, frequent, expert) | +|---|---|---| +| Reading level | Plain language, ~grade 9 | Domain-fluent; precise terms are precision, not jargon | +| Density | Minimal; one idea at a time; whitespace | Dense is fine; completeness and scannability over hand-holding | +| Vocabulary | Everyday words; **verbs, not program names** ("report a change", not "Income Reporting Module"); expand acronyms, unless one is more familiar to this reader than its expansion (SIN, AISH on a recipient's own portal) | Real domain terms, program names, case states, stage labels | +| Orientation | High — what is this, what's next, why it matters to me | Low — orient to *this file or task*, not the system | +| Tone | Calm, reassuring, human | Efficient, direct, action-first | +| Job | Help one person do one thing, confidently | Help an expert decide and move work | +| "Less is more" → | Cut everything non-essential | Cut noise, not data; the bar for "needed" is higher | +| "Responding easy" → | One obvious action, no dead ends | Quick actions, batch where it fits; don't over-confirm | + +Stakes amplify the tone dial: the higher the consequence to the reader (money, health, legal standing), the more a citizen needs calm and reassurance, and the less a worker wants anything slowing them down. + +Both readers, non-negotiable (not dials): WCAG 2.2 AA, government voice, and **no citizen PII** — roles, never people. + +**Cutting wordiness has a lookup.** For the "less is more" and "everyday words" dials, a companion reference lists ~860 common wordy or formal phrases and their plain replacements (`content-design-succinct-alternatives.md`). Use it reader-awarely: most entries cut padding ("due to the fact that" → "because") and help every reader; some swap a formal word for a plain one ("terminate" → "stop"), which suits a citizen but can wrongly strip a worker's precise vocabulary. Match the substitution to the reader; never blanket-replace. + +### Surface-specific moves + +A few patterns the dial table doesn't spell out, surfaced by real use: + +- **Errors (citizen):** state the fix as a positive instruction with an example of valid input ("Enter your 9-digit SIN, for example 123456789"), not a description of what went wrong. The field's own error styling already says "error"; don't repeat it in words. +- **Empty states:** give the next action, not just the diagnosis ("No clients match these filters. Clear filters to see all."). +- **Notifications:** they truncate. Front-load the point in the first few words; a citizen notification should answer "does this affect me, and do I need to do anything?". +- **Handheld (mobile):** citizens are often on a phone, so tighten further. Front-load the one thing that matters, keep it short, and assume labels and notifications truncate. Anything that needs scrolling to reach is at risk of being missed. + +## Steps + +1. Name the reader (audience, task and stage, surface). Ask if the audience is unclear. +2. Read the draft (or brief) for what it must do *for that reader*. Set aside anything that is a quality or NFR, not content (the component's own error styling, icon, or layout is its job; you own the words). +3. Apply the principles, dialed to the audience profile. +4. Emit the content-designed text, then the rationale. + +## Output + +- **The content-designed text first**, paste-ready: no markdown blockquote (the bar breaks copying), no em or en dashes (use periods or commas). Match formatting to the surface. +- **The rationale**: name the audience, then map each concrete change to its principle and its dial. Skip what already worked. Keep it skimmable; it should follow the principles too. +- Preserve meaning and intent; invent nothing. If a cut would drop real information, flag it rather than delete it. +- **Audience unknown?** Don't ship one hedged version. Ask which reader it is for; if useful, show a provisional design per candidate audience so the decision is concrete. + +## Worked example + +One AISH file at lifecycle Stage 3 (awaiting medical eligibility). Same state, two readers. + +Undesigned draft (policy voice, no reader in mind): + +``` +Your application is currently being processed. The assessment of your medical eligibility for +the Assured Income for the Severely Handicapped (AISH) program is pending and has been in the +review queue for 6 days. Processing typically takes approximately 8 weeks from the date of +submission. You will be contacted by email and text message in due course once a determination +has been made. No further action is required at this time. +``` + +**Citizen** (recipient's portal, "where is my file"): + +``` +We're reviewing your application + +Right now we're checking your medical eligibility. You don't need to do anything. + +We'll text and email you as soon as there's a decision. Most take about 8 weeks. +``` + +Why: leads with status and "you don't need to do anything" (the anxious reader's real question); grade-9, dropped "in due course", "determination", and the legal program name; four lines, one idea each; ends on what's next and when. + +**Worker** (case worker's file view, "what needs me on this file"): + +``` +Stage 3 · Awaiting medical eligibility · 6 days in queue +Next: review medical docs, set eligibility, assign reviewer +``` + +Why: keeps the working vocabulary ("Stage 3", "eligibility", "assign"); surfaces queue age for triage and the explicit next action; cuts reassurance an expert doesn't need. No reading-level softening, which would only slow them down. + +The citizen version *adds* orientation and *removes* domain language; the worker version *removes* orientation and *keeps* it. Opposite moves, same source state. + +## Common mistakes + +- **Skipping Step 1.** Writing before naming the reader. The dominant failure: copy that is simple where it should be dense, or dense where it should be simple. +- **Over-simplifying worker copy.** Plain language is not the worker goal; stripping their vocabulary slows them down. Precision is the courtesy. +- **Padding citizen copy with system orientation.** Citizens don't need the org chart or the legal program name; they need their next step. +- **Treating accessibility or voice as an audience dial.** They apply to both readers at full strength, always. + +## Extending it (a first foundation) + +The base is one set of principles; audiences are profiles, so it grows without restructuring: + +- More **profiles** as they earn it: a delegated third party (trustee, POA); a screen-reader-first reader named explicitly rather than treated as an afterthought. +- More **principles**: deepen the surface-specific moves (errors, empty states, notifications), plus service-language conventions. +- Stay **aligned with the code side**: this is the prose counterpart to the design system's User Types foundation. When one moves, check the other. + +## Related + +- DS **User Types** foundation — the canonical source this mirrors (served through the MCP). The interaction layer (components, density, layouts) lives there; this skill owns the words, not the layout. +- `content-design-succinct-alternatives.md` — the wordy-phrase-to-plain-wording lookup wired into Step 2. +- Source for the six principles: Todd Rogers & Jessica Lasky-Fink, *Writing for Busy Readers*. diff --git a/skills/content-design/content-design-succinct-alternatives.md b/skills/content-design/content-design-succinct-alternatives.md new file mode 100644 index 0000000000..542e1a924b --- /dev/null +++ b/skills/content-design/content-design-succinct-alternatives.md @@ -0,0 +1,870 @@ +# Succinct alternatives: wordy phrase to plainer wording + +A lookup of common wordy or formal phrases and their shorter, plain-language replacements, for use with the content-design skill. Adapted from Todd Rogers & Jessica Lasky-Fink, *Writing for Busy Readers*. Curated to drop entries irrelevant to government service content. + +**Use it reader-awarely.** Most entries cut padding ("due to the fact that" to "because") and help every reader. Some swap a formal word for an everyday one ("terminate" to "stop"), which suits a citizen but can wrongly strip a worker's precise vocabulary. Match the substitution to the reader (see the citizen/worker dial in the skill); never blanket-replace. + +| Wordy phrase | Plainer alternative | +| --- | --- | +| (a) large number of | many, most (or say how many) | +| (an) absence of | no, none | +| (have) regard to | take into account | +| (it is) compulsory | (you) must | +| (it is) mandatory | (you) must | +| (it is) obligatory | (you) must | +| (please find) enclosed | I enclose | +| (the) tenant | you | +| 12 midnight | midnight | +| 12 noon | noon | +| a man/woman by the name of | named | +| a number of | several, many | +| a percentage of | some | +| a sufficient number of | enough | +| absence of | no | +| absolutely certain | certain | +| absolutely essential | essential | +| abundance | enough, plenty, a lot (or say how many) | +| accede to | allow, agree to | +| accelerate | speed up | +| accentuate | stress | +| accommodation | where you live, home | +| accompanying | with | +| accomplish | do, finish | +| according to our records | our records show | +| accordingly | in line with this, so | +| acknowledge | thank you for | +| acquaint | tell | +| acquaint yourself with | find out about, read | +| acquiesce | agree | +| acquire | buy, get | +| actual experience | experience | +| add an additional | add | +| add up | add | +| added bonus | bonus | +| additional | extra, more | +| adjacent | next to | +| adjacent to | near | +| adjustment | change, alteration | +| admissible | allowed, acceptable | +| advance forward | advance | +| advance planning | planning | +| advance reservations | reservations | +| advance warning | warning | +| advantageous | useful, helpful | +| advise | tell, say (unless you are giving advice) | +| affirmative yes | yes | +| affix | add, write, fasten, stick on, fix to | +| afford an opportunity | let, allow | +| afforded | given | +| aforesaid | this, earlier in this document | +| aggregate | total | +| ahead of schedule | early | +| aligned | lined up, in line | +| all meet together | all meet | +| alleviate | ease, reduce | +| allocate | divide, share, give | +| along the lines of | like, as in | +| alternative | (a) choice, (the) other | +| alternative choice | choice | +| alternatively | or, on the other hand | +| ameliorate | improve, help | +| amendment | change | +| an adequate number of | enough | +| anonymous stranger | stranger | +| anticipate | expect | +| any particular | any | +| apparent | clear, plain, obvious, seeming | +| applicant (the) | you | +| application | use | +| appreciable | large, great | +| apprise | inform, tell | +| appropriate | proper, right, suitable | +| appropriate to | suitable for | +| approximately | about, roughly | +| arrangements are in the hands of | arranged by | +| artificial prosthesis | prosthesis | +| as a consequence of | because | +| as far as... is concerned | as for | +| as of the date of | from | +| as per | per | +| as regards | about, on the subject of | +| ascertain | find out | +| ask the question | ask | +| assemble | build, gather, put together | +| assemble together | assemble | +| assistance | help | +| at an early date | soon (or say when) | +| at an early time | early | +| at its discretion | can, may (or edit out) | +| at the moment | now (or edit out) | +| at the present time | now (or edit out) | +| at this point in time | now | +| ATM machine | ATM | +| attach together | attach | +| attempt | try | +| attend | come to, go to, be at | +| attired in | wore | +| attributable to | due to, because of | +| authorise | allow, let | +| authority | right, power, may (as in 'have the authority to') | +| autobiography of my life | autobiography | +| awkward predicament | awkward | +| axiomatic | obvious, goes without saying | +| basic fundamentals | fundamentals | +| beat out | beat | +| belated | late | +| beneficial | helpful, useful | +| best of health | well/healthy | +| bestow | give, award | +| blend together | blend | +| both agree | agree | +| both of them | both | +| bouquet of flowers | bouquet | +| breach | break | +| brief in duration | brief | +| brief moment | moment | +| brief summary | summary | +| by means of | by | +| cacophony of sound | cacophony | +| calculate | work out, decide | +| call your attention to the fact that | remind you, notify you | +| called a halt | stopped | +| cancel out | cancel | +| careful scrutiny | scrutiny | +| carry out the work | do the work | +| caused injuries to | injured | +| cease | finish, stop, end | +| circle around | circle | +| circulate around | circulate | +| circumvent | get round, avoid, skirt, circle | +| clarification | explanation, help | +| classify into groups | classify | +| clean up | clean | +| close proximity | proximity | +| closed fist | fist | +| cold temperature | cold | +| colder temperature | colder | +| collaborate together | collaborate | +| collide into each other | collide | +| combine | mix | +| combine together | combine | +| combined | together | +| commence | start, begin | +| communicate | talk, write, telephone (be specific) | +| compete with each other | compete | +| competent | able, can | +| compile | make, collect | +| complete | fill in, finish | +| completely annihilate | annihilate | +| completely destroy | destroy | +| completely eliminate | eliminate | +| completely engulf | engulf | +| completely fill | fill | +| completely surround | surrounded | +| completion | end | +| comply with | keep to, meet | +| component | part | +| component parts | parts | +| comprises | is made up of, includes | +| conceal | hide | +| concerning | about, on | +| concerning the matter of | about | +| conclusion | end | +| concur | agree | +| condition | rule | +| confer together | confer | +| confused state | confused | +| connect together | connect | +| consensus of opinion | consensus | +| consequently | so | +| considerable | great, important | +| constantly maintained | maintained | +| constitutes | makes up, forms, is | +| construe | interpret | +| consult | talk to, meet, ask | +| consumption | amount used | +| contemplate | think about | +| continue on | continue | +| continue to remain | stay | +| contrary to | against, despite | +| correct | put right | +| correspond | write | +| costs the sum of | costs | +| could possibly | could | +| counter | against | +| courteous | polite | +| crammed close together | crammed | +| crisis situation | crisis | +| crystal clear | clear | +| cumulative | added up, added together | +| curative process | curative | +| current incumbent | incumbent | +| current trend | trend | +| currently | now (or edit out) | +| customary | usual, normal | +| deceptive lie | lie | +| deduct | take off, take away | +| deem to be | treat as | +| defer | put off, delay | +| deficiency | lack of | +| delete | cross out | +| demonstrate | show, prove | +| denote | show | +| depart from | depart | +| depict | show | +| depreciated in value | depreciated | +| descend down | descend | +| described as | called | +| designate | point out, show, name | +| desirable benefits | benefits | +| desire | wish, want | +| despatch or dispatch | send, post | +| despite the fact that | though, although | +| despite… nonetheless | despite OR nonetheless | +| determine | decide, work out, set, end | +| detrimental | harmful, damaging | +| different kinds | kinds | +| difficult dilemma | dilemma | +| difficulties | problems | +| diminish | lessen, reduce | +| direct confrontation | confrontation | +| disappear from sight | disappear | +| disburse | pay, pay out | +| discharge | carry out | +| disclose | tell, show | +| disconnect | cut off, unplug | +| discontinue | stop, end | +| discrete | separate | +| discuss | talk about | +| disseminate | spread | +| documentation | papers, documents | +| domiciled in | living in | +| dominant | main | +| draw the attention of | show/remind/point out | +| drop down | drop | +| due to the fact that | because, as | +| duration | time, life | +| during the course of | during | +| during the time that | during | +| during which time | while | +| dwelling | home | +| dwindle down | dwindle | +| each and every | each | +| each individual ____ | each ___ | +| earlier in time | earlier | +| economical | cheap, good value | +| economics field | economics | +| eligible | allowed, qualified | +| eliminate altogether | eliminate | +| elucidate | explain, make clear | +| emergency situation | emergency | +| emphasise | stress | +| empower | allow, let | +| empty hole | hole | +| empty out | empty | +| empty space | space | +| enable | allow | +| enclosed | inside, with | +| enclosed herein | enclosed | +| enclosed herewith | enclosed | +| encounter | meet | +| end product | product | +| end result | result | +| endeavour | try | +| enquire | ask | +| enquiry | question | +| ensure | make sure | +| enter in | enter | +| entirely eliminate | eliminate | +| entitlement | right | +| envisage | expect, imagine | +| equal to one another | equal | +| equally as | equally OR as (one or the other, not both) | +| equivalent | equal, the same | +| eradicate completely | eradicate | +| erroneous | wrong | +| erupt (or explode) violently | erupt or explode | +| escape from | escape | +| establish | show, find out, set up | +| estimate roughly | estimate | +| estimated at about | estimated at | +| evaluate | test, check | +| evince | show, prove | +| evolve over time | evolve | +| ex officio | because of his or her position | +| exact same | same | +| exceeding the speed limit | speeding | +| exceptionally | only when, in this case | +| excessive | too many, too much | +| exclude | leave out | +| excluding | apart from, except | +| exclusively | only | +| exempt from | free from | +| exit out of | exit | +| expedite | hurry, speed up | +| expeditiously | as soon as possible, quickly | +| expenditure | spending | +| expire | run out | +| exposed opening | opening | +| extant | current, in force | +| extradite back | extradite | +| extreme in degree | extreme | +| extremity | limit | +| fabricate | make, make up | +| face mask | mask | +| facilitate | help, make possible | +| facilitate the effort | ease/help | +| factor | reason | +| failure to | if you do not | +| fall down | fall | +| favorable approval | approval | +| fellow classmates | classmates | +| fellow colleague | colleague | +| fellow countryman | countryman | +| fetch back | fetch | +| few in number | few | +| filled to capacity | filled | +| final conclusion | conclusion | +| final outcome | outcome | +| finalise | end, finish | +| finish up | finish | +| first and foremost | foremost | +| first conceived | conceived | +| first of all | first | +| flee from | flee | +| fly through the air | fly | +| follow after | follow | +| following | after | +| for the duration of | during, while | +| for the purpose of | to, for | +| for the reason of | because | +| for the reason that | because | +| foreign imports | imports | +| former graduate | graduate | +| former veteran | veteran | +| formulate | plan, devise | +| forthwith | now, at once | +| forward | send | +| free gift | gift | +| frequently | often | +| from out of the | out of/from | +| from the point of view of | for | +| from whence | whence | +| frontispiece illustration | frontispiece | +| frozen ice | ice | +| full gamut | gamut | +| fundamental | basic | +| furnish | give | +| further to | after, following | +| furthermore | then, also, and | +| fuse together | fuse | +| future plans | plans | +| gained entrance to | got in | +| gather together | gather | +| gathered together | met | +| general consensus | consensus | +| generate | produce, give, make | +| give consideration to | consider, think about | +| give rise to | cause | +| glance briefly | glance | +| goes under the name of | is called/known as | +| grant | give | +| green in color | green | +| grow in size | grow | +| had done previously | had done | +| harmful injuries | injuries | +| he is a man who | he | +| head up | head | +| heat up | heat | +| heavy in weight | heavy | +| hence why | why | +| henceforth | from now on, from today | +| hereby | now, by this (or edit out) | +| herein | here (or edit out) | +| hereinafter | after this (or edit out) | +| hereof | of this | +| hereto | to this | +| heretofore | until now, previously | +| hereunder | below | +| herewith | with this (or edit out) | +| hitherto | until now | +| HIV virus | HIV | +| hoist up | hoist | +| hold in abeyance | wait, postpone | +| hollow tube | tube | +| honest in character | honest | +| hope and trust | hope, trust (but not both) | +| hotter temperature | hotter | +| hourly basis | hourly | +| hurry up | hurry | +| I am hopeful that | I hope | +| I was unaware of the fact that | I was unaware that, I did not know that | +| if and when | if, when (but not both) | +| illustrate | show, explain | +| immediately | at once, now | +| implement | carry out, do | +| imply | suggest, hint at | +| important essentials | essentials | +| impossible to discover | cannot be found | +| in a confused state | confused | +| in a hasty manner | hastily | +| in a number of cases | some (or say how many) | +| in a satisfactory manner | satisfactorily | +| in accordance with | as under, in line with, because of | +| in addition | also | +| in addition (to) | and, as well as, also | +| in advance | before | +| in advance of our meeting | before we meet | +| in attendance | present/there | +| in case of | if | +| in conjunction with | and, with | +| in connection with | for, about | +| in consequence | because, as a result | +| in consequence of | because of | +| in excess of | more than | +| in isolation | by itself/alone | +| in large measure | largely | +| in lieu of | instead of | +| in many cases | often | +| in order that | so that | +| in order to | to | +| in receipt of | get, have, receive | +| in recognition of the fact that | recognizing that | +| in relation to | about | +| in respect of | about, for | +| in spite of the fact that | though, although | +| in succession | running | +| in the absence of | without | +| in the case of/in terms of | off | +| in the course of | while, during | +| in the direction of | toward | +| in the event of | in/if | +| in the event of/that | if | +| in the field of | in/with | +| in the majority of instances | most, mostly | +| in the near future | soon | +| in the neighbourhood of | about, around | +| in the possession of | has/have | +| in the vicinity/region/neighborhood of | about/near/around | +| in view of the fact that | as, because | +| inappropriate | wrong, unsuitable | +| inasmuch as | since, because | +| inception | start, beginning | +| incorporating | which includes | +| incredible to believe | incredible | +| incur | have to pay, owe | +| indicate | show, suggest | +| indicted on a charge | indicted | +| inform | tell | +| initially | at first | +| initiate | begin, start | +| initiate, institute | start | +| input into | input | +| insert | put in | +| insist adamantly | insist | +| instances | cases | +| integrate together | integrate | +| integrate with each other | integrate | +| intend to | will | +| interdependent upon each other | interdependent | +| intimate | say, hint | +| introduced a new | introduced | +| introduced for the first time | introduced | +| irrespective of | despite, even if | +| is of the opinion | thinks | +| ISBN number | ISBN | +| issue | give, send | +| it cannot be denied that | undeniably | +| it is known that | I/we know that | +| it is often the case that | often | +| jeopardise | risk, threaten | +| join together | join | +| joint collaboration | collaboration | +| jump up | jump | +| kneel down | kneel | +| knots per hour | knots | +| knowledgeable expert | expert | +| lag behind | lag | +| laptop computer | laptop | +| large in size | large | +| large volume of | many | +| last of all | last | +| later time | later | +| LCD display | LCD | +| leaving much to be desired | unsatisfactory/bad | +| lesbian woman | lesbian | +| less expensive | cheaper | +| lift up | lift | +| live witness | witness | +| local residents | residents | +| locality | place, area | +| locate | find, put | +| look ahead to the future | look to the future | +| look back in retrospect | look back | +| low degree of interest | little interest | +| low ebb | ebb | +| made an approach to | approached | +| made good their escape | escaped | +| made out of | made of | +| magnitude | size | +| major breakthrough | breakthrough | +| major feat | feat | +| manner | way | +| manually by hand | manually | +| manufacture | make | +| marginal | small, slight | +| match up | match | +| material | relevant | +| materialise | happen, occur | +| may in the future | may, might, could | +| may possibly | may | +| measure up to | fit/reach/match | +| meet up with | meet | +| merchandise | goods | +| merge together | merge | +| might possibly | might | +| mislay | lose | +| modification | change | +| moment in time | moment | +| more superior | superior | +| moreover | and, also, as well | +| mutual cooperation | cooperation | +| mutual respect for each other | mutual respect | +| natural instinct | instinct | +| nearly almost | nearly OR almost | +| negligible | very small | +| never at any time | never | +| nevertheless | but, however, even so | +| new beginning | beginning | +| new construction | construction | +| new innovation | innovation | +| new invention | invention | +| new recruit | recruit | +| none at all | none | +| nostalgia for the past | nostalgia | +| notify | tell, let us (or you) know | +| notwithstanding | even if, despite, still, yet | +| notwithstanding the fact that | although | +| now pending | pending | +| null and void | void | +| numerous | many (or say how many) | +| objective | aim, goal | +| obtain | get, receive | +| occasioned by | caused by, because of | +| of a bright color | bright | +| of a strange type | strange | +| of cheap quality | cheap | +| of the order of | about | +| off of | off | +| often times | often | +| old adage | adage | +| old cliché | cliché | +| old custom | custom | +| old proverb | proverb | +| on a daily basis | daily | +| on a regular basis | regularly | +| on account of the fact that | because | +| on behalf of | for | +| on numerous occasions | often | +| on request | if you ask | +| on the grounds that | because | +| on the occasion that | when, if | +| on the part of | by | +| once used to | did, used to | +| one of the purposes | one purpose | +| one of the reasons | one reason | +| operate | work, run | +| optimum | best, ideal | +| option | choice | +| orbit around | orbit | +| ordinarily | normally, usually | +| originally created | created | +| otherwise | or | +| outside of | outside | +| outstanding | unpaid | +| overexaggerate | exaggerate | +| owing to | because of | +| owing to the fact that | since, because | +| pair of twins | twins | +| palm of the hand | palm | +| partially | partly | +| participate | join in, take part | +| particulars | details, facts | +| passing fad | fad | +| past experience | experience | +| past history | history | +| past memories | memories | +| pay tribute to | thank/praise/honor | +| penetrate into | penetrate | +| per annum | a year | +| per capita | per person | +| perform | do | +| performs the function of | functions as | +| period in time | period | +| permeate through | permeate | +| permissible | allowed | +| permit | let, allow | +| personal friend | friend | +| personal opinion | opinion | +| personnel | people, staff | +| persons | people, anyone | +| peruse | read, read carefully, look at | +| pick and choose | pick | +| PIN number | PIN | +| place | put | +| placed under arrest | arrested | +| plan ahead | plan | +| plan in advance | plan | +| plunge down | plunge | +| point in time | point, time, or then | +| positive identification | identification | +| possess | have, own | +| possessions | belongings | +| postpone until later | postpone | +| practically | almost, nearly | +| pre-recorded | recorded | +| predominant | main | +| preplan | plan | +| prescribe | set, fix | +| present time | time | +| preserve | keep, protect | +| previous | earlier, before, last | +| previously listed above | previously listed | +| principal | main | +| prior to | before | +| prior/preparatory/previous to | before we meet | +| private industry | industry | +| proceed | go ahead | +| proceed ahead | proceed | +| procure | get, obtain, arrange | +| profusion of | plenty, too many (or say how many) | +| prohibit | ban, stop | +| projected | estimated | +| prolonged | long | +| promptly | quickly, at once | +| promulgate | advertise, announce | +| proportion | part | +| proposed plan | plan | +| protest against | protest | +| prove beneficial | benefit | +| provide | give | +| provided that | if, as long as | +| provisions | rules, terms | +| proximity | closeness, nearness | +| purchase | buy | +| pursuant to | under, because of, in line with | +| pursue after | pursue | +| put in an appearance | appear | +| raise up | raise | +| re-elect for another term | re-elect | +| reason is because | reason is | +| reason why | reason (or "reason is") | +| reconsider | think again about, look again at | +| recur again | recur | +| reduce | cut | +| reduction | cut | +| refer back | refer | +| referred to as | called | +| refers to | talks about, mentions | +| reflect back | reflect | +| regarding | about, on | +| regular routine | routine | +| regulation | rule | +| reimburse | repay, pay back | +| reiterate | repeat, restate | +| relating to | about | +| relative to the issue of | about | +| remain | stay | +| remainder | the rest, what is left | +| remittance | payment | +| remuneration | pay, wages, salary | +| render | make, give, send | +| rendered assistance to | helped | +| repeat again | repeat | +| reply back | reply | +| report | tell | +| represents | shows, stands for, is | +| request | ask, question | +| request the appropriation of | ask for (more) money | +| require | need, want, force | +| requirements | needs, rules | +| reside | live | +| residence | home, where you live | +| restriction | limit | +| retain | keep | +| retain her position as | remain as | +| retired for the night | went to bed | +| retreat back | retreat | +| return back | return | +| revert back | revert | +| review | look at (again) | +| revised | new, changed | +| rise up | rise | +| round in shape | round | +| safe haven | haven | +| safe sanctuary | sanctuary | +| said/such/same | the, this, that | +| sand dune | dune | +| scrutinise | read (look at) carefully | +| scrutinize in detail | scrutinize | +| select | choose | +| self-____ yourself | self-_____ | +| serious danger | danger | +| serves the function of | serves as | +| settle | pay | +| share the same | share | +| share together | share | +| sharp point | point | +| shiny in appearance | shiny | +| short in length | short | +| shortfall in supplies | shortage | +| shout out | shout | +| shuttle back and forth | shuttle | +| similarly | also, in the same way | +| single unit | unit | +| sink down | sink | +| sit down | sit | +| skipped over | skipped | +| skirt around | skirt | +| slightly ajar | ajar | +| small speck | speck | +| so far as x is concerned | as for x | +| soft to the touch | soft | +| sole of the foot | sole | +| solely | only | +| special ceremonies marking the event were held | ceremonies marked the event | +| specified | given, written, set | +| spell out in detail | spell out | +| spliced together | spliced | +| start off | start | +| start out | start | +| state | say, tell us, write down | +| statutory | legal, by law | +| still persists | persists | +| still remains | remains | +| subject to | depending on, under, keeping to | +| submit | send, give | +| submitted his resignation | resigned | +| subsequent to | after | +| subsequent to/upon | after | +| subsequently | later | +| substantial | large, great, a lot of | +| substantially | more or less | +| substantiate | prove | +| succeeded in defeating | defeated | +| succumbed to his injuries | died | +| sudden crisis | crisis | +| sudden impulse | impulse | +| suddenly exploded | exploded | +| sufficient | enough | +| sufficient consideration | enough thought | +| sum total | total | +| supplement | go with, add to | +| supplementary | extra, more | +| supply | give, sell, deliver | +| surrounded on all sides | surrounded | +| sustained injuries | was hurt | +| swoop down | swoop | +| sworn affidavit | affidavit | +| take action on the issue | act | +| tall in height | tall | +| tall in stature | tall | +| tall skyscraper | skyscraper | +| temper tantrum | tantrum | +| terminate | stop, end | +| terrible tragedy | tragedy | +| that being the case | if so | +| the fact that he had not succeeded | his failure | +| the fact that I had arrived | my arrival | +| the question as to whether | whether | +| the results so far achieved | the results | +| the tools they employed | their tools | +| there is no doubt but that | no doubt, doubtless | +| there is no doubt that | clearly | +| thereafter | then, afterwards | +| thereby | by that, because of that | +| therein | in that, there | +| thereof | of that | +| thereto | to that | +| this day and age | today/nowadays | +| this is a subject which | this subject | +| thus | so, therefore | +| tiny bit | bit | +| to date | so far, up to now | +| to the extent that | if, when | +| took into consideration | considered | +| tragically sad | tragic | +| transfer | change, move | +| transmit | send | +| true facts | facts | +| truly sincere | sincere | +| two equal halves | halves | +| ultimately | in the end, finally | +| unavailability | lack of | +| under active consideration | considered | +| under preparation | being prepared | +| under the circumstances | in this/that case | +| undergraduate student | undergraduate | +| underground subway | subway | +| undernoted | the following | +| undersigned | I, we | +| undertake | agree, promise, do | +| unexpected emergency | emergency | +| unexpected surprise | surprise | +| uniform | same, similar | +| unilateral | one-sided, one-way | +| unintentional mistake | mistake | +| universal panacea | panacea | +| unoccupied | empty | +| unsolved mystery | mystery | +| unthaw | thaw | +| until such time | until | +| until such time as | until | +| unusual in nature | unusual | +| used for fuel purposes | used for fuel | +| used to at one time | used to | +| used to in the past | used to | +| usual custom | custom | +| utilisation | use | +| utilise | use | +| vacillate back and forth | vacillate | +| variation | change | +| various differences | differences | +| veiled ambush | ambush | +| virtually | almost (or edit out) | +| visible to the eye | visible | +| visualise | see, predict | +| voiced approval | approved | +| wall mural | mural | +| wall sconce | sconce | +| wander around aimlessly | wander, roam | +| warn in advance | warn | +| was a witness of | saw | +| was suffering from | had | +| ways and means | ways | +| we are in receipt of | we received | +| we are of the opinion | we think | +| we have pleasure in | we are glad to | +| weather conditions | weather | +| wend one's way | go | +| whatsoever | whatever, what, any | +| when and if | when | +| whensoever | when | +| whereas | but | +| whether or not | whether | +| who is, which was | [often superfluous; omit entirely] | +| will be the speaker at | will speak | +| with a view to | to, so that | +| with effect from | from | +| with reference to | about | +| with regard to | about, for | +| with respect to | about, for | +| with the exception of | except | +| with the minimum of delay | quickly (or say when) | +| with the result that | so, so that | +| worked their way | went | +| write down | write | +| x o'clock A.M. in the morning | x o'clock A.M. (ditto for "P.M. in the evening") | +| you are requested | please | +| your attention is drawn to | please see, please note | +| zone | area, region | diff --git a/skills/using-goa-design-system/README.md b/skills/using-goa-design-system/README.md new file mode 100644 index 0000000000..8b1592eae3 --- /dev/null +++ b/skills/using-goa-design-system/README.md @@ -0,0 +1,62 @@ +# using-goa-design-system + +The skill that turns "I'm building X for users" into the right Government of Alberta product type, templates, and components. It is the "how should I approach this" layer. The GoA design system MCP is the "what is this" layer underneath it. + +## In a sentence + +For anyone building with the design system: tell your AI what you're making in plain terms ("a renewal form for citizens", "a case queue for workers") and it works out the right GoA product type, the matching page and section templates, and the exact components with their props and gotchas, before it writes any code. + +Under the hood: it is the composition layer to the MCP's knowledge layer. The MCP answers "what is this component"; this skill answers "I'm building this, how should I approach it," following the design system's service-first method so an AI tool uses our conventions by default. + +## What it does + +When you describe what you're building in user-facing terms, the skill walks the design system's structure top-down instead of jumping to a component: + +1. Names the service type: what the user is trying to accomplish, in service language ("getting permission", "requesting information", "getting financial support"). This uses Kate Tarling's Common Service Types vocabulary. +2. Maps that to a product type: workspace (worker-facing) or public-form (citizen-facing), saying the translation out loud, and naming a gap if it does not fit cleanly. +3. Pulls the matching templates by size (interaction, section, page, task, product) from the MCP. +4. Pulls each component's real props, token references, and embedded guidance (the do's and don'ts) from the MCP, before any code is written. +5. Hands back the plan and the known gotchas first, names what it assumed, and flags decisions a person should make. + +A guided descent: service, to product type, to template, to component, to tokens, with the MCP supplying the facts at each step. + +## How it works + +The MCP is the knowledge layer (facts, through its `search` and `get` tools). This skill is the composition layer (method: which questions to ask, in what order, and how to read the answers). It uses the MCP and never duplicates it. It carries no component specs, only the judgment for navigating to them. That is why it stays small, and why its facts stay current: they come live from the MCP. + +The structure it walks: + +- Service type: what the user is trying to accomplish (vocabulary layer) +- Product type: workspace (worker) or public-form (citizen) +- Example by size: interaction, section, page, task, product +- Components: the atomic UI building blocks +- Guidance atoms: the do's, don'ts, and tips tied to a component +- Tokens: spacing, color, and sizing values + +## What makes it distinctive + +- Service-first, not component-first. It establishes product type and template before reaching for a component, so the result follows the system's conventions instead of being correct-looking code that ignores them. +- Derives, does not interrogate. It infers worker-vs-citizen from the product type instead of asking. +- Advisory and transparent. It names its defaults ("I'll scaffold in React, the same works in Angular") and escalates real decisions to people. +- Same content, different lens. A designer gets design language; a developer gets technical language. It reframes, it does not withhold. +- Gotchas before code. It surfaces known failure modes up front, not at review time. +- Names gaps honestly. If no template captures the job, it says so and offers the closest pieces rather than forcing a near-miss. + +## The shorthand: a librarian + +You arrive with a fuzzy need ("I'm building an intake tool for caseworkers"). A component library hands you a pile of parts. This skill is the librarian: it works out what kind of thing you are really making, walks you to the right shelf, pulls the exact references with their specs, and warns you about the known pitfalls before you start. + +## Using it + +Install it, pointed at our repo: + +``` +npx skills add GovAlta/ui-components --skill using-goa-design-system +``` + +Then describe what you are building in plain terms, and the skill loads on its own when the work matches. Run `npx skills update` to pull the latest. It works alongside the GoA design system MCP, which supplies the live component facts. + +## Good to know + +- The service-type layer is vocabulary, not data yet. The skill leads with the Kate Tarling service types as a navigation lens; they are not a queryable layer in the MCP. As the service-mapping work formalizes them, the skill will get more opinionated about the building blocks each type expects. +- The bundled `taxonomy.md` is a cache. It is a fast lookup of sizes, product types, and current templates, and it can drift from the docs site. The docs site and the MCP are the source of truth. Keep `taxonomy.md` in sync when product types or templates change. diff --git a/skills/using-goa-design-system/SKILL.md b/skills/using-goa-design-system/SKILL.md new file mode 100644 index 0000000000..a530a5455e --- /dev/null +++ b/skills/using-goa-design-system/SKILL.md @@ -0,0 +1,85 @@ +--- +name: using-goa-design-system +description: Use when building or scoping a screen, page, or feature with the Government of Alberta Design System starting from a user-facing intent ("worker tool for case management", "public form for licence renewal", "error page for payment failure") rather than from a specific component or token. +--- + +# Using the GoA Design System + +## Overview + +The GoA design system has a layered structure: **product types** (workspace, public-form) → **examples by size** (interaction, section, page, task, product) → **components**, **guidance atoms**, **tokens**. This skill navigates from a user-facing intent down to the right artifacts. The MCP tools (`goa-design-system:search`, `goa-design-system:get`) are the knowledge layer; this skill is the composition layer — it walks the layered structure, surfaces guidance, and confirms specs by reading entries with `goa-design-system:get`. + +**Prerequisite:** this skill drives the `goa-design-system` MCP (`search`, `get`) at every step. If it isn't connected, tell the user and point them to install it (Claude Code: `claude mcp add --transport http goa-design-system https://mcp.design.alberta.ca/mcp --scope user`). Without it, the skill can't navigate. + +## When to use + +- A developer describes what they're building in user terms ("intake process", "case-management tool", "renewal form") +- A team is scoping a new screen and needs to know what product type and templates apply +- A reviewer is checking whether a screen uses the right layered structure + +When NOT to use: +- The developer already names a specific component (`goa-button`, `goa-form-item`) → go straight to MCP `goa-design-system:get` +- Purely token math (sizing, color values) → `goa-design-system:get` the component to see its token references +- A single guidance-atom question → `goa-design-system:search` and `goa-design-system:get` directly + +## The layered structure + +| Layer | What it is | Examples | +|---|---|---| +| Service type | What the user is trying to accomplish (Kate Tarling Common Service Types) | "Getting permission" (permit), "Requesting/sharing information" (case lookup), "Getting financial support" (benefit claim) | +| Product type | The kind of digital product the service is realized as (its own content collection) | `workspace` (worker), `public-form` (citizen) | +| Example by size | Scale of artifact | `interaction` → `section` → `page` → `task` → `product` | +| Components | Atomic UI building blocks | `goa-button`, `goa-form-item`, `goa-table` | +| Guidance atoms | Do/don't/tip/warning/info linked to component + topic | "use goa-block for spacing, not goa-container" | +| Tokens | Sizing, color, spacing values | `--goa-space-m`, `--goa-color-text-default` | + +Service type is the vocabulary layer (Kate Tarling), not a schema layer yet. The skill leads with it when recognizable, then narrates the mapping to product type. See `taxonomy.md` for the full framework. + +Public services typically have two sides: one to **provide and manage** (worker), and one to **receive** (citizen). When the intent names one side, name it. When the intent could apply to either, name both. The product type follows from which side the developer is building for. + +`task` is the unit for a complete user job — between a single page and a full product. When an intent is task-shaped (a complete job the user is trying to do), look for it in the tasks collection before composing from pages and components alone. + +For full enumeration of sizes, product types, and aliases, see `taxonomy.md`. + +## Navigation pattern + +1. Read the intent. Recognize the **service type**: what is the user trying to accomplish in service terms? If recognizable, name it using Kate Tarling's Common Service Types vocabulary (see `taxonomy.md`): "Getting permission," "Requesting/sharing information," "Getting financial support," etc. +2. Map the service type to a product type. Narrate the translation explicitly: "this is a [service type], most often expressed as a [productType] in the GoA system today." If the service doesn't map cleanly to today's product types (workspace, public-form), name the gap rather than forcing a fit. +3. `goa-design-system:get` the product type from the productTypes collection to surface its summary, demo URL, and listed components. +4. `goa-design-system:search` filtered by `size` and `productType` for sections, non-canonical pages, or filtered queries (step 3 already lists the canonical pages). +5. For each template, `goa-design-system:get` the entry to surface its components, source URLs, preview, and embedded guidance. For each component, `goa-design-system:get` to confirm props, types, and token references before generating code. +6. If a task entry matches the intent, surface it. If no task captures it, name the gap and offer the closest pages so the developer can compose the job themselves. +7. Surface result and gotchas to the developer before generating code. Apply the Decision calibration principles below: name what was defaulted (transparency) and frame through the developer's lens. + +## When to use which MCP tool + +The MCP is intentionally narrow. Guidance and specs are not separate tools; the skill surfaces them by reading component and template entries with `goa-design-system:get`. + +| Tool | Use for | +|---|---| +| `goa-design-system:search` | Open-ended discovery across the layered structure; supports filters on `size`, `productType`, `status`, etc. | +| `goa-design-system:get` | Fetching a known entity by ID, alias, or name. Returns the entry's full record — components, sources, props, token references, embedded guidance. | + +## Decision calibration + +Two principles when responding: + +**1. Default transparently, not silently.** When making a choice on the user's behalf (file scaffolding, default props, framework selection), name what was defaulted with a short explanation and a way to override. The line between "matters to decide" and "doesn't matter" is too blurry without full user context, so transparency is the safer floor. Example: "I'll use React for the scaffold; the same patterns work in Angular if you'd prefer." + +For consequential choices that warrant team coordination (state-management architecture, content patterns, conventions that affect the whole project), don't default at all. Name the decision and point to who should make it. Example: "this is a state-management architecture decision worth talking through with a developer on your team" or "this is a content-pattern decision worth aligning with a designer." + +**2. Frame through the user's lens, but show both layers.** A designer asking sees the full picture (design intent AND a basic sketch of implementation), framed in design language: user goals, interactions, accessibility, patterns. A developer asking sees the full picture too (implementation intent AND the design rationale behind it), framed in technical language: implementation paths, integration, types. Same content, different lens. Don't withhold one layer because the question signals the other; do reframe the language so it lands in the user's perspective. + +## Common mistakes + +- **Skipping the service type.** Service language ("intake service", "permit application service") deserves explicit acknowledgment, not silent translation to product type. Name the service type, then narrate the mapping to product type. +- **Skipping the product type layer.** Going straight to components produces correct-looking code that ignores the system's layered conventions. +- **Asking for user type.** Don't. Derive from product type. +- **Inventing tokens.** Always reference the actual token; never hardcode a value. If a value doesn't fit, use a component-level override; tokens themselves are referenced, not modified by teams. +- **Treating guidance atoms as optional.** They encode known failure modes; surface them before code, not after review. +- **Returning "not found" on an old slug.** Old slugs live in the `aliases` array on entries; treat aliases as additional lookup keys. See `taxonomy.md` for examples. +- **Pretending every job has a task entry.** Many do, but not all. When no task matches, surface the closest pages and name the gap, rather than approximating from a near-miss task. + +## Cross-references + +The MCP tools are documented by the design system MCP server. This skill **uses** them; it does not duplicate them. diff --git a/skills/using-goa-design-system/taxonomy.md b/skills/using-goa-design-system/taxonomy.md new file mode 100644 index 0000000000..ba3c67784c --- /dev/null +++ b/skills/using-goa-design-system/taxonomy.md @@ -0,0 +1,112 @@ +# GoA Design System: Layered Taxonomy + +A fast lookup for this skill. Treat the docs site as the source of truth; this file is a cache for orientation. + +## Contents + +- Sizes +- Product types +- User type +- Service types (vocabulary layer) +- Aliases (slugs) +- Pages within each product (today) +- What this file is NOT + +## Sizes + +The five-value `size` enum, smallest to biggest: + +| Size | Definition | Has page-like fields? | +|---|---|---| +| `interaction` | A single control or affordance behaving correctly | No | +| `section` | A composed region of a page | No | +| `page` | One full screen | Yes | +| `task` | A complete user job from intent to completion. Lives in the tasks collection | Yes | +| `product` | End-to-end digital product | Yes | + +"Page-like fields" means the entry can carry: `previewUrl`, `reactSourceUrl`, `angularSourceUrl`, `sourceUrl`, `stackblitzUrl`, `frameworks: ("react" | "angular" | "web-components")[]`. + +## Product types + +Product types are a **content collection** (not just a field on examples). Each one has an introductory narrative, a hero image, demo URL, and listed components. Examples link to a product type via the `productType` field, constrained today to: + +| Product type | Audience | Use for | +|---|---|---| +| `workspace` | Internal workers | Case management, dashboards, queues | +| `public-form` | Citizens | Form-first flows like applications and renewals | + +Other product types (e.g. `error-pages`) may exist as example overviews without yet being a productTypes collection entry. When in doubt, query the productTypes collection via `goa-design-system:get`. + +## User type + +**Don't ask the developer for user type. Derive it.** + +| Product type | User type | +|---|---| +| `workspace` | worker | +| `public-form` | citizen | +| (none / interaction-only) | both | + +This derivation replaces the previous `userType` field on examples. + +## Service types (vocabulary layer) + +A service type names what a citizen or worker is trying to accomplish in service terms. The Kate Tarling "Common Service Types" framework names nine: + +1. Registering, providing, or reporting information +2. Requesting, sharing, or checking information +3. Paying for something +4. Getting financial support or claiming something +5. Getting permission to do something +6. Scheduling something +7. Buying or ordering something +8. Becoming something +9. Protecting something + +**They are not modelled in the design system schema yet, but the skill leads with this framing.** When intent is recognizable in service-type terms, name it explicitly using this vocabulary as the first navigation step, then map to a `productType` as the next explicit step. When the framework is formalized in the schema (likely via a separate service-mapping data source), the skill will become more opinionated about expected building blocks per type. + +Map intent to productType: + +| Intent shape | Service type (informal) | Likely productType | +|---|---|---| +| "case management", "intake", "review and decide", "queue" | Requesting / sharing information; sometimes Getting permission | `workspace` | +| "apply for X", "register for Y" | Getting permission, Becoming something | `public-form` | +| "renew", "report status" | Registering / providing information | `public-form` | +| "claim", "request support" | Getting financial support | `public-form` | +| "schedule X", "book a Y" | Scheduling something | `public-form` (booking) or `workspace` (queue) | + +Don't promote service types into a structured field; use them as intent vocabulary until the framework is formally adopted in the schema. + +## Aliases (slugs) + +Old slugs are preserved in the `aliases` array on entries for search continuity and URL redirects. When a developer references a slug that doesn't resolve directly, look up entries whose `aliases` contains that slug. Common cases include: + +- `confirm-that-an-application-was-submitted` → `result-page` +- `ask-a-user-one-question-at-a-time` → `question-page` (one of the nine inline variants) +- `give-more-information-before-asking-a-question-a` → `question-page` +- 9 question-page variants now live as inline sections inside `/examples/question-page/` +- 401, 404, 500 now live as inline sections inside `/examples/error-pages/` + +The MCP should treat aliases as additional searchable IDs. + +## Pages within each product (today) + +Workspace product: +- `dashboard` +- `index-page` +- `case-detail` + +Public-form product: +- `start-page` +- `task-list-page` +- `question-page` (with 9 inline variants) +- `review-page` +- `result-page` + +Each is `size: page` with `productType: `. + +## What this file is NOT + +- Not the spec for any component. Use `goa-design-system:get` on the component entry. +- Not the list of guidance atoms. Use `goa-design-system:search` and `goa-design-system:get`; the skill surfaces guidance during navigation. +- Not a versioned source of truth. When in doubt, query the docs site or the MCP. From 59f1053003f2426fb073c37d8dff02f6dc36414e Mon Sep 17 00:00:00 2001 From: Thomas Jeffery <82968683+twjeffery@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:50:05 -0600 Subject: [PATCH 21/22] feat(#3789): add AI tools and resources section to get-started Co-authored-by: Benji Franck --- docs/src/components/Breadcrumbs.astro | 1 + docs/src/components/CodeSnippet.css | 8 +- docs/src/components/SiteNav.tsx | 4 +- docs/src/components/nav/GetStartedSubMenu.tsx | 86 +++--- docs/src/content/config.ts | 10 +- .../get-started/ai-tools-and-resources.mdx | 75 ++++++ .../goa-design-system-mcp.mdx | 253 ++++++++++++++++++ .../ai-tools-and-resources/skills.mdx | 50 ++++ docs/src/content/get-started/contribute.mdx | 4 +- .../content/get-started/out-of-support.mdx | 4 +- docs/src/content/get-started/qa-testing.mdx | 2 +- docs/src/lib/get-started-nav.ts | 69 +++-- 12 files changed, 476 insertions(+), 90 deletions(-) create mode 100644 docs/src/content/get-started/ai-tools-and-resources.mdx create mode 100644 docs/src/content/get-started/ai-tools-and-resources/goa-design-system-mcp.mdx create mode 100644 docs/src/content/get-started/ai-tools-and-resources/skills.mdx diff --git a/docs/src/components/Breadcrumbs.astro b/docs/src/components/Breadcrumbs.astro index 0d668720f9..7ae3e28be6 100644 --- a/docs/src/components/Breadcrumbs.astro +++ b/docs/src/components/Breadcrumbs.astro @@ -55,6 +55,7 @@ const SEGMENT_LABELS: Record = { "examples": "Examples", "designers": "Designers", "developers": "Developers", + "ai-tools-and-resources": "AI Tools and Resources", }; function segmentToLabel(segment: string): string { diff --git a/docs/src/components/CodeSnippet.css b/docs/src/components/CodeSnippet.css index d8916fd894..93aca2a9b3 100644 --- a/docs/src/components/CodeSnippet.css +++ b/docs/src/components/CodeSnippet.css @@ -124,10 +124,12 @@ mask-image: linear-gradient(to bottom, black 0%, black 60%, transparent 100%); } -pre { +.code-snippet .code-block .code-container pre, +.framework-switcher .code-block .code-container pre { margin: 0; - padding: var(--goa-space-m, 1rem); - overflow-x: auto; + padding: var(--goa-space-s, 0.75rem) var(--goa-space-m, 1rem); + white-space: pre-wrap; + overflow-wrap: anywhere; } code { diff --git a/docs/src/components/SiteNav.tsx b/docs/src/components/SiteNav.tsx index cd89579beb..8959250e0b 100644 --- a/docs/src/components/SiteNav.tsx +++ b/docs/src/components/SiteNav.tsx @@ -94,9 +94,7 @@ function getInitialMenuState(): boolean { } const EMPTY_GET_STARTED_NAV: GetStartedNav = { - topPages: [], - groups: [], - bottomPages: [], + sections: [], }; export function SiteNav({ diff --git a/docs/src/components/nav/GetStartedSubMenu.tsx b/docs/src/components/nav/GetStartedSubMenu.tsx index 1ae70ad26d..8faea4c8bb 100644 --- a/docs/src/components/nav/GetStartedSubMenu.tsx +++ b/docs/src/components/nav/GetStartedSubMenu.tsx @@ -1,14 +1,13 @@ /** * GetStartedSubMenu.tsx * - * Sub-menu for Get Started section showing grouped pages. - * Uses GoabWorkSideMenuGroup for expandable Designers/Developers sections. - * - * Nav structure is sourced from the get-started content collection via + * Sub-menu for Get Started section showing pages organized into sections. + * Each section is either flat (a list of items) or grouped (items inside an + * expandable group with a heading). Sections render in the order returned by * `getGetStartedNav()` in lib/get-started-nav.ts. */ -import { type MouseEvent } from "react"; +import { Fragment, type MouseEvent } from "react"; import { GoabWorkSideMenu, GoabWorkSideMenuItem, @@ -16,7 +15,7 @@ import { } from "@abgov/react-components"; import { MenuSecondaryContent } from "./MenuSecondaryContent"; import { withBase } from "@/lib/base-url"; -import type { GetStartedNav } from "@/lib/get-started-nav"; +import type { GetStartedNav, GetStartedNavSection } from "@/lib/get-started-nav"; interface GetStartedSubMenuProps { isOpen: boolean; @@ -41,6 +40,36 @@ export function GetStartedSubMenu({ onBack(); }; + const renderSection = (section: GetStartedNavSection) => { + if (section.type === "flat") { + return ( + + {section.pages.map((page) => ( + + ))} + + ); + } + + const containsCurrentPage = section.pages.some((p) => p.url === currentUrl); + + const handleGroupClickCapture = () => { + if (!isOpen && onExpandMenu) { + onExpandMenu(); + } + }; + + return ( +
+ + {section.pages.map((page) => ( + + ))} + +
+ ); + }; + const primaryContent = ( <> {/* Back to parent menu */} @@ -48,50 +77,7 @@ export function GetStartedSubMenu({
- {/* Top-level pages */} - {items.topPages.map((page) => ( - - ))} - - {/* Grouped sections */} -
- {items.groups.map((group) => { - const containsCurrentPage = group.pages.some((p) => p.url === currentUrl); - - const handleGroupClickCapture = () => { - if (!isOpen && onExpandMenu) { - onExpandMenu(); - } - }; - - return ( -
- - {group.pages.map((page) => ( - - ))} - -
- ); - })} -
- - {/* Bottom pages */} - {items.bottomPages.map((page) => ( - - ))} + {items.sections.map(renderSection)} ); diff --git a/docs/src/content/config.ts b/docs/src/content/config.ts index 24c1a69adc..c457369e3b 100644 --- a/docs/src/content/config.ts +++ b/docs/src/content/config.ts @@ -267,7 +267,15 @@ const getStarted = defineCollection({ description: z.string().optional(), // Submenu placement. "intro" and "appendix" are top-level (above and below // the grouped sections); "designers" and "developers" are grouped. - section: z.enum(["intro", "designers", "developers", "appendix"]), + section: z.enum([ + "intro", + "designers", + "developers", + "qa-testing", + "ai-tools-and-resources", + "contribute", + "out-of-support", + ]), // Sort order within section. order: z.number(), status: z.enum(["published", "draft", "deprecated"]).default("published"), diff --git a/docs/src/content/get-started/ai-tools-and-resources.mdx b/docs/src/content/get-started/ai-tools-and-resources.mdx new file mode 100644 index 0000000000..1a380972dc --- /dev/null +++ b/docs/src/content/get-started/ai-tools-and-resources.mdx @@ -0,0 +1,75 @@ +--- +id: ai-tools-and-resources +title: AI tools and resources +navLabel: Overview +description: AI tools and resources to help you and your AI work better with the GoA Design System +section: ai-tools-and-resources +order: 1 +status: published +--- +import DropInCallout from "../../components/DropInCallout.astro"; +import { withBase } from "@/lib/base-url"; + +AI tools and resources +These tools help you and your AI work with the GoA Design System. Each tool serves a different purpose, so use them together depending on the task. + +AI toolset + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolHow it worksWhat it gives your AI
GoA Design System MCPLookup tool. Your AI calls it on demand.Component APIs, examples, and guidance, like asking a reference librarian
SkillsInstructions that load when the work matches.Layered instructions for using the Design System in your work, like a playbook for the work at hand
MD filesStatic reference. Pasted upfront, stays in your AI's session.The full Design System reference, like a textbook on the desk
Figma MCPLookup tool. Your AI calls it on demand.Read a design in Figma to get the components, variables, and specs, like lifting measurements off a blueprint
+
+ + + All tools read from the same content this site renders, so answers stay consistent no matter which one you use. + + +GoA Design System MCP +

Your AI pulls live Design System knowledge as it builds, so it uses the right components, the right props, and real examples instead of guessing.

+

View more

+ +Skills +

Plain Markdown instruction files give your AI a workflow to follow when the work matches, so it builds a GoA service with the right structure, templates, and patterns from the start.

+

View more

+ +
+ MD files + +
+

Useful when you want to give your AI broad Design System context, include the reference in a project, or reach for the content offline.

+

How to access it: Per-framework downloadable bundles (React, Angular, Web Components).

+ +Figma MCP +

Built and maintained by Figma. Pair it with the GoA Design System MCP when work spans design and code, so your AI can bridge Figma designs to real coded components from the Design System.

+

How to access it: Figma's guide to the MCP server

+ + diff --git a/docs/src/content/get-started/ai-tools-and-resources/goa-design-system-mcp.mdx b/docs/src/content/get-started/ai-tools-and-resources/goa-design-system-mcp.mdx new file mode 100644 index 0000000000..f05e477c8e --- /dev/null +++ b/docs/src/content/get-started/ai-tools-and-resources/goa-design-system-mcp.mdx @@ -0,0 +1,253 @@ +--- +id: ai-tools-and-resources/goa-design-system-mcp +title: GoA Design System MCP +navLabel: Design System MCP +description: A live lookup tool your AI uses for component APIs, examples, and guidance +section: ai-tools-and-resources +order: 2 +status: published +--- +import DropInCallout from "../../../components/DropInCallout.astro"; +import { CodeSnippet } from "../../../components/CodeSnippet"; +import { CodeCopy } from "../../../components/CodeCopy"; +import { withBase } from "@/lib/base-url"; + +GoA Design System MCP +The live lookup tool your AI calls when it needs Design System component APIs, examples, or guidance. + + + + +View tool-specific setup + + + The MCP is one tool in a larger AI toolset. For the overview of all available AI tools and how they fit together, see AI tools and resources. + + +What's included + +

Everything the MCP returns comes from the same content this site renders.

+ + + + + + + + + + + + + + + + + + + + + + + +
TypeWhat's in it
ComponentsThe full GoA component library with props, variants, and accessibility notes. React, Angular, and Web Components.
ExamplesWorkspace and Public form product types, page patterns, section patterns, and smaller task examples.
GuidanceDo's, don'ts, and tips that apply across components. Accessibility grounded in WCAG 2.2 AA.
+
+ +What can you ask? + +

Ask your AI in plain language. It calls the MCP when a component, example, or guidance answer fits the request.

+ + + + + + + + + + + + + + + + + + + +
About a component
+
+ "What props does the Table component have?" + +
+
+
+ "How does FormItem handle error states?" + +
+
+
+ "Which GoA component should I use for a card layout?" + +
+
+
+ + + + + + + + + + + + + + + + + + + +
For an example or pattern
+
+ "Find dashboard examples in the workspace product type" + +
+
+
+ "Show me React examples for a question page" + +
+
+
+ "What's a good example of a filter bar?" + +
+
+
+ + + + + + + + + + + + + + + + + + + +
For guidance, do's, and don'ts
+
+ "What's the guidance for labelling buttons?" + +
+
+
+ "Show me the do's and don'ts for dropdowns" + +
+
+
+ "What accessibility guidance applies to forms?" + +
+
+
+ +Setup + +

Find your AI tool below to connect it. There's nothing to install.

+ + + To verify your setup, restart your AI tool and ask "Look up the GoA Button component." You should get a response covering Button's props, variants, and usage. + + +Claude Code (CLI) + +

View Claude Code's MCP documentation

+ +

The fastest path is the claude mcp add command:

+ + + +Claude Desktop + +

View Claude Desktop's MCP setup guide

+ +

Claude Desktop only supports local (stdio) MCP servers, so connect through the mcp-remote bridge. Edit the config file at:

+ +
    +
  • Mac: ~/Library/Application Support/Claude/claude_desktop_config.json
  • +
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • +
+ + + +Cursor + +

View Cursor's MCP documentation

+ +

Cursor supports remote HTTP MCP servers directly. Open Settings > Tools & MCP to add a server, or edit ~/.cursor/mcp.json (global) or .cursor/mcp.json in your project:

+ + + +ChatGPT + +

View ChatGPT's MCP connectors guide

+ +

Custom apps are in beta and require a ChatGPT Plus or Pro subscription.

+ +
    +
  1. In ChatGPT, open Settings > Apps > Advanced settings and turn on Developer mode.
  2. +
  3. Select Add app. A New App form opens.
  4. +
  5. Enter a Name (for example, "GoA Design System").
  6. +
  7. Under Connection, select Server URL and paste the MCP endpoint URL:
  8. +
  9. Set Authentication to None.
  10. +
  11. Check I understand and want to continue to acknowledge the risk warning.
  12. +
  13. Save.
  14. +
+ + + Using a different AI tool? If it supports remote MCP servers, paste the MCP endpoint URL into its config. For stdio-only tools, bridge through npx mcp-remote <URL>. View the list of MCP-compatible clients + + + diff --git a/docs/src/content/get-started/ai-tools-and-resources/skills.mdx b/docs/src/content/get-started/ai-tools-and-resources/skills.mdx new file mode 100644 index 0000000000..f9fde561da --- /dev/null +++ b/docs/src/content/get-started/ai-tools-and-resources/skills.mdx @@ -0,0 +1,50 @@ +--- +id: ai-tools-and-resources/skills +title: Skills +navLabel: Skills +description: Plain Markdown instruction files that give your AI a workflow to follow, for building GoA services +section: ai-tools-and-resources +order: 3 +status: published +--- +import DropInCallout from "../../../components/DropInCallout.astro"; +import { CodeSnippet } from "../../../components/CodeSnippet"; +import { withBase } from "@/lib/base-url"; + +Skills +Plain Markdown instruction files give your AI a workflow to follow when the work matches. They follow the open SKILL.md standard, so the same files work across Claude Code, Cursor, Copilot, and more. + + + Skills are one tool in a larger AI toolset. For the overview of all available AI tools and how they fit together, see AI tools and resources. + + +Available skills + +using-goa-design-system +This skill identifies the right product type, page and section templates, and components for what you're building. Describe what you're working on in plain language (a public form or a case-management tool, for example), and it pulls the relevant structure and live component specs from the MCP. +It loads when you're scoping or building a screen, page, or feature, so your AI follows the Design System's structure from the start rather than guessing. +How to add it: + +

View on GitHub

+ +content-design +This skill writes user-facing copy tailored to its reader (a citizen or a worker) because the same message requires a different approach for each audience. +It loads when you're working on the words in a service: labels, guidance, errors, empty states, and notifications. +How to add it: + +

View on GitHub

+ +How they work +Each skill is a folder with a SKILL.md file plus any supporting notes. Your AI reads the short description at startup and loads the full instructions only when your request matches, so they add capability without crowding the context. +Because they follow the open Agent Skills standard, the same files work in Claude Code, Cursor, GitHub Copilot, and other tools that read the format. + +Updates +Skills track the dev branch of the GoA Design System repository. To keep them current, run: + +To update automatically, add npx skills update -g -y to a SessionStart hook in your AI tool and they refresh every session. You'll only get an update when a skill itself changes, not every time something else in the repo changes. + + + Using a tool without the skills CLI? Clone a skill folder from the skills directory into your tool's skills folder, for example ~/.claude/skills, .cursor/skills, or .github/skills. + + + diff --git a/docs/src/content/get-started/contribute.mdx b/docs/src/content/get-started/contribute.mdx index 8e6f4e0437..ea77f0e1e9 100644 --- a/docs/src/content/get-started/contribute.mdx +++ b/docs/src/content/get-started/contribute.mdx @@ -2,8 +2,8 @@ id: contribute title: Contribute description: How to contribute to the GoA Design System -section: appendix -order: 2 +section: contribute +order: 1 status: published --- import { withBase } from "@/lib/base-url"; diff --git a/docs/src/content/get-started/out-of-support.mdx b/docs/src/content/get-started/out-of-support.mdx index fa61fea200..ab9e254813 100644 --- a/docs/src/content/get-started/out-of-support.mdx +++ b/docs/src/content/get-started/out-of-support.mdx @@ -2,8 +2,8 @@ id: out-of-support title: Out of support versions description: Design System versions that are no longer supported -section: appendix -order: 3 +section: out-of-support +order: 1 status: published --- import { withBase } from "@/lib/base-url"; diff --git a/docs/src/content/get-started/qa-testing.mdx b/docs/src/content/get-started/qa-testing.mdx index edb2d726e1..df80b892a3 100644 --- a/docs/src/content/get-started/qa-testing.mdx +++ b/docs/src/content/get-started/qa-testing.mdx @@ -2,7 +2,7 @@ id: qa-testing title: QA testing description: Testing process for Design System components -section: appendix +section: qa-testing order: 1 status: published --- diff --git a/docs/src/lib/get-started-nav.ts b/docs/src/lib/get-started-nav.ts index d73df4cb40..73c67d4e81 100644 --- a/docs/src/lib/get-started-nav.ts +++ b/docs/src/lib/get-started-nav.ts @@ -4,11 +4,12 @@ * Builds the Get Started submenu structure from the content collection. * Used by layouts to pass nav data to SiteNav -> GetStartedSubMenu. * - * Section convention: - * - "intro" => top-level pages above grouped sections - * - "designers" => Designers group - * - "developers" => Developers group - * - "appendix" => top-level pages below grouped sections + * Each section in the submenu is either: + * - "flat" — a list of items rendered without a group header + * - "grouped" — items rendered inside an expandable group with a heading + * + * Sections appear in `SECTION_ORDER` regardless of type, so flat and grouped + * sections can interleave freely. */ import { getCollection } from "astro:content"; @@ -18,26 +19,37 @@ export interface GetStartedNavItem { url: string; } -export interface GetStartedNavGroup { - name: string; +export interface GetStartedNavSection { slug: string; + type: "flat" | "grouped"; + name?: string; pages: GetStartedNavItem[]; } export interface GetStartedNav { - topPages: GetStartedNavItem[]; - groups: GetStartedNavGroup[]; - bottomPages: GetStartedNavItem[]; + sections: GetStartedNavSection[]; } -/** Display metadata for the grouped sections (not stored in content collection) */ -const GROUP_META: Record = { - designers: { name: "Designers" }, - developers: { name: "Developers" }, -}; +/** Display order of sections in the submenu (top to bottom). */ +const SECTION_ORDER = [ + "intro", + "designers", + "developers", + "qa-testing", + "ai-tools-and-resources", + "contribute", + "out-of-support", +]; -/** Canonical display order for grouped sections in the submenu */ -const GROUP_ORDER = ["designers", "developers"]; +/** + * Sections that render with a group heading. Any section listed here is + * "grouped"; everything else in `SECTION_ORDER` is "flat". + */ +const SECTION_NAMES: Record = { + designers: "Designers", + developers: "Developers", + "ai-tools-and-resources": "AI tools and resources", +}; function entryToItem(entry: { slug: string; @@ -62,16 +74,17 @@ export async function getGetStartedNav(): Promise { const entries = await getCollection("get-started"); const published = entries.filter((e) => e.data.status !== "deprecated"); - const topPages = bySection(published, "intro").map(entryToItem); - const bottomPages = bySection(published, "appendix").map(entryToItem); - - const groups = GROUP_ORDER.filter((slug) => - published.some((e) => e.data.section === slug), - ).map((slug) => ({ - name: GROUP_META[slug].name, - slug, - pages: bySection(published, slug).map(entryToItem), - })); + const sections: GetStartedNavSection[] = SECTION_ORDER + .filter((slug) => published.some((e) => e.data.section === slug)) + .map((slug) => { + const isGrouped = slug in SECTION_NAMES; + return { + slug, + type: isGrouped ? "grouped" : "flat", + name: isGrouped ? SECTION_NAMES[slug] : undefined, + pages: bySection(published, slug).map(entryToItem), + }; + }); - return { topPages, groups, bottomPages }; + return { sections }; } From b7f0716de390183f49a095e29553e8c87d71ce4d Mon Sep 17 00:00:00 2001 From: Mark E <51723535+Spark450@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:12:00 -0700 Subject: [PATCH 22/22] fix(#3683): vertically center text values for date and time input types (#4035) * fix(#3683): vertically center text values for date and time input types Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Mark E <51723535+Spark450@users.noreply.github.com> --- .../routes/bugs/3683/bug3683.component.html | 32 ++++++++ .../src/routes/bugs/3683/bug3683.component.ts | 17 ++++ .../src/routes/bugs/3683/bug3683.route.json | 6 ++ .../src/app/routes/bugs/bug3683.route.ts | 10 +++ apps/prs/react/src/routes/bugs/bug3683.tsx | 79 +++++++++++++++++++ docs/public/search-index.json | 42 +++++++++- .../src/components/input/Input.svelte | 11 +++ 7 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 apps/prs/angular/src/routes/bugs/3683/bug3683.component.html create mode 100644 apps/prs/angular/src/routes/bugs/3683/bug3683.component.ts create mode 100644 apps/prs/angular/src/routes/bugs/3683/bug3683.route.json create mode 100644 apps/prs/react/src/app/routes/bugs/bug3683.route.ts create mode 100644 apps/prs/react/src/routes/bugs/bug3683.tsx diff --git a/apps/prs/angular/src/routes/bugs/3683/bug3683.component.html b/apps/prs/angular/src/routes/bugs/3683/bug3683.component.html new file mode 100644 index 0000000000..36eb088eea --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3683/bug3683.component.html @@ -0,0 +1,32 @@ + + Bug #3683: Input date/time vertical alignment + + The text value in date and time input types should be vertically centered in the + input box, matching how type=text renders. Compare each type below against the + reference text input. + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/prs/angular/src/routes/bugs/3683/bug3683.component.ts b/apps/prs/angular/src/routes/bugs/3683/bug3683.component.ts new file mode 100644 index 0000000000..88c41be119 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3683/bug3683.component.ts @@ -0,0 +1,17 @@ +import { Component } from "@angular/core"; +import { GoabBlock, GoabFormItem, GoabInput, GoabText } from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug3683", + templateUrl: "./bug3683.component.html", + imports: [GoabBlock, GoabFormItem, GoabInput, GoabText], +}) +export class Bug3683Component { + dateVal = "2025-06-09"; + timeVal = "09:30"; + datetimeVal = "2025-06-09T09:30"; + monthVal = "2025-06"; + weekVal = "2025-W23"; + textVal = "Reference text value"; +} diff --git a/apps/prs/angular/src/routes/bugs/3683/bug3683.route.json b/apps/prs/angular/src/routes/bugs/3683/bug3683.route.json new file mode 100644 index 0000000000..7687ff9b07 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3683/bug3683.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "3683", + "path": "bugs/3683", + "title": "Input date/time vertical alignment" +} diff --git a/apps/prs/react/src/app/routes/bugs/bug3683.route.ts b/apps/prs/react/src/app/routes/bugs/bug3683.route.ts new file mode 100644 index 0000000000..51a5d08b32 --- /dev/null +++ b/apps/prs/react/src/app/routes/bugs/bug3683.route.ts @@ -0,0 +1,10 @@ +import Bug3683Route from "../../../routes/bugs/bug3683"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "bug", + id: "3683", + path: "bugs/3683", + title: "Input date/time vertical alignment", + component: Bug3683Route, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/routes/bugs/bug3683.tsx b/apps/prs/react/src/routes/bugs/bug3683.tsx new file mode 100644 index 0000000000..0a36bb9ec8 --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug3683.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { GoabFormItem, GoabInput, GoabBlock, GoabText } from "@abgov/react-components"; +import type { GoabInputOnChangeDetail } from "@abgov/ui-components-common"; + +export function Bug3683Route() { + const [dateVal, setDateVal] = useState("2025-06-09"); + const [timeVal, setTimeVal] = useState("09:30"); + const [datetimeVal, setDatetimeVal] = useState("2025-06-09T09:30"); + const [monthVal, setMonthVal] = useState("2025-06"); + const [weekVal, setWeekVal] = useState("2025-W23"); + const [textVal, setTextVal] = useState("Reference text value"); + + return ( + + Bug #3683: Input date/time vertical alignment + + The text value in date and time input types should be vertically centered in the + input box, matching how type=text renders. Compare each type below against the + reference text input. + + + + setTextVal(d.value)} + /> + + + + setDateVal(d.value)} + /> + + + + setTimeVal(d.value)} + /> + + + + setDatetimeVal(d.value)} + /> + + + + setMonthVal(d.value)} + /> + + + + setWeekVal(d.value)} + /> + + + ); +} + +export default Bug3683Route; diff --git a/docs/public/search-index.json b/docs/public/search-index.json index f5c71b4195..1dbc8888b2 100644 --- a/docs/public/search-index.json +++ b/docs/public/search-index.json @@ -2320,6 +2320,42 @@ "aliases": [], "slug": "workspace/index-page" }, + { + "type": "page", + "id": "ai-tools-and-resources/goa-design-system-mcp", + "title": "GoA Design System MCP", + "description": "A live lookup tool your AI uses for component APIs, examples, and guidance", + "status": "published", + "category": "get started", + "tags": [ + "ai-tools-and-resources" + ], + "slug": "get-started/ai-tools-and-resources/goa-design-system-mcp" + }, + { + "type": "page", + "id": "ai-tools-and-resources/skills", + "title": "Skills", + "description": "Plain Markdown instruction files that give your AI a workflow to follow, for building GoA services", + "status": "published", + "category": "get started", + "tags": [ + "ai-tools-and-resources" + ], + "slug": "get-started/ai-tools-and-resources/skills" + }, + { + "type": "page", + "id": "ai-tools-and-resources", + "title": "AI tools and resources", + "description": "AI tools and resources to help you and your AI work better with the GoA Design System", + "status": "published", + "category": "get started", + "tags": [ + "ai-tools-and-resources" + ], + "slug": "get-started/ai-tools-and-resources" + }, { "type": "page", "id": "automated-accessibility", @@ -2352,7 +2388,7 @@ "status": "published", "category": "get started", "tags": [ - "appendix" + "contribute" ], "slug": "get-started/contribute" }, @@ -2531,7 +2567,7 @@ "status": "published", "category": "get started", "tags": [ - "appendix" + "out-of-support" ], "slug": "get-started/out-of-support" }, @@ -2543,7 +2579,7 @@ "status": "published", "category": "get started", "tags": [ - "appendix" + "qa-testing" ], "slug": "get-started/qa-testing" }, diff --git a/libs/web-components/src/components/input/Input.svelte b/libs/web-components/src/components/input/Input.svelte index 2cfa646841..cfaf1737d8 100644 --- a/libs/web-components/src/components/input/Input.svelte +++ b/libs/web-components/src/components/input/Input.svelte @@ -572,6 +572,17 @@ box-shadow: var(--goa-text-input-border-focus); } + + /* V2: Vertically center date/time input labels in Safari */ + .container.v2 input::-webkit-datetime-edit, + .container.v2 input::-webkit-date-and-time-value { + display: flex; + align-items: center; + height: 100%; + padding-block: 0; + margin: 0; + } + /* type=range does not have an outline/box-shadow */ .goa-input.type--range { border: none;