@1agh/maude 0.58.2 → 0.58.3
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/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- 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/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -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 +1261 -1261
- 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 +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -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 +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -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 +306 -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 +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- 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 +13 -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-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -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 +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -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-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/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
// figma/to-strokes.ts — the FigJam → whiteboard mapping.
|
|
2
|
+
//
|
|
3
|
+
// Asserted against the PURPOSE-BUILT fixture board `Em6NOwaOFTYV7NlQT4NK8l`
|
|
4
|
+
// (`.ai/plans/notes/figma-import-fixtures.md`), where every node exists to
|
|
5
|
+
// exercise one named behaviour and node ids are stable — so these tests assert
|
|
6
|
+
// against SPECIFIC IDS rather than "a board".
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { isBindable } from '../annotations-bindings.ts';
|
|
10
|
+
import type { ArrowStroke, SectionStroke, StickyStroke, Stroke } from '../annotations-model.ts';
|
|
11
|
+
import { STICKY_PALETTE, sanitizeAnnotationSvg, strokesToSvg } from '../annotations-model.ts';
|
|
12
|
+
import { nearestStickyColor, toStrokes } from './to-strokes.ts';
|
|
13
|
+
import { normalizeDocument } from './types.ts';
|
|
14
|
+
|
|
15
|
+
const KEY = 'Em6NOwaOFTYV7NlQT4NK8l';
|
|
16
|
+
|
|
17
|
+
const solid = (r: number, g: number, b: number) => [
|
|
18
|
+
{ type: 'SOLID', visible: true, color: { r, g, b, a: 1 } },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const box = (x: number, y: number, width: number, height: number) => ({ x, y, width, height });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A trimmed replica of the real fixture board. Coordinates deliberately start
|
|
25
|
+
* deep in negative space, mirroring the measured real board (x ≈ −3 244…+11 037,
|
|
26
|
+
* y ≈ −6 272…+23 488) — an untranslated import lands tens of thousands of px
|
|
27
|
+
* off-screen, so the origin shift is part of what these tests cover.
|
|
28
|
+
*/
|
|
29
|
+
function fixtureBoard() {
|
|
30
|
+
return {
|
|
31
|
+
id: '0:0',
|
|
32
|
+
name: 'Document',
|
|
33
|
+
type: 'DOCUMENT',
|
|
34
|
+
children: [
|
|
35
|
+
{
|
|
36
|
+
id: '0:1',
|
|
37
|
+
name: 'Page 1',
|
|
38
|
+
type: 'CANVAS',
|
|
39
|
+
children: [
|
|
40
|
+
// ── sections: outer + NESTED ──
|
|
41
|
+
{
|
|
42
|
+
id: '1:2',
|
|
43
|
+
name: 'Sekce vnější',
|
|
44
|
+
type: 'SECTION',
|
|
45
|
+
absoluteBoundingBox: box(-3244, -6272, 2000, 1500),
|
|
46
|
+
fills: solid(0.9, 0.9, 0.92),
|
|
47
|
+
children: [
|
|
48
|
+
{
|
|
49
|
+
id: '1:3',
|
|
50
|
+
name: 'Sekce vnitřní (nested)',
|
|
51
|
+
type: 'SECTION',
|
|
52
|
+
absoluteBoundingBox: box(-3000, -6000, 800, 600),
|
|
53
|
+
children: [
|
|
54
|
+
{
|
|
55
|
+
id: '1:8',
|
|
56
|
+
name: 'Sticky yellow',
|
|
57
|
+
type: 'STICKY',
|
|
58
|
+
absoluteBoundingBox: box(-2900, -5900, 240, 240),
|
|
59
|
+
characters: 'Persona A',
|
|
60
|
+
fills: solid(0.99, 0.91, 0.65),
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
// ── stickies: palette + the WIDE one ──
|
|
67
|
+
{
|
|
68
|
+
id: '1:12',
|
|
69
|
+
name: 'Sticky green',
|
|
70
|
+
type: 'STICKY',
|
|
71
|
+
absoluteBoundingBox: box(0, 0, 240, 240),
|
|
72
|
+
characters: 'Zelená',
|
|
73
|
+
fills: solid(0.75, 0.89, 0.75),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: '1:20',
|
|
77
|
+
name: 'Sticky wide',
|
|
78
|
+
type: 'STICKY',
|
|
79
|
+
absoluteBoundingBox: box(400, 0, 416, 240),
|
|
80
|
+
characters: 'Široká',
|
|
81
|
+
fills: solid(1, 1, 1),
|
|
82
|
+
},
|
|
83
|
+
// ── shapes: one per shapeType ──
|
|
84
|
+
{
|
|
85
|
+
id: '2:17',
|
|
86
|
+
name: 'Příliš žluťoučký — "test" / <b> & \'x\'',
|
|
87
|
+
type: 'SHAPE_WITH_TEXT',
|
|
88
|
+
shapeType: 'SQUARE',
|
|
89
|
+
absoluteBoundingBox: box(0, 400, 200, 200),
|
|
90
|
+
characters: 'Čtverec',
|
|
91
|
+
strokes: solid(0.1, 0.1, 0.1),
|
|
92
|
+
fills: solid(1, 1, 1),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: '2:21',
|
|
96
|
+
name: 'Ellipse',
|
|
97
|
+
type: 'SHAPE_WITH_TEXT',
|
|
98
|
+
shapeType: 'ELLIPSE',
|
|
99
|
+
absoluteBoundingBox: box(300, 400, 200, 200),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: '2:24',
|
|
103
|
+
name: 'Rounded',
|
|
104
|
+
type: 'SHAPE_WITH_TEXT',
|
|
105
|
+
shapeType: 'ROUNDED_RECTANGLE',
|
|
106
|
+
absoluteBoundingBox: box(600, 400, 200, 200),
|
|
107
|
+
cornerRadius: 16,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: '2:28',
|
|
111
|
+
name: 'Diamond',
|
|
112
|
+
type: 'SHAPE_WITH_TEXT',
|
|
113
|
+
shapeType: 'DIAMOND',
|
|
114
|
+
absoluteBoundingBox: box(900, 400, 200, 200),
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: '2:32',
|
|
118
|
+
name: 'TriUp',
|
|
119
|
+
type: 'SHAPE_WITH_TEXT',
|
|
120
|
+
shapeType: 'TRIANGLE_UP',
|
|
121
|
+
absoluteBoundingBox: box(1200, 400, 200, 200),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: '2:36',
|
|
125
|
+
name: 'TriDown',
|
|
126
|
+
type: 'SHAPE_WITH_TEXT',
|
|
127
|
+
shapeType: 'TRIANGLE_DOWN',
|
|
128
|
+
absoluteBoundingBox: box(1500, 400, 200, 200),
|
|
129
|
+
},
|
|
130
|
+
// ── the two deliberately UNMAPPABLE shapes ──
|
|
131
|
+
{
|
|
132
|
+
id: '2:40',
|
|
133
|
+
name: 'Parallelogram',
|
|
134
|
+
type: 'SHAPE_WITH_TEXT',
|
|
135
|
+
shapeType: 'PARALLELOGRAM_RIGHT',
|
|
136
|
+
absoluteBoundingBox: box(1800, 400, 200, 200),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: '2:44',
|
|
140
|
+
name: 'EngDatabase',
|
|
141
|
+
type: 'SHAPE_WITH_TEXT',
|
|
142
|
+
shapeType: 'ENG_DATABASE',
|
|
143
|
+
absoluteBoundingBox: box(2100, 400, 200, 200),
|
|
144
|
+
},
|
|
145
|
+
// ── standalone TEXT (connector target C2) ──
|
|
146
|
+
{
|
|
147
|
+
id: '2:50',
|
|
148
|
+
name: 'Archetyp',
|
|
149
|
+
type: 'TEXT',
|
|
150
|
+
absoluteBoundingBox: box(0, 800, 120, 30),
|
|
151
|
+
characters: 'Archetyp',
|
|
152
|
+
style: { fontSize: 16 },
|
|
153
|
+
},
|
|
154
|
+
// ── GROUP (connector target C3) ──
|
|
155
|
+
{
|
|
156
|
+
id: '2:62',
|
|
157
|
+
name: 'Group 13935',
|
|
158
|
+
type: 'GROUP',
|
|
159
|
+
absoluteBoundingBox: box(400, 800, 300, 200),
|
|
160
|
+
children: [
|
|
161
|
+
{
|
|
162
|
+
id: '2:54',
|
|
163
|
+
name: 'member a',
|
|
164
|
+
type: 'RECTANGLE',
|
|
165
|
+
absoluteBoundingBox: box(400, 800, 100, 100),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: '2:58',
|
|
169
|
+
name: 'member b',
|
|
170
|
+
type: 'RECTANGLE',
|
|
171
|
+
absoluteBoundingBox: box(550, 800, 100, 100),
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
// ── rotation round-trip ──
|
|
176
|
+
{
|
|
177
|
+
id: '2:63',
|
|
178
|
+
name: 'Rotated',
|
|
179
|
+
type: 'SHAPE_WITH_TEXT',
|
|
180
|
+
shapeType: 'SQUARE',
|
|
181
|
+
absoluteBoundingBox: box(900, 800, 150, 150),
|
|
182
|
+
rotation: 15,
|
|
183
|
+
},
|
|
184
|
+
// ── connectors ──
|
|
185
|
+
{
|
|
186
|
+
id: '2:67', // happy path: shape → shape
|
|
187
|
+
name: 'C1',
|
|
188
|
+
type: 'CONNECTOR',
|
|
189
|
+
absoluteBoundingBox: box(200, 500, 100, 10),
|
|
190
|
+
connectorStart: { endpointNodeId: '2:17' },
|
|
191
|
+
connectorEnd: { endpointNodeId: '2:21' },
|
|
192
|
+
connectorStartCap: 'NONE',
|
|
193
|
+
connectorEndCap: 'ARROW_LINES',
|
|
194
|
+
connectorLineType: 'ELBOWED',
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: '2:71', // THE WIDENING CASE: → TEXT
|
|
198
|
+
name: 'C2',
|
|
199
|
+
type: 'CONNECTOR',
|
|
200
|
+
absoluteBoundingBox: box(900, 600, 100, 10),
|
|
201
|
+
connectorStart: { endpointNodeId: '2:28' },
|
|
202
|
+
connectorEnd: { endpointNodeId: '2:50' },
|
|
203
|
+
connectorEndCap: 'ARROW_LINES',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: '2:75', // THE MUST-DEGRADE CASE: → GROUP
|
|
207
|
+
name: 'C3',
|
|
208
|
+
type: 'CONNECTOR',
|
|
209
|
+
absoluteBoundingBox: box(700, 600, 100, 10),
|
|
210
|
+
connectorStart: { endpointNodeId: '2:24' },
|
|
211
|
+
connectorEnd: { endpointNodeId: '2:62' },
|
|
212
|
+
connectorEndCap: 'ARROW_EQUILATERAL',
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: '2:79', // across a section boundary, sticky-as-host
|
|
216
|
+
name: 'C4',
|
|
217
|
+
type: 'CONNECTOR',
|
|
218
|
+
absoluteBoundingBox: box(0, 200, 100, 10),
|
|
219
|
+
connectorStart: { endpointNodeId: '1:8' },
|
|
220
|
+
connectorEnd: { endpointNodeId: '2:17' },
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: '2:83', // no arrowheads at all, STRAIGHT
|
|
224
|
+
name: 'C5',
|
|
225
|
+
type: 'CONNECTOR',
|
|
226
|
+
absoluteBoundingBox: box(1300, 600, 100, 10),
|
|
227
|
+
connectorStart: { endpointNodeId: '2:32' },
|
|
228
|
+
connectorEnd: { endpointNodeId: '2:36' },
|
|
229
|
+
connectorStartCap: 'NONE',
|
|
230
|
+
connectorEndCap: 'NONE',
|
|
231
|
+
connectorLineType: 'STRAIGHT',
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: '2:99', // DEGENERATE self-connector (observed on the real board)
|
|
235
|
+
name: 'C-self',
|
|
236
|
+
type: 'CONNECTOR',
|
|
237
|
+
absoluteBoundingBox: box(0, 0, 0, 0),
|
|
238
|
+
connectorStart: { endpointNodeId: '2:62' },
|
|
239
|
+
connectorEnd: { endpointNodeId: '2:62' },
|
|
240
|
+
},
|
|
241
|
+
// ── unmappable node types ──
|
|
242
|
+
{ id: '3:1', name: 'W', type: 'WIDGET', absoluteBoundingBox: box(0, 1200, 100, 100) },
|
|
243
|
+
{ id: '3:2', name: 'T', type: 'TABLE', absoluteBoundingBox: box(200, 1200, 100, 100) },
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function translate() {
|
|
251
|
+
const doc = normalizeDocument(fixtureBoard(), { fileKey: KEY, surface: 'board' });
|
|
252
|
+
return toStrokes(doc, { resetIds: true });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const byFigmaNode = (result: ReturnType<typeof translate>, tool: string): Stroke[] =>
|
|
256
|
+
result.strokes.filter((s) => s.tool === tool);
|
|
257
|
+
|
|
258
|
+
describe('coordinates', () => {
|
|
259
|
+
test('the board is translated to a positive-space origin', () => {
|
|
260
|
+
// Untranslated, this board starts at (−3244, −6272) — tens of thousands of
|
|
261
|
+
// px off-screen. Every stroke must land at or after (0, 0).
|
|
262
|
+
const { strokes, origin } = translate();
|
|
263
|
+
expect(origin).toEqual({ x: -3244, y: -6272 });
|
|
264
|
+
for (const s of strokes) {
|
|
265
|
+
if ('x' in s && typeof s.x === 'number') expect(s.x).toBeGreaterThanOrEqual(0);
|
|
266
|
+
if ('y' in s && typeof s.y === 'number') expect(s.y).toBeGreaterThanOrEqual(0);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('stickies', () => {
|
|
272
|
+
test('absolute geometry is PRESERVED — 240×240 and the 416-wide variant', () => {
|
|
273
|
+
const stickies = byFigmaNode(translate(), 'sticky') as StickyStroke[];
|
|
274
|
+
const sizes = stickies.map((s) => `${s.w}x${s.h}`).sort();
|
|
275
|
+
// Never normalised to Maude's STICKY_DEFAULT_W (200) — that collapses layout.
|
|
276
|
+
expect(sizes).toEqual(['240x240', '240x240', '416x240']);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('colours snap onto the sticky paper palette', () => {
|
|
280
|
+
const stickies = byFigmaNode(translate(), 'sticky') as StickyStroke[];
|
|
281
|
+
for (const s of stickies) {
|
|
282
|
+
expect(STICKY_PALETTE as readonly string[]).toContain(s.color);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('nearestStickyColor picks the closest paper tint', () => {
|
|
287
|
+
expect(nearestStickyColor('#ffffff')).toBe('#ffffff');
|
|
288
|
+
expect(nearestStickyColor('#fdf0b0')).toBe('#fce8a6');
|
|
289
|
+
expect(nearestStickyColor(null)).toBe(STICKY_PALETTE[0]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('sticky body text survives', () => {
|
|
293
|
+
const stickies = byFigmaNode(translate(), 'sticky') as StickyStroke[];
|
|
294
|
+
expect(stickies.map((s) => s.text)).toContain('Persona A');
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('sections', () => {
|
|
299
|
+
test('nested sections both land, FLAT — nesting survives as geometry', () => {
|
|
300
|
+
const sections = byFigmaNode(translate(), 'section') as SectionStroke[];
|
|
301
|
+
expect(sections.length).toBe(2);
|
|
302
|
+
// SectionStroke has no parent field, by design.
|
|
303
|
+
for (const s of sections) expect('parentId' in s).toBe(false);
|
|
304
|
+
const [outer, inner] = sections.sort((a, b) => b.w - a.w);
|
|
305
|
+
// The inner one is geometrically contained in the outer one — which is how
|
|
306
|
+
// Maude sections carry their contents (drag carries what is inside).
|
|
307
|
+
expect(inner.x).toBeGreaterThanOrEqual(outer.x);
|
|
308
|
+
expect(inner.y).toBeGreaterThanOrEqual(outer.y);
|
|
309
|
+
expect(inner.x + inner.w).toBeLessThanOrEqual(outer.x + outer.w);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('section labels are carried', () => {
|
|
313
|
+
const labels = (byFigmaNode(translate(), 'section') as SectionStroke[]).map((s) => s.label);
|
|
314
|
+
expect(labels).toContain('Sekce vnitřní (nested)');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('shape vocabulary', () => {
|
|
319
|
+
test('the six mappable shapeTypes land on their Maude primitives', () => {
|
|
320
|
+
const { strokes } = translate();
|
|
321
|
+
const rects = strokes.filter((s) => s.tool === 'rect');
|
|
322
|
+
const ellipses = strokes.filter((s) => s.tool === 'ellipse');
|
|
323
|
+
const polys = strokes.filter((s) => s.tool === 'polygon');
|
|
324
|
+
// SQUARE + ROUNDED_RECTANGLE + the rotated SQUARE + the two GROUP members.
|
|
325
|
+
expect(rects.length).toBeGreaterThanOrEqual(3);
|
|
326
|
+
expect(ellipses.length).toBe(1);
|
|
327
|
+
expect(polys.map((s) => (s as { shape: string }).shape).sort()).toEqual([
|
|
328
|
+
'diamond',
|
|
329
|
+
'triangle',
|
|
330
|
+
'triangle-down',
|
|
331
|
+
]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('ROUNDED_RECTANGLE carries its cornerRadius', () => {
|
|
335
|
+
const { strokes } = translate();
|
|
336
|
+
const rounded = strokes.find((s) => s.tool === 'rect' && 'cornerRadius' in s && s.cornerRadius);
|
|
337
|
+
expect(rounded).toBeDefined();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('the two unmappable shapeTypes are SKIPPED AND REPORTED, never approximated', () => {
|
|
341
|
+
const { report, strokes } = translate();
|
|
342
|
+
const unmappable = report.entries.filter((e) => e.disposition === 'unmappable-shape');
|
|
343
|
+
expect(unmappable.map((e) => e.nodeId).sort()).toEqual(['2:40', '2:44']);
|
|
344
|
+
expect(unmappable.map((e) => e.detail).sort()).toEqual(['ENG_DATABASE', 'PARALLELOGRAM_RIGHT']);
|
|
345
|
+
// And nothing was emitted in their place.
|
|
346
|
+
expect(strokes.some((s) => s.id.includes('_2_40_'))).toBe(false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('unmappable NODE TYPES are reported too', () => {
|
|
350
|
+
const { report } = translate();
|
|
351
|
+
const types = report.entries
|
|
352
|
+
.filter((e) => e.disposition === 'unmappable-type')
|
|
353
|
+
.map((e) => e.type)
|
|
354
|
+
.sort();
|
|
355
|
+
expect(types).toEqual(['TABLE', 'WIDGET']);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('rotation round-trips (Figma CW → Maude CCW)', () => {
|
|
359
|
+
const { strokes } = translate();
|
|
360
|
+
const rotated = strokes.find((s) => 'rotation' in s && s.rotation);
|
|
361
|
+
expect(rotated?.rotation).toBe(-15);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('groups → the flat groupIds tag array', () => {
|
|
366
|
+
test('members carry the group tag; no group STROKE is invented', () => {
|
|
367
|
+
const { strokes } = translate();
|
|
368
|
+
const members = strokes.filter((s) => s.groupIds?.length);
|
|
369
|
+
expect(members.length).toBeGreaterThanOrEqual(2);
|
|
370
|
+
// Maude has no group object — only tags.
|
|
371
|
+
expect(strokes.some((s) => (s as { tool: string }).tool === 'group')).toBe(false);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("a nested section's sticky carries no spurious group tag", () => {
|
|
375
|
+
const { strokes } = translate();
|
|
376
|
+
const persona = strokes.find((s) => s.tool === 'sticky' && s.text === 'Persona A');
|
|
377
|
+
expect(persona?.groupIds).toBeUndefined();
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe('connectors — the flagship', () => {
|
|
382
|
+
const arrows = () => byFigmaNode(translate(), 'arrow') as ArrowStroke[];
|
|
383
|
+
|
|
384
|
+
test('the happy path binds BOTH ends', () => {
|
|
385
|
+
const { strokes } = translate();
|
|
386
|
+
const all = strokes.filter((s) => s.tool === 'arrow') as ArrowStroke[];
|
|
387
|
+
const bound = all.filter((a) => a.startBind && a.endBind);
|
|
388
|
+
expect(bound.length).toBeGreaterThanOrEqual(3);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('C2 (→ TEXT) produces a LIVE bind — the isBindable widening case', () => {
|
|
392
|
+
// Before DDR-216 D9 this endpoint degraded to a frozen line. "Always
|
|
393
|
+
// editable" means it stays a re-routable arrow.
|
|
394
|
+
const { strokes } = translate();
|
|
395
|
+
const textStroke = strokes.find((s) => s.tool === 'text' && s.text === 'Archetyp');
|
|
396
|
+
expect(textStroke).toBeDefined();
|
|
397
|
+
expect(isBindable(textStroke as Stroke)).toBe(true);
|
|
398
|
+
const bound = (strokes.filter((s) => s.tool === 'arrow') as ArrowStroke[]).find(
|
|
399
|
+
(a) => a.endBind?.hostId === textStroke?.id
|
|
400
|
+
);
|
|
401
|
+
expect(bound).toBeDefined();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('C3 (→ GROUP) DEGRADES to the group bbox and reports it', () => {
|
|
405
|
+
const { report } = translate();
|
|
406
|
+
const degraded = report.entries.filter((e) => e.disposition === 'bind-degraded-to-bbox');
|
|
407
|
+
expect(degraded.map((e) => e.nodeId)).toContain('2:75');
|
|
408
|
+
expect(degraded[0].detail).toBe('group endpoint');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('a degenerate self-connector is dropped, not emitted zero-length', () => {
|
|
412
|
+
const { report, strokes } = translate();
|
|
413
|
+
expect(report.entries.some((e) => e.disposition === 'bind-dropped-self-connector')).toBe(true);
|
|
414
|
+
const zeroLength = (strokes.filter((s) => s.tool === 'arrow') as ArrowStroke[]).filter(
|
|
415
|
+
(a) => a.x1 === a.x2 && a.y1 === a.y2
|
|
416
|
+
);
|
|
417
|
+
expect(zeroLength.length).toBe(0);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('arrowhead caps map from the FigJam enum', () => {
|
|
421
|
+
const all = arrows();
|
|
422
|
+
const heads = all.map((a) => `${a.startHead}/${a.endHead}`);
|
|
423
|
+
expect(heads).toContain('none/line');
|
|
424
|
+
expect(heads).toContain('none/triangle');
|
|
425
|
+
expect(heads).toContain('none/none');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('line types map', () => {
|
|
429
|
+
const types = arrows().map((a) => a.lineType);
|
|
430
|
+
expect(types).toContain('elbow');
|
|
431
|
+
expect(types).toContain('straight');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('a sticky hosts a bind across a section boundary', () => {
|
|
435
|
+
const { strokes } = translate();
|
|
436
|
+
const persona = strokes.find((s) => s.tool === 'sticky' && s.text === 'Persona A');
|
|
437
|
+
const bound = (strokes.filter((s) => s.tool === 'arrow') as ArrowStroke[]).find(
|
|
438
|
+
(a) => a.startBind?.hostId === persona?.id
|
|
439
|
+
);
|
|
440
|
+
expect(bound).toBeDefined();
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe('the hostile layer name never becomes anything executable', () => {
|
|
445
|
+
test('it is not used as a stroke id, and the text that ships is sanitized', () => {
|
|
446
|
+
const { strokes } = translate();
|
|
447
|
+
for (const s of strokes) {
|
|
448
|
+
// Ids derive from NODE IDS only.
|
|
449
|
+
expect(s.id).toMatch(/^fig_[0-9_]+(_g|_label)?_[0-9]+$/);
|
|
450
|
+
expect(s.id).not.toContain('<');
|
|
451
|
+
expect(s.id).not.toContain('žluť');
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('the emitted SVG round-trips through the canonical serializer', () => {
|
|
456
|
+
// The discipline that makes this translator safe: it never hand-writes SVG,
|
|
457
|
+
// so it can never emit a shape the canvas would reject.
|
|
458
|
+
const { strokes } = translate();
|
|
459
|
+
const svg = strokesToSvg(strokes);
|
|
460
|
+
expect(svg.startsWith('<svg')).toBe(true);
|
|
461
|
+
// A raw `<b>` from the hostile name must not survive as markup.
|
|
462
|
+
expect(svg).not.toContain('<b>');
|
|
463
|
+
expect(svg).not.toContain('<script');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// The sanitize → re-parse round-trip needs a DOMParser, which bun:test does
|
|
467
|
+
// not expose. It lives in `to-strokes-roundtrip.test.ts`, which registers
|
|
468
|
+
// happy-dom for that file only — the same split the existing
|
|
469
|
+
// annotations-layer / annotations-roundtrip pair already uses.
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe('hidden content is not emitted', () => {
|
|
473
|
+
test('visible:false nodes are skipped and reported', () => {
|
|
474
|
+
const doc = normalizeDocument(
|
|
475
|
+
{
|
|
476
|
+
id: '0:0',
|
|
477
|
+
name: 'root',
|
|
478
|
+
type: 'CANVAS',
|
|
479
|
+
children: [
|
|
480
|
+
{
|
|
481
|
+
id: '9:1',
|
|
482
|
+
name: 'hidden',
|
|
483
|
+
type: 'STICKY',
|
|
484
|
+
visible: false,
|
|
485
|
+
characters: 'you cannot see me',
|
|
486
|
+
absoluteBoundingBox: box(0, 0, 240, 240),
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
{ fileKey: KEY, surface: 'board' }
|
|
491
|
+
);
|
|
492
|
+
const { strokes, report } = toStrokes(doc, { resetIds: true });
|
|
493
|
+
expect(strokes.length).toBe(0);
|
|
494
|
+
expect(report.entries.some((e) => e.disposition === 'hidden-node-skipped')).toBe(true);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('a zero-glyph payload in sticky text is stripped and reported', () => {
|
|
498
|
+
const payload = [...'exfiltrate']
|
|
499
|
+
.map((c) => String.fromCodePoint(0xe0000 + c.charCodeAt(0)))
|
|
500
|
+
.join('');
|
|
501
|
+
const doc = normalizeDocument(
|
|
502
|
+
{
|
|
503
|
+
id: '0:0',
|
|
504
|
+
name: 'root',
|
|
505
|
+
type: 'CANVAS',
|
|
506
|
+
children: [
|
|
507
|
+
{
|
|
508
|
+
id: '9:2',
|
|
509
|
+
name: 'sticky',
|
|
510
|
+
type: 'STICKY',
|
|
511
|
+
characters: `Retro${payload}`,
|
|
512
|
+
absoluteBoundingBox: box(0, 0, 240, 240),
|
|
513
|
+
},
|
|
514
|
+
],
|
|
515
|
+
},
|
|
516
|
+
{ fileKey: KEY, surface: 'board' }
|
|
517
|
+
);
|
|
518
|
+
const { strokes, report } = toStrokes(doc, { resetIds: true });
|
|
519
|
+
expect((strokes[0] as StickyStroke).text).toBe('Retro');
|
|
520
|
+
expect(report.entries.some((e) => e.disposition === 'hidden-chars-dropped')).toBe(true);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('a 1px text node is normalized UP to the readable floor', () => {
|
|
524
|
+
const doc = normalizeDocument(
|
|
525
|
+
{
|
|
526
|
+
id: '0:0',
|
|
527
|
+
name: 'root',
|
|
528
|
+
type: 'CANVAS',
|
|
529
|
+
children: [
|
|
530
|
+
{
|
|
531
|
+
id: '9:3',
|
|
532
|
+
name: 'tiny',
|
|
533
|
+
type: 'TEXT',
|
|
534
|
+
characters: 'instructions for the assistant',
|
|
535
|
+
style: { fontSize: 1 },
|
|
536
|
+
absoluteBoundingBox: box(0, 0, 500, 4),
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
},
|
|
540
|
+
{ fileKey: KEY, surface: 'board' }
|
|
541
|
+
);
|
|
542
|
+
const { strokes, report } = toStrokes(doc, { resetIds: true });
|
|
543
|
+
expect((strokes[0] as { fontSize: number }).fontSize).toBeGreaterThanOrEqual(8);
|
|
544
|
+
expect(report.entries.some((e) => e.disposition === 'text-normalized')).toBe(true);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('white-on-white text is normalized to visible ink, not dropped', () => {
|
|
548
|
+
const doc = normalizeDocument(
|
|
549
|
+
{
|
|
550
|
+
id: '0:0',
|
|
551
|
+
name: 'root',
|
|
552
|
+
type: 'CANVAS',
|
|
553
|
+
children: [
|
|
554
|
+
{
|
|
555
|
+
id: '9:4',
|
|
556
|
+
name: 'invisible',
|
|
557
|
+
type: 'TEXT',
|
|
558
|
+
characters: 'hidden note',
|
|
559
|
+
fills: solid(1, 1, 1),
|
|
560
|
+
absoluteBoundingBox: box(0, 0, 200, 20),
|
|
561
|
+
},
|
|
562
|
+
],
|
|
563
|
+
},
|
|
564
|
+
{ fileKey: KEY, surface: 'board' }
|
|
565
|
+
);
|
|
566
|
+
const { strokes, report } = toStrokes(doc, { resetIds: true });
|
|
567
|
+
expect((strokes[0] as { color: string }).color).not.toBe('#ffffff');
|
|
568
|
+
expect(report.entries.some((e) => e.disposition === 'text-normalized')).toBe(true);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe('image fills become pending assets, never a hotlink', () => {
|
|
573
|
+
test('an IMAGE fill yields an ImageStroke with an empty href + a pending entry', () => {
|
|
574
|
+
const doc = normalizeDocument(
|
|
575
|
+
{
|
|
576
|
+
id: '0:0',
|
|
577
|
+
name: 'root',
|
|
578
|
+
type: 'CANVAS',
|
|
579
|
+
children: [
|
|
580
|
+
{
|
|
581
|
+
id: '9:5',
|
|
582
|
+
name: 'photo',
|
|
583
|
+
type: 'RECTANGLE',
|
|
584
|
+
absoluteBoundingBox: box(0, 0, 300, 200),
|
|
585
|
+
fills: [{ type: 'IMAGE', visible: true, imageRef: 'abc123' }],
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
},
|
|
589
|
+
{ fileKey: KEY, surface: 'board' }
|
|
590
|
+
);
|
|
591
|
+
const { strokes, pendingImages, report } = toStrokes(doc, { resetIds: true });
|
|
592
|
+
expect(strokes[0].tool).toBe('image');
|
|
593
|
+
expect((strokes[0] as { href: string }).href).toBe('');
|
|
594
|
+
expect(pendingImages).toEqual([{ strokeId: strokes[0].id, nodeId: '9:5', imageRef: 'abc123' }]);
|
|
595
|
+
expect(report.entries.some((e) => e.disposition === 'asset-pending')).toBe(true);
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
describe('loose vector artwork (the missing flow arrows)', () => {
|
|
600
|
+
test('a VECTOR becomes an image stroke rendered by Figma, not a dropped node', () => {
|
|
601
|
+
// Phase 0 of the live StudyFi file draws the flow between onboarding screens
|
|
602
|
+
// with NINE hand-drawn VECTOR nodes ("Arrow 35/37/38/…"), not CONNECTORs.
|
|
603
|
+
// They used to fall through to `unmappable-type`: side by side against
|
|
604
|
+
// Figma, the screens were right and the flow between them was simply gone.
|
|
605
|
+
const doc = normalizeDocument(
|
|
606
|
+
{
|
|
607
|
+
id: '0:0',
|
|
608
|
+
name: 'Page',
|
|
609
|
+
type: 'CANVAS',
|
|
610
|
+
children: [
|
|
611
|
+
{
|
|
612
|
+
id: '1:5',
|
|
613
|
+
name: 'Arrow 35',
|
|
614
|
+
type: 'VECTOR',
|
|
615
|
+
visible: true,
|
|
616
|
+
absoluteBoundingBox: { x: 0, y: 0, width: 60, height: 12 },
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
},
|
|
620
|
+
{ fileKey: 'dGNzRC2kmrmGnOxaBa0RI7', surface: 'board' }
|
|
621
|
+
);
|
|
622
|
+
const out = toStrokes(doc, { resetIds: true });
|
|
623
|
+
|
|
624
|
+
expect(out.strokes).toHaveLength(1);
|
|
625
|
+
expect(out.strokes[0]).toMatchObject({ tool: 'image', w: 60, h: 12 });
|
|
626
|
+
expect(out.report.entries.some((e) => e.disposition === 'unmappable-type')).toBe(false);
|
|
627
|
+
// PNG, not SVG — see below. `imageRef` is null because a vector has no
|
|
628
|
+
// Figma image handle; the NODE itself is what gets rendered.
|
|
629
|
+
expect(out.pendingImages).toHaveLength(1);
|
|
630
|
+
expect(out.pendingImages[0].format).toBe('png');
|
|
631
|
+
expect(out.pendingImages[0].imageRef).toBeNull();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test('a stroked path is sized by its RENDER bounds, not its geometric box', () => {
|
|
635
|
+
// The nine Phase-0 arrows are horizontal, so their geometric box is
|
|
636
|
+
// 121 × 0.0001 while what Figma actually draws is 121 × 22.09 (a 3px stroke
|
|
637
|
+
// plus the arrowhead). Sized by the geometric box the image is present,
|
|
638
|
+
// correctly referenced, and invisible — which is exactly how this shipped
|
|
639
|
+
// "working" twice.
|
|
640
|
+
const doc = normalizeDocument(
|
|
641
|
+
{
|
|
642
|
+
id: '0:0',
|
|
643
|
+
name: 'Page',
|
|
644
|
+
type: 'CANVAS',
|
|
645
|
+
children: [
|
|
646
|
+
{
|
|
647
|
+
id: '1:5',
|
|
648
|
+
name: 'Arrow 35',
|
|
649
|
+
type: 'VECTOR',
|
|
650
|
+
visible: true,
|
|
651
|
+
strokeWeight: 3,
|
|
652
|
+
absoluteBoundingBox: { x: 800, y: 1238, width: 121, height: 0.0001 },
|
|
653
|
+
absoluteRenderBounds: { x: 800, y: 1227, width: 121, height: 22.09 },
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
},
|
|
657
|
+
{ fileKey: 'dGNzRC2kmrmGnOxaBa0RI7', surface: 'board' }
|
|
658
|
+
);
|
|
659
|
+
const out = toStrokes(doc, { resetIds: true });
|
|
660
|
+
expect(out.strokes[0]).toMatchObject({ tool: 'image', w: 121 });
|
|
661
|
+
expect((out.strokes[0] as { h: number }).h).toBeCloseTo(22.09, 1);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test('a node with no render bounds still gets a non-zero box', () => {
|
|
665
|
+
const doc = normalizeDocument(
|
|
666
|
+
{
|
|
667
|
+
id: '0:0',
|
|
668
|
+
name: 'Page',
|
|
669
|
+
type: 'CANVAS',
|
|
670
|
+
children: [
|
|
671
|
+
{
|
|
672
|
+
id: '1:6',
|
|
673
|
+
name: 'Hairline',
|
|
674
|
+
type: 'LINE',
|
|
675
|
+
visible: true,
|
|
676
|
+
absoluteBoundingBox: { x: 0, y: 0, width: 80, height: 0 },
|
|
677
|
+
},
|
|
678
|
+
],
|
|
679
|
+
},
|
|
680
|
+
{ fileKey: 'dGNzRC2kmrmGnOxaBa0RI7', surface: 'board' }
|
|
681
|
+
);
|
|
682
|
+
const out = toStrokes(doc, { resetIds: true });
|
|
683
|
+
expect((out.strokes[0] as { h: number }).h).toBeGreaterThanOrEqual(1);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test('the rendered href SURVIVES sanitization — svg would be silently stripped', () => {
|
|
687
|
+
// The bug this pins cost an entire debugging round. Asking Figma for `svg`
|
|
688
|
+
// resolved fine, rewrote fine, and passed every count check — then
|
|
689
|
+
// `ASSET_IMAGE_HREF_RE` (png/jpeg/webp/gif only, because a nested SVG in an
|
|
690
|
+
// `<image>` is a script vector on a peer-synced board) stripped the HREF
|
|
691
|
+
// while KEEPING the element. The arrow rendered as nothing.
|
|
692
|
+
//
|
|
693
|
+
// Asserting the element survives is not enough. Assert the href does.
|
|
694
|
+
const withHref = (href: string) =>
|
|
695
|
+
sanitizeAnnotationSvg(
|
|
696
|
+
strokesToSvg([{ id: 'i1', tool: 'image', x: 0, y: 0, w: 60, h: 12, href } as never])
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
expect(withHref('assets/abc12345.png')).toContain('assets/abc12345.png');
|
|
700
|
+
expect(withHref('assets/abc12345.svg')).not.toContain('assets/abc12345.svg');
|
|
701
|
+
// …and the element itself stays either way, which is exactly why counting
|
|
702
|
+
// `<image>` tags proved nothing.
|
|
703
|
+
expect(withHref('assets/abc12345.svg')).toContain('<image');
|
|
704
|
+
});
|
|
705
|
+
});
|