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