@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.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- 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
|
+
}
|