@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.
Files changed (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,513 @@
1
+ /**
2
+ * @file figma/client.ts — the Figma REST client (DDR-216 D2/D4/D5).
3
+ * @scope apps/studio/figma/client.ts
4
+ * @purpose Fetch a document, its nodes, its rendered images and its styles —
5
+ * and nothing else. Produces the normalized tree in `types.ts`.
6
+ *
7
+ * @invariant SSRF CHOKEPOINT 1 — every request URL is composed from the
8
+ * hardcoded `API_BASE` below plus values already charset-validated
9
+ * by `url.ts`, each `encodeURIComponent`-ed. **No caller-supplied
10
+ * host, scheme, port or path prefix ever reaches a request.** This
11
+ * is the closure Round 1 of the DDR-216 security review attacked
12
+ * directly and could not break; if you are adding a method, compose
13
+ * it the same way rather than accepting a URL.
14
+ *
15
+ * @invariant THE TOKEN IS RESOLVED AT REQUEST TIME AND NEVER CACHED, LOGGED,
16
+ * OR RETURNED. `getProviderKey('figma')` is called inside the
17
+ * request that needs it. Errors are built from code-owned strings
18
+ * and the request PATH — never headers, never a raw response body
19
+ * (DDR-216 D2 + D10; `figma-routes.test.ts` asserts it).
20
+ *
21
+ * @invariant Figma's `/v1/images` answers with URLs Maude did not choose. This
22
+ * module returns them; it NEVER downloads one. That goes through
23
+ * `_fetch-asset.mjs`'s gate (DDR-216 D4 chokepoint 2 / D11).
24
+ */
25
+
26
+ import { getProviderKey } from '../generation/keys.ts';
27
+ import {
28
+ FigmaCapError,
29
+ MAX_RESPONSE_BYTES,
30
+ type NormalizedDocument,
31
+ normalizeDocument,
32
+ } from './types.ts';
33
+
34
+ /** The ONLY base. Never derived from input, never overridable at runtime. */
35
+ const API_BASE = 'https://api.figma.com/v1';
36
+
37
+ /** Whole-request budget. A slow file is a failure, not an indefinite hang. */
38
+ const REQUEST_TIMEOUT_MS = 30_000;
39
+
40
+ /** Bounded 429 backoff (DDR-216 D5) — never an unbounded sleep loop. */
41
+ const MAX_RETRIES = 3;
42
+ const MAX_BACKOFF_TOTAL_MS = 30_000;
43
+ /** Per-retry ceiling — 3 × this stays inside the total budget above. */
44
+ const MAX_SINGLE_BACKOFF_MS = 10_000;
45
+
46
+ /**
47
+ * `IMAGE_COST = 200` ⇒ ~30 req/min. Batch ids into as few calls as the endpoint
48
+ * allows; never one call per node (DDR-216 D5).
49
+ */
50
+ export const MAX_IMAGE_BATCH = 100;
51
+
52
+ export type FigmaErrorKind =
53
+ | 'not_configured'
54
+ | 'unauthorized'
55
+ | 'forbidden'
56
+ | 'not_found'
57
+ | 'rate_limited'
58
+ | 'too_large'
59
+ | 'network'
60
+ | 'bad_response';
61
+
62
+ export class FigmaApiError extends Error {
63
+ readonly kind: FigmaErrorKind;
64
+ readonly status?: number;
65
+ /** The request PATH only — never the query, never a header, never a body. */
66
+ readonly path: string;
67
+
68
+ constructor(kind: FigmaErrorKind, path: string, message: string, status?: number) {
69
+ super(message);
70
+ this.name = 'FigmaApiError';
71
+ this.kind = kind;
72
+ this.path = path;
73
+ if (status !== undefined) this.status = status;
74
+ }
75
+ }
76
+
77
+ /** Fixed, code-owned messages. No upstream string is ever interpolated. */
78
+ const MESSAGE_BY_KIND: Readonly<Record<FigmaErrorKind, string>> = {
79
+ not_configured: 'No Figma token configured — add one in Settings.',
80
+ unauthorized: 'Figma rejected the token — check it is current and has file_content:read.',
81
+ forbidden: 'Figma denied access to this resource for the configured token.',
82
+ not_found: 'Figma has no such file or node.',
83
+ rate_limited: 'Figma rate-limited this import — try again in a minute.',
84
+ too_large: 'Figma response is too large — import a specific frame instead.',
85
+ network: 'Could not reach the Figma API.',
86
+ bad_response: 'Figma returned a response this client could not read.',
87
+ };
88
+
89
+ function apiError(kind: FigmaErrorKind, path: string, status?: number): FigmaApiError {
90
+ return new FigmaApiError(kind, path, MESSAGE_BY_KIND[kind], status);
91
+ }
92
+
93
+ /**
94
+ * Compose a request URL. `path` is a code-owned literal with already-validated
95
+ * segments; `query` values are encoded here. There is deliberately no overload
96
+ * that accepts a full URL.
97
+ */
98
+ function buildUrl(path: string, query?: Record<string, string | undefined>): string {
99
+ const url = new URL(`${API_BASE}${path}`);
100
+ if (query) {
101
+ for (const [key, value] of Object.entries(query)) {
102
+ if (value !== undefined && value !== '') url.searchParams.set(key, value);
103
+ }
104
+ }
105
+ return url.toString();
106
+ }
107
+
108
+ function sleep(ms: number): Promise<void> {
109
+ return new Promise((resolve) => setTimeout(resolve, ms));
110
+ }
111
+
112
+ /**
113
+ * Parse `Retry-After` (delta-seconds only — the HTTP-date form is not worth a
114
+ * date parser here) and clamp it. An upstream header is untrusted input for
115
+ * scheduling purposes just like anything else: a hostile or buggy value must
116
+ * not be able to park this process for an hour.
117
+ */
118
+ export function retryAfterMs(header: string | null): number {
119
+ const seconds = header ? Number.parseInt(header, 10) : Number.NaN;
120
+ if (!Number.isFinite(seconds) || seconds < 0) return 1_000;
121
+ return Math.min(seconds * 1_000, MAX_SINGLE_BACKOFF_MS);
122
+ }
123
+
124
+ /**
125
+ * One authenticated GET. Resolves the token at call time, enforces the response
126
+ * byte cap while reading, and retries only on 429 within a bounded budget.
127
+ */
128
+ async function getJson<T>(path: string, query?: Record<string, string | undefined>): Promise<T> {
129
+ const token = await getProviderKey('figma');
130
+ if (!token) throw apiError('not_configured', path);
131
+
132
+ const url = buildUrl(path, query);
133
+ let spentBackoffMs = 0;
134
+
135
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
136
+ let res: Response;
137
+ try {
138
+ res = await fetch(url, {
139
+ headers: { 'X-Figma-Token': token, Accept: 'application/json' },
140
+ redirect: 'error', // the API never legitimately redirects
141
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
142
+ });
143
+ } catch {
144
+ // Deliberately swallow the cause: a fetch error message can carry the URL
145
+ // and, on some runtimes, request detail. D10 — output is code-owned.
146
+ throw apiError('network', path);
147
+ }
148
+
149
+ if (res.status === 429) {
150
+ const waitMs = retryAfterMs(res.headers.get('Retry-After'));
151
+ if (attempt === MAX_RETRIES || spentBackoffMs + waitMs > MAX_BACKOFF_TOTAL_MS) {
152
+ throw apiError('rate_limited', path, 429);
153
+ }
154
+ spentBackoffMs += waitMs;
155
+ await sleep(waitMs);
156
+ continue;
157
+ }
158
+
159
+ if (!res.ok) {
160
+ if (res.status === 401) throw apiError('unauthorized', path, 401);
161
+ if (res.status === 403) throw apiError('forbidden', path, 403);
162
+ if (res.status === 404) throw apiError('not_found', path, 404);
163
+ throw apiError('bad_response', path, res.status);
164
+ }
165
+
166
+ // Trust `Content-Length` as an early-out only; the real bound is the byte
167
+ // count of what actually arrived (a missing or lying header must not be
168
+ // the thing standing between an 8 MB cap and the heap).
169
+ const declared = Number.parseInt(res.headers.get('Content-Length') ?? '', 10);
170
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
171
+ throw apiError('too_large', path, res.status);
172
+ }
173
+
174
+ const buffer = await readCapped(res, path);
175
+ try {
176
+ return JSON.parse(new TextDecoder().decode(buffer)) as T;
177
+ } catch {
178
+ throw apiError('bad_response', path, res.status);
179
+ }
180
+ }
181
+
182
+ throw apiError('rate_limited', path, 429);
183
+ }
184
+
185
+ /**
186
+ * Read a response body, aborting the moment it exceeds the cap. Streaming
187
+ * rather than `res.arrayBuffer()` so an unbounded body is never fully
188
+ * materialized before the check — the cap has to hold against a response that
189
+ * does not declare its length.
190
+ */
191
+ async function readCapped(res: Response, path: string): Promise<Uint8Array> {
192
+ const body = res.body;
193
+ if (!body) throw apiError('bad_response', path, res.status);
194
+
195
+ const reader = body.getReader();
196
+ const chunks: Uint8Array[] = [];
197
+ let total = 0;
198
+ try {
199
+ while (true) {
200
+ const { done, value } = await reader.read();
201
+ if (done) break;
202
+ if (!value) continue;
203
+ total += value.byteLength;
204
+ if (total > MAX_RESPONSE_BYTES) {
205
+ await reader.cancel().catch(() => {});
206
+ throw apiError('too_large', path, res.status);
207
+ }
208
+ chunks.push(value);
209
+ }
210
+ } finally {
211
+ reader.releaseLock?.();
212
+ }
213
+
214
+ const out = new Uint8Array(total);
215
+ let offset = 0;
216
+ for (const chunk of chunks) {
217
+ out.set(chunk, offset);
218
+ offset += chunk.byteLength;
219
+ }
220
+ return out;
221
+ }
222
+
223
+ // ── Public surface ──────────────────────────────────────────────────────────
224
+
225
+ export interface FetchDocumentOptions {
226
+ fileKey: string;
227
+ surface: 'design' | 'board';
228
+ /** When present, fetches only this subtree — see the note below. */
229
+ nodeId?: string;
230
+ /** Figma's own tree-depth projection knob; independent of our own cap. */
231
+ depth?: number;
232
+ }
233
+
234
+ /**
235
+ * Fetch and normalize a document.
236
+ *
237
+ * **Prefers `/nodes` whenever a node id is present.** A whole enterprise file is
238
+ * tens of MB and blows both the byte cap and any reasonable memory budget;
239
+ * whole-file import is not a viable default, frame-scoped is (DDR-216 D5).
240
+ */
241
+ export async function fetchDocument(opts: FetchDocumentOptions): Promise<NormalizedDocument> {
242
+ const { fileKey, surface, nodeId, depth } = opts;
243
+ const meta = { fileKey, surface, origin: 'rest' as const };
244
+
245
+ if (nodeId) {
246
+ const path = `/files/${encodeURIComponent(fileKey)}/nodes`;
247
+ const body = await getJson<{ nodes?: Record<string, { document?: unknown }> }>(path, {
248
+ ids: nodeId,
249
+ depth: depth !== undefined ? String(depth) : undefined,
250
+ });
251
+ const entry = body?.nodes?.[nodeId];
252
+ if (!entry?.document) throw apiError('not_found', path, 404);
253
+ return normalizeDocument(entry.document, meta);
254
+ }
255
+
256
+ const path = `/files/${encodeURIComponent(fileKey)}`;
257
+ const body = await getJson<{ document?: unknown }>(path, {
258
+ depth: depth !== undefined ? String(depth) : undefined,
259
+ });
260
+ if (!body?.document) throw apiError('bad_response', path, 200);
261
+ return normalizeDocument(body.document, meta);
262
+ }
263
+
264
+ export interface FigmaImageResult {
265
+ /** nodeId → the URL Figma rendered it to, or null when it declined. */
266
+ images: Record<string, string | null>;
267
+ }
268
+
269
+ /**
270
+ * Ask Figma to render nodes. Returns URLs; **downloading is not this module's
271
+ * job** — the returned URLs are response-controlled and must go through
272
+ * `_fetch-asset.mjs`'s gate (DDR-216 D4/D11).
273
+ *
274
+ * Callers batch: `MAX_IMAGE_BATCH` ids per call, never one call per node.
275
+ */
276
+ export async function fetchImageUrls(
277
+ fileKey: string,
278
+ nodeIds: readonly string[],
279
+ format: 'png' | 'svg' | 'jpg' = 'png',
280
+ scale = 2,
281
+ opts: { outlineText?: boolean } = {}
282
+ ): Promise<FigmaImageResult> {
283
+ if (nodeIds.length === 0) return { images: {} };
284
+ if (nodeIds.length > MAX_IMAGE_BATCH) {
285
+ throw new FigmaCapError(
286
+ 'nodes',
287
+ `image batch of ${nodeIds.length} exceeds the ${MAX_IMAGE_BATCH}-id ceiling`
288
+ );
289
+ }
290
+ const path = `/images/${encodeURIComponent(fileKey)}`;
291
+ const body = await getJson<{ images?: Record<string, string | null>; err?: unknown }>(path, {
292
+ ids: nodeIds.join(','),
293
+ format,
294
+ // Scale is meaningless for svg and Figma rejects it there.
295
+ scale: format === 'svg' ? undefined : String(scale),
296
+ // Only meaningful for svg. `false` keeps real `<text>` runs in the output,
297
+ // which is what makes a rendered frame searchable instead of a picture of
298
+ // words. Omitted entirely for raster so the query stays byte-identical to
299
+ // what the asset lane has always sent.
300
+ svg_outline_text:
301
+ format === 'svg' && opts.outlineText !== undefined ? String(opts.outlineText) : undefined,
302
+ });
303
+ return { images: body?.images ?? {} };
304
+ }
305
+
306
+ /** One Figma comment, charset-bounded at the edge like every other name. */
307
+ export interface FigmaComment {
308
+ id: string;
309
+ /** UNTRUSTED free text. Never interpreted, only ever rendered as data. */
310
+ message: string;
311
+ /** The node the pin hangs off, when it is pinned to one. */
312
+ nodeId?: string;
313
+ /** Offset within that node, or absolute page coords for a canvas-level pin. */
314
+ x?: number;
315
+ y?: number;
316
+ /** Thread parent. Replies group under their root pin. */
317
+ parentId?: string;
318
+ resolved: boolean;
319
+ /** Display handle only — provenance, never an identifier we act on (D7). */
320
+ author?: string;
321
+ createdAt?: string;
322
+ }
323
+
324
+ /**
325
+ * A file's review comments — the annotations a designer actually left.
326
+ *
327
+ * These live nowhere in the document tree, which is why a tree-walking import
328
+ * misses every one of them (the live StudyFi file carries 133). Unresolved and
329
+ * resolved both come back; dropping resolved threads throws away the record of
330
+ * what was already decided, so that call belongs to the caller.
331
+ */
332
+ export async function fetchComments(fileKey: string): Promise<FigmaComment[]> {
333
+ const path = `/files/${encodeURIComponent(fileKey)}/comments`;
334
+ const body = await getJson<{ comments?: unknown[] }>(path);
335
+ const raw = body?.comments;
336
+ if (!Array.isArray(raw)) return [];
337
+
338
+ const out: FigmaComment[] = [];
339
+ for (const item of raw) {
340
+ if (!item || typeof item !== 'object') continue;
341
+ const c = item as Record<string, unknown>;
342
+ const id = typeof c.id === 'string' ? c.id : undefined;
343
+ if (!id) continue;
344
+ const meta = (c.client_meta ?? {}) as Record<string, unknown>;
345
+ const offset = (meta.node_offset ?? {}) as Record<string, unknown>;
346
+ const user = (c.user ?? {}) as Record<string, unknown>;
347
+
348
+ const num = (v: unknown): number | undefined =>
349
+ typeof v === 'number' && Number.isFinite(v) ? v : undefined;
350
+
351
+ out.push({
352
+ id,
353
+ message: typeof c.message === 'string' ? c.message : '',
354
+ nodeId: typeof meta.node_id === 'string' ? meta.node_id : undefined,
355
+ x: num(offset.x) ?? num(meta.x),
356
+ y: num(offset.y) ?? num(meta.y),
357
+ parentId: typeof c.parent_id === 'string' && c.parent_id ? c.parent_id : undefined,
358
+ resolved: Boolean(c.resolved_at),
359
+ author: typeof user.handle === 'string' ? user.handle : undefined,
360
+ createdAt: typeof c.created_at === 'string' ? c.created_at : undefined,
361
+ });
362
+ }
363
+ return out;
364
+ }
365
+
366
+ export interface FigmaStyleMeta {
367
+ key: string;
368
+ /** UNTRUSTED — charset-bounded before it becomes a token name (DDR-216 D6). */
369
+ name: string;
370
+ styleType: string;
371
+ /** UNTRUSTED and DELIBERATELY UNUSED — never carried into any output. */
372
+ description?: string;
373
+ nodeId?: string;
374
+ }
375
+
376
+ /** Paint / text / effect styles — the Phase-4 input. */
377
+ export async function fetchStyles(fileKey: string): Promise<FigmaStyleMeta[]> {
378
+ const path = `/files/${encodeURIComponent(fileKey)}/styles`;
379
+ const body = await getJson<{ meta?: { styles?: unknown[] } }>(path);
380
+ const raw = body?.meta?.styles;
381
+ if (!Array.isArray(raw)) return [];
382
+ const out: FigmaStyleMeta[] = [];
383
+ for (const item of raw) {
384
+ if (!item || typeof item !== 'object') continue;
385
+ const s = item as Record<string, unknown>;
386
+ const key = typeof s.key === 'string' ? s.key : undefined;
387
+ const styleType = typeof s.style_type === 'string' ? s.style_type : undefined;
388
+ if (!key || !styleType) continue;
389
+ const entry: FigmaStyleMeta = {
390
+ key,
391
+ name: typeof s.name === 'string' ? s.name : '',
392
+ styleType,
393
+ };
394
+ if (typeof s.node_id === 'string') entry.nodeId = s.node_id;
395
+ out.push(entry);
396
+ }
397
+ return out;
398
+ }
399
+
400
+ export interface FigmaVariablesResult {
401
+ /** False when the plan gates the endpoint — a NORMAL outcome, not an error. */
402
+ available: boolean;
403
+ raw?: unknown;
404
+ }
405
+
406
+ /**
407
+ * Local variables — richer than styles (modes → themes), but **Enterprise-plan
408
+ * gated**. A 403 here is the COMMON case (the dogfood account is Pro), so it
409
+ * degrades to `{ available: false }` and the caller says "using your styles"
410
+ * rather than surfacing a failure (DDR-216 D5).
411
+ */
412
+ export async function fetchLocalVariables(fileKey: string): Promise<FigmaVariablesResult> {
413
+ const path = `/files/${encodeURIComponent(fileKey)}/variables/local`;
414
+ try {
415
+ const body = await getJson<{ meta?: unknown }>(path);
416
+ return { available: true, raw: body?.meta };
417
+ } catch (err) {
418
+ if (err instanceof FigmaApiError && (err.kind === 'forbidden' || err.kind === 'not_found')) {
419
+ return { available: false };
420
+ }
421
+ throw err;
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Fetch MANY nodes by id, in batches, adapting to the response-byte cap.
427
+ *
428
+ * `fetchDocument` fetches one subtree, and a whole PAGE of a real file routinely
429
+ * blows the 8 MB cap — measured on a live StudyFi file, one page of 190
430
+ * top-level nodes did exactly that while its siblings came in at 1–7 k nodes.
431
+ * Refusing the page is the wrong answer when the caller wants the page: the
432
+ * cap is a bound on ONE RESPONSE, not on how much a caller may assemble.
433
+ *
434
+ * So: chunk the ids, and on a `too_large` HALVE the chunk and retry. A single
435
+ * id that still trips the cap is genuinely too big and is reported, not
436
+ * silently dropped. This keeps every individual request inside the cap while
437
+ * letting an import cover a page — the node-count and depth caps in
438
+ * `normalizeDocument` still bound the assembled total.
439
+ */
440
+ export async function fetchNodes(
441
+ fileKey: string,
442
+ ids: readonly string[],
443
+ opts: { chunk?: number; onSkip?: (id: string) => void } = {}
444
+ ): Promise<Map<string, unknown>> {
445
+ const out = new Map<string, unknown>();
446
+ const path = `/files/${encodeURIComponent(fileKey)}/nodes`;
447
+
448
+ const run = async (batch: readonly string[], chunk: number): Promise<void> => {
449
+ if (batch.length === 0) return;
450
+ try {
451
+ const body = await getJson<{ nodes?: Record<string, { document?: unknown }> }>(path, {
452
+ ids: batch.join(','),
453
+ });
454
+ for (const id of batch) {
455
+ const doc = body?.nodes?.[id]?.document;
456
+ if (doc) out.set(id, doc);
457
+ else opts.onSkip?.(id);
458
+ }
459
+ } catch (err) {
460
+ if (!(err instanceof FigmaApiError) || err.kind !== 'too_large') throw err;
461
+ if (batch.length === 1) {
462
+ // One node that alone exceeds the cap. Genuinely too big — report it
463
+ // rather than pretending the page imported whole.
464
+ opts.onSkip?.(batch[0]);
465
+ return;
466
+ }
467
+ const half = Math.max(1, Math.floor(batch.length / 2));
468
+ await run(batch.slice(0, half), half);
469
+ await run(batch.slice(half), half);
470
+ }
471
+ };
472
+
473
+ const size = Math.max(1, opts.chunk ?? 8);
474
+ for (let i = 0; i < ids.length; i += size) {
475
+ await run(ids.slice(i, i + size), size);
476
+ }
477
+ return out;
478
+ }
479
+
480
+ export interface FigmaPage {
481
+ id: string;
482
+ /** UNTRUSTED — charset-bounded before it becomes a filename. */
483
+ name: string;
484
+ }
485
+
486
+ /** The file's pages, cheaply (`depth: 1` — names and ids, no content). */
487
+ export async function fetchPages(fileKey: string): Promise<FigmaPage[]> {
488
+ const path = `/files/${encodeURIComponent(fileKey)}`;
489
+ const body = await getJson<{ document?: { children?: unknown[] } }>(path, { depth: '1' });
490
+ const kids = body?.document?.children;
491
+ if (!Array.isArray(kids)) return [];
492
+ const out: FigmaPage[] = [];
493
+ for (const k of kids) {
494
+ if (!k || typeof k !== 'object') continue;
495
+ const c = k as Record<string, unknown>;
496
+ if (c.type !== 'CANVAS') continue;
497
+ if (typeof c.id !== 'string') continue;
498
+ out.push({ id: c.id, name: typeof c.name === 'string' ? c.name : '' });
499
+ }
500
+ return out;
501
+ }
502
+
503
+ export interface FigmaIdentity {
504
+ /** UNTRUSTED — length/charset-bounded before display, never persisted. */
505
+ handle: string;
506
+ }
507
+
508
+ /** `GET /v1/me` — the Settings "probe this token" call. */
509
+ export async function fetchIdentity(): Promise<FigmaIdentity> {
510
+ const body = await getJson<{ handle?: unknown; email?: unknown }>('/me');
511
+ const handle = typeof body?.handle === 'string' ? body.handle : '';
512
+ return { handle };
513
+ }