@1agh/maude 0.58.2 → 0.58.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1261 -1261
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +306 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -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
|
-
|
|
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 & 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’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
|
|
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={
|
|
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">{
|
|
463
|
-
{!
|
|
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
|
-
{!
|
|
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' && !
|
|
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
|