@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.
- package/README.md +2 -1
- package/adapters/codex/AGENTS.md +10 -10
- package/adapters/codex/prompts/component-builder.md +59 -15
- package/adapters/codex/prompts/document-component.md +42 -10
- package/adapters/codex/prompts/storybook-chromatic-builder.md +52 -9
- package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
- package/adapters/codex/prompts/token-sync-layer.md +64 -4
- package/adapters/cursor/.cursor/commands/document-component.md +42 -10
- package/adapters/cursor/.cursor/rules/component-builder.mdc +60 -16
- package/adapters/cursor/.cursor/rules/component-pipeline.mdc +1 -1
- package/adapters/cursor/.cursor/rules/design-system-audit.mdc +1 -1
- package/adapters/cursor/.cursor/rules/figma-environment-setup.mdc +1 -1
- package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +1 -1
- package/adapters/cursor/.cursor/rules/repository-builder.mdc +1 -1
- package/adapters/cursor/.cursor/rules/retrofit-planner.mdc +1 -1
- package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +53 -10
- package/adapters/cursor/.cursor/rules/token-builder.mdc +1 -1
- package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +3 -1
- package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +1 -1
- package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +65 -5
- package/adapters/generic/AGENTS.md +10 -10
- package/adapters/generic/commands/document-component.md +42 -10
- package/adapters/generic/skills/component-builder/SKILL.md +59 -15
- package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +52 -9
- package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
- package/adapters/generic/skills/token-sync-layer/SKILL.md +64 -4
- package/package.json +1 -1
- package/references/component-doc-archetypes.md +15 -11
- package/references/component-doc-schema.md +23 -5
- package/references/doc-card-builder.md +565 -0
- package/references/doc-writing-standard.md +144 -0
- package/references/figma-component-standards.md +63 -16
- package/references/guide-voice.md +96 -0
- package/references/manifest-schema.md +24 -6
- package/references/native-adapter-config.md +930 -0
- package/references/sync-adapters.md +94 -12
- package/scripts/README.md +37 -3
- package/scripts/build-doc-card-builder.mjs +143 -0
- package/scripts/build-native-adapter-config.mjs +280 -0
- package/scripts/docs-check.mjs +18 -4
- package/scripts/docs-lint.mjs +163 -0
- package/scripts/install.mjs +14 -1
- package/scripts/lib/doc-card-plan.mjs +101 -0
- package/scripts/lib/doc-card-render.figma.js +371 -0
- package/scripts/lib/dtcg.mjs +87 -0
- package/scripts/lib/native-literal.mjs +205 -0
- package/scripts/lib/sd-native.mjs +770 -0
- package/scripts/validate-crosswalk.mjs +3 -29
- package/scripts/validate-token-output.mjs +338 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// Figma plugin-API renderer for the doc-card Usage band. This file is NOT a
|
|
2
|
+
// Node module — build-doc-card-builder.mjs concatenates it after the inlined
|
|
3
|
+
// planner (doc-card-plan.mjs) into references/doc-card-builder.md, and the
|
|
4
|
+
// result runs inside figma_execute (dynamic-page mode). Constraints honored
|
|
5
|
+
// here (see references/figma-scripting.md): async APIs only, style/font set
|
|
6
|
+
// BEFORE .characters, fonts loaded up front, resize() sizing modes re-asserted,
|
|
7
|
+
// bound paints seeded light-gray (never pure black).
|
|
8
|
+
//
|
|
9
|
+
// In-scope globals when assembled: planDocCard, DOC_CARD_RENDERER_VERSION
|
|
10
|
+
// (inlined planner) and the caller-filled slots RECORD, CANONICAL_FP.
|
|
11
|
+
|
|
12
|
+
// 32-bit FNV-1a — the render hash. Only ever compared against itself (the
|
|
13
|
+
// manifest's surfaces.docCard.render), so it does not need to match the
|
|
14
|
+
// sha256-based canonical fingerprint, which cannot run in the Figma sandbox.
|
|
15
|
+
function fnv1a(str) {
|
|
16
|
+
let h = 0x811c9dc5;
|
|
17
|
+
for (let i = 0; i < str.length; i++) {
|
|
18
|
+
h ^= str.charCodeAt(i);
|
|
19
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return h.toString(16).padStart(8, '0');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const REQUIRED_VARS = [
|
|
25
|
+
'textDefault', 'textMuted', 'tonePositive', 'toneNegative', 'border',
|
|
26
|
+
'spacePadding', 'spaceRowGap', 'spaceBlockGap', 'spaceItemGap',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Bound paint, seeded with a light-gray approximation — a failed/late bind must
|
|
30
|
+
// never render pure black (reads as accidental dark mode).
|
|
31
|
+
function boundPaint(variable) {
|
|
32
|
+
return figma.variables.setBoundVariableForPaint(
|
|
33
|
+
{ type: 'SOLID', color: { r: 0.85, g: 0.85, b: 0.87 } }, 'color', variable,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function renderDocCard({ card, record, vars, bodyTextStyle }) {
|
|
38
|
+
// Bind-or-throw: a missing variable or style is a gap in the token set —
|
|
39
|
+
// never fall back to a hex/px.
|
|
40
|
+
for (const key of REQUIRED_VARS) {
|
|
41
|
+
if (!vars || !vars[key]) {
|
|
42
|
+
throw new Error('renderDocCard: missing required variable "' + key
|
|
43
|
+
+ '" — resolve it via figma_get_variables / getVariableByIdAsync and pass the Variable object in vars');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!bodyTextStyle) {
|
|
47
|
+
throw new Error('renderDocCard: bodyTextStyle is required — find the "Body/Default" text style via getLocalTextStylesAsync');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Three-band cards are VERTICAL auto-layout frames. Appending into anything
|
|
51
|
+
// else preserves absolute position and mis-places the band — throw instead.
|
|
52
|
+
if (card.layoutMode !== 'VERTICAL') {
|
|
53
|
+
throw new Error('renderDocCard: the doc card must be a VERTICAL auto-layout frame (three-band card); got layoutMode=' + card.layoutMode);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Fonts, up front — before any text node exists. Eyebrow chrome is Bold of
|
|
57
|
+
// the body family; fall back to the body style's own font if no Bold exists.
|
|
58
|
+
const bodyFont = bodyTextStyle.fontName;
|
|
59
|
+
await figma.loadFontAsync(bodyFont);
|
|
60
|
+
let eyebrowFont = { family: bodyFont.family, style: 'Bold' };
|
|
61
|
+
try { await figma.loadFontAsync(eyebrowFont); } catch (e) { eyebrowFont = bodyFont; }
|
|
62
|
+
|
|
63
|
+
// One component per doc card: a band like "Usage — Select Menu Item" means this
|
|
64
|
+
// card documents multiple components. Rendering here would append a band we
|
|
65
|
+
// don't own and silently accumulate — refuse and ask for the card to be split.
|
|
66
|
+
// Checked before the specimen lookup so a multi-component card always gets
|
|
67
|
+
// this error, never a possible "no COMPONENT_SET found" from the lookup below.
|
|
68
|
+
const foreign = card.findChild((n) => n.name !== 'Usage' && n.name.startsWith('Usage'));
|
|
69
|
+
if (foreign) {
|
|
70
|
+
throw new Error('renderDocCard: card contains band "' + foreign.name
|
|
71
|
+
+ '" — one component per doc card; split this card so each component owns its own card before re-rendering');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Structural contract only: the card must contain a COMPONENT_SET. It is
|
|
75
|
+
// deliberately NOT measured — the render widens the card, the card's hug
|
|
76
|
+
// propagates into FILL siblings including the specimen, so any specimen
|
|
77
|
+
// measurement is a value this render mutates and the next one reads. (No
|
|
78
|
+
// named "Specimen" band lookup: no real card has ever used one, so that
|
|
79
|
+
// path never executed.)
|
|
80
|
+
const specimen = card.findOne((n) => n.type === 'COMPONENT_SET');
|
|
81
|
+
if (!specimen) {
|
|
82
|
+
throw new Error('renderDocCard: no COMPONENT_SET found inside the card — the specimen band must contain the component set');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The header band's record-derived content is written further below (after
|
|
86
|
+
// the Usage band rebuild), but its shape is validated here — before ANY
|
|
87
|
+
// mutation — so a shape mismatch throws with the card untouched. The
|
|
88
|
+
// builder owns this content: the status write-back only fires on a status
|
|
89
|
+
// change, so a re-voiced component that is already `stable` would otherwise
|
|
90
|
+
// keep its original blurb and date forever. The status chip itself is NOT
|
|
91
|
+
// touched — the finalize write-back still owns it.
|
|
92
|
+
//
|
|
93
|
+
// The header band's own name is unreliable (`Header` on Button, `Frame` on
|
|
94
|
+
// the other 12 cards measured), so it is located structurally instead — the
|
|
95
|
+
// card's child FRAME that is neither the `Usage` band nor the specimen nor
|
|
96
|
+
// an ancestor of it.
|
|
97
|
+
const headerBand = card.children.find((n) =>
|
|
98
|
+
n.type === 'FRAME'
|
|
99
|
+
&& n.name !== 'Usage'
|
|
100
|
+
&& n.id !== specimen.id
|
|
101
|
+
&& !n.findOne((d) => d.id === specimen.id));
|
|
102
|
+
if (!headerBand) {
|
|
103
|
+
throw new Error('renderDocCard: no header band found — expected a child frame holding the component name, description, status chip and date');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Two accepted header shapes (figma-component-standards.md "The header"):
|
|
107
|
+
// legacy cards carry a `Status Pill` descendant plus a label/value date
|
|
108
|
+
// frame; to-spec cards (built by /new-component per the written standard)
|
|
109
|
+
// carry `Status`/`Status Label` plus a `Last Updated` TEXT node. Neither is
|
|
110
|
+
// going away, so both are located structurally rather than by fixed
|
|
111
|
+
// child-index, resolving to the same three anchors below.
|
|
112
|
+
const titleRow = headerBand.children[0];
|
|
113
|
+
const hasStatusAnchor = !!(titleRow && titleRow.type === 'FRAME'
|
|
114
|
+
&& titleRow.findOne((d) => d.name === 'Status Pill' || d.name === 'Status'));
|
|
115
|
+
|
|
116
|
+
// Date anchor. To-spec: a direct TEXT child of the header band named
|
|
117
|
+
// `Last Updated` — that node IS the value. Legacy: a FRAME child whose
|
|
118
|
+
// first child is TEXT reading exactly "Last updated"; the value is that
|
|
119
|
+
// frame's other TEXT child, found by elimination against the label rather
|
|
120
|
+
// than assumed by index.
|
|
121
|
+
let dateValue = headerBand.children.find((n) => n.type === 'TEXT' && n.name === 'Last Updated');
|
|
122
|
+
if (!dateValue) {
|
|
123
|
+
const legacyDateFrame = headerBand.children.find((n) =>
|
|
124
|
+
n.type === 'FRAME' && n.children[0] && n.children[0].type === 'TEXT'
|
|
125
|
+
&& n.children[0].characters === 'Last updated');
|
|
126
|
+
if (legacyDateFrame) {
|
|
127
|
+
const dateLabel = legacyDateFrame.children[0];
|
|
128
|
+
dateValue = legacyDateFrame.children.find((n) => n !== dateLabel && n.type === 'TEXT');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Description anchor: the header band's own bare description TEXT node — a
|
|
133
|
+
// direct TEXT child that is neither the title row nor the resolved date
|
|
134
|
+
// node. Not assumed by fixed index: the to-spec shape's child count can
|
|
135
|
+
// differ from the legacy 3-child shape.
|
|
136
|
+
//
|
|
137
|
+
// A node already named `Header Description` (a prior run's rename) is
|
|
138
|
+
// unambiguous by construction, so it wins outright. Failing that the
|
|
139
|
+
// candidates must resolve to EXACTLY ONE: picking the first of several
|
|
140
|
+
// would, on a header that also exposes its component-name TEXT as a direct
|
|
141
|
+
// child, overwrite that name with the summary and then rename it — wrong,
|
|
142
|
+
// destructive, and self-perpetuating on every later run. A visible date
|
|
143
|
+
// LABEL sibling (the "Last updated" caption, distinct from the value node
|
|
144
|
+
// resolved above) is excluded rather than counted, so the to-spec shape
|
|
145
|
+
// that carries one is still accepted instead of being falsely rejected.
|
|
146
|
+
const named = headerBand.children.find((n) => n.type === 'TEXT' && n.name === 'Header Description');
|
|
147
|
+
const descCandidates = headerBand.children.filter((n) =>
|
|
148
|
+
n !== titleRow && n.type === 'TEXT' && n !== dateValue
|
|
149
|
+
&& n.name !== 'Last Updated' && n.characters !== 'Last updated');
|
|
150
|
+
const headerDescCandidate = named || (descCandidates.length === 1 ? descCandidates[0] : null);
|
|
151
|
+
const descAmbiguous = !named && descCandidates.length > 1;
|
|
152
|
+
|
|
153
|
+
const missingAnchors = [];
|
|
154
|
+
if (!hasStatusAnchor) missingAnchors.push('status (title row must contain a descendant named "Status Pill" or "Status")');
|
|
155
|
+
if (!headerDescCandidate) {
|
|
156
|
+
missingAnchors.push(descAmbiguous
|
|
157
|
+
? 'description (found ' + descCandidates.length + ' candidate TEXT children, cannot tell which is the description — name the right one "Header Description" by hand and re-run)'
|
|
158
|
+
: 'description (a bare TEXT child distinct from the title row and the date node)');
|
|
159
|
+
}
|
|
160
|
+
if (!dateValue) missingAnchors.push('date (either a "Last Updated" TEXT child, or a FRAME child whose first TEXT child reads "Last updated")');
|
|
161
|
+
if (missingAnchors.length) {
|
|
162
|
+
throw new Error('renderDocCard: header band does not match either accepted shape — missing anchor(s): '
|
|
163
|
+
+ missingAnchors.join('; ')
|
|
164
|
+
+ ' — refusing to guess which node to write; see "The header" in figma-component-standards.md for the two accepted shapes');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const plan = planDocCard(record, { fontSize: bodyTextStyle.fontSize });
|
|
168
|
+
|
|
169
|
+
// Eyebrow chrome (derived, not bound — layout chrome like the column unit):
|
|
170
|
+
// fontSize × 0.65 rounded, min 8; Bold; uppercase; letter-spacing +8%.
|
|
171
|
+
const eyebrowSize = Math.max(8, Math.round(bodyTextStyle.fontSize * 0.65));
|
|
172
|
+
|
|
173
|
+
// Idempotent + scoped: rebuild ONLY the Usage frame. The specimen is never touched
|
|
174
|
+
// — recreating a component set detaches downstream instances. (The header's
|
|
175
|
+
// record-derived content is written separately, below.)
|
|
176
|
+
const existing = card.findChild((n) => n.name === 'Usage');
|
|
177
|
+
if (existing) existing.remove();
|
|
178
|
+
|
|
179
|
+
const eyebrowText = (chars, colorVar) => {
|
|
180
|
+
const t = figma.createText();
|
|
181
|
+
t.fontName = eyebrowFont; // loaded above — set BEFORE .characters
|
|
182
|
+
t.fontSize = eyebrowSize;
|
|
183
|
+
t.letterSpacing = { value: 8, unit: 'PERCENT' };
|
|
184
|
+
t.textCase = 'UPPER';
|
|
185
|
+
t.characters = chars;
|
|
186
|
+
t.fills = [boundPaint(colorVar)];
|
|
187
|
+
return t;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const bodyText = async (chars, colorVar) => {
|
|
191
|
+
const t = figma.createText();
|
|
192
|
+
await t.setTextStyleIdAsync(bodyTextStyle.id); // style BEFORE characters
|
|
193
|
+
t.characters = chars;
|
|
194
|
+
t.fills = [boundPaint(colorVar)];
|
|
195
|
+
return t;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Appends `t` to `parent` as a full-width, height-hugging text node.
|
|
199
|
+
const fillWidth = (parent, t) => {
|
|
200
|
+
parent.appendChild(t);
|
|
201
|
+
t.textAutoResize = 'HEIGHT';
|
|
202
|
+
t.layoutSizingHorizontal = 'FILL';
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Resolved px of the spacing tokens (mode-aware, resolved against the card).
|
|
206
|
+
// The planner's cardWidth is the CONTENT-GRID width (columns × columnUnit);
|
|
207
|
+
// the frame's outer width adds the band padding and the inter-block gutters
|
|
208
|
+
// so the planned column count actually fits on one line.
|
|
209
|
+
const padPx = vars.spacePadding.resolveForConsumer(card).value;
|
|
210
|
+
const blockGapPx = vars.spaceBlockGap.resolveForConsumer(card).value;
|
|
211
|
+
const usageWidth = plan.cardWidth + 2 * padPx + (plan.columns - 1) * blockGapPx;
|
|
212
|
+
|
|
213
|
+
const usage = figma.createFrame();
|
|
214
|
+
usage.name = 'Usage';
|
|
215
|
+
usage.layoutMode = 'VERTICAL';
|
|
216
|
+
usage.fills = [];
|
|
217
|
+
usage.clipsContent = false;
|
|
218
|
+
card.appendChild(usage);
|
|
219
|
+
usage.resize(usageWidth, usage.height);
|
|
220
|
+
usage.counterAxisSizingMode = 'FIXED'; // VERTICAL frame: counter = width
|
|
221
|
+
usage.primaryAxisSizingMode = 'AUTO'; // height hugs — re-asserted after resize()
|
|
222
|
+
usage.setBoundVariable('paddingLeft', vars.spacePadding);
|
|
223
|
+
usage.setBoundVariable('paddingRight', vars.spacePadding);
|
|
224
|
+
usage.setBoundVariable('paddingTop', vars.spacePadding);
|
|
225
|
+
usage.setBoundVariable('paddingBottom', vars.spacePadding);
|
|
226
|
+
usage.setBoundVariable('itemSpacing', vars.spaceRowGap);
|
|
227
|
+
|
|
228
|
+
// Widen the card to fit (its own padding included). Card is VERTICAL (guarded
|
|
229
|
+
// above): counter axis = width, so a hugging card needs no resize; a fixed
|
|
230
|
+
// card is widened and its height sizing re-asserted after resize().
|
|
231
|
+
const cardOuter = usageWidth + card.paddingLeft + card.paddingRight;
|
|
232
|
+
if (card.counterAxisSizingMode !== 'AUTO' && card.width < cardOuter) {
|
|
233
|
+
card.resize(cardOuter, card.height);
|
|
234
|
+
card.primaryAxisSizingMode = 'AUTO';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const blocksCreated = [];
|
|
238
|
+
let first = true;
|
|
239
|
+
for (const row of plan.rows) {
|
|
240
|
+
if (!first) {
|
|
241
|
+
const divider = figma.createFrame();
|
|
242
|
+
divider.name = 'Row Divider';
|
|
243
|
+
divider.fills = [boundPaint(vars.border)];
|
|
244
|
+
usage.appendChild(divider);
|
|
245
|
+
divider.resize(divider.width, 1);
|
|
246
|
+
divider.layoutSizingHorizontal = 'FILL';
|
|
247
|
+
}
|
|
248
|
+
first = false;
|
|
249
|
+
|
|
250
|
+
const rowFrame = figma.createFrame();
|
|
251
|
+
rowFrame.name = row.name;
|
|
252
|
+
rowFrame.layoutMode = 'HORIZONTAL';
|
|
253
|
+
rowFrame.layoutWrap = 'WRAP';
|
|
254
|
+
rowFrame.fills = [];
|
|
255
|
+
rowFrame.counterAxisAlignItems = 'MIN'; // top-aligned…
|
|
256
|
+
rowFrame.primaryAxisAlignItems = 'MIN'; // …left-packed; never center/space-between
|
|
257
|
+
usage.appendChild(rowFrame);
|
|
258
|
+
rowFrame.layoutSizingHorizontal = 'FILL';
|
|
259
|
+
rowFrame.layoutSizingVertical = 'HUG';
|
|
260
|
+
rowFrame.setBoundVariable('itemSpacing', vars.spaceBlockGap);
|
|
261
|
+
rowFrame.setBoundVariable('counterAxisSpacing', vars.spaceRowGap);
|
|
262
|
+
|
|
263
|
+
for (const block of row.blocks) {
|
|
264
|
+
const bf = figma.createFrame();
|
|
265
|
+
bf.name = block.name;
|
|
266
|
+
bf.layoutMode = 'VERTICAL';
|
|
267
|
+
bf.fills = [];
|
|
268
|
+
rowFrame.appendChild(bf);
|
|
269
|
+
bf.resize(plan.columnUnit, bf.height); // every block is exactly one unit wide
|
|
270
|
+
bf.counterAxisSizingMode = 'FIXED';
|
|
271
|
+
bf.primaryAxisSizingMode = 'AUTO';
|
|
272
|
+
bf.setBoundVariable('itemSpacing', vars.spaceItemGap);
|
|
273
|
+
|
|
274
|
+
const eyebrowColor = block.type === 'list-tone'
|
|
275
|
+
? (block.tone === 'positive' ? vars.tonePositive : vars.toneNegative)
|
|
276
|
+
: vars.textMuted;
|
|
277
|
+
const eb = eyebrowText(block.eyebrow, eyebrowColor);
|
|
278
|
+
bf.appendChild(eb);
|
|
279
|
+
eb.textAutoResize = 'HEIGHT';
|
|
280
|
+
eb.layoutSizingHorizontal = 'FILL';
|
|
281
|
+
|
|
282
|
+
if (block.type === 'prose') {
|
|
283
|
+
fillWidth(bf, await bodyText(block.text, vars.textDefault));
|
|
284
|
+
} else if (block.type === 'list' || block.type === 'list-tone') {
|
|
285
|
+
for (const item of block.items) {
|
|
286
|
+
fillWidth(bf, await bodyText('• ' + item, vars.textDefault));
|
|
287
|
+
}
|
|
288
|
+
} else if (block.type === 'definition') {
|
|
289
|
+
for (const pair of block.terms) {
|
|
290
|
+
const pf = figma.createFrame();
|
|
291
|
+
pf.name = 'Definition: ' + pair.term;
|
|
292
|
+
pf.layoutMode = 'HORIZONTAL';
|
|
293
|
+
pf.fills = [];
|
|
294
|
+
bf.appendChild(pf);
|
|
295
|
+
pf.layoutSizingHorizontal = 'FILL';
|
|
296
|
+
pf.layoutSizingVertical = 'HUG';
|
|
297
|
+
pf.setBoundVariable('itemSpacing', vars.spaceItemGap);
|
|
298
|
+
const term = await bodyText(pair.term, vars.textDefault);
|
|
299
|
+
pf.appendChild(term);
|
|
300
|
+
term.textAutoResize = 'HEIGHT'; // long terms wrap, never truncate
|
|
301
|
+
term.resize(plan.termColumn, term.height);
|
|
302
|
+
term.layoutSizingHorizontal = 'FIXED'; // fixed term column: 30% of the unit
|
|
303
|
+
const meaning = await bodyText(pair.meaning, vars.textMuted);
|
|
304
|
+
pf.appendChild(meaning);
|
|
305
|
+
meaning.textAutoResize = 'HEIGHT';
|
|
306
|
+
meaning.layoutSizingHorizontal = 'FILL';
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
blocksCreated.push(block.name);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Metadata node — hidden, machine-read by the drift check's Figma-side pass.
|
|
314
|
+
const fp = eyebrowText(CANONICAL_FP, vars.textMuted);
|
|
315
|
+
fp.name = 'Doc Fingerprint';
|
|
316
|
+
fp.visible = false;
|
|
317
|
+
usage.appendChild(fp);
|
|
318
|
+
|
|
319
|
+
// The header band's record-derived content — its shape (status, description,
|
|
320
|
+
// and date anchors) was already validated above, before the Usage band
|
|
321
|
+
// rebuild.
|
|
322
|
+
|
|
323
|
+
// Load a text node's own fonts before writing, and never touch its style:
|
|
324
|
+
// the header's type is card chrome, not part of this projection. A
|
|
325
|
+
// zero-length node can't have mixed fonts (getRangeAllFontNames(0, 1) would
|
|
326
|
+
// exceed the text and throw), so it takes its own path via .fontName.
|
|
327
|
+
const writeChars = async (node, chars) => {
|
|
328
|
+
const len = node.characters.length;
|
|
329
|
+
if (len === 0) {
|
|
330
|
+
await figma.loadFontAsync(node.fontName);
|
|
331
|
+
} else {
|
|
332
|
+
for (const f of node.getRangeAllFontNames(0, len)) await figma.loadFontAsync(f);
|
|
333
|
+
}
|
|
334
|
+
node.characters = chars;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// Self-migrating: the anchor resolved above already preferred a node named
|
|
338
|
+
// `Header Description` over a positional match, so renaming here makes
|
|
339
|
+
// every later run deterministic by name rather than by position.
|
|
340
|
+
const headerDesc = headerDescCandidate;
|
|
341
|
+
if (headerDesc.name !== 'Header Description') headerDesc.name = 'Header Description';
|
|
342
|
+
// record.summary is schema-required but the renderer never calls
|
|
343
|
+
// validateRecord() itself — an unvalidated record's default ('') must not
|
|
344
|
+
// silently blank a live card's description.
|
|
345
|
+
if (plan.header.summary) await writeChars(headerDesc, plan.header.summary);
|
|
346
|
+
// Re-assert the one-column clamp (figma-component-standards.md): the header
|
|
347
|
+
// description never stretches across a wide matrix. Layout, not content —
|
|
348
|
+
// re-applied on every render regardless of whether the text changed.
|
|
349
|
+
headerDesc.textAutoResize = 'HEIGHT';
|
|
350
|
+
headerDesc.resize(plan.columnUnit, headerDesc.height);
|
|
351
|
+
headerDesc.layoutSizingHorizontal = 'FIXED';
|
|
352
|
+
|
|
353
|
+
// Single source for the header date: record.updatedAt (via plan.header —
|
|
354
|
+
// see figma-component-standards.md "Last updated"). `dateValue` was
|
|
355
|
+
// resolved above, before the Usage rebuild, under either header shape.
|
|
356
|
+
if (dateValue && plan.header.updatedAt) {
|
|
357
|
+
await writeChars(dateValue, plan.header.updatedAt);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
rendererVersion: DOC_CARD_RENDERER_VERSION,
|
|
362
|
+
columnUnit: plan.columnUnit,
|
|
363
|
+
columns: plan.columns,
|
|
364
|
+
cardWidth: plan.cardWidth,
|
|
365
|
+
rowsRendered: plan.rows.length,
|
|
366
|
+
blocksCreated,
|
|
367
|
+
headerWritten: true,
|
|
368
|
+
fingerprint: CANONICAL_FP,
|
|
369
|
+
renderHash: fnv1a(JSON.stringify(plan)),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared DTCG reading: flatten a token tree to dot-paths, resolve {alias} chains.
|
|
2
|
+
// Zero dependencies. Consumed by validate-crosswalk.mjs and validate-token-output.mjs,
|
|
3
|
+
// and copied alongside both when a skill installs either gate.
|
|
4
|
+
|
|
5
|
+
const REF = /^\{([^}]+)\}$/;
|
|
6
|
+
|
|
7
|
+
// Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
|
|
8
|
+
//
|
|
9
|
+
// A node carrying BOTH a $value and children yields its own value AND is descended
|
|
10
|
+
// into — the dual-node pattern, where `text.sm` has `$value: "14px"` plus a
|
|
11
|
+
// `text.sm.lineHeight` child. Stopping at the first $value drops those children,
|
|
12
|
+
// which makes every alias to one unresolvable: the crosswalk gate reported them as
|
|
13
|
+
// "missing from the DTCG source" though they exist, and the output validator could
|
|
14
|
+
// not check them at all.
|
|
15
|
+
export function flattenDtcg(obj, prefix = [], out = {}) {
|
|
16
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
17
|
+
if (key.startsWith('$')) continue;
|
|
18
|
+
if (!val || typeof val !== 'object') continue;
|
|
19
|
+
const path = [...prefix, key];
|
|
20
|
+
if ('$value' in val) out[path.join('.')] = val.$value;
|
|
21
|
+
flattenDtcg(val, path, out);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Flatten nested DTCG groups into { "dot.path": effectiveType }, applying the
|
|
27
|
+
// $type resolution of DTCG 5.2.2: a token's own $type wins, otherwise the
|
|
28
|
+
// nearest ancestor GROUP's. A node carrying a $value is a token, not a group
|
|
29
|
+
// (DTCG 6.1), so it is not an inheritance source for its children — the same
|
|
30
|
+
// rule hoistDualNodes computes as `inherited`, and the two must agree or two
|
|
31
|
+
// functions in this codebase disagree about the type of the same tree.
|
|
32
|
+
//
|
|
33
|
+
// Separate from flattenDtcg rather than folded into it: that function has four
|
|
34
|
+
// consumers and both validators re-export it, so its return shape is fixed.
|
|
35
|
+
//
|
|
36
|
+
// LIMIT, stated rather than hidden: this reads the RAW source, so it cannot see
|
|
37
|
+
// the $type carry hoistDualNodes applies during preprocessing. An untyped child
|
|
38
|
+
// of a dimension-typed dual node with no enclosing group type is a dimension to
|
|
39
|
+
// the pipeline and undefined here. Reference-derived typing (5.2.2 rule 1) is
|
|
40
|
+
// likewise not resolved — an alias is undefined, but its referent is typed, and
|
|
41
|
+
// the referent is the token an author edits.
|
|
42
|
+
export function flattenDtcgTypes(obj, prefix = [], out = {}, groupType = undefined) {
|
|
43
|
+
const inherited = '$value' in obj ? groupType : (obj.$type ?? groupType);
|
|
44
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
45
|
+
if (key.startsWith('$')) continue;
|
|
46
|
+
if (!val || typeof val !== 'object') continue;
|
|
47
|
+
const path = [...prefix, key];
|
|
48
|
+
if ('$value' in val) out[path.join('.')] = val.$type ?? inherited;
|
|
49
|
+
flattenDtcgTypes(val, path, out, inherited);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Follow {alias} chains to a leaf literal. Throws on missing or circular refs.
|
|
55
|
+
export function resolveValue(name, flat, seen = new Set()) {
|
|
56
|
+
if (!(name in flat)) throw new Error(`token "${name}" not found in DTCG source`);
|
|
57
|
+
const val = flat[name];
|
|
58
|
+
if (typeof val === 'string') {
|
|
59
|
+
const m = val.match(REF);
|
|
60
|
+
if (m) {
|
|
61
|
+
if (seen.has(name)) throw new Error(`circular reference at "${name}"`);
|
|
62
|
+
seen.add(name);
|
|
63
|
+
return resolveValue(m[1], flat, seen);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return val;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A token path defined in more than one source file with differing values means
|
|
70
|
+
// the build's source list spans modes. Style Dictionary dedupes these silently,
|
|
71
|
+
// dropping one whole mode — 864 such collisions produced a light-only build from
|
|
72
|
+
// a dark-default system.
|
|
73
|
+
export function findModeCollisions(sources) {
|
|
74
|
+
const seen = new Map();
|
|
75
|
+
for (const { file, dtcg } of sources) {
|
|
76
|
+
for (const [path, value] of Object.entries(flattenDtcg(dtcg))) {
|
|
77
|
+
if (!seen.has(path)) seen.set(path, []);
|
|
78
|
+
seen.get(path).push({ file, value });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const collisions = [];
|
|
82
|
+
for (const [path, defs] of seen) {
|
|
83
|
+
const distinct = new Set(defs.map((d) => JSON.stringify(d.value)));
|
|
84
|
+
if (defs.length > 1 && distinct.size > 1) collisions.push({ path, defs });
|
|
85
|
+
}
|
|
86
|
+
return collisions;
|
|
87
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Is an emitted value a well-formed Swift or Kotlin literal?
|
|
2
|
+
//
|
|
3
|
+
// A CSS function and a native call expression look alike until you check the
|
|
4
|
+
// callee. `linear-gradient` is not a valid identifier — the hyphen disqualifies
|
|
5
|
+
// it — while `UIColor` is. `calc` IS a valid identifier, but `1rem + 2px` is
|
|
6
|
+
// not a literal. Parsing rather than pattern-matching rejects all of them
|
|
7
|
+
// without naming any of them, which is the point: the next unanticipated case
|
|
8
|
+
// is caught by the same rule.
|
|
9
|
+
//
|
|
10
|
+
// Two consumers — sd-native.mjs's output filter, and
|
|
11
|
+
// validate-token-output.mjs's invalid-literal rule. Its own module for the same
|
|
12
|
+
// reason lib/dtcg.mjs is one: shared by both token gates.
|
|
13
|
+
//
|
|
14
|
+
// This asserts LITERAL well-formedness, not that the file compiles. A call to
|
|
15
|
+
// an undefined function still parses.
|
|
16
|
+
|
|
17
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*/;
|
|
18
|
+
|
|
19
|
+
// Swift and Kotlin disagree about numeric literals in OPPOSITE directions, so
|
|
20
|
+
// one shared rule necessarily over-accepts on one platform or false-fails on
|
|
21
|
+
// the other. Both sides measured rather than assumed:
|
|
22
|
+
//
|
|
23
|
+
// Swift, via `swiftc -parse`: `.5` and `-.5` are rejected — the compiler says
|
|
24
|
+
// "it must be written '0.5'" — while `0100` and `00` compile.
|
|
25
|
+
//
|
|
26
|
+
// Kotlin, via `kotlinc` 2.4.10: `0100` and `00` are rejected outright —
|
|
27
|
+
// "leading zeros are not allowed in integer literals" — while `.5` and `-.5`
|
|
28
|
+
// compile, which is why they stay accepted here. This matches the spec's
|
|
29
|
+
// lexical grammar exactly: IntegerLiteral is
|
|
30
|
+
// `DecDigitNoZero {DecDigitOrSeparator} DecDigit | DecDigit`, and
|
|
31
|
+
// DoubleLiteral is `[DecDigits] '.' DecDigits [DoubleExponent]`.
|
|
32
|
+
//
|
|
33
|
+
// Hex compiles on both and stays. A leading-zero integer is caught by the
|
|
34
|
+
// trailing-input rule at the end of parseLiteral rather than by the regex:
|
|
35
|
+
// `0100` matches only `0`, leaving `100` unconsumed.
|
|
36
|
+
const NUMBER_SWIFT = /^-?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?)/;
|
|
37
|
+
const NUMBER_KOTLIN = /^-?(?:0[xX][0-9a-fA-F]+|(?:0|[1-9]\d*)(?:\.\d+)?|\.\d+)/;
|
|
38
|
+
|
|
39
|
+
// The union of both, for a caller that names no platform. Every real consumer
|
|
40
|
+
// passes a GRAMMAR entry, which overrides this.
|
|
41
|
+
const NUMBER = /^-?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?|\.\d+)/;
|
|
42
|
+
|
|
43
|
+
// `escapes` are the characters legal after a backslash inside a string.
|
|
44
|
+
// \$ escapes Kotlin's template interpolation; it is not a valid Swift escape,
|
|
45
|
+
// so a shared set would over-accept on iOS.
|
|
46
|
+
export const GRAMMAR = {
|
|
47
|
+
'ios-swift': {
|
|
48
|
+
number: NUMBER_SWIFT,
|
|
49
|
+
suffixes: [],
|
|
50
|
+
units: [],
|
|
51
|
+
escapes: ['0', '\\', 't', 'n', 'r', '"', "'", 'u'],
|
|
52
|
+
},
|
|
53
|
+
'android-kotlin': {
|
|
54
|
+
number: NUMBER_KOTLIN,
|
|
55
|
+
suffixes: ['f', 'F', 'L'],
|
|
56
|
+
units: ['dp', 'sp', 'em'],
|
|
57
|
+
escapes: ['\\', 't', 'n', 'r', '"', "'", '$', 'u'],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// CSS constructs that validate-token-output.mjs diagnoses BY NAME.
|
|
62
|
+
//
|
|
63
|
+
// These are unimplemented rescues, not values with no native form: `calc` and
|
|
64
|
+
// `var` are valid identifiers, and `color-mix` has a rescue in sd-native.mjs
|
|
65
|
+
// that merely did not match this variant. So they must reach the output and
|
|
66
|
+
// fail loudly under no-foreign-syntax, never be silently dropped by a filter.
|
|
67
|
+
// Kept here, beside the grammar, so the build and the gate cannot drift apart.
|
|
68
|
+
export const CSS_CONSTRUCT = /^(?:color-mix|calc|var)\s*\(/;
|
|
69
|
+
|
|
70
|
+
export function parseLiteral(value, grammar = {}) {
|
|
71
|
+
const s = String(value);
|
|
72
|
+
const suffixes = grammar.suffixes ?? [];
|
|
73
|
+
const units = grammar.units ?? [];
|
|
74
|
+
const escapes = new Set(grammar.escapes ?? []);
|
|
75
|
+
const numberRe = grammar.number ?? NUMBER;
|
|
76
|
+
let i = 0;
|
|
77
|
+
|
|
78
|
+
const ws = () => {
|
|
79
|
+
while (i < s.length && /\s/.test(s[i])) i += 1;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Longest match wins, so a short unit cannot shadow a longer one.
|
|
83
|
+
const take = (words) => {
|
|
84
|
+
let best = null;
|
|
85
|
+
for (const w of words) {
|
|
86
|
+
if (s.startsWith(w, i) && (best === null || w.length > best.length)) best = w;
|
|
87
|
+
}
|
|
88
|
+
if (best !== null) i += best.length;
|
|
89
|
+
return best !== null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const string = () => {
|
|
93
|
+
i += 1; // opening quote
|
|
94
|
+
while (i < s.length) {
|
|
95
|
+
if (s[i] === '\\') {
|
|
96
|
+
if (!escapes.has(s[i + 1])) return false;
|
|
97
|
+
i += 2;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (s[i] === '"') {
|
|
101
|
+
i += 1;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (s[i] === '\n') return false;
|
|
105
|
+
i += 1;
|
|
106
|
+
}
|
|
107
|
+
return false; // unterminated
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const number = () => {
|
|
111
|
+
const m = s.slice(i).match(numberRe);
|
|
112
|
+
if (!m) return false;
|
|
113
|
+
i += m[0].length;
|
|
114
|
+
if (take(units.map((u) => `.${u}`))) return true;
|
|
115
|
+
take(suffixes);
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const literal = () => {
|
|
120
|
+
ws();
|
|
121
|
+
if (i >= s.length) return false;
|
|
122
|
+
if (s[i] === '"') return string();
|
|
123
|
+
|
|
124
|
+
// A parenthesised NUMBER, optionally with a unit. Compose letter spacing
|
|
125
|
+
// needs `(-0.03).em`, because `-0.03.em` parses as `-(0.03.em)` and
|
|
126
|
+
// kotlinc rejects it with "unresolved reference 'unaryMinus'" unless
|
|
127
|
+
// TextUnit defines that operator. The parenthesised form compiles either
|
|
128
|
+
// way, so it is what the transform emits.
|
|
129
|
+
//
|
|
130
|
+
// Deliberately not expression support: only a single number may sit inside
|
|
131
|
+
// the parens. Accepting `(1 + 2)` would make this gate vouch for
|
|
132
|
+
// arithmetic it cannot evaluate, which is the failure class the module
|
|
133
|
+
// exists to prevent.
|
|
134
|
+
if (s[i] === '(') {
|
|
135
|
+
const save = i;
|
|
136
|
+
i += 1;
|
|
137
|
+
ws();
|
|
138
|
+
const inner = s.slice(i).match(numberRe);
|
|
139
|
+
if (!inner) {
|
|
140
|
+
i = save;
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
i += inner[0].length;
|
|
144
|
+
ws();
|
|
145
|
+
if (s[i] !== ')') {
|
|
146
|
+
i = save;
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
i += 1;
|
|
150
|
+
if (take(units.map((u) => `.${u}`))) return true;
|
|
151
|
+
take(suffixes);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rest = s.slice(i);
|
|
156
|
+
const bool = rest.match(/^(?:true|false)(?![A-Za-z0-9_])/);
|
|
157
|
+
if (bool) {
|
|
158
|
+
i += bool[0].length;
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (numberRe.test(rest)) return number();
|
|
162
|
+
|
|
163
|
+
const id = rest.match(IDENT);
|
|
164
|
+
if (!id) return false;
|
|
165
|
+
i += id[0].length;
|
|
166
|
+
ws();
|
|
167
|
+
if (s[i] !== '(') return false; // a bare identifier is not a literal
|
|
168
|
+
i += 1;
|
|
169
|
+
ws();
|
|
170
|
+
if (s[i] === ')') {
|
|
171
|
+
i += 1;
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
for (;;) {
|
|
175
|
+
ws();
|
|
176
|
+
const save = i;
|
|
177
|
+
const label = s.slice(i).match(IDENT);
|
|
178
|
+
if (label) {
|
|
179
|
+
i += label[0].length;
|
|
180
|
+
ws();
|
|
181
|
+
if (s[i] === ':') i += 1;
|
|
182
|
+
else i = save; // not a label after all; re-read as a literal
|
|
183
|
+
}
|
|
184
|
+
if (!literal()) return false;
|
|
185
|
+
ws();
|
|
186
|
+
if (s[i] === ',') {
|
|
187
|
+
i += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (s[i] === ')') {
|
|
191
|
+
i += 1;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (!literal()) return { ok: false, offset: i, rest: s.slice(i) };
|
|
199
|
+
ws();
|
|
200
|
+
// Trailing input after a complete literal is a failure: `400 garbage`.
|
|
201
|
+
if (i !== s.length) return { ok: false, offset: i, rest: s.slice(i) };
|
|
202
|
+
return { ok: true };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const isValidLiteral = (value, grammar) => parseLiteral(value, grammar).ok;
|