@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,194 @@
1
+ // figma/comments-to-strokes.ts — the review record.
2
+ //
3
+ // Comments live on their own endpoint, nowhere in the document tree, so every
4
+ // tree-walking version of this importer brought across exactly zero of them.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { STICKY_PALETTE } from '../annotations-model.ts';
9
+ import type { FigmaComment } from './client.ts';
10
+ import { commentsToStrokes, indexNodes, MAX_COMMENT_STROKES } from './comments-to-strokes.ts';
11
+ import { ImportReport } from './sanitize.ts';
12
+ import { normalizeDocument } from './types.ts';
13
+
14
+ const KEY = 'dGNzRC2kmrmGnOxaBa0RI7';
15
+
16
+ function page() {
17
+ const doc = normalizeDocument(
18
+ {
19
+ id: '0:0',
20
+ name: 'Page',
21
+ type: 'CANVAS',
22
+ children: [
23
+ {
24
+ id: '1:2',
25
+ name: 'Screen',
26
+ type: 'FRAME',
27
+ visible: true,
28
+ absoluteBoundingBox: { x: 1000, y: 500, width: 375, height: 812 },
29
+ },
30
+ ],
31
+ },
32
+ { fileKey: KEY, surface: 'design' }
33
+ );
34
+ return doc.root;
35
+ }
36
+
37
+ const comment = (over: Partial<FigmaComment> = {}): FigmaComment => ({
38
+ id: 'c1',
39
+ message: 'Chybí',
40
+ nodeId: '1:2',
41
+ x: 20,
42
+ y: 30,
43
+ resolved: false,
44
+ ...over,
45
+ });
46
+
47
+ const ORIGIN = { x: 1000, y: 500 };
48
+
49
+ describe('positioning', () => {
50
+ test('a pin lands at its node position plus its offset, page-normalized', () => {
51
+ const { strokes } = commentsToStrokes(
52
+ [comment()],
53
+ indexNodes(page()),
54
+ ORIGIN,
55
+ new ImportReport()
56
+ );
57
+ expect(strokes).toHaveLength(1);
58
+ expect(strokes[0]).toMatchObject({ tool: 'sticky', x: 20, y: 30 });
59
+ });
60
+
61
+ test('a comment pinned to a node on another page is counted, not silently lost', () => {
62
+ const report = new ImportReport();
63
+ const { strokes, unplacedIds } = commentsToStrokes(
64
+ [comment({ nodeId: '9:9' })],
65
+ indexNodes(page()),
66
+ ORIGIN,
67
+ report
68
+ );
69
+ expect(strokes).toHaveLength(0);
70
+ expect(unplacedIds).toHaveLength(1);
71
+ });
72
+
73
+ test('a canvas-level pin uses its page coordinates directly', () => {
74
+ const { strokes } = commentsToStrokes(
75
+ [comment({ nodeId: '0:0', x: 1100, y: 600 })],
76
+ indexNodes(page()),
77
+ ORIGIN,
78
+ new ImportReport(),
79
+ '0:0'
80
+ );
81
+ expect(strokes[0]).toMatchObject({ x: 100, y: 100 });
82
+ });
83
+ });
84
+
85
+ describe('threads', () => {
86
+ test('replies fold into their root card in time order', () => {
87
+ const { strokes } = commentsToStrokes(
88
+ [
89
+ comment({ id: 'root', message: 'A/b test', author: 'maj' }),
90
+ comment({ id: 'r2', parentId: 'root', message: 'second', createdAt: '2026-02-01' }),
91
+ comment({ id: 'r1', parentId: 'root', message: 'first', createdAt: '2026-01-01' }),
92
+ ],
93
+ indexNodes(page()),
94
+ ORIGIN,
95
+ new ImportReport()
96
+ );
97
+ expect(strokes).toHaveLength(1);
98
+ expect((strokes[0] as { text: string }).text).toBe('maj: A/b test\n↳ first\n↳ second');
99
+ });
100
+ });
101
+
102
+ describe('resolved threads', () => {
103
+ test('are carried on grey paper rather than dropped', () => {
104
+ // A resolved comment is the record of a decision already made; dropping it
105
+ // is the same silent content loss the rest of this importer keeps relearning.
106
+ const { strokes } = commentsToStrokes(
107
+ [comment({ resolved: true })],
108
+ indexNodes(page()),
109
+ ORIGIN,
110
+ new ImportReport()
111
+ );
112
+ expect(strokes).toHaveLength(1);
113
+ expect((strokes[0] as { color: string }).color).toBe(STICKY_PALETTE[2]);
114
+ expect((strokes[0] as { text: string }).text).toStartWith('✓ ');
115
+ });
116
+
117
+ test('an open thread stays on the default yellow', () => {
118
+ const { strokes } = commentsToStrokes(
119
+ [comment()],
120
+ indexNodes(page()),
121
+ ORIGIN,
122
+ new ImportReport()
123
+ );
124
+ expect((strokes[0] as { color: string }).color).toBe(STICKY_PALETTE[0]);
125
+ });
126
+ });
127
+
128
+ describe('bounds', () => {
129
+ test('message text goes through the zero-glyph strip like every other import', () => {
130
+ const report = new ImportReport();
131
+ // U+E0041 — Unicode Tags block, zero glyphs, a payload channel.
132
+ const { strokes } = commentsToStrokes(
133
+ [comment({ message: `ok\u{E0041}` })],
134
+ indexNodes(page()),
135
+ ORIGIN,
136
+ report
137
+ );
138
+ expect((strokes[0] as { text: string }).text).toBe('ok');
139
+ expect(report.entries.some((e) => e.disposition === 'hidden-chars-dropped')).toBe(true);
140
+ });
141
+
142
+ test('an empty message produces no card', () => {
143
+ const { strokes } = commentsToStrokes(
144
+ [comment({ message: ' ' })],
145
+ indexNodes(page()),
146
+ ORIGIN,
147
+ new ImportReport()
148
+ );
149
+ expect(strokes).toHaveLength(0);
150
+ });
151
+
152
+ test('a pathological comment count is capped and reported', () => {
153
+ const many = Array.from({ length: MAX_COMMENT_STROKES + 25 }, (_, i) =>
154
+ comment({ id: `c${i}` })
155
+ );
156
+ const report = new ImportReport();
157
+ const { strokes } = commentsToStrokes(many, indexNodes(page()), ORIGIN, report);
158
+ expect(strokes).toHaveLength(MAX_COMMENT_STROKES);
159
+ expect(report.entries.some((e) => e.disposition === 'asset-cap-reached')).toBe(true);
160
+ });
161
+ });
162
+
163
+ describe('orphaned threads (the caller decides, not this module)', () => {
164
+ test('an unplaceable thread is returned by id, NOT reported here', () => {
165
+ // Per page, "not on this page" is the normal case — a comment lives on
166
+ // exactly one page. Reporting it here would fire once per page and read as
167
+ // content loss that never happened. Only the caller, which sees every page,
168
+ // can tell an unplaced thread from a deleted-target orphan.
169
+ const report = new ImportReport();
170
+ const { strokes, placedIds, unplacedIds } = commentsToStrokes(
171
+ [comment({ id: 'here' }), comment({ id: 'elsewhere', nodeId: '9:9' })],
172
+ indexNodes(page()),
173
+ ORIGIN,
174
+ report
175
+ );
176
+ expect(strokes).toHaveLength(1);
177
+ expect(placedIds).toEqual(['here']);
178
+ expect(unplacedIds).toEqual(['elsewhere']);
179
+ expect(report.entries.some((e) => e.disposition === 'comment-target-deleted')).toBe(false);
180
+ });
181
+
182
+ test('placedIds are raw Figma ids so the caller can intersect them', () => {
183
+ // The stroke id is sanitized (`figc_…`); the caller reconciles across pages
184
+ // on the RAW id, and a mismatch there would silently orphan every thread.
185
+ const { strokes, placedIds } = commentsToStrokes(
186
+ [comment({ id: '166-92:86' })],
187
+ indexNodes(page()),
188
+ ORIGIN,
189
+ new ImportReport()
190
+ );
191
+ expect(placedIds).toEqual(['166-92:86']);
192
+ expect((strokes[0] as { id: string }).id).not.toBe('166-92:86');
193
+ });
194
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @file figma/comments-to-strokes.ts — Figma review comments → annotations.
3
+ * @scope apps/studio/figma/comments-to-strokes.ts
4
+ * @purpose Bring across the notes a designer actually left on a file.
5
+ *
6
+ * @rationale Figma comments live on `/v1/files/:key/comments`, NOWHERE in the
7
+ * document tree. An importer that walks the tree — which is every
8
+ * version of this one until now — therefore misses every single
9
+ * one. The live StudyFi file carries 133 of them ("Chybí", "A/b
10
+ * test", "redirect do nastaveni…"): the actual review record, the
11
+ * part a handoff is FOR, imported as zero.
12
+ *
13
+ * @invariant A COMMENT IS DATA. The message is third-party free text: it goes
14
+ * through `cleanText`'s zero-glyph strip and length bound like every
15
+ * other imported string, is never parsed, and never reaches anything
16
+ * that could act on it (DDR-216 D1/D6a).
17
+ *
18
+ * @invariant RESOLVED THREADS ARE CARRIED, NOT DROPPED. A resolved comment is
19
+ * the record of a decision already made; discarding it silently is
20
+ * the same content loss this importer keeps relearning. They arrive
21
+ * on grey paper instead of yellow.
22
+ */
23
+
24
+ import {
25
+ DEFAULT_STICKY_COLOR,
26
+ STICKY_PALETTE,
27
+ type StickyStroke,
28
+ type Stroke,
29
+ } from '../annotations-model.ts';
30
+ import type { FigmaComment } from './client.ts';
31
+ import { cleanText, type ImportReport } from './sanitize.ts';
32
+ import type { FigmaNode } from './types.ts';
33
+
34
+ /** Matches the sticky cap in `to-strokes.ts` — a thread is not an essay. */
35
+ const COMMENT_TEXT_CAP = 1200;
36
+ /** Paper size. Wide enough for a sentence, short enough not to bury the design. */
37
+ const CARD_W = 240;
38
+ const CARD_MIN_H = 120;
39
+ const CARD_MAX_H = 520;
40
+ const CARD_FONT = 13;
41
+ /** Rough wrap width in characters at `CARD_FONT` — height is estimated, not laid out. */
42
+ const CHARS_PER_LINE = 30;
43
+ const LINE_H = 18;
44
+ /** Grey paper for a settled thread; slot 0 (yellow) stays "still open". */
45
+ const RESOLVED_COLOR = STICKY_PALETTE[2];
46
+ /** A pathological file should not paper the canvas over. */
47
+ export const MAX_COMMENT_STROKES = 300;
48
+
49
+ export interface CommentStrokesResult {
50
+ strokes: Stroke[];
51
+ /** Thread ids this page placed. Raw Figma ids, matching `unplacedIds`. */
52
+ placedIds: string[];
53
+ /**
54
+ * Thread ids this page could not place, because the node they name is not in
55
+ * it. Per page that is NORMAL — a comment lives on exactly one page — so this
56
+ * is deliberately not reported here. The caller intersects it across every
57
+ * page; a thread unplaced EVERYWHERE is an orphan whose target was deleted
58
+ * from the file, and only the caller can know that.
59
+ */
60
+ unplacedIds: string[];
61
+ }
62
+
63
+ /** Index every node on a page by id, so a pin can find what it hangs off. */
64
+ export function indexNodes(root: FigmaNode): Map<string, FigmaNode> {
65
+ const out = new Map<string, FigmaNode>();
66
+ const walk = (n: FigmaNode): void => {
67
+ out.set(n.id, n);
68
+ for (const c of n.children ?? []) walk(c);
69
+ };
70
+ walk(root);
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Turn a file's comments into sticky annotations positioned over the page.
76
+ *
77
+ * World coordinates match the artboards: the pin sits at its target node's
78
+ * absolute position plus the comment's offset within that node, normalized to
79
+ * the same page origin the canvas used.
80
+ */
81
+ export function commentsToStrokes(
82
+ comments: readonly FigmaComment[],
83
+ nodes: ReadonlyMap<string, FigmaNode>,
84
+ origin: { x: number; y: number },
85
+ report: ImportReport,
86
+ pageId?: string
87
+ ): CommentStrokesResult {
88
+ // Replies hang off their root pin; only roots get a card.
89
+ const repliesByParent = new Map<string, FigmaComment[]>();
90
+ const roots: FigmaComment[] = [];
91
+ for (const c of comments) {
92
+ if (c.parentId) {
93
+ const list = repliesByParent.get(c.parentId);
94
+ if (list) list.push(c);
95
+ else repliesByParent.set(c.parentId, [c]);
96
+ } else {
97
+ roots.push(c);
98
+ }
99
+ }
100
+
101
+ const strokes: Stroke[] = [];
102
+ const placedIds: string[] = [];
103
+ const unplacedIds: string[] = [];
104
+
105
+ for (const root of roots) {
106
+ if (strokes.length >= MAX_COMMENT_STROKES) {
107
+ report.add(root.id, 'COMMENT', 'asset-cap-reached', `>${MAX_COMMENT_STROKES} comments`);
108
+ break;
109
+ }
110
+
111
+ // Where does this pin live? Either inside a node on this page, or — for a
112
+ // canvas-level pin — at page coordinates directly.
113
+ let wx: number;
114
+ let wy: number;
115
+ if (root.nodeId && root.nodeId !== pageId) {
116
+ const target = nodes.get(root.nodeId);
117
+ const bb = target?.absoluteBoundingBox;
118
+ if (!bb) {
119
+ unplacedIds.push(root.id);
120
+ continue;
121
+ }
122
+ wx = bb.x + (root.x ?? 0);
123
+ wy = bb.y + (root.y ?? 0);
124
+ } else if (root.x !== undefined && root.y !== undefined) {
125
+ wx = root.x;
126
+ wy = root.y;
127
+ } else {
128
+ unplacedIds.push(root.id);
129
+ continue;
130
+ }
131
+
132
+ const body = composeThread(root, repliesByParent.get(root.id) ?? []);
133
+ const clean = cleanText(body, COMMENT_TEXT_CAP);
134
+ if (clean.strippedHidden) report.add(root.id, 'COMMENT', 'hidden-chars-dropped');
135
+ if (clean.truncated) report.add(root.id, 'COMMENT', 'truncated-text');
136
+ if (!clean.text.trim()) continue;
137
+
138
+ const lines = clean.text
139
+ .split('\n')
140
+ .reduce((n, l) => n + Math.max(1, Math.ceil(l.length / CHARS_PER_LINE)), 0);
141
+ const h = Math.min(CARD_MAX_H, Math.max(CARD_MIN_H, lines * LINE_H + 24));
142
+
143
+ const card: StickyStroke = {
144
+ id: `figc_${root.id.replace(/[^0-9A-Za-z]+/g, '_')}`,
145
+ tool: 'sticky',
146
+ color: root.resolved ? RESOLVED_COLOR : DEFAULT_STICKY_COLOR,
147
+ x: Math.round(wx - origin.x),
148
+ y: Math.round(wy - origin.y),
149
+ w: CARD_W,
150
+ h: Math.round(h),
151
+ text: clean.text,
152
+ fontSize: CARD_FONT,
153
+ };
154
+ strokes.push(card);
155
+ placedIds.push(root.id);
156
+ report.add(root.id, 'COMMENT', 'imported', root.resolved ? 'resolved' : 'open');
157
+ }
158
+
159
+ return { strokes, placedIds, unplacedIds };
160
+ }
161
+
162
+ /**
163
+ * One thread as one card's body. The author handle is provenance shown as text
164
+ * — a display string, never an identifier anything acts on (D7).
165
+ */
166
+ function composeThread(root: FigmaComment, replies: readonly FigmaComment[]): string {
167
+ const head = root.resolved ? '✓ ' : '';
168
+ const who = (c: FigmaComment) => (c.author ? `${c.author}: ` : '');
169
+ const parts = [`${head}${who(root)}${root.message}`];
170
+ const ordered = [...replies].sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
171
+ for (const r of ordered) parts.push(`↳ ${who(r)}${r.message}`);
172
+ return parts.join('\n');
173
+ }
@@ -0,0 +1,273 @@
1
+ // figma/endpoints.ts — `/_api/figma/*` orchestration (DDR-216 D2 + D3).
2
+ //
3
+ // Pure handlers behind the three Figma routes: validate inputs, call the key
4
+ // store or the REST client, and shape a `{ status, json }` result. NO HTTP /
5
+ // Request dependency — http.ts owns the gating (method · loopback Host ·
6
+ // same-origin CSRF · body byte cap) and this module owns orchestration,
7
+ // mirroring github/endpoints.ts and git/endpoints.ts.
8
+ //
9
+ // SECURITY — the three properties this file exists to hold (DDR-216):
10
+ //
11
+ // 1. MAIN-ORIGIN ONLY, by omission from BOTH CANVAS_SAFE_API (http.ts) and
12
+ // startCanvasServer's `routes` map (server.ts). A canvas-reachable Figma
13
+ // route is simultaneously a token-exfiltration primitive and an SSRF
14
+ // primitive, and canvases run as real unsandboxed JS on a separate origin
15
+ // (DDR-054) — so the gate is that the route does not exist from there.
16
+ // `test/canvas-origin-gate.test.ts` asserts all three 403 at the gate on
17
+ // the canvas origin (403, not 405 — the request never reaches a handler).
18
+ // That is only HALF the property: the MAIN origin is reachable from any
19
+ // page the user visits, so http.ts also applies `isTrustedRequestHost` +
20
+ // `sameOriginWrite` (writes) / `sameOriginRead` (the status GET, where a
21
+ // browser may omit `Origin`). `test/figma-routes.test.ts` owns that half.
22
+ //
23
+ // 2. THE KEY IS NEVER ECHOED. `connect` returns `{ configured: true }`;
24
+ // `status` returns presence only. Neither reads the stored value back —
25
+ // the same discipline `/_api/generate/keys` states in its own comment, so
26
+ // a proxy or a log between here and the client cannot capture it.
27
+ //
28
+ // 3. `figma` IS NOT A MEDIA-GENERATION PROVIDER. It produces no Modality and
29
+ // needs no AdapterFactory, so it is deliberately absent from
30
+ // generation/registry.ts; these routes call the key STORE directly. That
31
+ // also keeps ONE write path for the secret — two would be how one of them
32
+ // ends up ungated (DDR-216 D2, a Round-1 security finding).
33
+
34
+ import { deleteProviderKey, isConfigured, setProviderKey } from '../generation/keys.ts';
35
+ import { FigmaApiError, fetchIdentity } from './client.ts';
36
+
37
+ /** The provider id under which the PAT lives in `~/.config/maude/keys.json`. */
38
+ export const FIGMA_PROVIDER_ID = 'figma';
39
+
40
+ /** Where a user mints one, and the granular scope to ask for. The blanket
41
+ * `files:read` scope is deprecated — never name it in UI or docs. */
42
+ export const FIGMA_TOKEN_URL = 'https://www.figma.com/developers/api#access-tokens';
43
+ export const FIGMA_REQUIRED_SCOPE = 'file_content:read';
44
+
45
+ export interface FigmaEndpointResult {
46
+ status: number;
47
+ json: unknown;
48
+ }
49
+
50
+ /**
51
+ * Figma PATs are opaque, but they are not arbitrary text: bounding the shape
52
+ * here means a paste accident (a whole URL, a JSON blob, a stray newline) fails
53
+ * at the boundary rather than becoming a stored "key" that produces a confusing
54
+ * 401 later. Deliberately permissive on the alphabet and strict on everything
55
+ * structural — printable ASCII only, no whitespace, bounded length.
56
+ */
57
+ const TOKEN_SHAPE_RE = /^[!-~]{20,255}$/;
58
+
59
+ /**
60
+ * `/v1/me` returns a handle we display as "Connected as …". It is
61
+ * upstream-controlled, so it is length- and charset-bounded before it ever
62
+ * reaches client state, and it is NEVER persisted into config.json or any
63
+ * versioned file (DDR-216 D6 sink table).
64
+ */
65
+ export function boundDisplayHandle(raw: string): string {
66
+ return raw
67
+ .normalize('NFC')
68
+ .replace(/[^\p{L}\p{N} ._@-]/gu, '')
69
+ .trim()
70
+ .slice(0, 64);
71
+ }
72
+
73
+ export interface FigmaImportRequest {
74
+ url: string;
75
+ mode: 'board' | 'frames' | 'tokens';
76
+ dryRun?: boolean;
77
+ }
78
+
79
+ export interface FigmaExplodeRequest {
80
+ /** Canvas path RELATIVE to the design root. Never absolute, never `..`. */
81
+ canvas: string;
82
+ /** The `DCArtboard` id. Code-computed at import time, so a strict charset. */
83
+ artboard: string;
84
+ dryRun?: boolean;
85
+ confirmDocument?: boolean;
86
+ }
87
+
88
+ /**
89
+ * Validate an explode request.
90
+ *
91
+ * Narrower than `parseImportRequest`, because this one MUTATES an existing
92
+ * reviewed, versioned, peer-synced artifact. The caller supplies a target and
93
+ * nothing else: no output path, no node id, no URL, no size. Everything else is
94
+ * read from what the deterministic import already recorded (DDR-219 D8, which
95
+ * inherits DDR-216 D3's "the producer never picks its own target").
96
+ */
97
+ export function parseExplodeRequest(body: unknown): FigmaExplodeRequest | null {
98
+ if (!body || typeof body !== 'object') return null;
99
+ const b = body as Record<string, unknown>;
100
+ if (typeof b.canvas !== 'string' || b.canvas.length === 0 || b.canvas.length > 512) return null;
101
+ // Traversal is refused HERE as well as by the verb's realpath containment.
102
+ // Two independent checks, because this one is cheap and the failure is total.
103
+ if (b.canvas.includes('..') || b.canvas.startsWith('/') || /^[A-Za-z]:/.test(b.canvas)) {
104
+ return null;
105
+ }
106
+ if (!b.canvas.endsWith('.tsx')) return null;
107
+ if (typeof b.artboard !== 'string' || !/^[a-z0-9-]{1,64}$/.test(b.artboard)) return null;
108
+ return {
109
+ canvas: b.canvas,
110
+ artboard: b.artboard,
111
+ dryRun: b.dryRun === true,
112
+ confirmDocument: b.confirmDocument === true,
113
+ };
114
+ }
115
+
116
+ export interface FigmaEndpoints {
117
+ /** POST — store a PAT. Returns presence only, never the value. */
118
+ connect(body: unknown): FigmaEndpointResult;
119
+ /** DELETE — forget the PAT. */
120
+ disconnect(): FigmaEndpointResult;
121
+ /** GET — presence only. Reveals whether a key exists, never what it is. */
122
+ status(): FigmaEndpointResult;
123
+ /** POST — validate the stored token against `GET /v1/me`. */
124
+ probe(): Promise<FigmaEndpointResult>;
125
+ /** POST — run an import. Same work the CLI verb does, same guarantees. */
126
+ runImport(body: unknown): Promise<FigmaEndpointResult>;
127
+ /** POST — make ONE already-imported artboard editable via Dev Mode codegen. */
128
+ explode(body: unknown): Promise<FigmaEndpointResult>;
129
+ }
130
+
131
+ /**
132
+ * Validate an import request into a typed shape.
133
+ *
134
+ * Deliberately narrow: a mode from a fixed enum, a URL that `figma/url.ts` will
135
+ * reject if it is not a real Figma URL, and a boolean. No slug, no path, no
136
+ * output location — the caller does not get to choose where anything lands
137
+ * (the same reason DDR-174 has its orchestrator compute the target path rather
138
+ * than letting the producer pick one).
139
+ */
140
+ export function parseImportRequest(body: unknown): FigmaImportRequest | null {
141
+ if (!body || typeof body !== 'object') return null;
142
+ const b = body as Record<string, unknown>;
143
+ const mode = b.mode;
144
+ if (mode !== 'board' && mode !== 'frames' && mode !== 'tokens') return null;
145
+ if (typeof b.url !== 'string' || b.url.length === 0 || b.url.length > 2048) return null;
146
+ return { url: b.url, mode, dryRun: b.dryRun === true };
147
+ }
148
+
149
+ export interface FigmaEndpointDeps {
150
+ /**
151
+ * Runs the import. Injected so this module stays HTTP- and fs-free (the same
152
+ * split github/endpoints.ts uses) and so the route can be tested without a
153
+ * network. The real implementation is `bin/_import-figma.mjs`.
154
+ */
155
+ runImport?(req: FigmaImportRequest): Promise<{
156
+ summary: Record<string, unknown>;
157
+ }>;
158
+ /** Same injection, same reason. The real implementation is the verb's
159
+ * `explodeArtboard`, so the panel and the CLI cannot drift apart in what
160
+ * they validate, cap or report. */
161
+ explode?(req: FigmaExplodeRequest): Promise<{ summary: Record<string, unknown> }>;
162
+ }
163
+
164
+ export function createFigmaEndpoints(deps: FigmaEndpointDeps = {}): FigmaEndpoints {
165
+ return {
166
+ connect(body: unknown): FigmaEndpointResult {
167
+ if (!body || typeof body !== 'object') {
168
+ return { status: 400, json: { error: 'token required' } };
169
+ }
170
+ const token = (body as Record<string, unknown>).token;
171
+ if (typeof token !== 'string' || !TOKEN_SHAPE_RE.test(token.trim())) {
172
+ // Fixed message — never echoes what was sent (DDR-216 D10).
173
+ return { status: 400, json: { error: 'that does not look like a Figma access token' } };
174
+ }
175
+ try {
176
+ setProviderKey(FIGMA_PROVIDER_ID, token.trim());
177
+ } catch {
178
+ return { status: 400, json: { error: 'could not store the token' } };
179
+ }
180
+ // Presence flag only. Deliberately does NOT read the value back.
181
+ return { status: 200, json: { configured: isConfigured(FIGMA_PROVIDER_ID) } };
182
+ },
183
+
184
+ disconnect(): FigmaEndpointResult {
185
+ deleteProviderKey(FIGMA_PROVIDER_ID);
186
+ return { status: 200, json: { configured: false } };
187
+ },
188
+
189
+ status(): FigmaEndpointResult {
190
+ return {
191
+ status: 200,
192
+ json: {
193
+ configured: isConfigured(FIGMA_PROVIDER_ID),
194
+ tokenUrl: FIGMA_TOKEN_URL,
195
+ requiredScope: FIGMA_REQUIRED_SCOPE,
196
+ },
197
+ };
198
+ },
199
+
200
+ async probe(): Promise<FigmaEndpointResult> {
201
+ try {
202
+ const identity = await fetchIdentity();
203
+ return { status: 200, json: { ok: true, handle: boundDisplayHandle(identity.handle) } };
204
+ } catch (err) {
205
+ if (err instanceof FigmaApiError) {
206
+ // `err.message` is a fixed, code-owned string from MESSAGE_BY_KIND —
207
+ // it carries no upstream text, no header and no token (D2/D10).
208
+ const status = err.kind === 'not_configured' ? 400 : 502;
209
+ return { status, json: { ok: false, reason: err.kind, error: err.message } };
210
+ }
211
+ return {
212
+ status: 502,
213
+ json: { ok: false, reason: 'network', error: 'Figma probe failed.' },
214
+ };
215
+ }
216
+ },
217
+
218
+ async runImport(body: unknown): Promise<FigmaEndpointResult> {
219
+ const req = parseImportRequest(body);
220
+ if (!req) return { status: 400, json: { error: 'mode and url are required' } };
221
+ if (!deps.runImport) {
222
+ return { status: 501, json: { error: 'import is not available in this shell' } };
223
+ }
224
+ try {
225
+ const { summary } = await deps.runImport(req);
226
+ // The summary is the SAME code-generated, enum-coded accounting the CLI
227
+ // prints (DDR-216 D7/D10) — node ids and reason codes, never node text.
228
+ // It is safe to hand to a client precisely BECAUSE of that.
229
+ return { status: 200, json: { ok: true, ...summary } };
230
+ } catch (err) {
231
+ if (err instanceof FigmaApiError) {
232
+ return {
233
+ status: err.kind === 'not_configured' ? 400 : 502,
234
+ json: { ok: false, reason: err.kind, error: err.message },
235
+ };
236
+ }
237
+ // Anything else is reported generically — an import failure message must
238
+ // never become a channel for an upstream string (D10).
239
+ return { status: 500, json: { ok: false, reason: 'failed', error: 'Import failed.' } };
240
+ }
241
+ },
242
+
243
+ async explode(body: unknown): Promise<FigmaEndpointResult> {
244
+ const req = parseExplodeRequest(body);
245
+ if (!req) return { status: 400, json: { error: 'canvas and artboard are required' } };
246
+ if (!deps.explode) {
247
+ return { status: 501, json: { error: 'codegen is not available in this shell' } };
248
+ }
249
+ try {
250
+ const { summary } = await deps.explode(req);
251
+ return { status: 200, json: { ok: true, ...summary } };
252
+ } catch (err) {
253
+ // Codegen being unavailable is the COMMON case (no Dev/Full seat, Figma
254
+ // desktop not running, Dev Mode off, wrong tab). It is a 409, not a 500:
255
+ // nothing is wrong with the request or with us. The reason code is the
256
+ // client's OWN enum — `CodegenError.kind` / `CodegenConvertError.reason`
257
+ // are code-owned strings from fixed tables, never upstream text.
258
+ const kind = (err as { kind?: string; reason?: string } | null)?.kind;
259
+ const reason = (err as { reason?: string } | null)?.reason;
260
+ if (typeof kind === 'string') {
261
+ return {
262
+ status: 409,
263
+ json: { ok: false, reason: kind, error: (err as Error).message },
264
+ };
265
+ }
266
+ if (typeof reason === 'string') {
267
+ return { status: 422, json: { ok: false, reason, error: (err as Error).message } };
268
+ }
269
+ return { status: 500, json: { ok: false, reason: 'failed', error: 'Explode failed.' } };
270
+ }
271
+ },
272
+ };
273
+ }