@radicool/throughline 0.14.0 → 0.16.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 (49) hide show
  1. package/README.md +2 -1
  2. package/adapters/codex/AGENTS.md +10 -10
  3. package/adapters/codex/prompts/component-builder.md +59 -15
  4. package/adapters/codex/prompts/document-component.md +42 -10
  5. package/adapters/codex/prompts/storybook-chromatic-builder.md +52 -9
  6. package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
  7. package/adapters/codex/prompts/token-sync-layer.md +64 -4
  8. package/adapters/cursor/.cursor/commands/document-component.md +42 -10
  9. package/adapters/cursor/.cursor/rules/component-builder.mdc +60 -16
  10. package/adapters/cursor/.cursor/rules/component-pipeline.mdc +1 -1
  11. package/adapters/cursor/.cursor/rules/design-system-audit.mdc +1 -1
  12. package/adapters/cursor/.cursor/rules/figma-environment-setup.mdc +1 -1
  13. package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +1 -1
  14. package/adapters/cursor/.cursor/rules/repository-builder.mdc +1 -1
  15. package/adapters/cursor/.cursor/rules/retrofit-planner.mdc +1 -1
  16. package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +53 -10
  17. package/adapters/cursor/.cursor/rules/token-builder.mdc +1 -1
  18. package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +3 -1
  19. package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +1 -1
  20. package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +65 -5
  21. package/adapters/generic/AGENTS.md +10 -10
  22. package/adapters/generic/commands/document-component.md +42 -10
  23. package/adapters/generic/skills/component-builder/SKILL.md +59 -15
  24. package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +52 -9
  25. package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
  26. package/adapters/generic/skills/token-sync-layer/SKILL.md +64 -4
  27. package/package.json +1 -1
  28. package/references/component-doc-archetypes.md +15 -11
  29. package/references/component-doc-schema.md +23 -5
  30. package/references/doc-card-builder.md +565 -0
  31. package/references/doc-writing-standard.md +144 -0
  32. package/references/figma-component-standards.md +63 -16
  33. package/references/guide-voice.md +96 -0
  34. package/references/manifest-schema.md +24 -6
  35. package/references/native-adapter-config.md +930 -0
  36. package/references/sync-adapters.md +94 -12
  37. package/scripts/README.md +37 -3
  38. package/scripts/build-doc-card-builder.mjs +143 -0
  39. package/scripts/build-native-adapter-config.mjs +280 -0
  40. package/scripts/docs-check.mjs +18 -4
  41. package/scripts/docs-lint.mjs +163 -0
  42. package/scripts/install.mjs +14 -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/dtcg.mjs +87 -0
  46. package/scripts/lib/native-literal.mjs +205 -0
  47. package/scripts/lib/sd-native.mjs +770 -0
  48. package/scripts/validate-crosswalk.mjs +3 -29
  49. package/scripts/validate-token-output.mjs +338 -0
@@ -3,10 +3,13 @@
3
3
  // design-system.json, and reports drift. Zero dependencies.
4
4
  //
5
5
  // Drift classes: canonical-changed | stale | edited | missing-surface | edit-unverified
6
+ // | layout-upgrade-available
6
7
  // (edit-unverified = a surface the CLI cannot read, e.g. Figma — informational;
7
8
  // it is checked live by the Figma-connected skill instead.
8
9
  // missing-surface = a repo surface that declares a file which is now gone — failing;
9
- // distinct from edit-unverified, which has no file to read in the first place.)
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.)
10
13
  //
11
14
  // Usage: node docs-check.mjs [--root <dir>] (default root: cwd)
12
15
  import { readFileSync, existsSync } from 'node:fs';
@@ -14,11 +17,12 @@ import { join } from 'node:path';
14
17
  import { parseArgs } from 'node:util';
15
18
  import { pathToFileURL } from 'node:url';
16
19
  import { loadRecord, canonicalFingerprint, fingerprint } from './lib/doc-record.mjs';
20
+ import { DOC_CARD_RENDERER_VERSION } from './lib/doc-card-plan.mjs';
17
21
 
18
22
  // Surfaces whose rendered content the CLI can re-read from the repo.
19
23
  const REPO_SURFACES = new Set(['storybookMdx']);
20
24
 
21
- export function classifySurface({ currentCanonical, surface, currentRenderHash, fileMissing = false }) {
25
+ export function classifySurface({ currentCanonical, surface, currentRenderHash, fileMissing = false, expectedRenderer = null }) {
22
26
  const flags = [];
23
27
  if (surface.src !== currentCanonical) flags.push('stale');
24
28
  if (fileMissing) {
@@ -28,6 +32,10 @@ export function classifySurface({ currentCanonical, surface, currentRenderHash,
28
32
  } else if (surface.render !== currentRenderHash) {
29
33
  flags.push('edited');
30
34
  }
35
+ if (expectedRenderer !== null
36
+ && (!surface.renderer || Number(surface.renderer) < Number(expectedRenderer))) {
37
+ flags.push('layout-upgrade-available');
38
+ }
31
39
  return flags;
32
40
  }
33
41
 
@@ -57,7 +65,10 @@ export function checkComponent({ name, meta, root }) {
57
65
  fileMissing = true;
58
66
  }
59
67
  }
60
- const flags = classifySurface({ currentCanonical, surface, currentRenderHash, fileMissing });
68
+ const flags = classifySurface({
69
+ currentCanonical, surface, currentRenderHash, fileMissing,
70
+ expectedRenderer: surfaceName === 'docCard' ? DOC_CARD_RENDERER_VERSION : null,
71
+ });
61
72
  if (flags.length) out.push({ name, surface: surfaceName, flags });
62
73
  }
63
74
  return out;
@@ -89,7 +100,10 @@ function main() {
89
100
  const info = results.filter((r) => !r.flags.some((f) => FAILING.has(f)));
90
101
 
91
102
  for (const r of drift) console.error(` ✗ ${r.name} · ${r.surface}: ${r.flags.join(', ')}`);
92
- for (const r of info) console.log(` ~ ${r.name} · ${r.surface}: ${r.flags.join(', ')} (check in a Figma session)`);
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
+ }
93
107
 
94
108
  if (drift.length) {
95
109
  console.error(`✗ docs:check — ${drift.length} drifted surface(s); reconcile with /document-component`);
@@ -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,19 @@ 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 two
103
+ // reference-doc generators, and the renderer template one of them 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
+ 'build-native-adapter-config.mjs',
109
+ 'lib/doc-card-render.figma.js',
110
+ ]);
111
+
112
+ export const skipScript = (relPosix) =>
113
+ relPosix.startsWith('adapters/') || relPosix.endsWith('.test.mjs') || PLUGIN_INTERNAL.has(relPosix);
114
+
102
115
  export function install({ target, dir, pkgRoot = PKG_ROOT }) {
103
116
  if (!TARGETS.includes(target)) {
104
117
  throw new Error(`unknown target "${target}"; expected one of: ${TARGETS.join(', ')}`);
@@ -124,7 +137,7 @@ export function install({ target, dir, pkgRoot = PKG_ROOT }) {
124
137
  }
125
138
  const payload = [
126
139
  ...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'),
140
+ ...stagePayload(join(pkgRoot, 'scripts'), join(dir, BASE, 'scripts'), skipScript),
128
141
  ];
129
142
  return { target, dir, written, payload };
130
143
  }
@@ -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
+ }