@1agh/maude 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/acp/bridge.ts +144 -5
  3. package/apps/studio/acp/index.ts +11 -1
  4. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  5. package/apps/studio/annotations-layer.tsx +253 -3
  6. package/apps/studio/annotations-model.ts +107 -6
  7. package/apps/studio/api.ts +812 -64
  8. package/apps/studio/bin/_html-playwright.mjs +9 -1
  9. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  10. package/apps/studio/bin/_png-playwright.mjs +8 -1
  11. package/apps/studio/bin/_pw-launch.mjs +54 -0
  12. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  13. package/apps/studio/bin/_video-playwright.mjs +452 -0
  14. package/apps/studio/bin/prep.sh +8 -1
  15. package/apps/studio/canvas-edit.ts +1885 -104
  16. package/apps/studio/canvas-lib.tsx +19 -0
  17. package/apps/studio/canvas-list-watch.ts +6 -2
  18. package/apps/studio/canvas-shell.tsx +27 -0
  19. package/apps/studio/client/app.jsx +1029 -30
  20. package/apps/studio/client/github.js +7 -0
  21. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  22. package/apps/studio/client/panels/timeline-parse.js +229 -0
  23. package/apps/studio/client/panels/timeline-snap.js +55 -0
  24. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  25. package/apps/studio/config.schema.json +14 -0
  26. package/apps/studio/context-menu.tsx +1 -1
  27. package/apps/studio/context.ts +113 -1
  28. package/apps/studio/dist/client.bundle.js +88 -16
  29. package/apps/studio/dist/comment-mount.js +1 -1
  30. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  31. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  32. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  38. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  39. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  40. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  41. package/apps/studio/dist/runtime/remotion.js +42 -0
  42. package/apps/studio/dist/styles.css +1 -1
  43. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  44. package/apps/studio/exporters/_runtime.ts +69 -0
  45. package/apps/studio/exporters/html.ts +4 -3
  46. package/apps/studio/exporters/index.ts +28 -2
  47. package/apps/studio/exporters/pdf.ts +4 -3
  48. package/apps/studio/exporters/png.ts +5 -3
  49. package/apps/studio/exporters/pptx.ts +6 -4
  50. package/apps/studio/exporters/svg.ts +9 -5
  51. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  52. package/apps/studio/exporters/video-render-lib.ts +108 -0
  53. package/apps/studio/exporters/video.ts +184 -0
  54. package/apps/studio/http.ts +535 -27
  55. package/apps/studio/input-router.tsx +7 -1
  56. package/apps/studio/runtime-bundle.ts +30 -0
  57. package/apps/studio/server.ts +34 -9
  58. package/apps/studio/test/acp-bridge.test.ts +132 -0
  59. package/apps/studio/test/acp-commands.test.ts +8 -3
  60. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  62. package/apps/studio/test/canvas-edit.test.ts +90 -0
  63. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  64. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  65. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  66. package/apps/studio/test/clip-addressing.test.ts +732 -0
  67. package/apps/studio/test/config-reload.test.ts +230 -0
  68. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  69. package/apps/studio/test/edit-persistence.test.ts +91 -0
  70. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  71. package/apps/studio/test/file-lock.test.ts +84 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
  73. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  74. package/apps/studio/test/timeline-parse.test.ts +127 -0
  75. package/apps/studio/test/timeline-snap.test.ts +85 -0
  76. package/apps/studio/test/video-asset.test.ts +163 -0
  77. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  78. package/apps/studio/test/video-comp.test.ts +168 -0
  79. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  80. package/apps/studio/use-annotation-resize.tsx +6 -3
  81. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  82. package/apps/studio/video-comp.tsx +444 -0
  83. package/apps/studio/whats-new.json +36 -0
  84. package/apps/studio/ws.ts +5 -0
  85. package/package.json +8 -8
  86. package/plugins/design/templates/_shell.html +25 -2
  87. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -0,0 +1,229 @@
1
+ // timeline-parse.js — DDR-148. Parse a video-comp's sequence + keyframe
2
+ // structure from its raw .tsx source, so the Timeline panel can draw one row
3
+ // per sequence (positioned by frame) with the animation (interpolate) windows
4
+ // marked on it. Pure + dependency-free → unit-tested without a DOM.
5
+ //
6
+ // This is a pragmatic regex parser tuned to the shapes the video-comp skill
7
+ // teaches (literal or const `durationInFrames`/`from`, <TransitionSeries> with
8
+ // its overlap math, components referenced by name). It degrades gracefully:
9
+ // an unparseable comp yields `{ sequences: [] }` and the panel shows a scrub-
10
+ // only track. It is NOT a general TSX parser.
11
+
12
+ /**
13
+ * Collect top-level numeric consts so `durationInFrames={A}` resolves. Two
14
+ * passes: literal ints first, then expression consts built from them
15
+ * (`const TOTAL = A + B - XF;`) so a derived total resolves too.
16
+ */
17
+ function collectConsts(source) {
18
+ const consts = {};
19
+ for (const m of source.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(-?\d+)\s*;/g)) {
20
+ consts[m[1]] = Number(m[2]);
21
+ }
22
+ for (let pass = 0; pass < 3; pass += 1) {
23
+ for (const m of source.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*([^;]+);/g)) {
24
+ const name = m[1];
25
+ if (Object.hasOwn(consts, name)) continue;
26
+ const v = resolveNum(m[2], consts);
27
+ if (v != null) consts[name] = v;
28
+ }
29
+ }
30
+ return consts;
31
+ }
32
+
33
+ /** Resolve a `{...}` expression to an integer: a literal, a const, or simple arithmetic of them. */
34
+ function resolveNum(raw, consts) {
35
+ if (raw == null) return null;
36
+ const t = String(raw).trim();
37
+ if (/^-?\d+$/.test(t)) return Number(t);
38
+ if (Object.hasOwn(consts, t)) return consts[t];
39
+ const expr = t.replace(/[A-Za-z_$][\w$]*/g, (id) => (Object.hasOwn(consts, id) ? String(consts[id]) : id));
40
+ if (/^[-+*/()\d\s.]+$/.test(expr) && /\d/.test(expr)) {
41
+ try {
42
+ // eslint-disable-next-line no-new-func
43
+ const v = Function(`"use strict";return (${expr});`)();
44
+ if (Number.isFinite(v)) return Math.round(v);
45
+ } catch {
46
+ /* not arithmetic */
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** Animation (interpolate-of-frame) windows inside a named component's body. */
53
+ function keyframesForComponent(source, compName, consts, seqFrom) {
54
+ if (!compName) return [];
55
+ const def = source.search(new RegExp(`\\b(?:const|function)\\s+${compName}\\b`));
56
+ if (def < 0) return [];
57
+ // Heuristic body window — components in a comp are small; the next top-level
58
+ // `const X =`/`function X` bounds it well enough for the marker overview.
59
+ const rest = source.slice(def + compName.length);
60
+ const nextDef = rest.search(/\n(?:const|function)\s+[A-Z]/);
61
+ const body = rest.slice(0, nextDef > 0 ? nextDef : 2400);
62
+ const kf = [];
63
+ for (const m of body.matchAll(/interpolate\(\s*frame\s*,\s*\[\s*([^,\]]+?)\s*,\s*([^,\]]+?)\s*[,\]]/g)) {
64
+ const a = resolveNum(m[1], consts);
65
+ const b = resolveNum(m[2], consts);
66
+ if (a != null && b != null && b >= a) kf.push({ from: seqFrom + a, to: seqFrom + b });
67
+ }
68
+ return kf;
69
+ }
70
+
71
+ /**
72
+ * Parse the comp's timeline. `totalFrames` (from the comp meta) is the
73
+ * authoritative total; the parser only positions the sequences within it.
74
+ * Returns `{ total, sequences: [{ label, from, duration, keyframes[] }] }`.
75
+ */
76
+ /** The source slice of a named component's definition body (heuristic bounds). */
77
+ function componentBody(src, name) {
78
+ if (!name) return null;
79
+ const def = src.search(new RegExp(`\\b(?:const|function)\\s+${name}\\b`));
80
+ if (def < 0) return null;
81
+ const rest = src.slice(def + name.length + 1);
82
+ const next = rest.search(/\n(?:const|function|export)\s+[A-Za-z]/);
83
+ return rest.slice(0, next > 0 ? next : rest.length);
84
+ }
85
+
86
+ /** The `<VideoComp component={X} …>` usages in a canvas, each tagged with the id
87
+ * of its enclosing `<DCArtboard id="…">` (nearest preceding) so the Timeline can
88
+ * scope to whichever artboard the user selected. */
89
+ function videoCompUsages(src, consts) {
90
+ const out = [];
91
+ for (const m of src.matchAll(/<VideoComp\b([^>]*?)\/?>/g)) {
92
+ const a = m[1];
93
+ const comp = a.match(/component=\{([A-Za-z_$][\w$]*)\}/);
94
+ if (!comp) continue;
95
+ const before = src.slice(0, m.index);
96
+ const boards = [...before.matchAll(/<DCArtboard\b[^>]*?\bid=["']([^"']+)["']/g)];
97
+ out.push({
98
+ compName: comp[1],
99
+ artboardId: boards.length ? boards[boards.length - 1][1] : null,
100
+ duration: resolveNum(a.match(/durationInFrames=\{([^}]+)\}/)?.[1], consts),
101
+ fps: resolveNum(a.match(/fps=\{([^}]+)\}/)?.[1], consts) || 30,
102
+ });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
108
+ const src = String(source ?? '');
109
+ const consts = collectConsts(src);
110
+
111
+ // A canvas can hold SEVERAL video-comps (one per artboard). Scope the parse to
112
+ // ONE composition's body so a 2-comp canvas doesn't merge every sequence into
113
+ // one track. Preference: the artboard the user SELECTED (so the Timeline
114
+ // follows the canvas), then a comp with real media, then a duration match,
115
+ // then the first — and read fps/total from it.
116
+ let scope = src;
117
+ let scopedTotal = totalFrames;
118
+ let scopedFps = 0;
119
+ let scopedArtboardId = null;
120
+ const usages = videoCompUsages(src, consts)
121
+ .map((u) => ({ ...u, body: componentBody(src, u.compName) }))
122
+ .filter((u) => u.body);
123
+ if (usages.length) {
124
+ const target =
125
+ (selectedArtboardId && usages.find((u) => u.artboardId === selectedArtboardId)) ||
126
+ usages.find((u) => /<(?:Audio|OffthreadVideo|Video)\b/.test(u.body)) ||
127
+ usages.find((u) => u.duration != null && u.duration === totalFrames) ||
128
+ usages[0];
129
+ scope = target.body;
130
+ if (target.duration != null) scopedTotal = target.duration;
131
+ scopedFps = target.fps;
132
+ scopedArtboardId = target.artboardId ?? null;
133
+ }
134
+
135
+ const items = [];
136
+ const tagRe =
137
+ /<(TransitionSeries\.Sequence|TransitionSeries\.Transition|Series\.Sequence|Sequence)\b([^>]*)>/g;
138
+ let m;
139
+ while ((m = tagRe.exec(scope))) {
140
+ const kind = m[1];
141
+ const attrs = m[2];
142
+ const openEnd = tagRe.lastIndex;
143
+ if (/Transition$/.test(kind)) {
144
+ const d = attrs.match(/durationInFrames:\s*([^,}\s)]+)/);
145
+ items.push({ type: 'transition', dur: resolveNum(d?.[1], consts) ?? 0 });
146
+ } else {
147
+ const dm = attrs.match(/durationInFrames=\{([^}]+)\}/);
148
+ const fm = attrs.match(/from=\{([^}]+)\}/);
149
+ // Bound the media/child sniff to THIS clip's own body — up to its close
150
+ // tag — never the fixed 240-char window that used to bleed into a
151
+ // following loose <Audio> and badge the wrong row (dogfood: EndCard showed
152
+ // a phantom audio). FALLBACK badge only; the enumerator (comp-clips)
153
+ // overlays authoritative media by index (incl. wrapper-component media).
154
+ const closeIdx = scope.indexOf(`</${kind}>`, openEnd);
155
+ const bound = closeIdx >= 0 ? Math.min(closeIdx, openEnd + 400) : openEnd + 240;
156
+ const after = scope.slice(openEnd, bound);
157
+ const lm = after.match(/<([A-Z][A-Za-z0-9]*)\b/);
158
+ // Media child sniff (badge + replace affordance): the first
159
+ // <Video|OffthreadVideo|Audio|Img src="…"> DIRECTLY inside the clip body.
160
+ const mm = after.match(/<(Video|OffthreadVideo|Audio|Img)\b[^>]*src=["']([^"']+)["']/);
161
+ const nameM = attrs.match(/name=["']([^"']+)["']/);
162
+ items.push({
163
+ type: 'seq',
164
+ // DDR-150 dogfood fix: a `from` on a SERIES clip is a lie — Remotion's
165
+ // TransitionSeries/Series compute their own offsets and IGNORE the prop
166
+ // (one shipped drag-move used to insert it). Never let it position the
167
+ // row; series rows always flow from the cumulative cursor, exactly like
168
+ // the render.
169
+ series: kind !== 'Sequence',
170
+ dur: resolveNum(dm?.[1], consts),
171
+ from: kind === 'Sequence' && fm ? resolveNum(fm[1], consts) : null,
172
+ compName: nameM ? nameM[1] : lm ? lm[1] : null,
173
+ mediaTag: mm ? mm[1] : null,
174
+ mediaSrc: mm ? mm[2] : null,
175
+ });
176
+ }
177
+ }
178
+
179
+ let cursor = 0;
180
+ const sequences = [];
181
+ for (const it of items) {
182
+ if (it.type === 'transition') {
183
+ cursor -= it.dur || 0;
184
+ continue;
185
+ }
186
+ const from = it.from != null ? it.from : cursor;
187
+ const duration = it.dur != null ? it.dur : 30;
188
+ sequences.push({
189
+ label: it.compName ?? `Seq ${sequences.length + 1}`,
190
+ series: !!it.series,
191
+ mediaTag: it.mediaTag,
192
+ mediaSrc: it.mediaSrc,
193
+ from: Math.max(0, from),
194
+ duration: Math.max(1, duration),
195
+ keyframes: keyframesForComponent(src, it.compName, consts, Math.max(0, from)),
196
+ });
197
+ cursor = from + duration;
198
+ }
199
+
200
+ const derived = sequences.length
201
+ ? Math.max(...sequences.map((s) => s.from + s.duration))
202
+ : 30;
203
+ const total = Number.isFinite(scopedTotal) && scopedTotal > 0 ? scopedTotal : derived;
204
+
205
+ // Audio/music beds — their OWN rows (a `<Audio>` isn't a Sequence, so it never
206
+ // shows up above). Spans the whole comp unless it carries from/durationInFrames.
207
+ const audio = [];
208
+ for (const m of scope.matchAll(/<Audio\b([^>]*)>/g)) {
209
+ const attrs = m[1];
210
+ const srcM = attrs.match(/src=["']([^"']+)["']/);
211
+ const fromM = attrs.match(/from=\{([^}]+)\}/);
212
+ const durM = attrs.match(/durationInFrames=\{([^}]+)\}/);
213
+ const from = fromM ? (resolveNum(fromM[1], consts) ?? 0) : 0;
214
+ const dur = durM ? resolveNum(durM[1], consts) : null;
215
+ const name = srcM ? String(srcM[1]).split(/[\\/]/).pop() : 'audio';
216
+ audio.push({
217
+ kind: 'audio',
218
+ label: name,
219
+ from: Math.max(0, from),
220
+ duration: dur != null ? Math.max(1, dur) : Math.max(1, total - from),
221
+ });
222
+ }
223
+
224
+ // `artboardId` is the DCArtboard the timeline scoped to — the shell passes it
225
+ // to /_api/comp-clips + every clip op so the enumerator targets the SAME comp
226
+ // (on a multi-comp canvas its own fallback picks a different one — the
227
+ // showreel badge/replace mis-scoped to the wrong comp without this).
228
+ return { total, fps: scopedFps || undefined, sequences, audio, artboardId: scopedArtboardId };
229
+ }
@@ -0,0 +1,55 @@
1
+ // timeline-snap.js — DDR-150 P5/Polish (Task 15). Pure snap helpers for the
2
+ // Timeline clip drags (move `from` / trim duration). A dragged edge snaps to the
3
+ // nearest "meaningful" frame — a second tick, a neighbor clip's edge, or the
4
+ // playhead — within a pixel-derived threshold, UNLESS the user holds Alt (the
5
+ // override modifier). Pure + exported so the behavior is unit-tested without a
6
+ // DOM (timeline-snap.test.ts).
7
+
8
+ /**
9
+ * The frames a dragged clip edge should snap to:
10
+ * • 0 and the comp end (totalFrames)
11
+ * • every whole-second tick (multiples of fps)
12
+ * • every OTHER clip's start + end (skip the clip being dragged)
13
+ * • the playhead
14
+ * Deduped + clamped to [0, totalFrames]. `clips` is the parsed sequence list
15
+ * ({ from, duration }); `movingIndex` is excluded so a clip never snaps to itself.
16
+ */
17
+ export function computeSnapTargets({ fps, totalFrames, clips, movingIndex, playhead }) {
18
+ const targets = new Set([0, totalFrames]);
19
+ const step = Math.max(1, Math.round(fps || 30));
20
+ for (let f = 0; f <= totalFrames; f += step) targets.add(f);
21
+ (clips || []).forEach((c, i) => {
22
+ if (i === movingIndex) return;
23
+ const from = Number.isFinite(c?.from) ? c.from : 0;
24
+ const dur = Number.isFinite(c?.duration) ? c.duration : 0;
25
+ targets.add(from);
26
+ targets.add(from + dur);
27
+ });
28
+ if (Number.isFinite(playhead)) targets.add(playhead);
29
+ return [...targets].filter((t) => t >= 0 && t <= totalFrames).sort((a, b) => a - b);
30
+ }
31
+
32
+ /**
33
+ * Snap `candidate` to the nearest target within `threshold` frames (ties go to
34
+ * the closer, then the first). Returns `candidate` unchanged when nothing is in
35
+ * range or `threshold <= 0` (the Alt-override path passes 0). Pure.
36
+ */
37
+ export function snapFrame(candidate, targets, threshold) {
38
+ if (!(threshold > 0)) return candidate;
39
+ let best = candidate;
40
+ let bestD = threshold + 1;
41
+ for (const t of targets || []) {
42
+ const d = Math.abs(candidate - t);
43
+ if (d <= threshold && d < bestD) {
44
+ best = t;
45
+ bestD = d;
46
+ }
47
+ }
48
+ return best;
49
+ }
50
+
51
+ /** Pixel snap radius → frames, given the row width + total frames. ~8px feels right. */
52
+ export function snapThresholdFrames(rowW, totalFrames, px = 8) {
53
+ const w = Math.max(1, rowW || 1);
54
+ return (px / w) * Math.max(1, totalFrames - 1);
55
+ }
@@ -1211,6 +1211,113 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1211
1211
  .gp-draft .gp-sep { color: var(--fg-3); }
1212
1212
  .gp-x { appearance: none; border: none; background: transparent; color: var(--fg-2); font-size: 16px; line-height: 1; cursor: pointer; padding: 2px 5px; border-radius: var(--radius-xs); }
1213
1213
  .gp-x:hover { color: var(--fg-1); background: var(--bg-3); }
1214
+
1215
+ /* ── Timeline (DDR-148) — BOTTOM-docked transport + per-sequence track ── */
1216
+ .tl-panel { position: relative; display: flex; flex-direction: column; min-height: 0; width: 100%; background: var(--bg-1); border-top: 1px solid var(--border-default); user-select: none; -webkit-user-select: none; }
1217
+ /* DDR-150 P4 — drop-media-to-insert affordance */
1218
+ .tl-panel.is-drop { outline: 2px dashed var(--accent); outline-offset: -3px; background: color-mix(in oklch, var(--accent) 8%, var(--bg-1)); }
1219
+ /* drag the top edge to resize the panel taller/shorter */
1220
+ .tl-resize-handle { position: absolute; top: -3px; left: 0; right: 0; height: 8px; cursor: ns-resize; z-index: 6; touch-action: none; }
1221
+ .tl-resize-handle::after { content: ""; position: absolute; top: 3px; left: 50%; transform: translateX(-50%); width: 44px; height: 3px; border-radius: 2px; background: var(--fg-2); opacity: 0; transition: opacity 0.12s; }
1222
+ .tl-resize-handle:hover::after { opacity: 0.55; }
1223
+ .tl-volume { width: 66px; height: 4px; margin: 0 2px 0 4px; accent-color: var(--accent); cursor: pointer; }
1224
+ .tl-head { display: flex; align-items: center; gap: var(--space-2); height: 40px; padding: 0 var(--space-3) 0 var(--space-3); border-bottom: 1px solid var(--border-subtle); flex: none; }
1225
+ .tl-title { font-family: var(--font-display); font-weight: 600; font-size: var(--type-md); color: var(--fg-0); }
1226
+ .tl-spacer { flex: 1; }
1227
+ .tl-meta { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); white-space: nowrap; }
1228
+ .tl-x { appearance: none; border: none; background: transparent; color: var(--fg-2); font-size: 16px; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: var(--radius-xs); }
1229
+ .tl-x:hover { color: var(--fg-1); background: var(--bg-3); }
1230
+ .tl-empty { padding: var(--space-4); color: var(--fg-2); font-size: var(--type-sm); line-height: 1.5; }
1231
+ .tl-empty code, .tl-empty b { font-family: var(--font-mono); font-size: var(--type-xs); }
1232
+ .tl-empty code { background: var(--bg-2); padding: 1px 4px; border-radius: var(--radius-xs); color: var(--fg-1); }
1233
+ .tl-btn { appearance: none; display: grid; place-items: center; width: 28px; height: 28px; border: 1px solid var(--border-default); border-radius: var(--radius-sm); background: var(--bg-2); color: var(--fg-1); cursor: pointer; transition: background var(--dur-soft) var(--ease-out), color var(--dur-soft) var(--ease-out); }
1234
+ .tl-btn:hover { background: var(--bg-3); color: var(--fg-0); }
1235
+ .tl-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1236
+ .tl-btn.is-on { background: var(--accent-tint); border-color: var(--accent-muted); color: var(--accent); }
1237
+ .tl-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
1238
+ .tl-btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--accent-fg); }
1239
+ .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); }
1240
+ .tl-readout b { color: var(--fg-0); font-weight: 600; }
1241
+ .tl-readout .tl-sep { color: var(--fg-3); }
1242
+ .tl-readout .tl-time { color: var(--accent); margin-left: var(--space-2); }
1243
+ /* the scrolling track area */
1244
+ .tl-scroll { flex: 1; min-height: 0; overflow: auto; padding: var(--space-2) var(--space-3) var(--space-3); }
1245
+ .tl-tracks { position: relative; min-width: 100%; display: flex; flex-direction: column; gap: 6px; padding-top: 20px; cursor: ew-resize; }
1246
+ .tl-ruler { position: absolute; top: 0; left: 96px; right: 0; height: 16px; }
1247
+ .tl-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border-subtle); }
1248
+ .tl-tick-label { position: absolute; top: -1px; left: 3px; font-family: var(--font-mono); font-size: 9px; color: var(--fg-3); white-space: nowrap; }
1249
+ .tl-row { display: flex; align-items: center; gap: var(--space-2); height: 30px; }
1250
+ .tl-row-label { flex: none; width: 88px; display: flex; align-items: center; justify-content: flex-end; gap: 3px; font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); overflow: hidden; white-space: nowrap; text-align: right; }
1251
+ .tl-row-label-text { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
1252
+ /* DDR-150 dogfood #6 — per-row media-kind identity (video / image / audio / JSX). */
1253
+ .tl-kind { flex: none; width: 14px; height: 14px; display: grid; place-items: center; border-radius: 3px; font-size: 8px; line-height: 1; color: var(--fg-2); background: var(--bg-3); }
1254
+ .tl-kind[data-kind="video"] { color: #7dd3fc; background: color-mix(in oklch, #0ea5e9 22%, var(--bg-3)); }
1255
+ .tl-kind[data-kind="image"] { color: #86efac; background: color-mix(in oklch, #22c55e 20%, var(--bg-3)); }
1256
+ .tl-kind[data-kind="audio"] { color: #fcd34d; background: color-mix(in oklch, #f59e0b 20%, var(--bg-3)); }
1257
+ .tl-kind[data-kind="jsx"] { color: var(--fg-2); font-style: italic; }
1258
+ /* DDR-150 dogfood — the right-click clip menu reuses the canvas's shared
1259
+ .dc-context-menu component. Its injected CSS reads --maude-chrome-* tokens
1260
+ that the CANVAS defines (dark floating chrome) but the SHELL never set — so it
1261
+ fell back to white and looked nothing like the canvas menu. Define the same
1262
+ dark chrome tokens ON the menu so shell + canvas menus match. */
1263
+ .dc-context-menu {
1264
+ --maude-chrome-bg-0: oklch(0.165 0.012 255);
1265
+ --maude-chrome-fg-0: oklch(0.955 0.005 250);
1266
+ --maude-chrome-border: oklch(0.290 0.012 255);
1267
+ --maude-chrome-shadow: rgba(0, 0, 0, 0.46);
1268
+ --maude-chrome-font-mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1269
+ }
1270
+ /* Clip layer decomposition — expand toggle + indented sub-rows. */
1271
+ .tl-expand { flex: none; width: 16px; height: 16px; display: grid; place-items: center; padding: 0; border: none; background: transparent; color: var(--fg-1); font-size: 10px; line-height: 1; cursor: pointer; border-radius: 3px; }
1272
+ .tl-expand:hover:not(.tl-expand--spacer) { color: var(--fg-0); background: var(--accent); }
1273
+ .tl-expand--spacer { width: 16px; cursor: default; pointer-events: none; }
1274
+ .tl-row--layer .tl-row-label { color: var(--fg-2); }
1275
+ .tl-row--layer { opacity: 0.92; }
1276
+ .tl-layer-indent { flex: none; width: 10px; }
1277
+ .tl-layer-block { top: 5px; bottom: 5px; opacity: 0.7; border-style: dashed; }
1278
+ /* Hidden clip — dimmed + hatched so it reads as "won't render" but keeps its slot. */
1279
+ .tl-seq-block.is-hidden { opacity: 0.4; background-image: repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(0,0,0,0.25) 5px, rgba(0,0,0,0.25) 10px); }
1280
+ .tl-seq-block.is-hidden .tl-seq-name { text-decoration: line-through; }
1281
+ /* Kind tint on the block itself — a video clip reads different from JSX motion. */
1282
+ .tl-seq-block[data-kind="video"] { border-color: color-mix(in oklch, #0ea5e9 55%, var(--accent-muted)); background: color-mix(in oklch, #0ea5e9 16%, var(--bg-2)); }
1283
+ .tl-seq-block[data-kind="image"] { border-color: color-mix(in oklch, #22c55e 45%, var(--accent-muted)); background: color-mix(in oklch, #22c55e 13%, var(--bg-2)); }
1284
+ /* DDR-150 P5 — z-order raise/lower. Stacked ▲/▼; later sibling paints on top. */
1285
+ .tl-seq-reorder { display: inline-flex; flex-direction: column; flex: none; opacity: 0; transition: opacity var(--dur-1, 120ms) ease; }
1286
+ .tl-row:hover .tl-seq-reorder { opacity: 0.6; }
1287
+ .tl-seq-reorder:hover, .tl-seq-reorder:focus-within { opacity: 1; }
1288
+ .tl-reorder-btn { width: 13px; height: 8px; display: grid; place-items: center; padding: 0; border: none; border-radius: 2px; background: transparent; color: var(--fg-2); font-size: 7px; line-height: 1; cursor: pointer; }
1289
+ .tl-reorder-btn:hover:not(:disabled) { background: var(--accent); color: var(--accent-fg, #fff); }
1290
+ .tl-reorder-btn:disabled { opacity: 0.25; cursor: default; }
1291
+ @media (prefers-reduced-motion: reduce) { .tl-seq-reorder { transition: none; } }
1292
+ .tl-row-track { position: relative; flex: 1; height: 100%; background: var(--bg-2); border-radius: var(--radius-sm); }
1293
+ .tl-seq-block { position: absolute; top: 3px; bottom: 3px; appearance: none; border: 1px solid var(--accent-muted); border-radius: var(--radius-xs); background: color-mix(in oklch, var(--accent) 20%, var(--bg-2)); color: var(--fg-0); cursor: pointer; overflow: hidden; padding: 0; min-width: 6px; transition: background var(--dur-soft) var(--ease-out); }
1294
+ .tl-seq-block:hover { background: color-mix(in oklch, var(--accent) 32%, var(--bg-2)); }
1295
+ .tl-seq-block:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1296
+ .tl-seq-name { position: absolute; left: 6px; top: 50%; transform: translateY(-50%); font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--fg-0); pointer-events: none; white-space: nowrap; }
1297
+ .tl-seq-block.is-resizing { background: color-mix(in oklch, var(--accent) 42%, var(--bg-2)); border-color: var(--accent); }
1298
+ /* drag-to-retime handle at the block's right edge */
1299
+ .tl-seq-resize { position: absolute; top: 3px; bottom: 3px; width: 10px; z-index: 4; cursor: ew-resize; border-radius: 2px; touch-action: none; }
1300
+ .tl-seq-resize::after { content: ""; position: absolute; top: 20%; bottom: 20%; left: 4px; width: 2px; border-radius: 1px; background: var(--fg-2); opacity: 0; }
1301
+ .tl-seq-resize:hover::after { opacity: 0.9; background: var(--accent); }
1302
+ /* DDR-150 P3 — per-clip remove (×). Subtle until the row is hovered. */
1303
+ .tl-seq-remove { position: absolute; top: 3px; width: 15px; height: 15px; z-index: 5; display: grid; place-items: center; padding: 0; border: none; border-radius: var(--radius-xs); background: color-mix(in oklch, var(--bg-0) 70%, transparent); color: var(--fg-1); font-size: 13px; line-height: 1; cursor: pointer; opacity: 0; transition: opacity var(--dur-1, 120ms) ease; }
1304
+ .tl-row:hover .tl-seq-remove { opacity: 0.7; }
1305
+ .tl-seq-remove:hover { opacity: 1; background: var(--status-err, #dc2626); color: #fff; }
1306
+ .tl-seq-block.is-moving { cursor: grabbing; box-shadow: 0 0 0 1px var(--accent); }
1307
+ /* DDR-150 P3 — per-clip replace-media (⇄). */
1308
+ .tl-seq-replace { position: absolute; top: 3px; width: 15px; height: 15px; z-index: 5; display: grid; place-items: center; padding: 0; border: none; border-radius: var(--radius-xs); background: color-mix(in oklch, var(--bg-0) 70%, transparent); color: var(--fg-1); font-size: 11px; line-height: 1; cursor: pointer; opacity: 0; transition: opacity var(--dur-1, 120ms) ease; }
1309
+ .tl-row:hover .tl-seq-replace { opacity: 0.7; }
1310
+ .tl-seq-replace:hover { opacity: 1; background: var(--accent); color: var(--accent-fg, #fff); }
1311
+ /* DDR-150 P5 Task 14 — keyframe markers are click-to-seek */
1312
+ .tl-kf { position: absolute; top: 3px; bottom: 3px; min-width: 3px; border-radius: 2px; background: var(--accent); opacity: 0.85; pointer-events: auto; cursor: pointer; }
1313
+ .tl-kf:hover { opacity: 1; box-shadow: 0 0 0 1px var(--fg-0); }
1314
+ /* audio / music bed row — a distinct teal so it reads as a separate layer */
1315
+ .tl-audio-block { position: absolute; top: 3px; bottom: 3px; border: 1px solid color-mix(in oklch, var(--status-ok, #16a34a) 60%, transparent); border-radius: var(--radius-xs); background: color-mix(in oklch, var(--status-ok, #16a34a) 18%, var(--bg-2)); overflow: hidden; min-width: 6px; }
1316
+ .tl-audio-block .tl-seq-name { color: var(--fg-0); opacity: 0.9; }
1317
+ .tl-wave { position: absolute; inset: 3px 6px; width: calc(100% - 12px); height: calc(100% - 6px); fill: none; stroke: color-mix(in oklch, var(--status-ok, #16a34a) 80%, var(--fg-2)); stroke-width: 1; opacity: 0.55; pointer-events: none; }
1318
+ .tl-playhead { position: absolute; top: 16px; bottom: 0; width: 2px; background: var(--fg-0); box-shadow: 0 0 0 1px var(--bg-1); pointer-events: none; z-index: 3; }
1319
+ .tl-playhead::before { content: ""; position: absolute; top: -4px; left: -4px; border: 5px solid transparent; border-top-color: var(--fg-0); }
1320
+ @media (prefers-reduced-motion: reduce) { .tl-btn, .tl-seq-block { transition: none; } }
1214
1321
  /* tabbar/tab — DS treatment folded in */
1215
1322
  .gp-tabs { display: flex; gap: var(--space-1); padding: var(--space-2) var(--space-3) 0; border-bottom: 1px solid var(--border-subtle); }
1216
1323
  .gp-tab { appearance: none; border: 0; background: transparent; font-size: var(--type-sm); color: var(--fg-2); font-family: var(--font-body); padding: var(--space-3) var(--space-4); cursor: pointer; border-bottom: 2px solid transparent; }
@@ -140,6 +140,20 @@
140
140
  "description": "How hard DS reuse is enforced during canvas generation/edit. 'advisory' (default) = reuse findings (invented brand mark/icons, reinvented components, parallel product shell) surface as warnings. 'strict' = when the DS ships a canonical specimen (logo, iconography, component, platform showcase), reinventing it is a blocker the auto-fix loop must clear. Orthogonal to opt_out_scope on the SAME axis of 'how much the DS binds': a canvas's explicit opt_out_scope=full always wins over strict (explicit free-use beats project policy). A11y is independent of this knob. Per DDR-141.",
141
141
  "default": "advisory"
142
142
  },
143
+ "moodboard": {
144
+ "type": "object",
145
+ "description": "Knobs for the /design:setup-ds Stage-3 direction gate (the design-language moodboard). Per DDR-147.",
146
+ "properties": {
147
+ "variants": {
148
+ "type": "integer",
149
+ "minimum": 1,
150
+ "maximum": 3,
151
+ "default": 3,
152
+ "description": "How many direction tiles the interactive direction gate composes by default. The SINGLE authoritative home of this default — docs reference this knob, never a literal. Effective count still degrades to the number of pairwise-distinct research seeds (floor 1), and --quick / autonomous / re-bootstrap force 1. Default tiles are main-agent seed-only compositions (no web, no fan-out); the blind-sub-agent + self-harvest path is an explicit escalation. Per DDR-147."
153
+ }
154
+ },
155
+ "additionalProperties": false
156
+ },
143
157
  "designSystems": {
144
158
  "type": "array",
145
159
  "description": "Design systems available in this project. Single-DS projects have one entry; multi-DS projects (marketing vs. admin vs. mobile) list each here. Each canvas's .meta.json declares which DS it uses via the 'designSystem' field. The System view's DS picker iterates this array.",
@@ -297,7 +297,7 @@ export function useContextMenuOptional(): ContextMenuValue | null {
297
297
  // ─────────────────────────────────────────────────────────────────────────────
298
298
  // View
299
299
 
300
- function ContextMenuView({
300
+ export function ContextMenuView({
301
301
  target,
302
302
  sections,
303
303
  onClose,
@@ -206,8 +206,47 @@ export function normalizeDesignSystems<T extends DevServerConfig>(cfg: T): T {
206
206
  return { ...cfg, designSystems };
207
207
  }
208
208
 
209
+ /**
210
+ * True when a config-declared relative path stays inside the design root once
211
+ * joined to it. Rejects absolute paths and `..` escapes. Security clamp from
212
+ * the DDR-149 fan-out review: `canvasGroups[].path` / `tokensCssRel` feed
213
+ * directory walks (`/_index-data`) and a served stylesheet URL — a poisoned
214
+ * (e.g. peer-committed) config must not walk or serve outside the design root,
215
+ * and with hot-reload the escape would apply live, no restart gate.
216
+ */
217
+ export function isContainedRel(rel: unknown): boolean {
218
+ if (typeof rel !== 'string' || !rel) return false;
219
+ const p = rel.replace(/\\/g, '/');
220
+ if (p.startsWith('/') || /^[A-Za-z]:/.test(p)) return false;
221
+ const norm = path.posix.normalize(p);
222
+ return norm !== '..' && !norm.startsWith('../');
223
+ }
224
+
225
+ function clampToDesignRoot(cfg: DevServerConfig): DevServerConfig {
226
+ const groups = cfg.canvasGroups ?? [];
227
+ const canvasGroups = groups.filter((g) => isContainedRel(g?.path));
228
+ for (const g of groups) {
229
+ if (!canvasGroups.includes(g)) {
230
+ console.warn(` warn: canvasGroups path escapes the design root — ignored: ${g?.path}`);
231
+ }
232
+ }
233
+ let tokensCssRel = cfg.tokensCssRel;
234
+ if (!isContainedRel(tokensCssRel)) {
235
+ console.warn(` warn: tokensCssRel escapes the design root — using default: ${tokensCssRel}`);
236
+ tokensCssRel = DEFAULT_CONFIG.tokensCssRel;
237
+ }
238
+ const designSystems = cfg.designSystems?.filter((d) => {
239
+ const ok =
240
+ isContainedRel(d?.path) && (d?.tokensCssRel == null || isContainedRel(d.tokensCssRel));
241
+ if (!ok)
242
+ console.warn(` warn: designSystems entry escapes the design root — ignored: ${d?.name}`);
243
+ return ok;
244
+ });
245
+ return { ...cfg, canvasGroups, tokensCssRel, designSystems };
246
+ }
247
+
209
248
  function normalizeConfig(cfg: DevServerConfig): DevServerConfig {
210
- return normalizeDesignSystems(cfg);
249
+ return normalizeDesignSystems(clampToDesignRoot(cfg));
211
250
  }
212
251
 
213
252
  export function createContext(): Context {
@@ -245,3 +284,76 @@ export function createContext(): Context {
245
284
  bus: createBus(),
246
285
  };
247
286
  }
287
+
288
+ /**
289
+ * Hot-reload `.design/config.json` into an existing Context. `/design:setup-ds`
290
+ * (and any hand edit) rewrites the config mid-session; without this the server
291
+ * keeps serving the boot snapshot — `/_index-data` never lists a newly added
292
+ * canvas group, so scaffolded DS files stay invisible even on a manual tree
293
+ * reload. RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
294
+ *
295
+ * CONTRACT: `ctx.cfg` and `ctx.paths` are mutated IN PLACE — module factories
296
+ * capture the object references (`const { cfg, paths } = ctx`) and must see the
297
+ * fresh values through them. Never copy a cfg VALUE at construction time in a
298
+ * long-lived module (that re-introduces the stale-config bug); read from the
299
+ * shared object at use time instead.
300
+ *
301
+ * Deliberately NOT hot-reloadable: `designRoot` — the fs-watcher and every
302
+ * runtime path hang off it; changing it requires a restart (warn + keep old).
303
+ * A config.json that is missing or invalid mid-edit keeps the current cfg
304
+ * (a running server must not downgrade to defaults on a half-written save).
305
+ *
306
+ * Returns true when the config actually changed.
307
+ */
308
+ export function reloadConfig(ctx: Context): boolean {
309
+ const next = loadConfig(ctx.paths.repoRoot);
310
+ // Boot from defaults + config.json created later IS a legit reload; but a
311
+ // file that vanished or fails to parse mid-edit must not clobber a working
312
+ // cfg. loadConfig encodes both cases in _source.
313
+ if (next._source !== '.design/config.json') {
314
+ if (ctx.cfg._source === '.design/config.json') {
315
+ console.warn(
316
+ ` warn: config.json ${next._source === 'defaults' ? 'missing' : 'invalid'} on reload — keeping the running config.`
317
+ );
318
+ }
319
+ return false;
320
+ }
321
+
322
+ const nextDesignRel = next.designRoot.replace(/^\/+|\/+$/g, '');
323
+ if (nextDesignRel !== ctx.paths.designRel) {
324
+ console.warn(
325
+ ` warn: designRoot changed (${ctx.paths.designRel} → ${nextDesignRel}) — not hot-reloadable, restart the server to apply.`
326
+ );
327
+ next.designRoot = ctx.cfg.designRoot;
328
+ }
329
+
330
+ // linkedHub is boot-pinned like designRoot: the sync runtime captures it once
331
+ // at startup (sync/index.ts), so a live swap would let use-time readers
332
+ // (syncTsx gating) drift out of step with the hub the socket is actually
333
+ // attached to — and a poisoned config must never re-point sync without a
334
+ // restart (DDR-149 fan-out review).
335
+ if (JSON.stringify(next.linkedHub) !== JSON.stringify(ctx.cfg.linkedHub)) {
336
+ console.warn(' warn: linkedHub changed — not hot-reloadable, restart the server to apply.');
337
+ if (ctx.cfg.linkedHub === undefined) delete next.linkedHub;
338
+ else next.linkedHub = ctx.cfg.linkedHub;
339
+ }
340
+
341
+ if (JSON.stringify(ctx.cfg) === JSON.stringify(next)) return false;
342
+
343
+ // In-place swap so every captured `ctx.cfg` reference sees the new values.
344
+ // INVARIANT: no `await` between the delete loop and the assign — the swap is
345
+ // atomic only because it is synchronous; an interleaved request must never
346
+ // observe a partially-emptied cfg.
347
+ const cfg = ctx.cfg as unknown as Record<string, unknown>;
348
+ for (const key of Object.keys(cfg)) delete cfg[key];
349
+ Object.assign(cfg, next);
350
+
351
+ ctx.projectLabel = ctx.cfg.projectLabel || `${ctx.cfg.name} Design`;
352
+ ctx.paths.tokensUrlRel = path.posix.join(
353
+ ctx.paths.designRel,
354
+ ctx.cfg.tokensCssRel.replace(/^\/+/, '')
355
+ );
356
+ ctx.paths.systemDirRel =
357
+ ctx.cfg.canvasGroups.find((g) => /system/i.test(g.path))?.path ?? 'system';
358
+ return true;
359
+ }