@1agh/maude 0.39.1 → 0.41.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 (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. 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
+ }
@@ -353,6 +353,13 @@
353
353
  .st-rp-tab .st-rp-tabct { font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); }
354
354
  .st-rp-body { flex: 1; overflow-y: auto; padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); }
355
355
  .st-rp-hd { font-family: var(--font-mono); font-size: var(--type-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--fg-2); }
356
+ /* Stage H (INV-3) edit-scope strip — local (this element) vs shared (edits N places). */
357
+ .st-scope { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); font-size: var(--type-xs); border-bottom: 1px solid var(--border-subtle); flex: none; animation: st-pop var(--dur-soft) var(--ease-out); }
358
+ .st-scope-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
359
+ .st-scope--local { color: var(--fg-2); }
360
+ .st-scope--local .st-scope-dot { background: var(--fg-3); }
361
+ .st-scope--shared { color: var(--accent); }
362
+ .st-scope--shared .st-scope-dot { background: var(--accent); }
356
363
  .st-rp-empty { color: var(--fg-2); font-size: var(--type-sm); line-height: 1.6; display: flex; flex-direction: column; gap: var(--space-4); }
357
364
 
358
365
  /* comment pin (inline badge in the panel) */
@@ -476,6 +483,24 @@
476
483
  .st-export-recent-row:hover { background: var(--bg-2); }
477
484
  .st-export-recent-row .st-mono { color: var(--fg-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 50%; }
478
485
 
486
+ /* ─── AssetPicker (feature-element-editing-robustness Stage F1) ───────────── */
487
+ .st-btn { appearance: none; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; min-height: 28px; border: 1px solid var(--border-default); border-radius: var(--radius-sm); background: var(--bg-2); color: var(--fg-1); font-family: var(--font-mono); font-size: var(--type-xs); padding: 0 12px; transition: background var(--dur-soft) var(--ease-out), border-color var(--dur-soft) var(--ease-out); }
488
+ .st-btn:hover { background: var(--bg-3); border-color: var(--border-strong, var(--border-default)); }
489
+ .st-btn:disabled { opacity: 0.55; cursor: default; }
490
+ .st-asset-picker { width: 620px; }
491
+ .st-ap-toolbar { display: flex; align-items: center; gap: var(--space-3); }
492
+ .st-ap-err { color: var(--danger, #e5484d); font-family: var(--font-mono); font-size: var(--type-xs); }
493
+ .st-ap-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: var(--space-3); max-height: 52vh; overflow-y: auto; padding: 2px; }
494
+ .st-ap-empty { grid-column: 1 / -1; color: var(--fg-2); font-family: var(--font-mono); font-size: var(--type-xs); padding: var(--space-6) 0; text-align: center; }
495
+ .st-ap-cell { appearance: none; cursor: pointer; display: flex; flex-direction: column; gap: 4px; padding: 6px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-2); text-align: left; transition: border-color var(--dur-soft) var(--ease-out), background var(--dur-soft) var(--ease-out); }
496
+ .st-ap-cell:hover { border-color: var(--accent, var(--border-strong, var(--border-default))); background: var(--bg-3); }
497
+ .st-ap-thumb { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: var(--radius-xs); background: var(--bg-4, var(--bg-3)); display: block; }
498
+ .st-ap-name { font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
499
+ .st-cp-replace { width: 100%; justify-content: center; }
500
+ /* Media "Replace…" — a full-width button, NOT inside the 3-col .st-cp-row grid
501
+ (which would drop it into the 10px provenance-dot column and clip it). */
502
+ .st-cp-mediabtn { padding: var(--space-1) 0 var(--space-2); }
503
+
479
504
  /* ─── Inspector panel fields / layers / css (Plan C P11–P13) ───────────────
480
505
  * Lifted from Studio.css; complements the already-present `.st-insp-row`,
481
506
  * `.st-insp-label`, `.st-rp-*`. */
@@ -716,6 +741,15 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
716
741
  .st-cp-boxv--mb, .st-cp-boxv--pb { bottom: 1px; left: 50%; transform: translateX(-50%); }
717
742
  .st-cp-boxv--ml, .st-cp-boxv--pl { left: 0; top: 50%; transform: translateY(-50%); }
718
743
  .st-cp-boxv--mr, .st-cp-boxv--pr { right: 0; top: 50%; transform: translateY(-50%); }
744
+ /* feature-element-editing-robustness Stage B — Position inset widget. One ring
745
+ (no nested padding box): the 4 inset inputs sit on the outer box edges around
746
+ a centered core showing the current `position` keyword (Figma constraints box). */
747
+ .st-cp-boxv--it { top: 1px; left: 50%; transform: translateX(-50%); }
748
+ .st-cp-boxv--ib { bottom: 1px; left: 50%; transform: translateX(-50%); }
749
+ .st-cp-boxv--il { left: 0; top: 50%; transform: translateY(-50%); }
750
+ .st-cp-boxv--ir { right: 0; top: 50%; transform: translateY(-50%); }
751
+ .st-cp-boxtag--i { color: var(--accent); }
752
+ .st-cp-boxcore--pos { text-transform: none; letter-spacing: 0; }
719
753
  .st-cp-boxhint { display: flex; flex-wrap: wrap; gap: var(--space-1); }
720
754
  .st-cp-boxhint + .st-cp-row { margin-top: var(--space-1); }
721
755
  .st-cp-chip { font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); background: var(--bg-2); border: 1px solid var(--border-default); border-radius: var(--radius-xs); padding: 1px 6px; }
@@ -762,6 +796,20 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
762
796
  .st-cp-add:hover { border-color: var(--accent); color: var(--fg-0); }
763
797
  .st-cp-note { font-size: 10px; color: var(--fg-2); font-style: italic; }
764
798
 
799
+ /* Stage M — Fixed / Hug / Fill sizing-mode segmented control (per axis). */
800
+ .st-cp-modes { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-1); }
801
+ .st-cp-modeseg { display: flex; align-items: stretch; gap: var(--space-1); }
802
+ .st-cp-modeax { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); }
803
+ .st-cp-modebtn { flex: 1; appearance: none; cursor: pointer; border: 1px solid var(--border-default); background: var(--bg-3); color: var(--fg-1); font-family: var(--font-mono); font-size: 10px; min-height: 22px; border-radius: var(--radius-xs); padding: 0 var(--space-1); }
804
+ .st-cp-modebtn:hover:not(.is-active):not(:disabled) { background: var(--bg-4); color: var(--fg-0); }
805
+ .st-cp-modebtn.is-active { background: var(--accent-tint); border-color: var(--accent); color: var(--accent); }
806
+ .st-cp-modebtn:disabled { opacity: 0.5; cursor: default; }
807
+ /* Stage M — sub-header for the flex-child row group + the make-flex CTA. */
808
+ .st-cp-subhd { font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); text-transform: uppercase; letter-spacing: 0.04em; margin-top: var(--space-2); }
809
+ .st-cp-makeflex { align-self: flex-start; appearance: none; cursor: pointer; border: 1px dashed var(--border-default); background: transparent; color: var(--fg-1); font-family: var(--font-mono); font-size: var(--type-xs); border-radius: var(--radius-xs); padding: 4px var(--space-2); }
810
+ .st-cp-makeflex:hover:not(:disabled) { border-color: var(--accent); color: var(--fg-0); }
811
+ .st-cp-makeflex:disabled { opacity: 0.5; cursor: default; }
812
+
765
813
  /* provenance legend */
766
814
  .st-cp-legend { display: flex; flex-wrap: wrap; gap: var(--space-3); padding-top: var(--space-1); border-top: 1px solid var(--border-subtle); }
767
815
  .st-cp-legend span { display: inline-flex; align-items: center; gap: var(--space-1); font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); }
@@ -1211,6 +1259,113 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1211
1259
  .gp-draft .gp-sep { color: var(--fg-3); }
1212
1260
  .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
1261
  .gp-x:hover { color: var(--fg-1); background: var(--bg-3); }
1262
+
1263
+ /* ── Timeline (DDR-148) — BOTTOM-docked transport + per-sequence track ── */
1264
+ .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; }
1265
+ /* DDR-150 P4 — drop-media-to-insert affordance */
1266
+ .tl-panel.is-drop { outline: 2px dashed var(--accent); outline-offset: -3px; background: color-mix(in oklch, var(--accent) 8%, var(--bg-1)); }
1267
+ /* drag the top edge to resize the panel taller/shorter */
1268
+ .tl-resize-handle { position: absolute; top: -3px; left: 0; right: 0; height: 8px; cursor: ns-resize; z-index: 6; touch-action: none; }
1269
+ .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; }
1270
+ .tl-resize-handle:hover::after { opacity: 0.55; }
1271
+ .tl-volume { width: 66px; height: 4px; margin: 0 2px 0 4px; accent-color: var(--accent); cursor: pointer; }
1272
+ .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; }
1273
+ .tl-title { font-family: var(--font-display); font-weight: 600; font-size: var(--type-md); color: var(--fg-0); }
1274
+ .tl-spacer { flex: 1; }
1275
+ .tl-meta { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); white-space: nowrap; }
1276
+ .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); }
1277
+ .tl-x:hover { color: var(--fg-1); background: var(--bg-3); }
1278
+ .tl-empty { padding: var(--space-4); color: var(--fg-2); font-size: var(--type-sm); line-height: 1.5; }
1279
+ .tl-empty code, .tl-empty b { font-family: var(--font-mono); font-size: var(--type-xs); }
1280
+ .tl-empty code { background: var(--bg-2); padding: 1px 4px; border-radius: var(--radius-xs); color: var(--fg-1); }
1281
+ .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); }
1282
+ .tl-btn:hover { background: var(--bg-3); color: var(--fg-0); }
1283
+ .tl-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1284
+ .tl-btn.is-on { background: var(--accent-tint); border-color: var(--accent-muted); color: var(--accent); }
1285
+ .tl-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
1286
+ .tl-btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--accent-fg); }
1287
+ .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); }
1288
+ .tl-readout b { color: var(--fg-0); font-weight: 600; }
1289
+ .tl-readout .tl-sep { color: var(--fg-3); }
1290
+ .tl-readout .tl-time { color: var(--accent); margin-left: var(--space-2); }
1291
+ /* the scrolling track area */
1292
+ .tl-scroll { flex: 1; min-height: 0; overflow: auto; padding: var(--space-2) var(--space-3) var(--space-3); }
1293
+ .tl-tracks { position: relative; min-width: 100%; display: flex; flex-direction: column; gap: 6px; padding-top: 20px; cursor: ew-resize; }
1294
+ .tl-ruler { position: absolute; top: 0; left: 96px; right: 0; height: 16px; }
1295
+ .tl-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border-subtle); }
1296
+ .tl-tick-label { position: absolute; top: -1px; left: 3px; font-family: var(--font-mono); font-size: 9px; color: var(--fg-3); white-space: nowrap; }
1297
+ .tl-row { display: flex; align-items: center; gap: var(--space-2); height: 30px; }
1298
+ .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; }
1299
+ .tl-row-label-text { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
1300
+ /* DDR-150 dogfood #6 — per-row media-kind identity (video / image / audio / JSX). */
1301
+ .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); }
1302
+ .tl-kind[data-kind="video"] { color: #7dd3fc; background: color-mix(in oklch, #0ea5e9 22%, var(--bg-3)); }
1303
+ .tl-kind[data-kind="image"] { color: #86efac; background: color-mix(in oklch, #22c55e 20%, var(--bg-3)); }
1304
+ .tl-kind[data-kind="audio"] { color: #fcd34d; background: color-mix(in oklch, #f59e0b 20%, var(--bg-3)); }
1305
+ .tl-kind[data-kind="jsx"] { color: var(--fg-2); font-style: italic; }
1306
+ /* DDR-150 dogfood — the right-click clip menu reuses the canvas's shared
1307
+ .dc-context-menu component. Its injected CSS reads --maude-chrome-* tokens
1308
+ that the CANVAS defines (dark floating chrome) but the SHELL never set — so it
1309
+ fell back to white and looked nothing like the canvas menu. Define the same
1310
+ dark chrome tokens ON the menu so shell + canvas menus match. */
1311
+ .dc-context-menu {
1312
+ --maude-chrome-bg-0: oklch(0.165 0.012 255);
1313
+ --maude-chrome-fg-0: oklch(0.955 0.005 250);
1314
+ --maude-chrome-border: oklch(0.290 0.012 255);
1315
+ --maude-chrome-shadow: rgba(0, 0, 0, 0.46);
1316
+ --maude-chrome-font-mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1317
+ }
1318
+ /* Clip layer decomposition — expand toggle + indented sub-rows. */
1319
+ .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; }
1320
+ .tl-expand:hover:not(.tl-expand--spacer) { color: var(--fg-0); background: var(--accent); }
1321
+ .tl-expand--spacer { width: 16px; cursor: default; pointer-events: none; }
1322
+ .tl-row--layer .tl-row-label { color: var(--fg-2); }
1323
+ .tl-row--layer { opacity: 0.92; }
1324
+ .tl-layer-indent { flex: none; width: 10px; }
1325
+ .tl-layer-block { top: 5px; bottom: 5px; opacity: 0.7; border-style: dashed; }
1326
+ /* Hidden clip — dimmed + hatched so it reads as "won't render" but keeps its slot. */
1327
+ .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); }
1328
+ .tl-seq-block.is-hidden .tl-seq-name { text-decoration: line-through; }
1329
+ /* Kind tint on the block itself — a video clip reads different from JSX motion. */
1330
+ .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)); }
1331
+ .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)); }
1332
+ /* DDR-150 P5 — z-order raise/lower. Stacked ▲/▼; later sibling paints on top. */
1333
+ .tl-seq-reorder { display: inline-flex; flex-direction: column; flex: none; opacity: 0; transition: opacity var(--dur-1, 120ms) ease; }
1334
+ .tl-row:hover .tl-seq-reorder { opacity: 0.6; }
1335
+ .tl-seq-reorder:hover, .tl-seq-reorder:focus-within { opacity: 1; }
1336
+ .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; }
1337
+ .tl-reorder-btn:hover:not(:disabled) { background: var(--accent); color: var(--accent-fg, #fff); }
1338
+ .tl-reorder-btn:disabled { opacity: 0.25; cursor: default; }
1339
+ @media (prefers-reduced-motion: reduce) { .tl-seq-reorder { transition: none; } }
1340
+ .tl-row-track { position: relative; flex: 1; height: 100%; background: var(--bg-2); border-radius: var(--radius-sm); }
1341
+ .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); }
1342
+ .tl-seq-block:hover { background: color-mix(in oklch, var(--accent) 32%, var(--bg-2)); }
1343
+ .tl-seq-block:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1344
+ .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; }
1345
+ .tl-seq-block.is-resizing { background: color-mix(in oklch, var(--accent) 42%, var(--bg-2)); border-color: var(--accent); }
1346
+ /* drag-to-retime handle at the block's right edge */
1347
+ .tl-seq-resize { position: absolute; top: 3px; bottom: 3px; width: 10px; z-index: 4; cursor: ew-resize; border-radius: 2px; touch-action: none; }
1348
+ .tl-seq-resize::after { content: ""; position: absolute; top: 20%; bottom: 20%; left: 4px; width: 2px; border-radius: 1px; background: var(--fg-2); opacity: 0; }
1349
+ .tl-seq-resize:hover::after { opacity: 0.9; background: var(--accent); }
1350
+ /* DDR-150 P3 — per-clip remove (×). Subtle until the row is hovered. */
1351
+ .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; }
1352
+ .tl-row:hover .tl-seq-remove { opacity: 0.7; }
1353
+ .tl-seq-remove:hover { opacity: 1; background: var(--status-err, #dc2626); color: #fff; }
1354
+ .tl-seq-block.is-moving { cursor: grabbing; box-shadow: 0 0 0 1px var(--accent); }
1355
+ /* DDR-150 P3 — per-clip replace-media (⇄). */
1356
+ .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; }
1357
+ .tl-row:hover .tl-seq-replace { opacity: 0.7; }
1358
+ .tl-seq-replace:hover { opacity: 1; background: var(--accent); color: var(--accent-fg, #fff); }
1359
+ /* DDR-150 P5 Task 14 — keyframe markers are click-to-seek */
1360
+ .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; }
1361
+ .tl-kf:hover { opacity: 1; box-shadow: 0 0 0 1px var(--fg-0); }
1362
+ /* audio / music bed row — a distinct teal so it reads as a separate layer */
1363
+ .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; }
1364
+ .tl-audio-block .tl-seq-name { color: var(--fg-0); opacity: 0.9; }
1365
+ .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; }
1366
+ .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; }
1367
+ .tl-playhead::before { content: ""; position: absolute; top: -4px; left: -4px; border: 5px solid transparent; border-top-color: var(--fg-0); }
1368
+ @media (prefers-reduced-motion: reduce) { .tl-btn, .tl-seq-block { transition: none; } }
1214
1369
  /* tabbar/tab — DS treatment folded in */
1215
1370
  .gp-tabs { display: flex; gap: var(--space-1); padding: var(--space-2) var(--space-3) 0; border-bottom: 1px solid var(--border-subtle); }
1216
1371
  .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; }