@1agh/maude 0.58.1 → 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/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- 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 +129 -23
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- 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/client/styles/6-acp-chat.css +12 -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 +1491 -1491
- 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/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -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 +755 -32
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- 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/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- 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/fixtures/mock-acp-agent-wedged.mjs +37 -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-canvas-path.test.ts +200 -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 +465 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +434 -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 +36 -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,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,200 @@
|
|
|
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 FigmaEndpoints {
|
|
80
|
+
/** POST — store a PAT. Returns presence only, never the value. */
|
|
81
|
+
connect(body: unknown): FigmaEndpointResult;
|
|
82
|
+
/** DELETE — forget the PAT. */
|
|
83
|
+
disconnect(): FigmaEndpointResult;
|
|
84
|
+
/** GET — presence only. Reveals whether a key exists, never what it is. */
|
|
85
|
+
status(): FigmaEndpointResult;
|
|
86
|
+
/** POST — validate the stored token against `GET /v1/me`. */
|
|
87
|
+
probe(): Promise<FigmaEndpointResult>;
|
|
88
|
+
/** POST — run an import. Same work the CLI verb does, same guarantees. */
|
|
89
|
+
runImport(body: unknown): Promise<FigmaEndpointResult>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validate an import request into a typed shape.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately narrow: a mode from a fixed enum, a URL that `figma/url.ts` will
|
|
96
|
+
* reject if it is not a real Figma URL, and a boolean. No slug, no path, no
|
|
97
|
+
* output location — the caller does not get to choose where anything lands
|
|
98
|
+
* (the same reason DDR-174 has its orchestrator compute the target path rather
|
|
99
|
+
* than letting the producer pick one).
|
|
100
|
+
*/
|
|
101
|
+
export function parseImportRequest(body: unknown): FigmaImportRequest | null {
|
|
102
|
+
if (!body || typeof body !== 'object') return null;
|
|
103
|
+
const b = body as Record<string, unknown>;
|
|
104
|
+
const mode = b.mode;
|
|
105
|
+
if (mode !== 'board' && mode !== 'frames' && mode !== 'tokens') return null;
|
|
106
|
+
if (typeof b.url !== 'string' || b.url.length === 0 || b.url.length > 2048) return null;
|
|
107
|
+
return { url: b.url, mode, dryRun: b.dryRun === true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FigmaEndpointDeps {
|
|
111
|
+
/**
|
|
112
|
+
* Runs the import. Injected so this module stays HTTP- and fs-free (the same
|
|
113
|
+
* split github/endpoints.ts uses) and so the route can be tested without a
|
|
114
|
+
* network. The real implementation is `bin/_import-figma.mjs`.
|
|
115
|
+
*/
|
|
116
|
+
runImport?(req: FigmaImportRequest): Promise<{
|
|
117
|
+
summary: Record<string, unknown>;
|
|
118
|
+
}>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createFigmaEndpoints(deps: FigmaEndpointDeps = {}): FigmaEndpoints {
|
|
122
|
+
return {
|
|
123
|
+
connect(body: unknown): FigmaEndpointResult {
|
|
124
|
+
if (!body || typeof body !== 'object') {
|
|
125
|
+
return { status: 400, json: { error: 'token required' } };
|
|
126
|
+
}
|
|
127
|
+
const token = (body as Record<string, unknown>).token;
|
|
128
|
+
if (typeof token !== 'string' || !TOKEN_SHAPE_RE.test(token.trim())) {
|
|
129
|
+
// Fixed message — never echoes what was sent (DDR-216 D10).
|
|
130
|
+
return { status: 400, json: { error: 'that does not look like a Figma access token' } };
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
setProviderKey(FIGMA_PROVIDER_ID, token.trim());
|
|
134
|
+
} catch {
|
|
135
|
+
return { status: 400, json: { error: 'could not store the token' } };
|
|
136
|
+
}
|
|
137
|
+
// Presence flag only. Deliberately does NOT read the value back.
|
|
138
|
+
return { status: 200, json: { configured: isConfigured(FIGMA_PROVIDER_ID) } };
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
disconnect(): FigmaEndpointResult {
|
|
142
|
+
deleteProviderKey(FIGMA_PROVIDER_ID);
|
|
143
|
+
return { status: 200, json: { configured: false } };
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
status(): FigmaEndpointResult {
|
|
147
|
+
return {
|
|
148
|
+
status: 200,
|
|
149
|
+
json: {
|
|
150
|
+
configured: isConfigured(FIGMA_PROVIDER_ID),
|
|
151
|
+
tokenUrl: FIGMA_TOKEN_URL,
|
|
152
|
+
requiredScope: FIGMA_REQUIRED_SCOPE,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async probe(): Promise<FigmaEndpointResult> {
|
|
158
|
+
try {
|
|
159
|
+
const identity = await fetchIdentity();
|
|
160
|
+
return { status: 200, json: { ok: true, handle: boundDisplayHandle(identity.handle) } };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (err instanceof FigmaApiError) {
|
|
163
|
+
// `err.message` is a fixed, code-owned string from MESSAGE_BY_KIND —
|
|
164
|
+
// it carries no upstream text, no header and no token (D2/D10).
|
|
165
|
+
const status = err.kind === 'not_configured' ? 400 : 502;
|
|
166
|
+
return { status, json: { ok: false, reason: err.kind, error: err.message } };
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
status: 502,
|
|
170
|
+
json: { ok: false, reason: 'network', error: 'Figma probe failed.' },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async runImport(body: unknown): Promise<FigmaEndpointResult> {
|
|
176
|
+
const req = parseImportRequest(body);
|
|
177
|
+
if (!req) return { status: 400, json: { error: 'mode and url are required' } };
|
|
178
|
+
if (!deps.runImport) {
|
|
179
|
+
return { status: 501, json: { error: 'import is not available in this shell' } };
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const { summary } = await deps.runImport(req);
|
|
183
|
+
// The summary is the SAME code-generated, enum-coded accounting the CLI
|
|
184
|
+
// prints (DDR-216 D7/D10) — node ids and reason codes, never node text.
|
|
185
|
+
// It is safe to hand to a client precisely BECAUSE of that.
|
|
186
|
+
return { status: 200, json: { ok: true, ...summary } };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (err instanceof FigmaApiError) {
|
|
189
|
+
return {
|
|
190
|
+
status: err.kind === 'not_configured' ? 400 : 502,
|
|
191
|
+
json: { ok: false, reason: err.kind, error: err.message },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// Anything else is reported generically — an import failure message must
|
|
195
|
+
// never become a channel for an upstream string (D10).
|
|
196
|
+
return { status: 500, json: { ok: false, reason: 'failed', error: 'Import failed.' } };
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|