@radicool/throughline 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/adapters/codex/AGENTS.md +11 -10
  3. package/adapters/codex/prompts/component-builder.md +107 -0
  4. package/adapters/codex/prompts/design-system-audit.md +20 -0
  5. package/adapters/codex/prompts/document-component.md +58 -0
  6. package/adapters/codex/prompts/repository-builder.md +14 -0
  7. package/adapters/codex/prompts/retrofit-planner.md +21 -1
  8. package/adapters/codex/prompts/storybook-chromatic-builder.md +84 -2
  9. package/adapters/cursor/.cursor/commands/document-component.md +58 -0
  10. package/adapters/cursor/.cursor/rules/component-builder.mdc +108 -1
  11. package/adapters/cursor/.cursor/rules/component-pipeline.mdc +1 -1
  12. package/adapters/cursor/.cursor/rules/design-system-audit.mdc +21 -1
  13. package/adapters/cursor/.cursor/rules/figma-environment-setup.mdc +1 -1
  14. package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +1 -1
  15. package/adapters/cursor/.cursor/rules/repository-builder.mdc +15 -1
  16. package/adapters/cursor/.cursor/rules/retrofit-planner.mdc +22 -2
  17. package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +85 -3
  18. package/adapters/cursor/.cursor/rules/token-builder.mdc +1 -1
  19. package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +1 -1
  20. package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +1 -1
  21. package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +1 -1
  22. package/adapters/generic/AGENTS.md +11 -10
  23. package/adapters/generic/commands/document-component.md +58 -0
  24. package/adapters/generic/skills/component-builder/SKILL.md +107 -0
  25. package/adapters/generic/skills/design-system-audit/SKILL.md +20 -0
  26. package/adapters/generic/skills/repository-builder/SKILL.md +14 -0
  27. package/adapters/generic/skills/retrofit-planner/SKILL.md +21 -1
  28. package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +84 -2
  29. package/package.json +1 -1
  30. package/references/component-doc-archetypes.md +90 -0
  31. package/references/component-doc-schema.md +154 -0
  32. package/references/doc-card-builder.md +565 -0
  33. package/references/doc-writing-standard.md +144 -0
  34. package/references/figma-component-standards.md +63 -16
  35. package/references/guide-voice.md +96 -0
  36. package/references/manifest-schema.md +46 -6
  37. package/scripts/README.md +34 -0
  38. package/scripts/build-doc-card-builder.mjs +143 -0
  39. package/scripts/build-docs-digest.mjs +74 -0
  40. package/scripts/docs-check.mjs +117 -0
  41. package/scripts/docs-lint.mjs +163 -0
  42. package/scripts/install.mjs +13 -1
  43. package/scripts/lib/doc-card-plan.mjs +101 -0
  44. package/scripts/lib/doc-card-render.figma.js +371 -0
  45. package/scripts/lib/doc-record.mjs +54 -0
@@ -0,0 +1,143 @@
1
+ // Generates references/doc-card-builder.md — the canonical figma_execute
2
+ // snippet that renders a doc card's Usage band — by inlining the pure planner
3
+ // (lib/doc-card-plan.mjs) above the Figma renderer template
4
+ // (lib/doc-card-render.figma.js). Mirrors the adapters generate.mjs idiom:
5
+ // run bare to write, run with --check to gate CI. Zero dependencies.
6
+ import { readFileSync, writeFileSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+
10
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const PLANNER = join(REPO_ROOT, 'scripts', 'lib', 'doc-card-plan.mjs');
12
+ const RENDERER = join(REPO_ROOT, 'scripts', 'lib', 'doc-card-render.figma.js');
13
+ const OUT = join(REPO_ROOT, 'references', 'doc-card-builder.md');
14
+
15
+ const HEADER = [
16
+ '# Doc-card Usage-band builder (GENERATED)',
17
+ '',
18
+ '> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/doc-card-plan.mjs`',
19
+ '> (the pure planner, unit-tested in Node) + `scripts/lib/doc-card-render.figma.js`',
20
+ '> (the Figma renderer). Regenerate with `node scripts/build-doc-card-builder.mjs`;',
21
+ '> CI gates freshness with `--check`.',
22
+ '',
23
+ 'The canonical `figma_execute` snippet that renders a component doc card\'s',
24
+ '`Usage` band from its `.doc.json` record. Every card is identical by',
25
+ 'construction — never hand-build the usage body. The builder owns the `Usage`',
26
+ 'band and the header\'s record-derived content (its short description and date);',
27
+ 'it reads the specimen and never writes it. The status chip keeps its own owner',
28
+ '— the finalize write-back in `references/figma-component-standards.md`.',
29
+ '',
30
+ '## How to call it',
31
+ '',
32
+ '1. Load the record and compute its canonical fingerprint in Node',
33
+ ' (`canonicalFingerprint` in `scripts/lib/doc-record.mjs`).',
34
+ '2. Read `figma.docCardVariables` from `design-system.json`.',
35
+ ' - If present, resolve each of the nine roles to a Variable object **by',
36
+ ' the recorded name** — do not re-derive, do not substitute a similar',
37
+ ' name. Look each name up via `figma_get_variables`, then in the script',
38
+ ' fetch it as a Variable object with',
39
+ ' `figma.variables.getVariableByIdAsync(id)`. If a recorded name no',
40
+ ' longer resolves to exactly one variable in the file, **throw** rather',
41
+ ' than guess — the token was renamed or removed, and silently picking a',
42
+ ' neighbour is how cards drift apart.',
43
+ ' - If the field is absent (a project\'s first doc-card render, or any',
44
+ ' render after the field is cleared), do not resolve fresh by judgement',
45
+ ' yet — first check whether a doc card already exists in the file. If',
46
+ ' one does, recover all nine roles from it by resolving each bound',
47
+ ' variable id back to its name (`figma.variables.getVariableByIdAsync(id)`):',
48
+ ' - `spacePadding` ← the `Usage` frame\'s `paddingLeft`.',
49
+ ' - `spaceRowGap` ← the `Usage` frame\'s `itemSpacing`.',
50
+ ' - `spaceBlockGap` ← a `Usage Row *` frame\'s `itemSpacing`.',
51
+ ' - `spaceItemGap` ← a `Block: *` frame\'s `itemSpacing` (blocks are the',
52
+ ' children of a `Usage Row *`).',
53
+ ' - `border` ← a `Row Divider` frame\'s',
54
+ ' `fills[0].boundVariables.color`.',
55
+ ' - `tonePositive` ← the first TEXT child of the `Block: Do` frame\'s',
56
+ ' `fills[0].boundVariables.color`.',
57
+ ' - `toneNegative` ← the first TEXT child of the `Block: Don\'t` frame,',
58
+ ' same property.',
59
+ ' - `textMuted` ← the first TEXT child of any block other than',
60
+ ' `Block: Do` / `Block: Don\'t`, same property (tone blocks colour',
61
+ ' their eyebrow differently, so exclude them here).',
62
+ ' - `textDefault` ← the second child of that same block when it is a',
63
+ ' TEXT node — `Block: Overview` is reliable; definition blocks nest',
64
+ ' frames there instead, so skip those. Same property.',
65
+ ' A single-row card has no `Row Divider` (no `border`); a card without',
66
+ ' `Block: Do` / `Block: Don\'t` yields no `tonePositive` / `toneNegative`.',
67
+ ' Read another rendered card for the roles that specific card can\'t',
68
+ ' yield, or fall back to judgement for just those. Only when no',
69
+ ' rendered card exists at all does the caller choose every role by',
70
+ ' judgement — establishing the project\'s rhythm, not guessing at one.',
71
+ ' Either way, resolve the nine roles once, **write the mapping back to',
72
+ ' `design-system.json`** as `figma.docCardVariables`, then render. Every',
73
+ ' later render reads it.',
74
+ ' The nine roles: `textDefault`, `textMuted` (text colors), `tonePositive`,',
75
+ ' `toneNegative` (Do/Don\'t eyebrow colors — success/danger roles), `border`',
76
+ ' (row dividers), `spacePadding`, `spaceRowGap`, `spaceBlockGap`,',
77
+ ' `spaceItemGap` (spacing roles: band padding, row gap, block gutter,',
78
+ ' within-block gap).',
79
+ '3. Find the body text style: `(await figma.getLocalTextStylesAsync())',
80
+ ' .find((s) => s.name === \'Body/Default\')`. Missing variables or style =',
81
+ ' the builder throws (bind-or-throw — the gap is in the token set; fix it',
82
+ ' there, never hardcode around it).',
83
+ '4. Prepend the two slots, then the snippet below, then the call:',
84
+ '',
85
+ '```js',
86
+ 'const RECORD = /* the parsed .doc.json object */;',
87
+ 'const CANONICAL_FP = \'/* canonicalFingerprint(RECORD), 16 hex chars */\';',
88
+ '// … the generated snippet …',
89
+ 'const card = await figma.getNodeByIdAsync(cardNodeId);',
90
+ 'const summary = await renderDocCard({ card, record: RECORD, vars, bodyTextStyle });',
91
+ '```',
92
+ '',
93
+ '5. Pass an explicit `timeout` (30000 is right for one card; the ~30s',
94
+ ' `figma_execute` ceiling fits a single card comfortably — render cards one',
95
+ ' call at a time, never batched).',
96
+ '6. Verify from the returned summary — `rowsRendered`, `blocksCreated`,',
97
+ ' `cardWidth` — not from a screenshot, then stamp the manifest from it:',
98
+ ' `surfaces.docCard = { src: summary.fingerprint, render: summary.renderHash,',
99
+ ' renderer: summary.rendererVersion }`. Never re-read the card to stamp.',
100
+ '',
101
+ '## The snippet',
102
+ '',
103
+ '```js',
104
+ ].join('\n');
105
+
106
+ const FOOTER = [
107
+ '```',
108
+ '',
109
+ 'Layout contract and rationale:',
110
+ '`docs/superpowers/specs/2026-08-09-doc-card-layout-and-voice-design.md`.',
111
+ '',
112
+ ].join('\n');
113
+
114
+ export function buildDocCardBuilder({ plannerSource, rendererSource }) {
115
+ const inlined = plannerSource
116
+ .replace(/^export const /gm, 'const ')
117
+ .replace(/^export function /gm, 'function ');
118
+ for (const [name, src] of [['doc-card-plan.mjs', inlined], ['doc-card-render.figma.js', rendererSource]]) {
119
+ if (/^\s*(import|export)\b/m.test(src)) {
120
+ throw new Error(`${name} must stay import-free (only top-level \`export const\`/\`export function\` allowed in the planner) — it is inlined into the Figma snippet where no module system exists`);
121
+ }
122
+ }
123
+ return `${HEADER}\n${inlined}\n${rendererSource}${FOOTER}`;
124
+ }
125
+
126
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
127
+ const result = buildDocCardBuilder({
128
+ plannerSource: readFileSync(PLANNER, 'utf8'),
129
+ rendererSource: readFileSync(RENDERER, 'utf8'),
130
+ });
131
+ if (process.argv.includes('--check')) {
132
+ let onDisk = null;
133
+ try { onDisk = readFileSync(OUT, 'utf8'); } catch (e) { /* missing counts as drift */ }
134
+ if (onDisk !== result) {
135
+ console.error('✗ references/doc-card-builder.md out of date; run: node scripts/build-doc-card-builder.mjs');
136
+ process.exit(1);
137
+ }
138
+ console.log('✓ doc-card builder in sync');
139
+ } else {
140
+ writeFileSync(OUT, result);
141
+ console.log('✓ wrote references/doc-card-builder.md');
142
+ }
143
+ }
@@ -0,0 +1,74 @@
1
+ // docs:digest — aggregates every component doc record into two AI-facing
2
+ // artifacts: index.json (machine map) and llms.txt (narrative index).
3
+ // Zero dependencies.
4
+ //
5
+ // Usage: node build-docs-digest.mjs [--root <dir>]
6
+ import { readdirSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { parseArgs } from 'node:util';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { loadRecord } from './lib/doc-record.mjs';
11
+
12
+ const DOCS_DIR = join('design-system', 'docs');
13
+ const COMPONENTS_DIR = join(DOCS_DIR, 'components');
14
+
15
+ export function buildIndex(records) {
16
+ return {
17
+ generatedFrom: 'design-system/docs/components/*.doc.json',
18
+ components: records.map((r) => ({
19
+ name: r.name,
20
+ summary: r.summary ?? '',
21
+ description: r.description ?? '',
22
+ whenToUse: r.whenToUse ?? [],
23
+ whenNotToUse: r.whenNotToUse ?? [],
24
+ variants: r.variants ?? {},
25
+ states: r.states ?? {},
26
+ dos: r.dos ?? [],
27
+ donts: r.donts ?? [],
28
+ accessibility: r.accessibility ?? {},
29
+ tokensUsed: r.tokensUsed ?? [],
30
+ status: r.status ?? 'draft',
31
+ })),
32
+ };
33
+ }
34
+
35
+ export function buildLlmsTxt(records) {
36
+ const lines = ['# Design system — component usage guide', ''];
37
+ lines.push('Generated documentation for AI and human consumers. One section per component.', '');
38
+ for (const r of records) {
39
+ lines.push(`## ${r.name}`, '');
40
+ if (r.summary) lines.push(r.summary, '');
41
+ if (r.description) lines.push(r.description, '');
42
+ if ((r.whenToUse ?? []).length) lines.push('**When to use:** ' + r.whenToUse.join('; '));
43
+ if ((r.whenNotToUse ?? []).length) lines.push('**When not to use:** ' + r.whenNotToUse.join('; '));
44
+ if ((r.dos ?? []).length) lines.push('**Do:** ' + r.dos.join('; '));
45
+ if ((r.donts ?? []).length) lines.push("**Don't:** " + r.donts.join('; '));
46
+ if ((r.tokensUsed ?? []).length) lines.push('**Tokens:** ' + r.tokensUsed.join(', '));
47
+ lines.push('');
48
+ }
49
+ return lines.join('\n');
50
+ }
51
+
52
+ export function loadAllRecords(root) {
53
+ const dir = join(root, COMPONENTS_DIR);
54
+ if (!existsSync(dir)) return [];
55
+ return readdirSync(dir)
56
+ .filter((f) => f.endsWith('.doc.json'))
57
+ .sort()
58
+ .map((f) => loadRecord(join(dir, f)));
59
+ }
60
+
61
+ function main() {
62
+ const { values } = parseArgs({ options: { root: { type: 'string', default: '.' } } });
63
+ const root = values.root;
64
+ const records = loadAllRecords(root);
65
+ const outDir = join(root, DOCS_DIR);
66
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
67
+ writeFileSync(join(outDir, 'index.json'), JSON.stringify(buildIndex(records), null, 2) + '\n');
68
+ writeFileSync(join(outDir, 'llms.txt'), buildLlmsTxt(records));
69
+ console.log(`✓ docs:digest — ${records.length} component(s) → design-system/docs/{index.json,llms.txt}`);
70
+ }
71
+
72
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
73
+ main();
74
+ }
@@ -0,0 +1,117 @@
1
+ // docs:check — the documentation drift gate. Compares each component's canonical
2
+ // record and its rendered surfaces against the fingerprints recorded in
3
+ // design-system.json, and reports drift. Zero dependencies.
4
+ //
5
+ // Drift classes: canonical-changed | stale | edited | missing-surface | edit-unverified
6
+ // | layout-upgrade-available
7
+ // (edit-unverified = a surface the CLI cannot read, e.g. Figma — informational;
8
+ // it is checked live by the Figma-connected skill instead.
9
+ // missing-surface = a repo surface that declares a file which is now gone — failing;
10
+ // distinct from edit-unverified, which has no file to read in the first place.
11
+ // layout-upgrade-available = informational, docCard only: the card's layout
12
+ // predates DOC_CARD_RENDERER_VERSION — re-render on next touch, never a failure.)
13
+ //
14
+ // Usage: node docs-check.mjs [--root <dir>] (default root: cwd)
15
+ import { readFileSync, existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { parseArgs } from 'node:util';
18
+ import { pathToFileURL } from 'node:url';
19
+ import { loadRecord, canonicalFingerprint, fingerprint } from './lib/doc-record.mjs';
20
+ import { DOC_CARD_RENDERER_VERSION } from './lib/doc-card-plan.mjs';
21
+
22
+ // Surfaces whose rendered content the CLI can re-read from the repo.
23
+ const REPO_SURFACES = new Set(['storybookMdx']);
24
+
25
+ export function classifySurface({ currentCanonical, surface, currentRenderHash, fileMissing = false, expectedRenderer = null }) {
26
+ const flags = [];
27
+ if (surface.src !== currentCanonical) flags.push('stale');
28
+ if (fileMissing) {
29
+ flags.push('missing-surface');
30
+ } else if (currentRenderHash === null) {
31
+ flags.push('edit-unverified');
32
+ } else if (surface.render !== currentRenderHash) {
33
+ flags.push('edited');
34
+ }
35
+ if (expectedRenderer !== null
36
+ && (!surface.renderer || Number(surface.renderer) < Number(expectedRenderer))) {
37
+ flags.push('layout-upgrade-available');
38
+ }
39
+ return flags;
40
+ }
41
+
42
+ export function checkComponent({ name, meta, root }) {
43
+ const out = [];
44
+ const doc = meta && meta.doc;
45
+ if (!doc) return out;
46
+
47
+ const recordPath = join(root, doc.path);
48
+ if (!existsSync(recordPath)) {
49
+ out.push({ name, surface: 'canonical', flags: ['missing-record'] });
50
+ return out;
51
+ }
52
+ const currentCanonical = canonicalFingerprint(loadRecord(recordPath));
53
+ if (currentCanonical !== doc.fingerprint) {
54
+ out.push({ name, surface: 'canonical', flags: ['canonical-changed'] });
55
+ }
56
+
57
+ for (const [surfaceName, surface] of Object.entries(doc.surfaces || {})) {
58
+ let currentRenderHash = null;
59
+ let fileMissing = false;
60
+ if (REPO_SURFACES.has(surfaceName) && surface.file) {
61
+ const filePath = join(root, surface.file);
62
+ if (existsSync(filePath)) {
63
+ currentRenderHash = fingerprint(readFileSync(filePath, 'utf8'));
64
+ } else {
65
+ fileMissing = true;
66
+ }
67
+ }
68
+ const flags = classifySurface({
69
+ currentCanonical, surface, currentRenderHash, fileMissing,
70
+ expectedRenderer: surfaceName === 'docCard' ? DOC_CARD_RENDERER_VERSION : null,
71
+ });
72
+ if (flags.length) out.push({ name, surface: surfaceName, flags });
73
+ }
74
+ return out;
75
+ }
76
+
77
+ export function checkAll(manifest, root) {
78
+ const out = [];
79
+ const meta = (manifest && manifest.components && manifest.components.meta) || {};
80
+ for (const [name, m] of Object.entries(meta)) {
81
+ out.push(...checkComponent({ name, meta: m, root }));
82
+ }
83
+ return out;
84
+ }
85
+
86
+ const FAILING = new Set(['canonical-changed', 'stale', 'edited', 'missing-record', 'missing-surface']);
87
+
88
+ function main() {
89
+ const { values } = parseArgs({ options: { root: { type: 'string', default: '.' } } });
90
+ const root = values.root;
91
+ const manifestPath = join(root, 'design-system.json');
92
+ if (!existsSync(manifestPath)) {
93
+ console.error(`docs:check — no design-system.json at ${root}`);
94
+ process.exit(1);
95
+ }
96
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
97
+ const results = checkAll(manifest, root);
98
+
99
+ const drift = results.filter((r) => r.flags.some((f) => FAILING.has(f)));
100
+ const info = results.filter((r) => !r.flags.some((f) => FAILING.has(f)));
101
+
102
+ for (const r of drift) console.error(` ✗ ${r.name} · ${r.surface}: ${r.flags.join(', ')}`);
103
+ for (const r of info) {
104
+ const note = r.flags.includes('edit-unverified') ? ' (check in a Figma session)' : '';
105
+ console.log(` ~ ${r.name} · ${r.surface}: ${r.flags.join(', ')}${note}`);
106
+ }
107
+
108
+ if (drift.length) {
109
+ console.error(`✗ docs:check — ${drift.length} drifted surface(s); reconcile with /document-component`);
110
+ process.exit(1);
111
+ }
112
+ console.log(`✓ docs:check — no drift${info.length ? ` (${info.length} Figma surface(s) unverified)` : ''}`);
113
+ }
114
+
115
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
116
+ main();
117
+ }
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ // Copy lint for component doc records (.doc.json). Warnings only — findings
3
+ // never affect the exit code; the lint shapes a draft before user approval
4
+ // rather than gating CI. Rules: references/doc-writing-standard.md (pinned in
5
+ // the design spec's lint table).
6
+ //
7
+ // Usage: node docs-lint.mjs <path/to/Component.doc.json> [--json]
8
+ // Output: one warning per line — `<file>: <block-path>: <rule>: <message>`;
9
+ // --json emits {"warnings":[{path, rule, message}]}.
10
+ // Exit: 0 for any parseable record; 2 for unusable invocation.
11
+
12
+ import { readFileSync } from 'node:fs';
13
+ import { pathToFileURL } from 'node:url';
14
+
15
+ // Machinery vocabulary banned from user-facing prose — the system's own
16
+ // build-compliance language. Real names of things (aria-label, role, Enter)
17
+ // are not banned; readers search for those.
18
+ const MACHINERY = [
19
+ 'token', 'tokens', 'variable', 'variables', 'binding', 'bindings',
20
+ 'fingerprint', 'fingerprints', 'provenance', 'projection', 'projections',
21
+ 'surface', 'surfaces',
22
+ ];
23
+
24
+ // Visual-treatment words that must not LEAD a variant/state meaning.
25
+ const TREATMENT = [
26
+ 'fill', 'filled', 'solid', 'stroke', 'border', 'bordered', 'outline',
27
+ 'shadow', 'opacity', 'elevation',
28
+ ];
29
+
30
+ const STOPWORDS = new Set([
31
+ 'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'for',
32
+ 'with', 'as', 'at', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'it',
33
+ 'its', 'this', 'that', 'these', 'those', 'you', 'your', 'not', 'no', 'do',
34
+ 'does', 'did', 'has', 'have', 'had', 'can', 'could', 'should', 'would',
35
+ 'will', 'may', 'might', 'must', 'when', 'how', 'what', 'which', 'who',
36
+ 'while', 'than', 'then', 'so', 'if', 'into', 'onto', 'from', 'over',
37
+ 'under', 'up', 'down', 'out', 'about',
38
+ ]);
39
+
40
+ const words = (s) => String(s).toLowerCase().match(/[a-z0-9'’-]+/g) || [];
41
+ // Naive plural/verb-s stemming: enough to match "Triggers" to "trigger".
42
+ const stem = (w) => (w.length > 3 && w.endsWith('s') ? w.slice(0, -1) : w);
43
+
44
+ export function lintRecord(record) {
45
+ const warnings = [];
46
+ const warn = (path, rule, message) => warnings.push({ path, rule, message });
47
+
48
+ // Every user-facing prose field, as [block-path, text].
49
+ const prose = [];
50
+ if (typeof record.summary === 'string') prose.push(['summary', record.summary]);
51
+ if (typeof record.description === 'string') prose.push(['description', record.description]);
52
+ for (const key of ['whenToUse', 'whenNotToUse', 'dos', 'donts']) {
53
+ (Array.isArray(record[key]) ? record[key] : []).forEach((t, i) => prose.push([`${key}[${i}]`, t]));
54
+ }
55
+ const meanings = [];
56
+ for (const axis of Object.keys(record.variants || {})) {
57
+ for (const [term, meaning] of Object.entries(record.variants[axis] || {})) {
58
+ meanings.push([`variants.${axis}.${term}`, meaning]);
59
+ }
60
+ }
61
+ for (const [term, meaning] of Object.entries(record.states || {})) {
62
+ meanings.push([`states.${term}`, meaning]);
63
+ }
64
+ prose.push(...meanings);
65
+ const a11y = record.accessibility || {};
66
+ (Array.isArray(a11y.keyboard) ? a11y.keyboard : []).forEach((t, i) => prose.push([`accessibility.keyboard[${i}]`, t]));
67
+ (Array.isArray(a11y.notes) ? a11y.notes : []).forEach((t, i) => prose.push([`accessibility.notes[${i}]`, t]));
68
+
69
+ for (const [path, text] of prose) {
70
+ const ws = words(text);
71
+ const banned = MACHINERY.find((b) => ws.includes(b));
72
+ if (banned) {
73
+ warn(path, 'machinery-vocabulary',
74
+ `"${banned}" is the system's machinery vocabulary — describe the thing and how to use it, never how it was made`);
75
+ }
76
+ if (/`[^`]+`/.test(String(text))) {
77
+ warn(path, 'no-inline-code',
78
+ 'inline-code backticks render as literal characters on the doc card and are stripped from the Figma description — write the term as plain text');
79
+ }
80
+ for (const sentence of String(text).split(/[.!?]+/)) {
81
+ const n = words(sentence).length;
82
+ if (n > 35) warn(path, 'run-on-sentence', `sentence has ${n} words (max 35)`);
83
+ }
84
+ }
85
+
86
+ if (typeof record.summary === 'string') {
87
+ const n = words(record.summary).length;
88
+ if (n > 12) warn('summary', 'summary-length', `${n} words (max 12)`);
89
+ }
90
+
91
+ if (typeof record.description === 'string') {
92
+ const n = words(record.description).length;
93
+ if (n < 15 || n > 70) warn('description', 'description-length', `${n} words (want 15–70)`);
94
+ }
95
+
96
+ if (typeof record.summary === 'string' && Array.isArray(record.whenToUse)
97
+ && typeof record.whenToUse[0] === 'string') {
98
+ const summaryStems = [...new Set(
99
+ words(record.summary).filter((w) => !STOPWORDS.has(w)).map(stem),
100
+ )];
101
+ const targetStems = new Set(words(record.whenToUse[0]).map(stem));
102
+ if (summaryStems.length > 0) {
103
+ const matched = summaryStems.filter((s) => targetStems.has(s)).length;
104
+ const pct = matched / summaryStems.length;
105
+ if (pct > 0.6) {
106
+ warn('whenToUse[0]', 'summary-echo',
107
+ `${Math.round(pct * 100)}% of the summary's content words reappear — describe a situation, not the summary again`);
108
+ }
109
+ }
110
+ }
111
+
112
+ for (const key of ['dos', 'donts']) {
113
+ (Array.isArray(record[key]) ? record[key] : []).forEach((entry, i) => {
114
+ const path = `${key}[${i}]`;
115
+ const n = words(entry).length;
116
+ if (n > 14) warn(path, 'guidance-length', `${n} words (max 14)`);
117
+ if (!/\.$/.test(String(entry).trim())) {
118
+ warn(path, 'terminal-stop', 'end the entry with a full stop');
119
+ }
120
+ if (key === 'donts' && !/^(don['’]?t|do not|never|avoid)\b/i.test(String(entry).trim())) {
121
+ warn(path, 'dont-shape', "open with Don't / Never / Avoid and name the alternative");
122
+ }
123
+ });
124
+ }
125
+
126
+ for (const [path, meaning] of meanings) {
127
+ const ws = words(meaning);
128
+ if (ws.length < 3) {
129
+ warn(path, 'empty-meaning', `${ws.length} word(s) — say what it means, not just that it exists`);
130
+ }
131
+ if (ws.slice(0, 4).some((w) => TREATMENT.includes(w))) {
132
+ warn(path, 'treatment-lead', 'leads with visual treatment — lead with meaning; treatment is optional detail');
133
+ }
134
+ }
135
+
136
+ return warnings;
137
+ }
138
+
139
+ const invokedAsCli = process.argv[1]
140
+ && import.meta.url === pathToFileURL(process.argv[1]).href;
141
+ if (invokedAsCli) {
142
+ const args = process.argv.slice(2);
143
+ const asJson = args.includes('--json');
144
+ const file = args.find((a) => !a.startsWith('--'));
145
+ if (!file) {
146
+ console.error('usage: docs-lint.mjs <path/to/Component.doc.json> [--json]');
147
+ process.exit(2);
148
+ }
149
+ let record;
150
+ try {
151
+ record = JSON.parse(readFileSync(file, 'utf8'));
152
+ } catch (e) {
153
+ console.error(`docs-lint: cannot read ${file}: ${e.message}`);
154
+ process.exit(2);
155
+ }
156
+ const warnings = lintRecord(record);
157
+ if (asJson) {
158
+ console.log(JSON.stringify({ warnings }, null, 2));
159
+ } else {
160
+ for (const w of warnings) console.log(`${file}: ${w.path}: ${w.rule}: ${w.message}`);
161
+ }
162
+ process.exit(0);
163
+ }
@@ -99,6 +99,18 @@ function stagePayload(srcRoot, destRoot, skip) {
99
99
  return written;
100
100
  }
101
101
 
102
+ // Scripts that never run from a consuming repo: the installer itself, the
103
+ // doc-card builder generator, and the renderer template it inlines (the
104
+ // renderer reaches Figma pre-inlined inside references/doc-card-builder.md).
105
+ const PLUGIN_INTERNAL = new Set([
106
+ 'install.mjs',
107
+ 'build-doc-card-builder.mjs',
108
+ 'lib/doc-card-render.figma.js',
109
+ ]);
110
+
111
+ export const skipScript = (relPosix) =>
112
+ relPosix.startsWith('adapters/') || relPosix.endsWith('.test.mjs') || PLUGIN_INTERNAL.has(relPosix);
113
+
102
114
  export function install({ target, dir, pkgRoot = PKG_ROOT }) {
103
115
  if (!TARGETS.includes(target)) {
104
116
  throw new Error(`unknown target "${target}"; expected one of: ${TARGETS.join(', ')}`);
@@ -124,7 +136,7 @@ export function install({ target, dir, pkgRoot = PKG_ROOT }) {
124
136
  }
125
137
  const payload = [
126
138
  ...stagePayload(join(pkgRoot, 'references'), join(dir, BASE, 'references')),
127
- ...stagePayload(join(pkgRoot, 'scripts'), join(dir, BASE, 'scripts'), (r) => r.startsWith('adapters/') || r.endsWith('.test.mjs') || r === 'install.mjs'),
139
+ ...stagePayload(join(pkgRoot, 'scripts'), join(dir, BASE, 'scripts'), skipScript),
128
140
  ];
129
141
  return { target, dir, written, payload };
130
142
  }
@@ -0,0 +1,101 @@
1
+ // Pure layout planner for the component doc card's Usage band.
2
+ // ZERO imports, `export const`/`export function` only — this module is inlined
3
+ // verbatim into the generated Figma snippet (references/doc-card-builder.md) by
4
+ // build-doc-card-builder.mjs, so it must run in both Node and the Figma plugin
5
+ // sandbox. build-doc-card-builder.mjs enforces the no-imports rule.
6
+ //
7
+ // Layout contract: docs/superpowers/specs/2026-08-09-doc-card-layout-and-voice-design.md
8
+
9
+ // Single source of truth for the doc-card layout version. Imported by
10
+ // docs-check.mjs and embedded (via inlining) into the generated builder snippet.
11
+ export const DOC_CARD_RENDERER_VERSION = '4';
12
+
13
+ // columnUnit = clamp(round(bodyFontSize × 30), 280, 480) px.
14
+ // 30 ≈ 60ch × ~0.5em average glyph width for UI text faces. Layout chrome, not
15
+ // a design value — the one documented exception to the no-hardcoded-px rule.
16
+ export function columnUnit(bodyFontSize) {
17
+ return Math.min(480, Math.max(280, Math.round(bodyFontSize * 30)));
18
+ }
19
+
20
+ // columns = max(max blocks in any row, 3). Content alone decides: the grid
21
+ // never mints a column no row can fill, and never drops below the 3-unit floor.
22
+ // The specimen is deliberately NOT an input — the render widens the card, the
23
+ // card's hug propagates into FILL siblings including the specimen, so any
24
+ // specimen measurement is a value this render mutates and the next one reads.
25
+ export function cardColumns(maxBlocksPerRow) {
26
+ return Math.max(3, maxBlocksPerRow);
27
+ }
28
+
29
+ function listBlock(eyebrow, items) {
30
+ if (!Array.isArray(items) || items.length === 0) return null;
31
+ return { type: 'list', name: `Block: ${eyebrow}`, eyebrow, items };
32
+ }
33
+
34
+ function definitionBlock(eyebrow, meanings) {
35
+ const terms = Object.keys(meanings || {}).map((k) => ({ term: k, meaning: meanings[k] }));
36
+ if (terms.length === 0) return null;
37
+ return { type: 'definition', name: `Block: ${eyebrow}`, eyebrow, terms };
38
+ }
39
+
40
+ // The whole layout decision, as data. Rows keep canonical numbering (an absent
41
+ // row's number is skipped, never renumbered) so node names stay stable across
42
+ // sparse records. bodyTextStyle: only .fontSize is read — passing a full Figma
43
+ // TextStyle object is fine.
44
+ export function planDocCard(record, bodyTextStyle) {
45
+ const unit = columnUnit(bodyTextStyle.fontSize);
46
+
47
+ const row1 = [];
48
+ if (typeof record.description === 'string' && record.description.trim() !== '') {
49
+ row1.push({ type: 'prose', name: 'Block: Overview', eyebrow: 'Overview', text: record.description });
50
+ }
51
+ const whenTo = listBlock('When to use', record.whenToUse);
52
+ if (whenTo) row1.push(whenTo);
53
+ const whenNot = listBlock('When not to use', record.whenNotToUse);
54
+ if (whenNot) row1.push(whenNot);
55
+
56
+ const row2 = [];
57
+ if (Array.isArray(record.dos) && record.dos.length) {
58
+ row2.push({ type: 'list-tone', name: 'Block: Do', eyebrow: '✓ Do', tone: 'positive', items: record.dos });
59
+ }
60
+ if (Array.isArray(record.donts) && record.donts.length) {
61
+ row2.push({ type: 'list-tone', name: "Block: Don't", eyebrow: "✕ Don't", tone: 'negative', items: record.donts });
62
+ }
63
+
64
+ const row3 = [];
65
+ for (const axis of Object.keys(record.variants || {})) {
66
+ const block = definitionBlock(`What each ${axis} means`, record.variants[axis]);
67
+ if (block) row3.push(block);
68
+ }
69
+ const stateBlock = definitionBlock('What each state means', record.states);
70
+ if (stateBlock) row3.push(stateBlock);
71
+ const a11y = record.accessibility || {};
72
+ // role is not rendered on the card — it lives in the description field / MDX.
73
+ const a11yBlock = listBlock('Accessibility', [...(a11y.keyboard || []), ...(a11y.notes || [])]);
74
+ if (a11yBlock) row3.push(a11yBlock);
75
+
76
+ const rows = [
77
+ { name: 'Usage Row 1', blocks: row1 },
78
+ { name: 'Usage Row 2', blocks: row2 },
79
+ { name: 'Usage Row 3', blocks: row3 },
80
+ ].filter((r) => r.blocks.length > 0);
81
+
82
+ const maxBlocksPerRow = rows.reduce((m, r) => Math.max(m, r.blocks.length), 0);
83
+ const columns = cardColumns(maxBlocksPerRow);
84
+
85
+ return {
86
+ rendererVersion: DOC_CARD_RENDERER_VERSION,
87
+ columnUnit: unit,
88
+ columns,
89
+ cardWidth: columns * unit,
90
+ termColumn: Math.round(unit * 0.3),
91
+ // The header band's record-derived content. Carried in the plan (not read
92
+ // straight off the record by the renderer) so renderHash describes every
93
+ // string the builder writes onto the card, header included. Always strings:
94
+ // an undefined would drop the key from JSON.stringify and move the hash.
95
+ header: {
96
+ summary: typeof record.summary === 'string' ? record.summary : '',
97
+ updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : '',
98
+ },
99
+ rows,
100
+ };
101
+ }