@1agh/maude 0.58.2 → 0.59.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 (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,407 @@
1
+ /**
2
+ * @file figma/sanitize.ts — the content rules (DDR-216 D6a + D6b).
3
+ * @scope apps/studio/figma/sanitize.ts
4
+ * @purpose The ONE place Figma-derived strings are made safe to emit, and
5
+ * the ONE place imported nodes are made VISIBLE. Both translators
6
+ * (`to-strokes.ts`, `to-artboard.ts`) go through here — D6's
7
+ * "single writers" requirement is what makes the standing grep test
8
+ * ("no translator interpolates a node's `name`/`characters` into
9
+ * output itself") a real control rather than a hope.
10
+ *
11
+ * @invariant TWO INDEPENDENT MECHANISMS, because Round 2 of the DDR-216 review
12
+ * broke a single one twice:
13
+ *
14
+ * D6a — CHARACTER CLASSES. An escaper covers characters that could
15
+ * terminate a literal. It does NOT cover characters with NO GLYPH.
16
+ * A payload encoded in the Unicode Tags block (U+E0000–E007F) — a
17
+ * full ASCII alphabet that renders as literally nothing — passes
18
+ * opacity 1, fontSize 16, high contrast, non-zero area, the escaper
19
+ * AND `sanitizeAnnotationSvg`, and is reconstructed as plain text by
20
+ * a model reading the file. Same for zero-width characters and the
21
+ * Trojan-Source bidi technique (CVE-2021-42574), where the rendered
22
+ * order a human reviews differs from the source order.
23
+ *
24
+ * D6b — VISIBILITY, BY NORMALIZATION NOT DETECTION. The first draft
25
+ * tried to DETECT invisible nodes (opacity / fontSize / ΔE / clip)
26
+ * and the review broke it four ways — occlusion by an opaque later
27
+ * sibling, ancestor clipping, opacity compounded down the tree, and
28
+ * blend mode over a matching background — none of which a per-node
29
+ * property check can see. The translator AUTHORS these nodes, so it
30
+ * can GUARANTEE visibility instead of proving invisibility: clamp
31
+ * the size, force the contrast, clamp the geometry. Hidden text
32
+ * becomes VISIBLE text, which is strictly better than dropping it.
33
+ *
34
+ * @invariant Neither mechanism makes an instruction stop reading like an
35
+ * instruction. That is DDR-216 residual 1, named there, not a bug
36
+ * here. What these rules buy is that nothing an agent later reads
37
+ * is invisible to the human who imported it.
38
+ *
39
+ * @invariant DEPENDENCY-FREE — pure string/number work.
40
+ */
41
+
42
+ /** Every disposition this feature can report. A FIXED set: the per-import
43
+ * summary's "never silently dropped" promise is only true because a caller
44
+ * cannot invent a reason string (DDR-216 D5/D7).
45
+ *
46
+ * A frozen ARRAY, with the type derived from it, rather than a bare union —
47
+ * because a union alone is a compile-time promise and this repo runs **no
48
+ * `tsc` gate** (CLAUDE.md states the omission deliberately: `quality` is
49
+ * lint/tests/build/parity/tarball/tokens/site-content, no typecheck). That gap
50
+ * is not hypothetical: `assets.ts` shipped `'asset-degraded'` — absent from the
51
+ * union — to `main`, onto the wire (`http.ts` `/_api/figma/import`), into verb
52
+ * stdout that an agent reads (D10), and into `FigmaImportPanel`. Found by the
53
+ * DDR-219 design-stage review, fixed here with `asset-degraded` admitted as the
54
+ * legitimate outcome it always was. See DDR-219 D9. */
55
+ export const DISPOSITIONS = Object.freeze([
56
+ 'imported',
57
+ 'hidden-chars-dropped',
58
+ 'hidden-node-skipped',
59
+ 'text-normalized',
60
+ 'geometry-clamped',
61
+ 'truncated-text',
62
+ 'truncated-attr',
63
+ 'unmappable-type',
64
+ 'unmappable-shape',
65
+ /** A comment whose pinned node no longer exists in the file — no position to place it at. */
66
+ 'comment-target-deleted',
67
+ 'bind-degraded-to-bbox',
68
+ 'bind-dropped-self-connector',
69
+ 'asset-pending',
70
+ 'asset-skipped',
71
+ 'asset-cap-reached',
72
+ /** A vector Figma declined to render as SVG, re-requested as PNG. */
73
+ 'asset-degraded',
74
+ 'jsx-cap-reached',
75
+ 'value-rejected',
76
+ // ── The codegen route (DDR-219 D9). Three dispositions, one rule: what makes
77
+ // them safe is that `detail` carries a BOUNDED token plus a count, never the
78
+ // upstream family or utility name verbatim — `detail` is the one field on the
79
+ // wire no sanitizer touches, and it reaches verb stdout (D10), the HTTP route
80
+ // and the panel.
81
+ /** A Tailwind utility the mapper does not know, or whose value failed its grammar. */
82
+ 'codegen-utility-unmapped',
83
+ /** A font family that did not survive the copy — a CSS fallback is NOT a report. */
84
+ 'font-substituted',
85
+ /** Dev Mode unreachable / no seat / wrong document. The COMMON case, not an error state. */
86
+ 'codegen-unavailable',
87
+ /** The converter itself could not be LOADED. Contract: REFUSE, never "let a model do it". */
88
+ 'codegen-converter-unavailable',
89
+ /**
90
+ * The converter ran and REFUSED this frame — a parse error, an element outside
91
+ * the allowlist, a construct it does not understand (DDR-219 D5 rule 4).
92
+ *
93
+ * Deliberately NOT folded into `codegen-unavailable`: "Figma was unreachable"
94
+ * and "we looked at what Figma sent and would not emit it" are different
95
+ * facts, and conflating outcomes is how this feature reported success three
96
+ * times while losing content.
97
+ */
98
+ 'codegen-frame-refused',
99
+ ] as const);
100
+
101
+ export type Disposition = (typeof DISPOSITIONS)[number];
102
+
103
+ const DISPOSITION_SET: ReadonlySet<string> = new Set(DISPOSITIONS);
104
+
105
+ export function isDisposition(v: string): v is Disposition {
106
+ return DISPOSITION_SET.has(v);
107
+ }
108
+
109
+ /** `detail` is a code-owned note, and the only field on the wire that no
110
+ * sanitizer touches — so it is the one place an upstream string would ride out
111
+ * to a model unescaped (DDR-219 D9). It is NOT charset-restricted to ASCII:
112
+ * real notes carry `—` and `→` legitimately. What distinguishes a code-owned
113
+ * note from interpolated node text is that it is SHORT and carries no
114
+ * zero-glyph payload, so those are what get asserted. Longest real note today
115
+ * is 36 chars. */
116
+ export const MAX_DETAIL_LEN = 64;
117
+
118
+ export interface ReportEntry {
119
+ /** `^[0-9]+:[0-9]+$` — an identifier, never node text (DDR-216 D7). */
120
+ nodeId: string;
121
+ type: string;
122
+ disposition: Disposition;
123
+ /** A short code-owned note. NEVER node text, never an upstream string. */
124
+ detail?: string;
125
+ }
126
+
127
+ /** Collects dispositions. One per node per outcome; text is never quoted in. */
128
+ export class ImportReport {
129
+ readonly entries: ReportEntry[] = [];
130
+
131
+ add(nodeId: string, type: string, disposition: Disposition, detail?: string): void {
132
+ // The backstop the missing `tsc` gate cannot provide. A disposition outside
133
+ // the set is a programming error, never bad input — so it throws rather than
134
+ // degrading: silent acceptance is exactly how `asset-degraded` reached main.
135
+ if (!DISPOSITION_SET.has(disposition)) {
136
+ throw new Error(`ImportReport: unknown disposition ${JSON.stringify(disposition)}`);
137
+ }
138
+ if (detail !== undefined && !isCodeOwnedDetail(detail)) {
139
+ throw new Error(
140
+ `ImportReport: detail must be a short code-owned note (<=${MAX_DETAIL_LEN} chars, no zero-glyph); got ${detail.length} chars`
141
+ );
142
+ }
143
+ this.entries.push(
144
+ detail ? { nodeId, type, disposition, detail } : { nodeId, type, disposition }
145
+ );
146
+ }
147
+
148
+ count(disposition: Disposition): number {
149
+ return this.entries.filter((e) => e.disposition === disposition).length;
150
+ }
151
+ }
152
+
153
+ /** True when `detail` looks like the code-owned note it is contracted to be. */
154
+ export function isCodeOwnedDetail(detail: string): boolean {
155
+ return detail.length <= MAX_DETAIL_LEN && !ZERO_GLYPH_ONESHOT_RE.test(detail);
156
+ }
157
+
158
+ // ── D6a — character classes ─────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Characters with no glyph, or which reorder what a human sees relative to what
162
+ * is stored. Enumerated as ranges rather than a "printable ASCII only" filter,
163
+ * because unlike DDR-172's CSS values this text legitimately carries diacritics,
164
+ * CJK and emoji — the fixture's own `Příliš žluťoučký` must survive intact.
165
+ */
166
+ /**
167
+ * Characters with no glyph, or which reorder what a human sees relative to what
168
+ * is stored.
169
+ *
170
+ * POSITIVE-CATEGORY rule, not a hand-kept range list. The first version
171
+ * enumerated ranges and was called "genuinely complete"; it was not — it missed
172
+ * U+3164 HANGUL FILLER (Unicode category **Lo**, a LETTER, which is exactly why
173
+ * whitespace/format filters keep it), U+FFA0, U+115F/U+1160, U+2800 BRAILLE
174
+ * PATTERN BLANK, U+180E, U+061C ARABIC LETTER MARK (a Trojan-Source control the
175
+ * paper lists), and U+FFF9–FFFB. Two of those are a binary alphabet; four are
176
+ * base-4 (post-implementation review F10).
177
+ *
178
+ * So: reject every format/control/unassigned/private-use code point by CATEGORY
179
+ * (`\p{Cf}\p{Cc}\p{Co}\p{Cn}`), plus an explicit list of code points that are
180
+ * *letters or symbols* by category but render blank. A category rule cannot rot
181
+ * the way an enumeration does.
182
+ */
183
+ const ZERO_GLYPH_SOURCE = [
184
+ // Format / control / private-use / unassigned — the categories that cover
185
+ // every bidi control, every zero-width, the Tags block, and anything Unicode
186
+ // adds later without this list needing an edit.
187
+ '[\\p{Cf}\\p{Cc}\\p{Co}\\p{Cn}]',
188
+ // Blank-rendering code points that are LETTERS or SYMBOLS by category, so no
189
+ // category rule catches them. Listed one per alternative rather than as a
190
+ // character class: `\u034F` is a COMBINING character, and a class mixing it
191
+ // with base characters is the `noMisleadingCharacterClass` footgun.
192
+ '\\u00AD', // soft hyphen
193
+ '\\u034F', // combining grapheme joiner
194
+ '\\u061C', // arabic letter mark (Trojan Source)
195
+ '\\u115F', // hangul choseong filler
196
+ '\\u1160', // hangul jungseong filler
197
+ '\\u17B4', // khmer inherent AQ
198
+ '\\u17B5', // khmer inherent AA
199
+ '[\\u180B-\\u180E]', // mongolian selectors + vowel separator
200
+ '\\u2800', // braille pattern blank
201
+ '\\u3164', // HANGUL FILLER — category Lo, i.e. a LETTER
202
+ '[\\uFE00-\\uFE0F]', // variation selectors
203
+ '\\uFFA0', // halfwidth hangul filler
204
+ '[\\uFFF9-\\uFFFB]', // interlinear annotation
205
+ '[\\u{E0100}-\\u{E01EF}]', // variation selectors supplement
206
+ ].join('|');
207
+
208
+ const ZERO_GLYPH_RE = new RegExp(ZERO_GLYPH_SOURCE, 'gu');
209
+
210
+ /** The same rule without `g`. `RegExp.prototype.test` on a GLOBAL regex advances
211
+ * `lastIndex` and so alternates true/false across calls on the same instance —
212
+ * a one-shot predicate must never share the `g` instance. */
213
+ const ZERO_GLYPH_ONESHOT_RE = new RegExp(ZERO_GLYPH_SOURCE, 'u');
214
+
215
+ /** Tab / newline / carriage return are legitimate content — never stripped. */
216
+ const KEEP_WHITESPACE_RE = /[\t\n\r]/;
217
+
218
+ export interface CleanTextResult {
219
+ text: string;
220
+ /** True when D6a removed something — the caller reports `hidden-chars-dropped`. */
221
+ strippedHidden: boolean;
222
+ /** True when the length cap bit — the caller reports `truncated-text`. */
223
+ truncated: boolean;
224
+ }
225
+
226
+ /**
227
+ * D6a: NFC-normalize, strip zero-glyph/bidi characters, collapse runs of
228
+ * whitespace-ish control leftovers, and bound the length.
229
+ *
230
+ * `maxLen` is a per-sink capacity, not a security bound — the security bound is
231
+ * the character classes. A sticky that would overflow its card is truncated and
232
+ * REPORTED rather than silently overflowing (DDR-216 D5's bounded-degradation
233
+ * class, which is why `truncated` comes back rather than being swallowed).
234
+ */
235
+ export function cleanText(raw: string, maxLen: number): CleanTextResult {
236
+ const normalized = raw.normalize('NFC');
237
+ const stripped = normalized.replace(ZERO_GLYPH_RE, (ch) =>
238
+ KEEP_WHITESPACE_RE.test(ch) ? ch : ''
239
+ );
240
+ const strippedHidden = stripped.length !== normalized.length;
241
+ const truncated = stripped.length > maxLen;
242
+ return { text: truncated ? stripped.slice(0, maxLen) : stripped, strippedHidden, truncated };
243
+ }
244
+
245
+ /**
246
+ * Attribute values (`data-dc-element`, a section label chip, …) get a strict
247
+ * ALLOWLIST charset rather than D6a's denylist: they are short, structural, and
248
+ * never need the full Unicode range. Empty after sanitizing ⇒ the caller falls
249
+ * back to a node-id-derived name (DDR-216 D6 sink table).
250
+ */
251
+ export function attrValue(raw: string, maxLen = 64): string {
252
+ return raw
253
+ .normalize('NFC')
254
+ .replace(/[^A-Za-z0-9 _-]+/g, ' ')
255
+ .replace(/\s+/g, ' ')
256
+ .trim()
257
+ .slice(0, maxLen);
258
+ }
259
+
260
+ /**
261
+ * A report token for `detail` — strict, and deliberately NOT `attrValue`.
262
+ *
263
+ * `attrValue` rewrites rejected characters to SPACES, which is right for a label
264
+ * and wrong here: a class token like `ignore.all.prior.instructions.and` comes
265
+ * back as that sentence, and `detail` reaches verb stdout (which D10 declares
266
+ * entirely code-owned), the HTTP summary and the panel. Charset-bounding is a
267
+ * markup control; it was never a semantic one (post-implementation review F3).
268
+ *
269
+ * So: a Tailwind FAMILY shape only — lowercase, digits, hyphens, NO SPACES ever
270
+ * — and anything else collapses to a fixed word. `unrecognized x7` is less
271
+ * informative than the raw token and cannot be read as an instruction, which is
272
+ * the right trade for a field an agent reads.
273
+ */
274
+ export function reportToken(raw: string): string {
275
+ const head = /^[a-z][a-z0-9-]{0,23}/.exec(raw.trim().toLowerCase());
276
+ return head ? head[0] : 'unrecognized';
277
+ }
278
+
279
+ /**
280
+ * A JSX-safe identifier derived from a NODE ID — never from a Figma string.
281
+ * `2:17` → `Node_2_17`. This is the whole identifier story: there is no Figma
282
+ * text anywhere in the identifier space, which is why Round 1 and Round 2 both
283
+ * failed to construct an identifier-space attack.
284
+ */
285
+ export function identifierFromNodeId(nodeId: string): string {
286
+ const safe = nodeId.replace(/[^0-9]+/g, '_').replace(/^_+|_+$/g, '');
287
+ return `Node_${safe || '0'}`;
288
+ }
289
+
290
+ /**
291
+ * Escape a string for emission as a JSX **text child** inside a `{'…'}` string
292
+ * expression. Covers everything that could terminate the literal or the
293
+ * expression — including U+2028/U+2029, which terminate a JS string literal in
294
+ * some parsers and break tooling in others (Round 1 named both caveats; they are
295
+ * written into the rule here rather than left to the caller).
296
+ *
297
+ * Text is NEVER emitted as an attribute, never as markup, never through
298
+ * `dangerouslySetInnerHTML`.
299
+ */
300
+ export function jsxStringLiteral(raw: string): string {
301
+ const body = raw
302
+ .replace(/\\/g, '\\\\')
303
+ .replace(/'/g, "\\'")
304
+ .replace(/\n/g, '\\n')
305
+ .replace(/\r/g, '\\r')
306
+ // Written as explicit escapes, never as literal characters in this source:
307
+ // a literal U+2028 here is invisible in every editor and one careless
308
+ // copy-paste away from silently vanishing from the rule.
309
+ .replace(/\u2028/g, '\\u2028')
310
+ .replace(/\u2029/g, '\\u2029')
311
+ .replace(/</g, '\\u003c')
312
+ .replace(/>/g, '\\u003e')
313
+ .replace(/\{/g, '\\u007b')
314
+ .replace(/\}/g, '\\u007d');
315
+ return `'${body}'`;
316
+ }
317
+
318
+ // ── D6b — visibility, by normalization ──────────────────────────────────────
319
+
320
+ /** Below this a glyph is not readable at any sane zoom — clamp, don't drop. */
321
+ export const MIN_FONT_SIZE = 8;
322
+ /** WCAG-ish floor. Not an a11y claim — a "a human can see this exists" floor. */
323
+ export const MIN_CONTRAST_RATIO = 2.5;
324
+
325
+ function srgbChannel(c: number): number {
326
+ const v = c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
327
+ return v;
328
+ }
329
+
330
+ /** WCAG relative luminance from 0..1 linear sRGB components. */
331
+ export function relativeLuminance(r: number, g: number, b: number): number {
332
+ return 0.2126 * srgbChannel(r) + 0.7152 * srgbChannel(g) + 0.0722 * srgbChannel(b);
333
+ }
334
+
335
+ export function contrastRatio(
336
+ fg: { r: number; g: number; b: number },
337
+ bg: { r: number; g: number; b: number }
338
+ ): number {
339
+ const l1 = relativeLuminance(fg.r, fg.g, fg.b);
340
+ const l2 = relativeLuminance(bg.r, bg.g, bg.b);
341
+ const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
342
+ return (hi + 0.05) / (lo + 0.05);
343
+ }
344
+
345
+ export function hexToRgb01(hex: string): { r: number; g: number; b: number } | null {
346
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
347
+ if (!m) return null;
348
+ const n = Number.parseInt(m[1], 16);
349
+ return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
350
+ }
351
+
352
+ export function rgb01ToHex(c: { r: number; g: number; b: number }): string {
353
+ const to = (v: number) =>
354
+ Math.max(0, Math.min(255, Math.round(v * 255)))
355
+ .toString(16)
356
+ .padStart(2, '0');
357
+ return `#${to(c.r)}${to(c.g)}${to(c.b)}`;
358
+ }
359
+
360
+ /**
361
+ * D6b: force a foreground colour to be visible against its own resolved
362
+ * background. Returns the original when it already clears the floor, otherwise
363
+ * black or white — whichever wins — so white-on-white becomes black-on-white
364
+ * rather than being dropped.
365
+ */
366
+ export function ensureContrast(fgHex: string, bgHex: string): { hex: string; changed: boolean } {
367
+ const fg = hexToRgb01(fgHex);
368
+ const bg = hexToRgb01(bgHex);
369
+ if (!fg || !bg) return { hex: fgHex, changed: false };
370
+ if (contrastRatio(fg, bg) >= MIN_CONTRAST_RATIO) return { hex: fgHex, changed: false };
371
+ const black = { r: 0, g: 0, b: 0 };
372
+ const white = { r: 1, g: 1, b: 1 };
373
+ const pick = contrastRatio(black, bg) >= contrastRatio(white, bg) ? black : white;
374
+ return { hex: rgb01ToHex(pick), changed: true };
375
+ }
376
+
377
+ /** D6b: a readable size, always. */
378
+ export function ensureFontSize(size: number): { size: number; changed: boolean } {
379
+ if (!Number.isFinite(size) || size < MIN_FONT_SIZE) {
380
+ return { size: MIN_FONT_SIZE, changed: true };
381
+ }
382
+ return { size, changed: false };
383
+ }
384
+
385
+ /**
386
+ * D6b: clamp a node's world position into the board's occupied bounds (padded),
387
+ * so a stroke parked tens of thousands of units away — which on a real board
388
+ * (measured span x −3 244…+11 037) looks like ordinary geometry, not an
389
+ * anomaly — cannot hide off-screen.
390
+ */
391
+ export interface Bounds {
392
+ minX: number;
393
+ minY: number;
394
+ maxX: number;
395
+ maxY: number;
396
+ }
397
+
398
+ export function clampIntoBounds(
399
+ x: number,
400
+ y: number,
401
+ bounds: Bounds,
402
+ pad = 2000
403
+ ): { x: number; y: number; changed: boolean } {
404
+ const cx = Math.max(bounds.minX - pad, Math.min(bounds.maxX + pad, x));
405
+ const cy = Math.max(bounds.minY - pad, Math.min(bounds.maxY + pad, y));
406
+ return { x: cx, y: cy, changed: cx !== x || cy !== y };
407
+ }