@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.
- package/README.md +1 -1
- package/apps/studio/acp/bridge.ts +144 -5
- package/apps/studio/acp/index.ts +11 -1
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +253 -3
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +812 -64
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/canvas-edit.ts +1885 -104
- package/apps/studio/canvas-lib.tsx +19 -0
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +27 -0
- package/apps/studio/client/app.jsx +1029 -30
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +107 -0
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +1 -1
- package/apps/studio/context.ts +113 -1
- package/apps/studio/dist/client.bundle.js +88 -16
- package/apps/studio/dist/comment-mount.js +1 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/http.ts +535 -27
- package/apps/studio/input-router.tsx +7 -1
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/test/acp-bridge.test.ts +132 -0
- package/apps/studio/test/acp-commands.test.ts +8 -3
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +90 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/ws.ts +5 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +25 -2
- 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,
|
package/apps/studio/context.ts
CHANGED
|
@@ -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
|
+
}
|