@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,264 @@
1
+ // FigmaImportPanel.jsx — paste a Figma URL, get real Maude artifacts (DDR-216).
2
+ //
3
+ // Structural prior: BrandUploadPanel (paste-a-thing → confirm → result). What
4
+ // this adds is the per-import SUMMARY, which is not decoration: the governing
5
+ // principle trades fidelity for editability, so some nodes legitimately arrive
6
+ // editable-but-different, and the feature is only honest if the user is told
7
+ // which. Every line of that summary is a NODE ID plus a fixed reason code —
8
+ // never node text (DDR-216 D7/D10).
9
+ //
10
+ // No inline `style=` anywhere: the canvas CSP is `style-src 'self'`, which
11
+ // silently DROPS inline styles rather than erroring, so a panel styled that way
12
+ // renders unstyled with no clue why.
13
+
14
+ import { useCallback, useEffect, useState } from 'react';
15
+
16
+ /** Human labels for the disposition enum. Keep in sync with figma/sanitize.ts. */
17
+ const DISPOSITION_LABEL = {
18
+ imported: 'imported',
19
+ 'hidden-chars-dropped': 'invisible characters removed',
20
+ 'hidden-node-skipped': 'hidden — not imported',
21
+ 'text-normalized': 'made readable (size or contrast)',
22
+ 'geometry-clamped': 'moved back into view',
23
+ 'truncated-text': 'text shortened to fit',
24
+ 'truncated-attr': 'name shortened',
25
+ 'unmappable-type': 'no Maude equivalent',
26
+ 'unmappable-shape': 'no Maude equivalent for this shape',
27
+ 'bind-degraded-to-bbox': 'arrow attached to the group outline',
28
+ 'bind-dropped-self-connector': 'connector pointed at itself — skipped',
29
+ 'asset-pending': 'image queued',
30
+ 'asset-skipped': 'image could not be fetched',
31
+ 'asset-cap-reached': 'image limit reached',
32
+ 'jsx-cap-reached': 'too large — trimmed',
33
+ 'value-rejected': 'a style value was not usable',
34
+ };
35
+
36
+ const MODES = [
37
+ { id: 'frames', label: 'Design frames', hint: 'become editable canvases' },
38
+ { id: 'board', label: 'FigJam board', hint: 'becomes your whiteboard' },
39
+ { id: 'tokens', label: 'Styles', hint: 'become design tokens' },
40
+ ];
41
+
42
+ function Summary({ result }) {
43
+ if (!result) return null;
44
+ const groups = new Map();
45
+ for (const d of result.dispositions ?? []) {
46
+ const list = groups.get(d.disposition) ?? [];
47
+ list.push(d);
48
+ groups.set(d.disposition, list);
49
+ }
50
+ const notable = [...groups.entries()].filter(([k]) => k !== 'imported');
51
+ const importedCount = groups.get('imported')?.length ?? 0;
52
+
53
+ return (
54
+ <div className="st-figma-summary" data-testid="figma-import-summary">
55
+ <div className="st-figma-summary-hd">
56
+ {importedCount} imported
57
+ {result.mode === 'frames' && result.frameCount ? ` · ${result.frameCount} frame(s)` : ''}
58
+ {result.mode === 'board' && result.strokeCount ? ` · ${result.strokeCount} strokes` : ''}
59
+ {result.mode === 'tokens' && result.count ? ` · ${result.count} tokens` : ''}
60
+ </div>
61
+
62
+ {result.assets && result.assets.pending > 0 && (
63
+ <div className="st-figma-summary-row">
64
+ images &amp; marks: {result.assets.resolved} of {result.assets.pending} fetched
65
+ </div>
66
+ )}
67
+
68
+ {notable.length === 0 ? (
69
+ <div className="st-figma-summary-row">Everything came through cleanly.</div>
70
+ ) : (
71
+ <ul className="st-figma-summary-list">
72
+ {notable.map(([disposition, items]) => (
73
+ <li key={disposition}>
74
+ <span className="st-figma-summary-kind">
75
+ {DISPOSITION_LABEL[disposition] ?? disposition}
76
+ </span>{' '}
77
+ <span className="st-figma-summary-count">{items.length}</span>
78
+ <span className="st-figma-summary-ids">
79
+ {items
80
+ .slice(0, 8)
81
+ .map((d) => d.nodeId)
82
+ .join(', ')}
83
+ {items.length > 8 ? ` +${items.length - 8}` : ''}
84
+ </span>
85
+ </li>
86
+ ))}
87
+ </ul>
88
+ )}
89
+
90
+ {result.mode === 'tokens' && result.path && (
91
+ <div className="st-figma-summary-row">
92
+ Saved. Turn it into a design system with{' '}
93
+ <code>maude design import-tokens</code>.
94
+ </div>
95
+ )}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ export default function FigmaImportPanel({ onClose, onImported }) {
101
+ const [connected, setConnected] = useState(null); // null = loading
102
+ const [url, setUrl] = useState('');
103
+ const [mode, setMode] = useState('frames');
104
+ const [busy, setBusy] = useState(false);
105
+ const [error, setError] = useState(null);
106
+ const [result, setResult] = useState(null);
107
+
108
+ const loadStatus = useCallback(() => {
109
+ fetch('/_api/figma/status')
110
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error('status'))))
111
+ .then((d) => setConnected(Boolean(d?.configured)))
112
+ .catch(() => setConnected(false));
113
+ }, []);
114
+
115
+ useEffect(() => {
116
+ loadStatus();
117
+ }, [loadStatus]);
118
+
119
+ useEffect(() => {
120
+ function onKey(e) {
121
+ if (e.key === 'Escape' && !busy) {
122
+ e.preventDefault();
123
+ onClose?.();
124
+ }
125
+ }
126
+ window.addEventListener('keydown', onKey);
127
+ return () => window.removeEventListener('keydown', onKey);
128
+ }, [onClose, busy]);
129
+
130
+ // A FigJam link is a `/board/` URL — preselect the matching mode so the
131
+ // common case needs no thought. Never override an explicit choice.
132
+ const [modeTouched, setModeTouched] = useState(false);
133
+ useEffect(() => {
134
+ if (modeTouched) return;
135
+ if (/figma\.com\/board\//.test(url)) setMode('board');
136
+ else if (/figma\.com\/(design|file|proto)\//.test(url)) setMode('frames');
137
+ }, [url, modeTouched]);
138
+
139
+ async function runImport(dryRun) {
140
+ if (busy || !url.trim()) return;
141
+ setBusy(true);
142
+ setError(null);
143
+ setResult(null);
144
+ try {
145
+ const res = await fetch('/_api/figma/import', {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({ url: url.trim(), mode, dryRun }),
149
+ });
150
+ const body = await res.json().catch(() => ({}));
151
+ if (!res.ok || body.ok === false) {
152
+ setError(body.error || 'Import failed.');
153
+ return;
154
+ }
155
+ setResult({ ...body, dryRun });
156
+ if (!dryRun) onImported?.(body);
157
+ } catch {
158
+ setError('Could not reach the local server.');
159
+ } finally {
160
+ setBusy(false);
161
+ }
162
+ }
163
+
164
+ return (
165
+ <div className="st-scrim" role="presentation">
166
+ <div
167
+ className="st-dialog st-figma-dialog"
168
+ role="dialog"
169
+ aria-modal="true"
170
+ aria-labelledby="figma-import-title"
171
+ data-testid="figma-import-panel"
172
+ >
173
+ <div className="st-dialog-hd">
174
+ <h2 id="figma-import-title">Import from Figma</h2>
175
+ <button type="button" className="st-btn" onClick={onClose} aria-label="Close">
176
+ ✕
177
+ </button>
178
+ </div>
179
+
180
+ {connected === false && (
181
+ <div className="st-figma-notice" data-testid="figma-not-connected">
182
+ Add a Figma access token in Settings → Figma first.
183
+ </div>
184
+ )}
185
+
186
+ <label className="st-figma-label" htmlFor="figma-url">
187
+ Figma link
188
+ </label>
189
+ <input
190
+ id="figma-url"
191
+ className="st-figma-input"
192
+ type="url"
193
+ spellCheck={false}
194
+ placeholder="https://www.figma.com/design/…"
195
+ value={url}
196
+ onChange={(e) => setUrl(e.target.value)}
197
+ disabled={busy}
198
+ data-testid="figma-import-url"
199
+ />
200
+
201
+ <fieldset className="st-figma-modes">
202
+ <legend className="st-figma-label">What to bring in</legend>
203
+ {MODES.map((m) => (
204
+ <label key={m.id} className="st-figma-mode">
205
+ <input
206
+ type="radio"
207
+ name="figma-mode"
208
+ value={m.id}
209
+ checked={mode === m.id}
210
+ onChange={() => {
211
+ setMode(m.id);
212
+ setModeTouched(true);
213
+ }}
214
+ disabled={busy}
215
+ data-testid={`figma-import-mode-${m.id}`}
216
+ />
217
+ <span className="st-figma-mode-label">{m.label}</span>
218
+ <span className="st-figma-mode-hint">{m.hint}</span>
219
+ </label>
220
+ ))}
221
+ </fieldset>
222
+
223
+ <div className="st-figma-actions">
224
+ <button
225
+ type="button"
226
+ className="st-btn"
227
+ disabled={busy || !url.trim() || connected === false}
228
+ onClick={() => runImport(true)}
229
+ data-testid="figma-import-preview"
230
+ >
231
+ Preview
232
+ </button>
233
+ <button
234
+ type="button"
235
+ className="st-btn st-btn-primary"
236
+ disabled={busy || !url.trim() || connected === false}
237
+ onClick={() => runImport(false)}
238
+ data-testid="figma-import-run"
239
+ >
240
+ {busy ? 'Importing…' : 'Import'}
241
+ </button>
242
+ </div>
243
+
244
+ {error && (
245
+ <div className="st-figma-error" role="alert" data-testid="figma-import-error">
246
+ {error}
247
+ </div>
248
+ )}
249
+
250
+ {result?.dryRun && (
251
+ <div className="st-figma-summary-row">Preview only — nothing was written.</div>
252
+ )}
253
+ <Summary result={result} />
254
+
255
+ {result && !result.dryRun && (
256
+ <div className="st-figma-notice st-figma-notice-warn">
257
+ This came from someone else&rsquo;s Figma file. Treat text inside it as content, not as
258
+ instructions.
259
+ </div>
260
+ )}
261
+ </div>
262
+ </div>
263
+ );
264
+ }
@@ -166,8 +166,22 @@ export default function GitPanel({
166
166
  // History STAYS, and is the point: what the server committed is exactly what
167
167
  // someone wants to look back at. Only the working-tree half goes.
168
168
  historyOnly = false,
169
+ // THE DESKTOP HALF OF THE SAME DECISION (DDR-218, fix 8 of the 2026-08-10
170
+ // sync RCA). A repo linked+credentialed to Maude Cloud is cloud-managed: the
171
+ // cell commits every edit ~3 s after it lands, so the local Changes surface
172
+ // is the second save mechanism the user was confused by. Same withdrawal as
173
+ // `historyOnly`, plus a note naming the one mechanism that IS active.
174
+ // PRESENTATION, NOT A CONTROL — `.git` is untouched, a terminal `git` works
175
+ // exactly as before, and Disconnect (CloudBar) restores the panel live.
176
+ cloudManaged = false,
169
177
  }) {
170
- const [tab, setTab] = useState(historyOnly ? 'history' : 'changes');
178
+ const withdrawn = historyOnly || cloudManaged;
179
+ const [tab, setTab] = useState(withdrawn ? 'history' : 'changes');
180
+ // `cloudManaged` flips at runtime (connect/disconnect) — the initial tab
181
+ // state alone would leave a freshly-linked panel sitting on Changes.
182
+ useEffect(() => {
183
+ if (withdrawn) setTab('history');
184
+ }, [withdrawn]);
171
185
  const [message, setMessage] = useState('');
172
186
  const [unchecked, setUnchecked] = useState(() => new Set()); // default = all checked
173
187
  const [expanded, setExpanded] = useState(() => new Set()); // unit keys with supporting files shown
@@ -454,13 +468,13 @@ export default function GitPanel({
454
468
  <aside
455
469
  className={'st-rpanel gp-panel' + (resizing ? ' is-resizing' : '')}
456
470
  style={width ? { width, flexBasis: width } : undefined}
457
- aria-label={historyOnly ? 'History' : 'Changes'}
471
+ aria-label={withdrawn ? 'History' : 'Changes'}
458
472
  data-testid="git-panel"
459
473
  >
460
474
  <div className="gp-head">
461
475
  <div className="gp-panel-hd">
462
- <span className="gp-panel-title">{historyOnly ? 'History' : 'Changes'}</span>
463
- {!historyOnly && count > 0 && <span className="gp-count">{count} unsaved</span>}
476
+ <span className="gp-panel-title">{withdrawn ? 'History' : 'Changes'}</span>
477
+ {!withdrawn && count > 0 && <span className="gp-count">{count} unsaved</span>}
464
478
  <span className="gp-spacer" />
465
479
  <span className="gp-draft" title="Your project and shared draft">
466
480
  <Icon name="folder" size={12} />
@@ -476,7 +490,7 @@ export default function GitPanel({
476
490
  ×
477
491
  </button>
478
492
  </div>
479
- {!historyOnly && (
493
+ {!withdrawn && (
480
494
  <div className="gp-tabs" role="tablist" aria-label="Changes and history">
481
495
  <button
482
496
  type="button"
@@ -498,6 +512,12 @@ export default function GitPanel({
498
512
  </button>
499
513
  </div>
500
514
  )}
515
+ {cloudManaged && (
516
+ <div className="gp-cloud-note" role="status" data-testid="git-cloud-managed">
517
+ <Icon name="check" size={13} /> Cloud is saving — changes sync automatically. Disconnect
518
+ to commit locally.
519
+ </div>
520
+ )}
501
521
  </div>
502
522
 
503
523
  {banner && (
@@ -543,7 +563,7 @@ export default function GitPanel({
543
563
  </div>
544
564
  )}
545
565
 
546
- {tab === 'changes' && !historyOnly ? (
566
+ {tab === 'changes' && !withdrawn ? (
547
567
  notRepo ? (
548
568
  <div className="gp-empty">
549
569
  <span className="gp-empty-glyph">
@@ -796,6 +796,170 @@ function GemmaModelCard() {
796
796
 
797
797
  // feature-unified-settings-modal — one modal for every Maude preference, laid
798
798
  // out as a left vertical tab rail + an internally-scrolling pane. Categories:
799
+ /**
800
+ * Figma connect card (DDR-216 D2/D3 / T11).
801
+ *
802
+ * Same trust model as ProviderCard, DIFFERENT routes: `figma` is deliberately
803
+ * not in the media-generation registry, so this card talks to
804
+ * `/_api/figma/{status,connect,probe}` — all three main-origin-only, loopback-
805
+ * and same-origin-gated, and none of which ever echoes the token back.
806
+ *
807
+ * The scope named here is `file_content:read`, never the deprecated blanket
808
+ * `files:read`. The "Connected as …" handle comes from `/v1/me` and is
809
+ * upstream-controlled — bounded server-side and rendered as a React text child
810
+ * (never `dangerouslySetInnerHTML`), and deliberately never persisted.
811
+ */
812
+ function FigmaConnectCard() {
813
+ const [status, setStatus] = useState(null); // null = loading
814
+ const [tokenInput, setTokenInput] = useState('');
815
+ const [busy, setBusy] = useState(false);
816
+ const [msg, setMsg] = useState(null);
817
+ const [handle, setHandle] = useState(null);
818
+
819
+ const load = useCallback(() => {
820
+ fetch('/_api/figma/status')
821
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
822
+ .then(setStatus)
823
+ .catch(() => setStatus({ configured: false }));
824
+ }, []);
825
+
826
+ useEffect(() => {
827
+ load();
828
+ }, [load]);
829
+
830
+ async function connect() {
831
+ const token = tokenInput.trim();
832
+ if (!token || busy) return;
833
+ setBusy(true);
834
+ setMsg(null);
835
+ try {
836
+ const res = await fetch('/_api/figma/connect', {
837
+ method: 'POST',
838
+ headers: { 'Content-Type': 'application/json' },
839
+ body: JSON.stringify({ token }),
840
+ });
841
+ const body = await res.json().catch(() => ({}));
842
+ if (!res.ok) {
843
+ setMsg(body.error || 'could not save the token');
844
+ return;
845
+ }
846
+ // Clear the field immediately — the value is never read back from the
847
+ // server, so leaving it on screen only risks a shoulder-surf.
848
+ setTokenInput('');
849
+ load();
850
+ await probe();
851
+ } finally {
852
+ setBusy(false);
853
+ }
854
+ }
855
+
856
+ async function probe() {
857
+ setMsg(null);
858
+ const res = await fetch('/_api/figma/probe', { method: 'POST' });
859
+ const body = await res.json().catch(() => ({}));
860
+ if (body.ok) {
861
+ setHandle(body.handle || '');
862
+ setMsg(null);
863
+ } else {
864
+ setHandle(null);
865
+ setMsg(body.error || 'Figma did not accept that token.');
866
+ }
867
+ }
868
+
869
+ async function disconnect() {
870
+ if (busy) return;
871
+ setBusy(true);
872
+ try {
873
+ await fetch('/_api/figma/connect', { method: 'DELETE' });
874
+ setHandle(null);
875
+ setMsg(null);
876
+ load();
877
+ } finally {
878
+ setBusy(false);
879
+ }
880
+ }
881
+
882
+ const configured = Boolean(status?.configured);
883
+
884
+ return (
885
+ <div className="st-provider-card" data-testid="figma-connect-card">
886
+ <div className="st-provider-hd">
887
+ <span className="st-provider-name">Figma</span>
888
+ {configured && (
889
+ <span className="st-provider-configured">
890
+ <Icon name="check" size={12} /> connected
891
+ </span>
892
+ )}
893
+ </div>
894
+ <p className="st-settings-intro">
895
+ Import real Figma documents — frames become editable canvases, FigJam boards become the
896
+ whiteboard, and paint/text/effect styles become design tokens. The translation is plain
897
+ code: no AI reads your file.
898
+ </p>
899
+ <p className="st-settings-intro">
900
+ Create a personal access token with the <code>{status?.requiredScope || 'file_content:read'}</code>{' '}
901
+ scope. It is stored on this machine only and is never sent anywhere but Figma.
902
+ </p>
903
+ {status?.tokenUrl && (
904
+ <a className="st-provider-keylink" href={status.tokenUrl} target="_blank" rel="noreferrer">
905
+ Get a token <Icon name="external" size={12} />
906
+ </a>
907
+ )}
908
+ <div className="st-provider-keyrow">
909
+ <input
910
+ className="st-provider-keyinput"
911
+ type="password"
912
+ autoComplete="off"
913
+ spellCheck={false}
914
+ placeholder={configured ? '•••••••• stored' : 'figd_…'}
915
+ value={tokenInput}
916
+ onChange={(e) => setTokenInput(e.target.value)}
917
+ onKeyDown={(e) => {
918
+ if (e.key === 'Enter') connect();
919
+ }}
920
+ aria-label="Figma personal access token"
921
+ data-testid="figma-connect-input"
922
+ />
923
+ <button
924
+ type="button"
925
+ className="st-btn"
926
+ disabled={busy || !tokenInput.trim()}
927
+ onClick={connect}
928
+ data-testid="figma-connect-save"
929
+ >
930
+ {configured ? 'Replace' : 'Save'}
931
+ </button>
932
+ {configured && (
933
+ <button
934
+ type="button"
935
+ className="st-btn"
936
+ disabled={busy}
937
+ onClick={disconnect}
938
+ data-testid="figma-disconnect"
939
+ >
940
+ Remove
941
+ </button>
942
+ )}
943
+ </div>
944
+ {configured && (
945
+ <button type="button" className="st-btn" disabled={busy} onClick={probe}>
946
+ Test connection
947
+ </button>
948
+ )}
949
+ {handle !== null && (
950
+ <div className="st-provider-status" data-testid="figma-connected-as">
951
+ Connected as {handle || 'your Figma account'}
952
+ </div>
953
+ )}
954
+ {msg && (
955
+ <div className="st-provider-status" data-testid="figma-connect-error">
956
+ {msg}
957
+ </div>
958
+ )}
959
+ </div>
960
+ );
961
+ }
962
+
799
963
  const TABS = [
800
964
  { id: 'appearance', label: 'Appearance' },
801
965
  { id: 'canvas-view', label: 'Canvas & View' },
@@ -813,6 +977,11 @@ const TABS = [
813
977
  // does not have. There is nothing for the reader to act on, so a whole tab of
814
978
  // dead controls is worse than no tab.
815
979
  { id: 'ai-generation', label: 'AI generation', local: true },
980
+ // DDR-216 — Figma is its OWN tab, not a row under "AI generation". It is not
981
+ // a media-generation provider (it produces no modality and needs no adapter),
982
+ // and putting it in that registry would create a second write path for the
983
+ // same secret — which is how one of them ends up ungated.
984
+ { id: 'figma', label: 'Figma', local: true },
816
985
  { id: 'subtitles', label: 'Subtitles', local: true },
817
986
  { id: 'video', label: 'Video', local: true },
818
987
  ];
@@ -1243,6 +1412,18 @@ export default function SettingsPanel({
1243
1412
  )}
1244
1413
  </section>
1245
1414
 
1415
+ {/* Figma — the import credential (DDR-216 D2/D3). Separate routes from
1416
+ the generation keys above, and separate on purpose. */}
1417
+ <section
1418
+ role="tabpanel"
1419
+ id="st-spanel-figma"
1420
+ aria-labelledby="st-stab-figma"
1421
+ hidden={tab !== 'figma'}
1422
+ >
1423
+ <div className="st-rp-hd">Figma</div>
1424
+ <FigmaConnectCard />
1425
+ </section>
1426
+
1246
1427
  {/* Subtitles — kept mounted so WhisperModelCard's download poll survives
1247
1428
  a tab switch (unmounting mid-download would drop the interval). */}
1248
1429
  <section
@@ -80,7 +80,7 @@ function StatusIcon({ present }) {
80
80
  );
81
81
  }
82
82
 
83
- export function SetupChecklist({ report, loading, refresh, onStartTour, onBringBrand }) {
83
+ export function SetupChecklist({ report, loading, refresh, onStartTour, onBringBrand, onImportFigma }) {
84
84
  return (
85
85
  <div className="setup-cl">
86
86
  <ul className="setup-cl-list">
@@ -131,6 +131,16 @@ export function SetupChecklist({ report, loading, refresh, onStartTour, onBringB
131
131
  Bring my existing brand
132
132
  </a>
133
133
  )}
134
+ {onImportFigma && (
135
+ <button
136
+ type="button"
137
+ data-testid="onboarding-import-figma"
138
+ className="btn btn--ghost btn--sm"
139
+ onClick={onImportFigma}
140
+ >
141
+ Import from Figma
142
+ </button>
143
+ )}
134
144
  {refresh && (
135
145
  <button
136
146
  type="button"
@@ -148,7 +158,13 @@ export function SetupChecklist({ report, loading, refresh, onStartTour, onBringB
148
158
 
149
159
  // Standalone modal, reachable from Help ▸ Quick setup… — reuses the shared
150
160
  // help-modal chrome (backdrop + header + body), same shape as ReadinessDialog.
151
- export default function SetupChecklistDialog({ open, onClose, onStartTour, onBringBrand }) {
161
+ export default function SetupChecklistDialog({
162
+ open,
163
+ onClose,
164
+ onStartTour,
165
+ onBringBrand,
166
+ onImportFigma,
167
+ }) {
152
168
  const { report, loading, refresh } = useSetupReadiness(open);
153
169
  useEffect(() => {
154
170
  if (!open) return undefined;
@@ -210,6 +226,14 @@ export default function SetupChecklistDialog({ open, onClose, onStartTour, onBri
210
226
  }
211
227
  : undefined
212
228
  }
229
+ onImportFigma={
230
+ onImportFigma
231
+ ? () => {
232
+ onClose();
233
+ onImportFigma();
234
+ }
235
+ : undefined
236
+ }
213
237
  />
214
238
  ) : (
215
239
  <p className="setup-cl-note">
@@ -44,7 +44,7 @@ import { ContextMenuView } from '../../context-menu.tsx';
44
44
  function rowKind(seq) {
45
45
  if (seq.placeholder) return 'placeholder';
46
46
  const t = seq.mediaTag;
47
- if (t === 'Video' || t === 'OffthreadVideo') return 'video';
47
+ if (t === 'Video' || t === 'OffthreadVideo' || t === 'MaudeVideo') return 'video';
48
48
  if (t === 'Img') return 'image';
49
49
  if (t === 'Audio') return 'audio';
50
50
  return 'jsx';
@@ -981,7 +981,7 @@ export default function TimelinePanel({
981
981
  // bare tag name as the clip's identity — "Clip N" beats "bca08b1e.mov";
982
982
  // the real src stays in the tooltip.
983
983
  const rawName =
984
- ['Video', 'OffthreadVideo', 'Audio', 'Img'].includes(seq.label) && seq.mediaSrc
984
+ ['Video', 'OffthreadVideo', 'Audio', 'Img', 'MaudeVideo', 'MaudeAudio', 'MaudeImg'].includes(seq.label) && seq.mediaSrc
985
985
  ? String(seq.mediaSrc).split('/').pop()
986
986
  : seq.label;
987
987
  const isHashName = /^[a-f0-9]{6,16}\.\w+$/i.test(rawName || '');
@@ -123,7 +123,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
123
123
  if (usages.length) {
124
124
  const target =
125
125
  (selectedArtboardId && usages.find((u) => u.artboardId === selectedArtboardId)) ||
126
- usages.find((u) => /<(?:Audio|OffthreadVideo|Video)\b/.test(u.body)) ||
126
+ usages.find((u) => /<(?:Maude)?(?:Audio|OffthreadVideo|Video)\b/.test(u.body)) ||
127
127
  usages.find((u) => u.duration != null && u.duration === totalFrames) ||
128
128
  usages[0];
129
129
  scope = target.body;
@@ -162,7 +162,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
162
162
  const lm = after.match(/<([A-Z][A-Za-z0-9]*)\b/);
163
163
  // Media child sniff (badge + replace affordance): the first
164
164
  // <Video|OffthreadVideo|Audio|Img src="…"> DIRECTLY inside the clip body.
165
- const mm = after.match(/<(Video|OffthreadVideo|Audio|Img)\b[^>]*src=["']([^"']+)["']/);
165
+ const mm = after.match(/<(?:Maude)?(Video|OffthreadVideo|Audio|Img)\b[^>]*src=["']([^"']+)["']/);
166
166
  const nameM = attrs.match(/name=["']([^"']+)["']/);
167
167
  items.push({
168
168
  type: 'seq',
@@ -241,7 +241,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
241
241
  // every clip span. Remotion renders it from frame 0 for the whole comp, so
242
242
  // show it as a read-only overlay row (`loose: true` — no clip ops; it has no
243
243
  // enumerator identity). Dogfood 2026-07-30: these were invisible before.
244
- for (const lm of scope.matchAll(/<(OffthreadVideo|Video|Img)\b([^>]*?)\/?>/g)) {
244
+ for (const lm of scope.matchAll(/<(?:Maude)?(OffthreadVideo|Video|Img)\b([^>]*?)\/?>/g)) {
245
245
  const at = lm.index ?? 0;
246
246
  if (clipSpans.some(([s2, e2]) => at >= s2 && at <= e2)) continue;
247
247
  const srcM = lm[2].match(/src=["']([^"']+)["']/);
@@ -2120,6 +2120,9 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2120
2120
  .gp-save { flex: 1; justify-content: center; }
2121
2121
  .gp-save:disabled, .gp-save[aria-disabled="true"], .gp-publish:disabled, .gp-publish[aria-disabled="true"] { background: var(--bg-3); color: var(--fg-3); border-color: var(--border-default); cursor: not-allowed; }
2122
2122
  .gp-hint { font-size: var(--type-xs); color: var(--fg-2); }
2123
+ /* cloud-managed posture (DDR-218) — the one save mechanism, named */
2124
+ .gp-cloud-note { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-1) 0; font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
2125
+ .gp-cloud-note svg { color: var(--accent); flex: 0 0 auto; }
2123
2126
 
2124
2127
  /* publish / get-latest bar */
2125
2128
  .gp-publishbar { flex: none; border-top: 1px solid var(--border-default); padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); background: var(--bg-1); }
@@ -2318,6 +2321,10 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2318
2321
  .gi-menu-item:hover { background: var(--bg-2); color: var(--fg-0); }
2319
2322
  .gi-menu-item:hover svg { color: var(--fg-1); }
2320
2323
  .gi-menu-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2324
+ /* the already-linked project's row — a state, not an action (fix 7, sync RCA) */
2325
+ .gi-menu-item.is-connected { cursor: default; }
2326
+ .gi-menu-item.is-connected:hover { background: transparent; color: var(--fg-1); }
2327
+ .gi-menu-item.is-connected:hover svg { color: var(--fg-2); }
2321
2328
  .gi-menu-item--danger, .gi-menu-item--danger svg { color: var(--status-error); }
2322
2329
  .gi-menu-item--danger:hover { background: color-mix(in oklab, var(--status-error) 14%, transparent); color: var(--status-error); }
2323
2330
  .gi-menu-sep { height: 1px; background: var(--border-subtle); margin: var(--space-1) 0; }