@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.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- 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/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -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 +1231 -1231
- 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 +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -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 +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -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 +328 -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 +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- 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 +17 -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-explode.test.ts +438 -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/perf-canvas.mjs +201 -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 +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -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-panel-surface.test.ts +90 -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/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -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">
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// feature-sync-progress-modal — the per-file Sync panel (right dock).
|
|
2
|
+
//
|
|
3
|
+
// Pure surfacing of the `sync:status` payload: the DDR-102 per-document
|
|
4
|
+
// `items` list plus the DDR-217 asset lane's live progress. No new sync
|
|
5
|
+
// semantics and NO new sync words — the header sentence comes from
|
|
6
|
+
// `syncPresentation` (the one rule the status-bar chip and the cloud rail
|
|
7
|
+
// already share), and rows map DocSyncState onto that same vocabulary:
|
|
8
|
+
// pending → syncing, connected → synced, auth-rejected → refused.
|
|
9
|
+
//
|
|
10
|
+
// DDR-054 — slugs are local canvas identifiers and asset keys are local
|
|
11
|
+
// paths, but everything renders through safeName anyway: this payload is
|
|
12
|
+
// read back off disk (`_sync.json`), and a bounded text-only row is free.
|
|
13
|
+
|
|
14
|
+
import { useMemo } from 'react';
|
|
15
|
+
|
|
16
|
+
import { safeName, syncPresentation } from '../../sync/presentation.ts';
|
|
17
|
+
|
|
18
|
+
/** DocSyncState → the presentation vocabulary (never invent a new word). */
|
|
19
|
+
const STATE_WORD = {
|
|
20
|
+
pending: 'syncing',
|
|
21
|
+
connected: 'synced',
|
|
22
|
+
'auth-rejected': 'refused',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** AuthFailureClass → plain words for the refused-row detail. */
|
|
26
|
+
const REASON_WORD = {
|
|
27
|
+
'rate-limit': 'rate limited',
|
|
28
|
+
'not-authorized': 'not authorized',
|
|
29
|
+
'invalid-token': 'sign-in expired',
|
|
30
|
+
generic: 'refused by the hub',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function Row({ item }) {
|
|
34
|
+
const name = safeName(item.slug, '(unnamed)');
|
|
35
|
+
const state = STATE_WORD[item.state] || 'syncing';
|
|
36
|
+
const reason = item.state === 'auth-rejected' ? REASON_WORD[item.reason] || REASON_WORD.generic : null;
|
|
37
|
+
return (
|
|
38
|
+
<li className={'sp-row is-' + item.state} data-testid={'sync-row-' + item.slug}>
|
|
39
|
+
<span className="sp-row-dot" aria-hidden="true" />
|
|
40
|
+
<span className="sp-row-name" title={name}>
|
|
41
|
+
{name}
|
|
42
|
+
</span>
|
|
43
|
+
<span className="sp-row-state">{reason ? `refused — ${reason}` : state}</span>
|
|
44
|
+
</li>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Which declared canvas group a slug belongs to (slugs collapse '/' → '-'). */
|
|
49
|
+
function groupOf(slug, groupPaths) {
|
|
50
|
+
for (const p of groupPaths) {
|
|
51
|
+
const g = p.replace(/\//g, '-');
|
|
52
|
+
if (slug === g || slug.startsWith(g + '-')) return p;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// FAIL CLOSED, like presentation.ts `readCounts`: this payload is JSON.parse
|
|
58
|
+
// of `_sync.json` with no schema, so a partial write / older producer must
|
|
59
|
+
// degrade to "nothing to show", never crash the render or print NaN.
|
|
60
|
+
|
|
61
|
+
const isCount = (n) => typeof n === 'number' && Number.isInteger(n) && n >= 0;
|
|
62
|
+
|
|
63
|
+
/** The per-doc rows, or [] when the shape can't be trusted. */
|
|
64
|
+
function readItems(raw) {
|
|
65
|
+
if (!Array.isArray(raw)) return [];
|
|
66
|
+
return raw.filter((i) => i && typeof i.slug === 'string' && typeof i.state === 'string');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The asset lane, or null when any count is unreadable. */
|
|
70
|
+
function readAssets(raw) {
|
|
71
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
72
|
+
if (![raw.total, raw.done, raw.pushed, raw.skipped, raw.failedCount].every(isCount)) return null;
|
|
73
|
+
const failures = Array.isArray(raw.failures)
|
|
74
|
+
? raw.failures.filter((f) => f && typeof f.key === 'string')
|
|
75
|
+
: [];
|
|
76
|
+
return { ...raw, failures };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default function SyncPanel({
|
|
80
|
+
status, // the live `sync:status` payload (never null while mounted)
|
|
81
|
+
project, // display name for the header sentence (hub-supplied → safeName'd)
|
|
82
|
+
groupPaths = [], // declared canvas-group paths, for row grouping
|
|
83
|
+
resizing,
|
|
84
|
+
onClose,
|
|
85
|
+
}) {
|
|
86
|
+
const p = syncPresentation(status, { project });
|
|
87
|
+
const items = readItems(status?.items);
|
|
88
|
+
const truncated = isCount(status?.itemsTruncated) ? status.itemsTruncated : 0;
|
|
89
|
+
const assets = readAssets(status?.assets);
|
|
90
|
+
const assetFailures = assets?.failures || [];
|
|
91
|
+
|
|
92
|
+
const { attention, byGroup } = useMemo(() => {
|
|
93
|
+
const attention = items.filter((i) => i.state === 'auth-rejected');
|
|
94
|
+
const rest = items.filter((i) => i.state !== 'auth-rejected');
|
|
95
|
+
const byGroup = new Map();
|
|
96
|
+
for (const item of rest) {
|
|
97
|
+
const g = groupOf(item.slug, groupPaths) ?? 'canvases';
|
|
98
|
+
if (!byGroup.has(g)) byGroup.set(g, []);
|
|
99
|
+
byGroup.get(g).push(item);
|
|
100
|
+
}
|
|
101
|
+
return { attention, byGroup };
|
|
102
|
+
}, [items, groupPaths]);
|
|
103
|
+
|
|
104
|
+
// Same fail-closed rule for the header chip — readCounts-shaped, so the
|
|
105
|
+
// chip can never say "NaN synced" while the note below fails closed.
|
|
106
|
+
const rawDocs = status?.docs;
|
|
107
|
+
const docs =
|
|
108
|
+
rawDocs && [rawDocs.synced, rawDocs.pending, rawDocs.rejected].every(isCount)
|
|
109
|
+
? rawDocs
|
|
110
|
+
: null;
|
|
111
|
+
const counts =
|
|
112
|
+
docs &&
|
|
113
|
+
[
|
|
114
|
+
`${docs.synced} synced`,
|
|
115
|
+
docs.pending > 0 ? `${docs.pending} syncing` : null,
|
|
116
|
+
docs.rejected > 0 ? `${docs.rejected} refused` : null,
|
|
117
|
+
]
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.join(' · ');
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<aside
|
|
123
|
+
className={'st-rpanel sp-panel' + (resizing ? ' is-resizing' : '')}
|
|
124
|
+
aria-label="Sync"
|
|
125
|
+
data-testid="sync-panel"
|
|
126
|
+
>
|
|
127
|
+
<div className="gp-head">
|
|
128
|
+
<div className="gp-panel-hd">
|
|
129
|
+
<span className="gp-panel-title">Sync</span>
|
|
130
|
+
{counts && <span className="gp-count">{counts}</span>}
|
|
131
|
+
<span className="gp-spacer" />
|
|
132
|
+
<button type="button" className="gp-x" aria-label="Close" onClick={onClose}>
|
|
133
|
+
×
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
{/* The one-rule sentence, live — same aria pattern as the rail note:
|
|
137
|
+
a polite announcement when the phase changes, never a focus steal. */}
|
|
138
|
+
{p && (
|
|
139
|
+
<div className="sp-note" role="status" aria-live="polite">
|
|
140
|
+
<span className={'sp-note-dot is-' + p.phase} aria-hidden="true" />
|
|
141
|
+
<span>
|
|
142
|
+
{p.title}
|
|
143
|
+
{p.next ? ` ${p.next}` : ''}
|
|
144
|
+
</span>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div className="sp-body">
|
|
150
|
+
{items.length === 0 && !assets && (
|
|
151
|
+
<div className="gp-empty">
|
|
152
|
+
<p>No per-file detail yet — it arrives with the first sync report after this panel shipped. If this persists, restart Maude.</p>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{attention.length > 0 && (
|
|
157
|
+
<section aria-label="Needs attention">
|
|
158
|
+
<div className="gp-sect-label sp-sect-attention">Needs attention</div>
|
|
159
|
+
<ul className="sp-list">
|
|
160
|
+
{attention.map((item) => (
|
|
161
|
+
<Row key={item.slug} item={item} />
|
|
162
|
+
))}
|
|
163
|
+
</ul>
|
|
164
|
+
</section>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{[...byGroup.entries()].map(([group, rows]) => (
|
|
168
|
+
<section key={group} aria-label={group}>
|
|
169
|
+
<div className="gp-sect-label">
|
|
170
|
+
{group} <span className="gp-group-count">{rows.length}</span>
|
|
171
|
+
</div>
|
|
172
|
+
<ul className="sp-list">
|
|
173
|
+
{rows.map((item) => (
|
|
174
|
+
<Row key={item.slug} item={item} />
|
|
175
|
+
))}
|
|
176
|
+
</ul>
|
|
177
|
+
</section>
|
|
178
|
+
))}
|
|
179
|
+
|
|
180
|
+
{truncated > 0 && (
|
|
181
|
+
<div className="sp-truncated">
|
|
182
|
+
+{truncated} more synced canvas{truncated === 1 ? '' : 'es'} not listed.
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{assets && (
|
|
187
|
+
<section aria-label="Assets" data-testid="sync-assets">
|
|
188
|
+
<div className="gp-sect-label">
|
|
189
|
+
assets <span className="gp-group-count">{assets.total}</span>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="sp-assets-line">
|
|
192
|
+
{assets.finished
|
|
193
|
+
? `${assets.pushed} pushed · ${assets.skipped} already there` +
|
|
194
|
+
(assets.failedCount > 0 ? ` · ${assets.failedCount} failed` : '')
|
|
195
|
+
: `Pushing assets — ${assets.done} of ${assets.total}…`}
|
|
196
|
+
</div>
|
|
197
|
+
{!assets.finished && assets.active && (
|
|
198
|
+
<div className="sp-assets-active" title={safeName(assets.active, '')}>
|
|
199
|
+
↑ {safeName(assets.active, '')}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
{assets.failedCount > 0 && (
|
|
203
|
+
<ul className="sp-list">
|
|
204
|
+
{assetFailures.map((f) => (
|
|
205
|
+
<li className="sp-row is-auth-rejected" key={f.key}>
|
|
206
|
+
<span className="sp-row-dot" aria-hidden="true" />
|
|
207
|
+
<span className="sp-row-name" title={safeName(f.key, '(unnamed)')}>
|
|
208
|
+
{safeName(f.key, '(unnamed)')}
|
|
209
|
+
</span>
|
|
210
|
+
<span className="sp-row-state">{safeName(f.reason, 'failed')}</span>
|
|
211
|
+
</li>
|
|
212
|
+
))}
|
|
213
|
+
{assets.failedCount > assetFailures.length && (
|
|
214
|
+
<li className="sp-truncated">
|
|
215
|
+
+{assets.failedCount - assetFailures.length} more failed. All retry on the next
|
|
216
|
+
launch.
|
|
217
|
+
</li>
|
|
218
|
+
)}
|
|
219
|
+
</ul>
|
|
220
|
+
)}
|
|
221
|
+
{assets.failedCount > 0 && assets.finished && (
|
|
222
|
+
<div className="sp-assets-retry">Failed assets retry on the next launch.</div>
|
|
223
|
+
)}
|
|
224
|
+
</section>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
</aside>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -23,6 +23,7 @@ import { ImagePlus, MessageSquarePlus, Plus, Scissors, Sparkles, Type } from 'lu
|
|
|
23
23
|
|
|
24
24
|
import ClipInspector from './ClipInspector.jsx';
|
|
25
25
|
import { computeSnapTargets, snapFrame } from './timeline-snap.js';
|
|
26
|
+
import { activeComp } from './timeline-comp-target.js';
|
|
26
27
|
import { getFilmstrip, getPeaks, peaksToPath, stripBucket } from './timeline-media-cache.js';
|
|
27
28
|
import {
|
|
28
29
|
clampPxPerFrame,
|
|
@@ -44,7 +45,7 @@ import { ContextMenuView } from '../../context-menu.tsx';
|
|
|
44
45
|
function rowKind(seq) {
|
|
45
46
|
if (seq.placeholder) return 'placeholder';
|
|
46
47
|
const t = seq.mediaTag;
|
|
47
|
-
if (t === 'Video' || t === 'OffthreadVideo') return 'video';
|
|
48
|
+
if (t === 'Video' || t === 'OffthreadVideo' || t === 'MaudeVideo') return 'video';
|
|
48
49
|
if (t === 'Img') return 'image';
|
|
49
50
|
if (t === 'Audio') return 'audio';
|
|
50
51
|
return 'jsx';
|
|
@@ -143,6 +144,10 @@ const SCROLL_PAD_X = 24;
|
|
|
143
144
|
|
|
144
145
|
export default function TimelinePanel({
|
|
145
146
|
comps = [],
|
|
147
|
+
// issue #75 — the comp the shell's transport targets. The panel reads its
|
|
148
|
+
// meta (fps / duration) off the SAME comp, so the readout can never describe
|
|
149
|
+
// a different artboard than the one Play actually moves.
|
|
150
|
+
compId = null,
|
|
146
151
|
sequences = [],
|
|
147
152
|
audio = [],
|
|
148
153
|
transitions = [],
|
|
@@ -204,7 +209,14 @@ export default function TimelinePanel({
|
|
|
204
209
|
// storyline layout moves layer detail into the clip inspector).
|
|
205
210
|
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
206
211
|
const [dropActive, setDropActive] = useState(false);
|
|
207
|
-
const comp = comps
|
|
212
|
+
const comp = activeComp(comps, compId);
|
|
213
|
+
// issue #75 — which artboard the rows + transport belong to. `null` means the
|
|
214
|
+
// shell genuinely could NOT tell (a comp mounted outside any artboard, a
|
|
215
|
+
// resolver fall-through). That case must render as an explicit unknown, not
|
|
216
|
+
// as no chip at all: "no chip" already means "single comp, nothing to
|
|
217
|
+
// disambiguate", and reusing it for "I don't know" would make the ambiguous
|
|
218
|
+
// case look like the safe one (security-review 2026-08-12).
|
|
219
|
+
const scopedArtboard = comp?.artboardLabel || comp?.artboardId || null;
|
|
208
220
|
const totalFrames = Math.max(1, total || comp?.durationInFrames || 1);
|
|
209
221
|
const fps = comp?.fps ?? 30;
|
|
210
222
|
const clamped = clamp(Math.round(frame), 0, totalFrames - 1);
|
|
@@ -981,7 +993,7 @@ export default function TimelinePanel({
|
|
|
981
993
|
// bare tag name as the clip's identity — "Clip N" beats "bca08b1e.mov";
|
|
982
994
|
// the real src stays in the tooltip.
|
|
983
995
|
const rawName =
|
|
984
|
-
['Video', 'OffthreadVideo', 'Audio', 'Img'].includes(seq.label) && seq.mediaSrc
|
|
996
|
+
['Video', 'OffthreadVideo', 'Audio', 'Img', 'MaudeVideo', 'MaudeAudio', 'MaudeImg'].includes(seq.label) && seq.mediaSrc
|
|
985
997
|
? String(seq.mediaSrc).split('/').pop()
|
|
986
998
|
: seq.label;
|
|
987
999
|
const isHashName = /^[a-f0-9]{6,16}\.\w+$/i.test(rawName || '');
|
|
@@ -1423,6 +1435,22 @@ export default function TimelinePanel({
|
|
|
1423
1435
|
onChange={(e) => onVolume?.(Number(e.target.value) / 100)}
|
|
1424
1436
|
/>
|
|
1425
1437
|
) : null}
|
|
1438
|
+
{/* issue #75 — on a multi-comp canvas the rows + transport belong to
|
|
1439
|
+
ONE artboard, and nothing said which. Show it (label, id as the
|
|
1440
|
+
fallback) so the panel and the canvas agree out loud. */}
|
|
1441
|
+
{comps.length > 1 ? (
|
|
1442
|
+
<span
|
|
1443
|
+
className={`tl-artboard${scopedArtboard ? '' : ' is-unknown'}`}
|
|
1444
|
+
data-testid="timeline-artboard"
|
|
1445
|
+
title={
|
|
1446
|
+
scopedArtboard
|
|
1447
|
+
? `Timeline is scoped to artboard "${scopedArtboard}" — pan to another artboard to switch`
|
|
1448
|
+
: "This canvas has several video comps and the Timeline couldn't tell which artboard this one sits in — the transport may be moving a comp you're not looking at"
|
|
1449
|
+
}
|
|
1450
|
+
>
|
|
1451
|
+
{scopedArtboard ?? 'artboard ?'}
|
|
1452
|
+
</span>
|
|
1453
|
+
) : null}
|
|
1426
1454
|
<span className="tl-readout" data-testid="timeline-readout">
|
|
1427
1455
|
<b>{clamped}</b>
|
|
1428
1456
|
<span className="tl-sep">/</span>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file timeline-comp-target.js — which announced comp the Timeline drives.
|
|
3
|
+
* @scope apps/studio/client/panels/
|
|
4
|
+
* @purpose On a multi-comp canvas the Timeline shows ONE comp's rows (the
|
|
5
|
+
* parser scopes to the viewport-active artboard) but the transport
|
|
6
|
+
* (play / pause / scrub / mute / loop) is a targeted postMessage —
|
|
7
|
+
* it needs the Player's own comp id.
|
|
8
|
+
*
|
|
9
|
+
* This used to be resolved by matching the parsed row total against
|
|
10
|
+
* each comp's `durationInFrames`. Two comps of the SAME length (the
|
|
11
|
+
* common case — one composition duplicated across artboards) made
|
|
12
|
+
* `find` return the FIRST one, so the panel drew artboard #2's rows
|
|
13
|
+
* while Play moved artboard #1 (issue #75). Duration is not an
|
|
14
|
+
* identity, so the primary key is now the artboard the comp is
|
|
15
|
+
* mounted in (`artboardId`, resolved by DOM containment in
|
|
16
|
+
* video-comp.tsx). Duration stays as a fallback, and is only trusted
|
|
17
|
+
* when it identifies exactly ONE comp.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Bidi overrides + isolates, zero-width / format characters, and C0/C1
|
|
21
|
+
// controls (newlines and tabs included). An artboard label is author-written
|
|
22
|
+
// text from an untrusted origin (DDR-054) that the shell RENDERS as chrome, so
|
|
23
|
+
// a trailing RLO or a label built entirely of zero-width joiners could make the
|
|
24
|
+
// Timeline's chip read as an artboard other than the one the transport is
|
|
25
|
+
// scoped to — defeating the exact signal the chip exists to give. Same class
|
|
26
|
+
// `sanitizeDisplayText` (file-preview.jsx) and `figma/sanitize.ts` already
|
|
27
|
+
// neutralize; widened here because a label, unlike a filename, has no shape
|
|
28
|
+
// constraints at all. Written as escapes on purpose — a literal invisible
|
|
29
|
+
// character in this class would be unreviewable.
|
|
30
|
+
const DISPLAY_UNSAFE =
|
|
31
|
+
/[\u202a-\u202e\u2066-\u2069\u200b-\u200f\u2060\u00ad\ufeff\u0000-\u001f\u007f-\u009f]/g;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Neutralize + cap one canvas-origin display string. Returns null for a value
|
|
35
|
+
* that is absent, or that is empty ONCE the unsafe characters are gone — an
|
|
36
|
+
* all-invisible label must read as "no label", not as a blank chip that looks
|
|
37
|
+
* like the panel failed to render.
|
|
38
|
+
*
|
|
39
|
+
* @param {unknown} s
|
|
40
|
+
* @param {number} [max]
|
|
41
|
+
* @returns {string|null}
|
|
42
|
+
*/
|
|
43
|
+
export function sanitizeArtboardText(s, max = 120) {
|
|
44
|
+
if (typeof s !== 'string') return null;
|
|
45
|
+
const clean = s.replace(DISPLAY_UNSAFE, '').trim().slice(0, max).trim();
|
|
46
|
+
return clean || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {Array<{id: string, artboardId?: string|null, durationInFrames?: number}>} comps
|
|
51
|
+
* @param {{ artboardId?: string|string[]|null, total?: number }} [opts] `artboardId`
|
|
52
|
+
* may be a single id or a confidence-ordered list of candidates.
|
|
53
|
+
* @returns {string|null} the comp id the transport must target
|
|
54
|
+
*/
|
|
55
|
+
export function resolveCompTarget(comps, opts = {}) {
|
|
56
|
+
if (!Array.isArray(comps) || comps.length === 0) return null;
|
|
57
|
+
const { artboardId = null, total = 0 } = opts;
|
|
58
|
+
// Candidates in confidence order. The caller passes the artboard the ROW
|
|
59
|
+
// PARSER scoped to (derived lexically from the .tsx) followed by the viewport
|
|
60
|
+
// signal canvas-lib reports on pan (derived structurally). They usually agree;
|
|
61
|
+
// when the lexical one names an artboard no comp is actually mounted in — a
|
|
62
|
+
// `<DCArtboard id=…>` inside a comment or a string literal is enough — the
|
|
63
|
+
// pan signal is the better answer, and trying both is what keeps a source
|
|
64
|
+
// file's stale opinion from silently out-ranking the live DOM.
|
|
65
|
+
const candidates = (Array.isArray(artboardId) ? artboardId : [artboardId]).filter(Boolean);
|
|
66
|
+
// Pass 1 — the artboard each comp ANNOUNCED (DOM containment). Runs across
|
|
67
|
+
// every candidate before pass 2 starts, deliberately.
|
|
68
|
+
for (const cand of candidates) {
|
|
69
|
+
const hit = comps.find((c) => c.artboardId === cand);
|
|
70
|
+
if (hit) return hit.id;
|
|
71
|
+
}
|
|
72
|
+
// Pass 2 — an author-set comp id that equals an artboard id (the documented
|
|
73
|
+
// "match the enclosing DCArtboard id" convention). Strictly WEAKER than pass
|
|
74
|
+
// 1: comp ids and artboard ids are separate author-chosen namespaces, so a
|
|
75
|
+
// `<VideoComp id="outro">` mounted inside `intro` must never be able to claim
|
|
76
|
+
// outro's transport while a comp genuinely mounted in outro exists.
|
|
77
|
+
for (const cand of candidates) {
|
|
78
|
+
const hit = comps.find((c) => c.id === cand);
|
|
79
|
+
if (hit) return hit.id;
|
|
80
|
+
}
|
|
81
|
+
if (total > 0) {
|
|
82
|
+
const byTotal = comps.filter((c) => c.durationInFrames === total);
|
|
83
|
+
// Exactly one — unambiguous. Several — duration tells us nothing, so fall
|
|
84
|
+
// through rather than silently picking the first (that WAS the bug).
|
|
85
|
+
if (byTotal.length === 1) return byTotal[0].id;
|
|
86
|
+
}
|
|
87
|
+
return comps[0].id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The comp record the panel should read meta (fps, duration) off — same target
|
|
92
|
+
* as the transport, so the readout can never describe a different comp than the
|
|
93
|
+
* one Play moves.
|
|
94
|
+
*
|
|
95
|
+
* @param {Array<{id: string}>} comps
|
|
96
|
+
* @param {string|null} compId
|
|
97
|
+
*/
|
|
98
|
+
export function activeComp(comps, compId) {
|
|
99
|
+
if (!Array.isArray(comps) || comps.length === 0) return null;
|
|
100
|
+
return comps.find((c) => c.id === compId) ?? comps[0];
|
|
101
|
+
}
|
|
@@ -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=["']([^"']+)["']/);
|
|
@@ -209,6 +209,10 @@
|
|
|
209
209
|
.st-live-dot.is-connected { background: var(--presence-online); }
|
|
210
210
|
.st-sb-sync-dot { width: 7px; height: 7px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
|
|
211
211
|
.st-sb-sync-dot.is-online { background: var(--presence-online); }
|
|
212
|
+
/* feature-sync-progress-modal — the chip doubles as the Sync-panel toggle. */
|
|
213
|
+
.st-sb-sync--btn { appearance: none; border: 0; padding: 0; border-radius: 0; background: transparent; font: inherit; cursor: pointer; }
|
|
214
|
+
.st-sb-sync--btn:hover .val, .st-sb-sync--btn.is-open .val { color: var(--accent); }
|
|
215
|
+
.st-sb-sync--btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
212
216
|
|
|
213
217
|
/* Phase 28 — clickable "changes" slot (opens the Changes panel). Button reset; the
|
|
214
218
|
.st-sb-slot + .st-sb-slot rule still supplies the divider + left padding. */
|
|
@@ -1984,6 +1988,10 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
1984
1988
|
.tl-btn.is-on { background: var(--accent-tint); border-color: var(--accent-muted); color: var(--accent); }
|
|
1985
1989
|
.tl-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
|
1986
1990
|
.tl-btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--accent-fg); }
|
|
1991
|
+
/* issue #75 — which artboard's comp this timeline is scoped to (multi-comp only). */
|
|
1992
|
+
.tl-artboard { margin-left: var(--space-2); padding: 2px 7px; border-radius: 999px; border: 1px solid var(--border-default); background: var(--bg-2); color: var(--fg-1); font-size: var(--type-xs); line-height: 1.4; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1993
|
+
/* The shell could not resolve the artboard — read as a warning, not as a name. */
|
|
1994
|
+
.tl-artboard.is-unknown { color: var(--fg-3); border-style: dashed; font-style: italic; }
|
|
1987
1995
|
.tl-readout { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); display: inline-flex; align-items: baseline; gap: 4px; margin-left: var(--space-2); }
|
|
1988
1996
|
.tl-readout b { color: var(--fg-0); font-weight: 600; }
|
|
1989
1997
|
.tl-readout .tl-sep { color: var(--fg-3); }
|
|
@@ -2120,6 +2128,9 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2120
2128
|
.gp-save { flex: 1; justify-content: center; }
|
|
2121
2129
|
.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
2130
|
.gp-hint { font-size: var(--type-xs); color: var(--fg-2); }
|
|
2131
|
+
/* cloud-managed posture (DDR-218) — the one save mechanism, named */
|
|
2132
|
+
.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); }
|
|
2133
|
+
.gp-cloud-note svg { color: var(--accent); flex: 0 0 auto; }
|
|
2123
2134
|
|
|
2124
2135
|
/* publish / get-latest bar */
|
|
2125
2136
|
.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 +2329,10 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2318
2329
|
.gi-menu-item:hover { background: var(--bg-2); color: var(--fg-0); }
|
|
2319
2330
|
.gi-menu-item:hover svg { color: var(--fg-1); }
|
|
2320
2331
|
.gi-menu-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2332
|
+
/* the already-linked project's row — a state, not an action (fix 7, sync RCA) */
|
|
2333
|
+
.gi-menu-item.is-connected { cursor: default; }
|
|
2334
|
+
.gi-menu-item.is-connected:hover { background: transparent; color: var(--fg-1); }
|
|
2335
|
+
.gi-menu-item.is-connected:hover svg { color: var(--fg-2); }
|
|
2321
2336
|
.gi-menu-item--danger, .gi-menu-item--danger svg { color: var(--status-error); }
|
|
2322
2337
|
.gi-menu-item--danger:hover { background: color-mix(in oklab, var(--status-error) 14%, transparent); color: var(--status-error); }
|
|
2323
2338
|
.gi-menu-sep { height: 1px; background: var(--border-subtle); margin: var(--space-1) 0; }
|
|
@@ -2655,6 +2670,28 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2655
2670
|
.rb-trigger-ver--ro .rb-trigger-ver-name { color: var(--fg-1); font-family: var(--font-mono); font-size: var(--type-xs); }
|
|
2656
2671
|
/* GitPanel read-only footer hint (web studio — actions live in the terminal). */
|
|
2657
2672
|
.gp-ro-hint { padding: var(--space-3) var(--space-4); color: var(--fg-2); }
|
|
2673
|
+
|
|
2674
|
+
/* feature-sync-progress-modal — the Sync panel (per-file sync rows). Reuses
|
|
2675
|
+
* the gp- head chrome; sp- covers only the sync-specific rows/sections. */
|
|
2676
|
+
.sp-panel { display: flex; flex-direction: column; }
|
|
2677
|
+
.sp-body { flex: 1; overflow-y: auto; padding-bottom: var(--space-4); }
|
|
2678
|
+
.sp-note { display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-2) var(--space-1) var(--space-2); font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
|
|
2679
|
+
.sp-note-dot { width: 7px; height: 7px; margin-top: 3px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
|
|
2680
|
+
.sp-note-dot.is-synced { background: var(--presence-online); }
|
|
2681
|
+
.sp-note-dot.is-refused, .sp-note-dot.is-stalled, .sp-note-dot.is-offline { background: var(--status-error); }
|
|
2682
|
+
.sp-sect-attention { color: var(--status-error); }
|
|
2683
|
+
.sp-list { list-style: none; margin: 0; padding: 0; }
|
|
2684
|
+
.sp-row { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); min-width: 0; }
|
|
2685
|
+
.sp-row-dot { width: 6px; height: 6px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
|
|
2686
|
+
.sp-row.is-connected .sp-row-dot { background: var(--presence-online); }
|
|
2687
|
+
.sp-row.is-auth-rejected .sp-row-dot { background: var(--status-error); }
|
|
2688
|
+
.sp-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); font-size: var(--type-xs); }
|
|
2689
|
+
.sp-row-state { flex: none; font-size: var(--type-xs); color: var(--fg-2); }
|
|
2690
|
+
.sp-row.is-auth-rejected .sp-row-state { color: var(--status-error); }
|
|
2691
|
+
.sp-truncated { padding: var(--space-1) var(--space-4); font-size: var(--type-xs); color: var(--fg-2); }
|
|
2692
|
+
.sp-assets-line { padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); }
|
|
2693
|
+
.sp-assets-active { padding: 0 var(--space-4); font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2694
|
+
.sp-assets-retry { padding: var(--space-1) var(--space-4); font-size: var(--type-xs); color: var(--fg-2); }
|
|
2658
2695
|
/* The popup — opens UPWARD from the dock (gi-menu anatomy). SOLID opaque menu
|
|
2659
2696
|
* surface + border here (NOT the canvas `.panel`, which isn't in the client bundle
|
|
2660
2697
|
* — phase-28 lesson; the tree must never bleed through). */
|