@1agh/maude 0.51.1 → 0.52.1

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 (48) hide show
  1. package/apps/studio/api.ts +389 -14
  2. package/apps/studio/bin/_video-playwright.mjs +15 -6
  3. package/apps/studio/canvas-edit.ts +141 -20
  4. package/apps/studio/canvas-lib.tsx +61 -0
  5. package/apps/studio/client/app.jsx +824 -121
  6. package/apps/studio/client/panels/ClipInspector.jsx +409 -0
  7. package/apps/studio/client/panels/CloudBar.jsx +12 -2
  8. package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
  9. package/apps/studio/client/panels/timeline-media-cache.js +274 -0
  10. package/apps/studio/client/panels/timeline-parse.js +46 -1
  11. package/apps/studio/client/panels/timeline-scale.js +64 -0
  12. package/apps/studio/client/report-bug.jsx +15 -15
  13. package/apps/studio/client/styles/3-shell-maude.css +446 -3
  14. package/apps/studio/client/styles/4-components-maude.css +55 -0
  15. package/apps/studio/client/styles/4-components.css +0 -83
  16. package/apps/studio/clip-ops.ts +1980 -0
  17. package/apps/studio/cloud/endpoints.ts +88 -17
  18. package/apps/studio/credential-grammar.ts +100 -0
  19. package/apps/studio/debug-bundle.ts +12 -0
  20. package/apps/studio/dist/client.bundle.js +2048 -2048
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/exporters/video.ts +49 -10
  23. package/apps/studio/http.ts +113 -6
  24. package/apps/studio/ripple.ts +335 -0
  25. package/apps/studio/sync/hub-link.ts +3 -3
  26. package/apps/studio/sync/hubs-config.ts +32 -0
  27. package/apps/studio/sync/workspace-signin.ts +8 -4
  28. package/apps/studio/test/_helpers.ts +5 -3
  29. package/apps/studio/test/canvas-create-api.test.ts +8 -2
  30. package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
  31. package/apps/studio/test/clip-addressing.test.ts +7 -6
  32. package/apps/studio/test/clip-ops.test.ts +836 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +37 -2
  34. package/apps/studio/test/comments-api.test.ts +49 -0
  35. package/apps/studio/test/credential-grammar.test.ts +107 -0
  36. package/apps/studio/test/read-only-role.test.ts +95 -0
  37. package/apps/studio/test/ripple.test.ts +197 -0
  38. package/apps/studio/test/timeline-scale.test.ts +65 -0
  39. package/apps/studio/test/workspace-signin.test.ts +4 -1
  40. package/apps/studio/whats-new.json +35 -0
  41. package/cli/commands/share.mjs +8 -4
  42. package/cli/lib/ddr-to-kgai.mjs +6 -1
  43. package/cli/lib/ddr-to-kgai.test.mjs +1 -2
  44. package/cli/lib/hubs-config.mjs +15 -1
  45. package/cli/lib/hubs-config.test.mjs +19 -0
  46. package/cli/lib/share-plan.mjs +5 -1
  47. package/cli/lib/share-plan.test.mjs +11 -2
  48. package/package.json +8 -8
@@ -0,0 +1,274 @@
1
+ // timeline-media-cache.js — feature-enhanced-video-editing (Task 7).
2
+ // Client-side extraction of REAL clip visuals for the Timeline: filmstrip
3
+ // thumbnails (a hidden <video> seeked N times → canvas → JPEG dataURLs) and
4
+ // audio waveform peaks (fetch → WebAudio decodeAudioData → max-abs bins).
5
+ //
6
+ // Contract:
7
+ // • async + cancellable-by-neglect: callers pass an onReady callback; a
8
+ // stale generation simply ignores the result. Today's plain blocks are the
9
+ // instant fallback while extraction runs.
10
+ // • never blocks the scrub: extraction is queued (max 2 concurrent decodes —
11
+ // WKWebView memory) and kicked from idle callbacks.
12
+ // • cached in memory keyed `<src>|<bucket>` AND persisted server-side under
13
+ // `<designRoot>/_canvas-state/timeline-media/` (per-machine runtime state,
14
+ // DDR-115) via /_api/timeline-media.
15
+
16
+ const memory = new Map(); // key → { strip?: string[], peaks?: number[] }
17
+ const pending = new Map(); // key → Promise
18
+ let running = 0;
19
+ const queue = [];
20
+ const MAX_CONCURRENT = 2;
21
+ const THUMB_W = 160;
22
+
23
+ function pump() {
24
+ while (running < MAX_CONCURRENT && queue.length) {
25
+ const job = queue.shift();
26
+ running += 1;
27
+ const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 40));
28
+ idle(() => {
29
+ job()
30
+ .catch(() => {})
31
+ .finally(() => {
32
+ running -= 1;
33
+ pump();
34
+ });
35
+ });
36
+ }
37
+ }
38
+
39
+ function enqueue(fn) {
40
+ return new Promise((resolve) => {
41
+ queue.push(() => fn().then(resolve, () => resolve(null)));
42
+ pump();
43
+ });
44
+ }
45
+
46
+ /** Server-persisted cache key — the content-addressed sha8 in the asset path
47
+ * when present (stable across machines/paths), else a djb2 of the src. */
48
+ export function mediaCacheKey(src, bucket) {
49
+ const sha = String(src).match(/([a-f0-9]{8,})\.[a-z0-9]+$/i)?.[1]?.slice(0, 12);
50
+ if (sha) return `${sha}-${bucket}`;
51
+ let h = 5381;
52
+ for (let i = 0; i < src.length; i += 1) h = ((h << 5) + h + src.charCodeAt(i)) >>> 0;
53
+ return `h${h.toString(16)}-${bucket}`;
54
+ }
55
+
56
+ async function serverGet(key) {
57
+ try {
58
+ const r = await fetch(`/_api/timeline-media?key=${encodeURIComponent(key)}`);
59
+ if (!r.ok) return null;
60
+ const j = await r.json();
61
+ return j?.ok && j.data && typeof j.data === 'object' ? j.data : null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function serverPut(key, data) {
68
+ fetch(`/_api/timeline-media?key=${encodeURIComponent(key)}`, {
69
+ method: 'PUT',
70
+ headers: { 'content-type': 'application/json' },
71
+ body: JSON.stringify(data),
72
+ }).catch(() => {});
73
+ }
74
+
75
+ /** Thumbnail bucket for a block width in px: 1 / 3 / 5 / 7 frames by zoom. */
76
+ export function stripBucket(widthPx) {
77
+ if (widthPx < 90) return 1;
78
+ if (widthPx < 200) return 3;
79
+ if (widthPx < 340) return 5;
80
+ return 7;
81
+ }
82
+
83
+ async function extractStrip(url, count) {
84
+ const video = document.createElement('video');
85
+ video.muted = true;
86
+ video.playsInline = true;
87
+ video.preload = 'auto';
88
+ video.crossOrigin = 'anonymous';
89
+ // WKWebView refuses to decode a detached <video> in some codec paths (the
90
+ // desktop "no filmstrips" bug) — keep it in the document, invisible, for the
91
+ // whole extraction, and kick the load explicitly.
92
+ video.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:2px;height:2px;opacity:0;pointer-events:none;';
93
+ document.body.appendChild(video);
94
+ video.src = url;
95
+ try {
96
+ video.load();
97
+ } catch {
98
+ /* load() is best-effort */
99
+ }
100
+ try {
101
+ await new Promise((res, rej) => {
102
+ const to = setTimeout(() => rej(new Error('metadata timeout')), 8000);
103
+ const ok = () => {
104
+ clearTimeout(to);
105
+ res();
106
+ };
107
+ video.addEventListener('loadedmetadata', ok, { once: true });
108
+ video.addEventListener('canplay', ok, { once: true });
109
+ video.addEventListener(
110
+ 'error',
111
+ () => rej(new Error(`video error (${video.error?.code ?? '?'})`)),
112
+ { once: true }
113
+ );
114
+ });
115
+ } catch (err) {
116
+ video.remove();
117
+ console.warn('[timeline] filmstrip extraction failed for', url, err?.message || err);
118
+ throw err;
119
+ }
120
+ const dur = video.duration;
121
+ if (!Number.isFinite(dur) || dur <= 0) throw new Error('no duration');
122
+ const ratio = (video.videoHeight || 9) / (video.videoWidth || 16);
123
+ const canvas = document.createElement('canvas');
124
+ canvas.width = THUMB_W;
125
+ canvas.height = Math.max(2, Math.round(THUMB_W * ratio));
126
+ const g = canvas.getContext('2d');
127
+ const strip = [];
128
+ try {
129
+ for (let k = 0; k < count; k += 1) {
130
+ const t = ((k + 0.5) / count) * dur;
131
+ // eslint-disable-next-line no-await-in-loop
132
+ await new Promise((res, rej) => {
133
+ const to = setTimeout(() => rej(new Error('seek timeout')), 6000);
134
+ video.addEventListener(
135
+ 'seeked',
136
+ () => {
137
+ clearTimeout(to);
138
+ res();
139
+ },
140
+ { once: true }
141
+ );
142
+ video.currentTime = Math.min(dur - 0.01, Math.max(0, t));
143
+ });
144
+ g.drawImage(video, 0, 0, canvas.width, canvas.height);
145
+ strip.push(canvas.toDataURL('image/jpeg', 0.55));
146
+ }
147
+ } catch (err) {
148
+ // WKWebView quirk — some codecs never fire `seeked` when paused. A partial
149
+ // strip (or a single poster frame) still beats an anonymous slab.
150
+ if (strip.length === 0) {
151
+ try {
152
+ await new Promise((res, rej) => {
153
+ if (video.readyState >= 2) {
154
+ res();
155
+ return;
156
+ }
157
+ const to = setTimeout(() => rej(new Error('data timeout')), 4000);
158
+ video.addEventListener(
159
+ 'loadeddata',
160
+ () => {
161
+ clearTimeout(to);
162
+ res();
163
+ },
164
+ { once: true }
165
+ );
166
+ });
167
+ g.drawImage(video, 0, 0, canvas.width, canvas.height);
168
+ strip.push(canvas.toDataURL('image/jpeg', 0.55));
169
+ } catch {
170
+ video.remove();
171
+ console.warn('[timeline] filmstrip seek+poster both failed for', url, err?.message || err);
172
+ throw err; // truly nothing extractable
173
+ }
174
+ }
175
+ while (strip.length < count) strip.push(strip[strip.length - 1]);
176
+ }
177
+ video.removeAttribute('src');
178
+ try {
179
+ video.load();
180
+ } catch {
181
+ /* teardown */
182
+ }
183
+ video.remove();
184
+ return strip;
185
+ }
186
+
187
+ async function extractPeaks(url, bins = 400) {
188
+ const res = await fetch(url);
189
+ if (!res.ok) throw new Error(`fetch ${res.status}`);
190
+ const buf = await res.arrayBuffer();
191
+ const Ctor = window.AudioContext || window.webkitAudioContext;
192
+ if (!Ctor) throw new Error('no AudioContext');
193
+ const ctx = new Ctor();
194
+ try {
195
+ const decoded = await ctx.decodeAudioData(buf);
196
+ const data = decoded.getChannelData(0);
197
+ const step = Math.max(1, Math.floor(data.length / bins));
198
+ const peaks = [];
199
+ for (let b = 0; b < bins; b += 1) {
200
+ let max = 0;
201
+ const start = b * step;
202
+ const end = Math.min(data.length, start + step);
203
+ // Sample sparsely inside the bin — full max over 10M samples is wasted work.
204
+ const inner = Math.max(1, Math.floor((end - start) / 64));
205
+ for (let i = start; i < end; i += inner) {
206
+ const v = Math.abs(data[i]);
207
+ if (v > max) max = v;
208
+ }
209
+ peaks.push(Math.round(max * 100) / 100);
210
+ }
211
+ return peaks;
212
+ } finally {
213
+ ctx.close?.().catch?.(() => {});
214
+ }
215
+ }
216
+
217
+ /** Filmstrip for a video src (resolved URL). Resolves to string[] | null. */
218
+ export function getFilmstrip(url, bucket) {
219
+ const key = mediaCacheKey(url, `s${bucket}`);
220
+ const hit = memory.get(key);
221
+ if (hit?.strip) return Promise.resolve(hit.strip);
222
+ if (pending.has(key)) return pending.get(key);
223
+ const p = (async () => {
224
+ const cached = await serverGet(key);
225
+ if (Array.isArray(cached?.strip)) {
226
+ memory.set(key, { strip: cached.strip });
227
+ return cached.strip;
228
+ }
229
+ const strip = await enqueue(() => extractStrip(url, bucket));
230
+ if (!strip) return null;
231
+ memory.set(key, { strip });
232
+ serverPut(key, { strip });
233
+ return strip;
234
+ })().finally(() => pending.delete(key));
235
+ pending.set(key, p);
236
+ return p;
237
+ }
238
+
239
+ /** Waveform peaks for an audio src (resolved URL). Resolves to number[] | null. */
240
+ export function getPeaks(url) {
241
+ const key = mediaCacheKey(url, 'peaks');
242
+ const hit = memory.get(key);
243
+ if (hit?.peaks) return Promise.resolve(hit.peaks);
244
+ if (pending.has(key)) return pending.get(key);
245
+ const p = (async () => {
246
+ const cached = await serverGet(key);
247
+ if (Array.isArray(cached?.peaks)) {
248
+ memory.set(key, { peaks: cached.peaks });
249
+ return cached.peaks;
250
+ }
251
+ const peaks = await enqueue(() => extractPeaks(url));
252
+ if (!peaks) return null;
253
+ memory.set(key, { peaks });
254
+ serverPut(key, { peaks });
255
+ return peaks;
256
+ })().finally(() => pending.delete(key));
257
+ pending.set(key, p);
258
+ return p;
259
+ }
260
+
261
+ /** Build the SVG path for a peak array in a 100×12 viewBox (mirrored wave). */
262
+ export function peaksToPath(peaks) {
263
+ if (!peaks?.length) return '';
264
+ const n = peaks.length;
265
+ const top = [];
266
+ const bottom = [];
267
+ for (let i = 0; i < n; i += 1) {
268
+ const px = (i / (n - 1)) * 100;
269
+ const a = Math.max(0.04, Math.min(1, peaks[i])) * 5;
270
+ top.push(`${px.toFixed(2)} ${(6 - a).toFixed(2)}`);
271
+ bottom.unshift(`${px.toFixed(2)} ${(6 + a).toFixed(2)}`);
272
+ }
273
+ return `M${top.join(' L')} L${bottom.join(' L')} Z`;
274
+ }
@@ -133,6 +133,10 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
133
133
  }
134
134
 
135
135
  const items = [];
136
+ // Spans of clip bodies — used below to spot LOOSE media (a bare
137
+ // <OffthreadVideo>/<Img> sibling outside every Sequence), which previously
138
+ // never showed on the timeline at all (dogfood 2026-07-30).
139
+ const clipSpans = [];
136
140
  const tagRe =
137
141
  /<(TransitionSeries\.Sequence|TransitionSeries\.Transition|Series\.Sequence|Sequence)\b([^>]*)>/g;
138
142
  let m;
@@ -152,6 +156,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
152
156
  // a phantom audio). FALLBACK badge only; the enumerator (comp-clips)
153
157
  // overlays authoritative media by index (incl. wrapper-component media).
154
158
  const closeIdx = scope.indexOf(`</${kind}>`, openEnd);
159
+ clipSpans.push([m.index, closeIdx >= 0 ? closeIdx : openEnd + 240]);
155
160
  const bound = closeIdx >= 0 ? Math.min(closeIdx, openEnd + 400) : openEnd + 240;
156
161
  const after = scope.slice(openEnd, bound);
157
162
  const lm = after.match(/<([A-Z][A-Za-z0-9]*)\b/);
@@ -178,13 +183,23 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
178
183
 
179
184
  let cursor = 0;
180
185
  const sequences = [];
186
+ // Task 5 (enhanced-video-editing) — seam transitions between series beats,
187
+ // so the storyline lane can draw its ⧓ chips: { afterIndex, dur } where
188
+ // afterIndex is the sequence-row index the transition FOLLOWS.
189
+ const transitions = [];
190
+ let pendingTransition = null;
181
191
  for (const it of items) {
182
192
  if (it.type === 'transition') {
183
193
  cursor -= it.dur || 0;
194
+ if (sequences.length > 0) pendingTransition = { afterIndex: sequences.length - 1, dur: it.dur || 0 };
184
195
  continue;
185
196
  }
186
197
  const from = it.from != null ? it.from : cursor;
187
198
  const duration = it.dur != null ? it.dur : 30;
199
+ if (pendingTransition) {
200
+ transitions.push(pendingTransition);
201
+ pendingTransition = null;
202
+ }
188
203
  sequences.push({
189
204
  label: it.compName ?? `Seq ${sequences.length + 1}`,
190
205
  series: !!it.series,
@@ -216,14 +231,44 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
216
231
  audio.push({
217
232
  kind: 'audio',
218
233
  label: name,
234
+ src: srcM ? srcM[1] : null,
219
235
  from: Math.max(0, from),
220
236
  duration: dur != null ? Math.max(1, dur) : Math.max(1, total - from),
221
237
  });
222
238
  }
223
239
 
240
+ // Loose VISUAL media — a bare <OffthreadVideo>/<Video>/<Img> sibling outside
241
+ // every clip span. Remotion renders it from frame 0 for the whole comp, so
242
+ // show it as a read-only overlay row (`loose: true` — no clip ops; it has no
243
+ // enumerator identity). Dogfood 2026-07-30: these were invisible before.
244
+ for (const lm of scope.matchAll(/<(OffthreadVideo|Video|Img)\b([^>]*?)\/?>/g)) {
245
+ const at = lm.index ?? 0;
246
+ if (clipSpans.some(([s2, e2]) => at >= s2 && at <= e2)) continue;
247
+ const srcM = lm[2].match(/src=["']([^"']+)["']/);
248
+ if (!srcM) continue;
249
+ const name = String(srcM[1]).split(/[\\/]/).pop();
250
+ sequences.push({
251
+ label: name || lm[1],
252
+ series: false,
253
+ loose: true,
254
+ mediaTag: lm[1],
255
+ mediaSrc: srcM[1],
256
+ from: 0,
257
+ duration: Math.max(1, total),
258
+ keyframes: [],
259
+ });
260
+ }
261
+
224
262
  // `artboardId` is the DCArtboard the timeline scoped to — the shell passes it
225
263
  // to /_api/comp-clips + every clip op so the enumerator targets the SAME comp
226
264
  // (on a multi-comp canvas its own fallback picks a different one — the
227
265
  // showreel badge/replace mis-scoped to the wrong comp without this).
228
- return { total, fps: scopedFps || undefined, sequences, audio, artboardId: scopedArtboardId };
266
+ return {
267
+ total,
268
+ fps: scopedFps || undefined,
269
+ sequences,
270
+ audio,
271
+ transitions,
272
+ artboardId: scopedArtboardId,
273
+ };
229
274
  }
@@ -0,0 +1,64 @@
1
+ // timeline-scale.js — feature-enhanced-video-editing (Task 4). Pure zoom/scale
2
+ // helpers for the Timeline: all block/handle/caret/playhead math goes through
3
+ // ONE px-per-frame scale (replacing the old %-of-total positioning), so drags
4
+ // land on the same frames at every zoom level. Pure + DOM-free → unit-tested.
5
+
6
+ /** Fit-to-width scale: the whole comp spans the viewport exactly. */
7
+ export function fitPxPerFrame(viewportPx, totalFrames) {
8
+ const w = Math.max(1, viewportPx || 1);
9
+ const t = Math.max(1, totalFrames || 1);
10
+ return w / t;
11
+ }
12
+
13
+ /** Hard zoom bounds: never below fit (no dead space right of the comp), never
14
+ * above MAX_PX_PER_FRAME (a frame wider than ~40px stops being useful). */
15
+ export const MAX_PX_PER_FRAME = 40;
16
+
17
+ export function clampPxPerFrame(pxPerFrame, viewportPx, totalFrames) {
18
+ const fit = fitPxPerFrame(viewportPx, totalFrames);
19
+ const v = Number(pxPerFrame);
20
+ if (!Number.isFinite(v) || v <= 0) return fit;
21
+ return Math.min(MAX_PX_PER_FRAME, Math.max(fit, v));
22
+ }
23
+
24
+ export function frameToPx(frame, pxPerFrame) {
25
+ return frame * pxPerFrame;
26
+ }
27
+
28
+ export function pxToFrame(px, pxPerFrame) {
29
+ return pxPerFrame > 0 ? px / pxPerFrame : 0;
30
+ }
31
+
32
+ /**
33
+ * Zoom keeping `anchorFrame` stationary on screen: given the current scroll
34
+ * offset and the anchor's viewport position, return the scrollLeft that puts
35
+ * the anchor back at the same viewport x under the new scale.
36
+ */
37
+ export function zoomAroundScroll(prevPxPerFrame, nextPxPerFrame, anchorFrame, prevScrollLeft) {
38
+ const anchorViewportX = frameToPx(anchorFrame, prevPxPerFrame) - prevScrollLeft;
39
+ return Math.max(0, frameToPx(anchorFrame, nextPxPerFrame) - anchorViewportX);
40
+ }
41
+
42
+ /**
43
+ * Adaptive ruler tick step (in frames): the densest step from the ladder whose
44
+ * ticks stay ≥ `minPx` apart. Ladder walks 1 f → 5 f → 10 f → 1 s → 5 s → 10 s
45
+ * → 30 s → 60 s, so zooming in refines seconds into frames.
46
+ */
47
+ export function tickStepFrames(pxPerFrame, fps, minPx = 56) {
48
+ const f = Math.max(1, Math.round(fps || 30));
49
+ const ladder = [1, 5, 10, f, 5 * f, 10 * f, 30 * f, 60 * f];
50
+ for (const step of ladder) {
51
+ if (step * pxPerFrame >= minPx) return step;
52
+ }
53
+ return ladder[ladder.length - 1];
54
+ }
55
+
56
+ /** Ruler tick labels: whole seconds label as `Ns`, sub-second ticks as `Nf`. */
57
+ export function tickLabel(frame, fps) {
58
+ const f = Math.max(1, Math.round(fps || 30));
59
+ if (frame % f === 0) return `${frame / f}s`;
60
+ return `${frame}f`;
61
+ }
62
+
63
+ /** Multiplicative zoom step (⌘+/⌘− and the slider use the same factor). */
64
+ export const ZOOM_STEP = 1.5;
@@ -327,24 +327,24 @@ export function ReportBugDialog({ open, onClose }) {
327
327
  const canPreview = description.trim().length > 0;
328
328
 
329
329
  return (
330
- <div className="help-modal-backdrop" onClick={close} data-testid="report-bug-dialog">
330
+ <div className="st-scrim" onClick={close} data-testid="report-bug-dialog">
331
331
  <div
332
- className="help-modal rb-modal"
332
+ className="st-dialog rb-dialog"
333
333
  role="dialog"
334
334
  aria-modal="true"
335
335
  aria-labelledby="rb-title"
336
336
  onClick={(e) => e.stopPropagation()}
337
337
  >
338
- <header className="help-modal-hd">
339
- <span className="title" id="rb-title">
338
+ <div className="st-dialog-hd">
339
+ <span className="st-dialog-title" id="rb-title">
340
340
  Report a bug
341
341
  </span>
342
- <button type="button" className="help-modal-close" onClick={close} aria-label="Close">
342
+ <button type="button" className="st-iconbtn" onClick={close} aria-label="Close">
343
343
  ×
344
344
  </button>
345
- </header>
345
+ </div>
346
346
 
347
- <div className="help-modal-body rb-body">
347
+ <div className="st-dialog-bd rb-body">
348
348
  {step === 'describe' && (
349
349
  <>
350
350
  <label className="rb-label" htmlFor="rb-desc">
@@ -352,7 +352,7 @@ export function ReportBugDialog({ open, onClose }) {
352
352
  </label>
353
353
  <textarea
354
354
  id="rb-desc"
355
- className="rb-desc"
355
+ className="textarea"
356
356
  data-testid="report-bug-description"
357
357
  rows={5}
358
358
  autoFocus
@@ -364,12 +364,12 @@ export function ReportBugDialog({ open, onClose }) {
364
364
  Next you'll see exactly what gets attached — nothing is sent until you approve it.
365
365
  </p>
366
366
  <footer className="rb-actions">
367
- <button type="button" className="rb-btn" onClick={close}>
367
+ <button type="button" className="st-btn" onClick={close}>
368
368
  Cancel
369
369
  </button>
370
370
  <button
371
371
  type="button"
372
- className="rb-btn rb-primary"
372
+ className="st-btn rb-primary"
373
373
  data-testid="report-bug-preview"
374
374
  disabled={!canPreview}
375
375
  onClick={() => setStep('preview')}
@@ -501,12 +501,12 @@ export function ReportBugDialog({ open, onClose }) {
501
501
  repository only maintainers can open.
502
502
  </p>
503
503
  <footer className="rb-actions">
504
- <button type="button" className="rb-btn" onClick={() => setStep('describe')}>
504
+ <button type="button" className="st-btn" onClick={() => setStep('describe')}>
505
505
  Back
506
506
  </button>
507
507
  <button
508
508
  type="button"
509
- className="rb-btn rb-primary"
509
+ className="st-btn rb-primary"
510
510
  data-testid="report-bug-send"
511
511
  onClick={send}
512
512
  >
@@ -556,7 +556,7 @@ export function ReportBugDialog({ open, onClose }) {
556
556
  </p>
557
557
  )}
558
558
  <footer className="rb-actions">
559
- <button type="button" className="rb-btn rb-primary" onClick={close}>
559
+ <button type="button" className="st-btn rb-primary" onClick={close}>
560
560
  Done
561
561
  </button>
562
562
  </footer>
@@ -574,10 +574,10 @@ export function ReportBugDialog({ open, onClose }) {
574
574
  attach them by hand.
575
575
  </p>
576
576
  <footer className="rb-actions">
577
- <button type="button" className="rb-btn" onClick={() => setStep('preview')}>
577
+ <button type="button" className="st-btn" onClick={() => setStep('preview')}>
578
578
  Back
579
579
  </button>
580
- <button type="button" className="rb-btn rb-primary" onClick={saveFallback}>
580
+ <button type="button" className="st-btn rb-primary" onClick={saveFallback}>
581
581
  Save locally &amp; open GitHub
582
582
  </button>
583
583
  </footer>