@1agh/maude 0.58.2 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -189,6 +189,34 @@ function projectFromHubUrl(hubUrl, cloudHost) {
189
189
  return norm(host.slice(0, -(cloudHost.length + 1)).replace(/^view-/, ''));
190
190
  }
191
191
 
192
+ /**
193
+ * Is this project row the one THIS folder is already linked to?
194
+ *
195
+ * Fix 7 of the 2026-08-10 sync RCA: every row used to render the same
196
+ * `Connect <name>` button, including the project this folder was already
197
+ * syncing with — the UI lying about an established link. Only a CREDENTIALED
198
+ * link counts (the `linkedHub` half alone comes from a committed config.json —
199
+ * attacker-authorable, B2), so a linked-but-uncredentialed row still offers
200
+ * Connect: it genuinely needs the sign-in.
201
+ *
202
+ * Addresses inside the configured cloud zone compare by the project label they
203
+ * carry (`projectFromHubUrl`, NFKD-folded like the platform); addresses that
204
+ * carry none (self-hosts, e2e stubs at `http://127.0.0.1:<port>`) fall back to
205
+ * an exact origin match, so two stubs on different ports never collapse.
206
+ */
207
+ export function isLinkedProjectRow({ projectUrl, linkedHubUrl, linkedHubCredentialed, cloudUrl }) {
208
+ if (!linkedHubUrl || !linkedHubCredentialed) return false;
209
+ const cloudHost = hostOf(cloudUrl);
210
+ const linked = projectFromHubUrl(linkedHubUrl, cloudHost);
211
+ const row = projectFromHubUrl(projectUrl, cloudHost);
212
+ if (linked && row) return linked === row;
213
+ try {
214
+ return new URL(String(projectUrl)).origin === new URL(String(linkedHubUrl)).origin;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
192
220
  /**
193
221
  * Does this folder look like the cloud project a link is naming?
194
222
  *
@@ -326,6 +354,7 @@ function Spark({ size = 15 }) {
326
354
  function Icon({ name, size = 15 }) {
327
355
  const p = {
328
356
  'chevron-up': <polyline points="3.5 10 8 5.5 12.5 10" />,
357
+ check: <polyline points="3 8.5 6.5 12 13 4.5" />,
329
358
  external: (
330
359
  <>
331
360
  <path d="M6 3.5H3.2A.7.7 0 0 0 2.5 4.2v8.6a.7.7 0 0 0 .7.7h8.6a.7.7 0 0 0 .7-.7V10" />
@@ -369,7 +398,11 @@ function Icon({ name, size = 15 }) {
369
398
  );
370
399
  }
371
400
 
372
- export default function CloudBar({ syncStatus }) {
401
+ // `onLinkedHub` lifts the resolved link state to the app shell (DDR-218): the
402
+ // GitPanel's cloud-managed posture turns on the same `linkedHub.credentialed`
403
+ // fact this panel's Connected row does, and CloudBar owns every action that
404
+ // changes it (status resolve, attach, detach) — so it reports, the app decides.
405
+ export default function CloudBar({ syncStatus, onLinkedHub }) {
373
406
  const [state, setState] = useState('loading'); // loading | out | in
374
407
  const [email, setEmail] = useState(null);
375
408
  const [menuOpen, setMenuOpen] = useState(false);
@@ -417,6 +450,7 @@ export default function CloudBar({ syncStatus }) {
417
450
  linkedHub: r.json?.linkedHub ?? null,
418
451
  resolved: true,
419
452
  });
453
+ onLinkedHub?.(r.json?.linkedHub ?? null);
420
454
  if (r.ok && r.json?.connected) {
421
455
  setEmail(r.json.email);
422
456
  setState('in');
@@ -476,11 +510,22 @@ export default function CloudBar({ syncStatus }) {
476
510
  setPending(null);
477
511
  if (r.ok && r.json?.ok) {
478
512
  setConnected({ project: r.json.project ?? pending.project, sync: r.json.sync });
513
+ adoptLink(r.json.url);
479
514
  } else {
480
515
  setError(r.json?.error || 'The workspace could not be connected.');
481
516
  }
482
517
  }
483
518
 
519
+ // A successful attach IS a credentialed link — mirror it into `local` so the
520
+ // project list flips to Connected without a second status fetch (the server
521
+ // just wrote both halves: linkedHub in config.json + the hub credential).
522
+ function adoptLink(url) {
523
+ if (typeof url === 'string' && url) {
524
+ setLocal((l) => ({ ...l, linkedHub: { url, credentialed: true } }));
525
+ onLinkedHub?.({ url, credentialed: true });
526
+ }
527
+ }
528
+
484
529
  // Escape answers the decision modal the same way the scrim does: Not now.
485
530
  // Declining must always be the cheap move — the dashboard re-mints a code on
486
531
  // the next "Open in Maude".
@@ -562,12 +607,29 @@ export default function CloudBar({ syncStatus }) {
562
607
  setBusy('');
563
608
  if (r.ok && r.json?.ok) {
564
609
  setConnected({ project: r.json.project ?? projectId, sync: r.json.sync });
610
+ adoptLink(r.json.url);
565
611
  } else {
566
612
  setError(r.json?.error || 'The workspace could not be connected.');
567
613
  }
568
614
  setMenuOpen(false);
569
615
  }
570
616
 
617
+ /** The in-app `maude design unlink` — drops the link AND stops syncing now. */
618
+ async function disconnect() {
619
+ setBusy('disconnect');
620
+ setError('');
621
+ const r = await api('/_api/cloud/detach', { method: 'POST' });
622
+ setBusy('');
623
+ setMenuOpen(false);
624
+ if (r.ok && r.json?.ok) {
625
+ setLocal((l) => ({ ...l, linkedHub: null }));
626
+ setConnected(null);
627
+ onLinkedHub?.(null);
628
+ } else {
629
+ setError(r.json?.error || 'The workspace could not be disconnected.');
630
+ }
631
+ }
632
+
571
633
  async function signOut() {
572
634
  setMenuOpen(false);
573
635
  await api('/_api/cloud/signout', { method: 'POST' });
@@ -801,6 +863,35 @@ export default function CloudBar({ syncStatus }) {
801
863
  <Icon name="external" size={15} /> View {p.name || p.id} in the browser
802
864
  <span className="gi-menu-login" style={{ marginLeft: 'auto' }}>viewer</span>
803
865
  </button>
866
+ ) : isLinkedProjectRow({
867
+ projectUrl: p.url,
868
+ linkedHubUrl: local.linkedHub?.url,
869
+ linkedHubCredentialed: local.linkedHub?.credentialed,
870
+ cloudUrl,
871
+ }) ? (
872
+ // The project this folder is ALREADY linked to. Connected is
873
+ // a state, not an action — the one action left is leaving.
874
+ // (Same wrapper shape as the "Loading projects…" row; the
875
+ // button is the focusable menu entry.)
876
+ <div
877
+ key={p.id}
878
+ className="gi-menu-item is-connected"
879
+ data-testid={`cloud-project-${p.id}`}
880
+ >
881
+ <Icon name="check" size={15} /> {p.name || p.id}
882
+ <span className="gi-menu-login" style={{ marginLeft: 'auto' }}>Connected</span>
883
+ <button
884
+ type="button"
885
+ className="btn btn--ghost btn--sm"
886
+ role="menuitem"
887
+ disabled={busy === 'disconnect'}
888
+ onClick={disconnect}
889
+ aria-label={`Disconnect this folder from ${p.name || p.id}`}
890
+ data-testid={`cloud-disconnect-${p.id}`}
891
+ >
892
+ {busy === 'disconnect' ? 'Disconnecting…' : 'Disconnect'}
893
+ </button>
894
+ </div>
804
895
  ) : (
805
896
  <button
806
897
  key={p.id}
@@ -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