@odori/cli 0.0.4 → 0.0.5

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/src/render.ts CHANGED
@@ -469,6 +469,13 @@ export type RenderOptions = {
469
469
  /** Container and codec. Defaults to H.264 in MP4. */
470
470
  format?: VideoFormat;
471
471
  skipUnchangedFrames?: boolean;
472
+ /**
473
+ * Mix the composition's cues into the file. Defaults to true, because the
474
+ * score is part of the video. Set false for a silent cut: a loop for a
475
+ * landing page, a clip going into an editor that has its own audio, or a
476
+ * reviewer who just wants the picture.
477
+ */
478
+ audio?: boolean;
472
479
  /** Reuse encoded chunks whose frames still look identical. */
473
480
  cache?: boolean;
474
481
  signal?: AbortSignal;
@@ -571,7 +578,7 @@ export const renderMovie = async (
571
578
  const captureMs = performance.now() - captureStart;
572
579
  onProgress?.(1, "encoding");
573
580
 
574
- const mixInputs: MixInput[] = (target.audio ?? [])
581
+ const mixInputs: MixInput[] = (options.audio === false ? [] : (target.audio ?? []))
575
582
  .map((cue) => {
576
583
  const file = resolveCueFile(config, cue.src);
577
584
  if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
@@ -8,15 +8,11 @@ import {HomeView} from "./views/HomeView";
8
8
  import {CommandPalette} from "./components/CommandPalette";
9
9
  import {Wordmark} from "./components/Wordmark";
10
10
  import {Button, Icon, Kbd} from "./components/ui";
11
- import {useTheme, type Theme} from "./theme";
11
+ import {Settings} from "./components/Settings";
12
+ import {useTheme} from "./theme";
12
13
 
13
14
  const VIEWS = ["videos", "components", "brands", "assets"] as const;
14
15
 
15
- const THEMES: Array<{id: Theme; label: string; icon: "display" | "sun" | "moon"}> = [
16
- {id: "system", label: "System", icon: "display"},
17
- {id: "light", label: "Light", icon: "sun"},
18
- {id: "dark", label: "Dark", icon: "moon"},
19
- ];
20
16
  export type StudioView = (typeof VIEWS)[number] | "home";
21
17
 
22
18
  const ROUTES: readonly string[] = ["home", ...VIEWS];
@@ -48,7 +44,9 @@ export const Studio = () => {
48
44
  const routeRef = useRef(route);
49
45
  routeRef.current = route;
50
46
  const search = useRef<HTMLInputElement>(null);
51
- const {theme, setTheme} = useTheme();
47
+ // Mounted here as well as in the menu so the stored theme is applied on
48
+ // load, whether or not anybody opens settings.
49
+ useTheme();
52
50
 
53
51
  const navigate = (view: StudioView, selection?: string | null) => {
54
52
  const next: Route = {view, selection: selection ?? null};
@@ -132,21 +130,7 @@ export const Studio = () => {
132
130
  />
133
131
  <Kbd>⌘K</Kbd>
134
132
  </div>
135
- <div className="tabs" role="group" aria-label="Theme">
136
- {THEMES.map((option) => (
137
- <Button
138
- key={option.id}
139
- icon
140
- active={theme === option.id}
141
- aria-pressed={theme === option.id}
142
- aria-label={option.label}
143
- title={`${option.label} theme`}
144
- onClick={() => setTheme(option.id)}
145
- >
146
- <Icon name={option.icon} />
147
- </Button>
148
- ))}
149
- </div>
133
+ <Settings />
150
134
  </div>
151
135
  </header>
152
136
 
@@ -65,24 +65,82 @@ export const ExportPanel = ({
65
65
  }) => {
66
66
  const [job, setJob] = useState<JobState | null>(null);
67
67
  const [busy, setBusy] = useState(false);
68
+ /** Set when a job stops reporting progress, so a wedge reads as a wedge. */
69
+ const [stalled, setStalled] = useState(false);
70
+ /* Sound is part of the video, so it ships unless it is turned off. GIF has
71
+ no audio track at all and a still is one frame, so the choice only exists
72
+ where it means something. */
73
+ const [sound, setSound] = useState(true);
68
74
  const [format, setFormat] = useState<Format>("mp4");
69
75
  const [quality, setQuality] = useState<Quality>("studio");
70
76
  const [scale, setScale] = useState(1);
71
77
  const [notice, setNotice] = useState<string | null>(null);
72
78
 
79
+ /**
80
+ * Watch a job until it finishes, and say so if it does not.
81
+ *
82
+ * The old loop treated every unreadable response as "try again" and, after
83
+ * fifteen minutes of that, returned without a word. A dev server restarted
84
+ * mid-export takes its job records with it, so what you saw was a bar frozen
85
+ * at whatever percentage it had reached, no error, and no end: the export
86
+ * looked like it was still going hours later. A watcher that cannot see the
87
+ * job has to say the job is unwatchable.
88
+ */
73
89
  const poll = async (id: string) => {
74
- for (let attempt = 0; attempt < 900; attempt += 1) {
90
+ let unreachable = 0;
91
+ let last = -1;
92
+ let stalledFor = 0;
93
+
94
+ for (let attempt = 0; attempt < 1800; attempt += 1) {
75
95
  await new Promise((wait) => setTimeout(wait, 1000));
76
- const response = await fetch(`/__odori/jobs/${id}`);
77
- if (!response.ok) continue;
78
- const payload = (await response.json()) as JobState;
96
+ let payload: JobState | null = null;
97
+ try {
98
+ const response = await fetch(`/__odori/jobs/${id}`);
99
+ if (response.ok) payload = (await response.json()) as JobState;
100
+ } catch {
101
+ // Network error and 404 are the same thing here: no news about the job.
102
+ }
103
+
104
+ if (!payload) {
105
+ unreachable += 1;
106
+ // Ten seconds of silence is a server that went away, not a slow reply.
107
+ if (unreachable >= 10) {
108
+ setJob((current) => ({
109
+ ...(current ?? {id, progress: 0}),
110
+ status: "failed",
111
+ error:
112
+ "Lost contact with the dev server, so this export can no longer be tracked. It may still have finished; check the out directory, or run odori jobs.",
113
+ }));
114
+ return;
115
+ }
116
+ continue;
117
+ }
118
+
119
+ unreachable = 0;
79
120
  setJob(payload);
80
121
  if (payload.status === "ready" || payload.status === "failed") return;
122
+
123
+ /* A long render is normal; a silent one is the thing worth naming. The
124
+ encoder reports every five percent, so two minutes without a number
125
+ means something is wedged rather than slow. */
126
+ if (payload.progress === last) stalledFor += 1;
127
+ else {
128
+ stalledFor = 0;
129
+ last = payload.progress;
130
+ }
131
+ setStalled(stalledFor >= 120);
81
132
  }
133
+
134
+ setJob((current) => ({
135
+ ...(current ?? {id, progress: 0}),
136
+ status: "failed",
137
+ error: "Stopped watching after thirty minutes. Run odori jobs to see whether it is still going.",
138
+ }));
82
139
  };
83
140
 
84
141
  const post = async (path: string, body?: unknown) => {
85
142
  setBusy(true);
143
+ setStalled(false);
86
144
  setJob({id: "pending", status: "queued", progress: 0});
87
145
  try {
88
146
  const response = await fetch(path, {
@@ -135,7 +193,7 @@ export const ExportPanel = ({
135
193
  const run = async () => {
136
194
  setNotice(null);
137
195
  if (format === "frame") return post("/__odori/still", {videoId, input, frame});
138
- return post("/__odori/exports", {videoId, input, format, quality, scale});
196
+ return post("/__odori/exports", {videoId, input, format, quality, scale, audio: hasSound ? sound : false});
139
197
  };
140
198
 
141
199
  const file = outputName(videoId);
@@ -143,6 +201,8 @@ export const ExportPanel = ({
143
201
  // video's layout and every frame number in the project depends on it.
144
202
  const still = format === "frame";
145
203
  const hasQuality = format === "mp4" || format === "webm";
204
+ /** GIF carries no audio track and a still is one frame. */
205
+ const hasSound = format !== "gif" && format !== "frame";
146
206
  const inFlight = job && job.status !== "ready" && job.status !== "failed";
147
207
  // The button names the file it will write - extension implied by the type
148
208
  // above it - and reports its own progress while writing it.
@@ -220,6 +280,22 @@ export const ExportPanel = ({
220
280
  </div>
221
281
  ) : null}
222
282
 
283
+ {hasSound ? (
284
+ <div className="export-row">
285
+ <span className="export-label">Sound</span>
286
+ <div className="export-options">
287
+ <Button variant="outline" active={sound} disabled={busy} onClick={() => setSound(true)}
288
+ title="Mix the brand's cues into the file">
289
+ On
290
+ </Button>
291
+ <Button variant="outline" active={!sound} disabled={busy} onClick={() => setSound(false)}
292
+ title="Write the picture with no audio track">
293
+ Off
294
+ </Button>
295
+ </div>
296
+ </div>
297
+ ) : null}
298
+
223
299
  <div className="export-actions">
224
300
  <Button
225
301
  variant="primary"
@@ -256,10 +332,18 @@ export const ExportPanel = ({
256
332
  </span>
257
333
  </div>
258
334
  {job.status === "rendering" || job.status === "encoding" ? (
259
- <div className="progress">
335
+ <div className="progress" data-stalled={stalled ? "true" : undefined}>
260
336
  <span style={{width: `${Math.round((job.progress ?? 0) * 100)}%`}} />
261
337
  </div>
262
338
  ) : null}
339
+ {/* A bar that has not moved in two minutes looks exactly like one
340
+ that is about to. Saying which is the whole point. */}
341
+ {stalled ? (
342
+ <p className="hint" style={{marginTop: 6}}>
343
+ No progress for two minutes. It may be a slow frame, or the render may be wedged. Run{" "}
344
+ <code>odori jobs</code> to see the log, and the dev server terminal for the error.
345
+ </p>
346
+ ) : null}
263
347
  </>
264
348
  ) : null}
265
349
 
@@ -119,7 +119,11 @@ export const Navigator = ({
119
119
  <p className="hint navigator-empty">Nothing matches {query}.</p>
120
120
  ) : (
121
121
  runs.map((run) => (
122
- <div key={run.group ?? "."}>
122
+ /* Keyed by the first item, not by the group. A run without a group
123
+ used to key on a constant, so a list that goes ungrouped, then
124
+ grouped, then ungrouped again handed React two siblings with the
125
+ same key - which it is free to drop or duplicate. */
126
+ <div key={run.items[0].id}>
123
127
  {run.group ? <h3 className="navigator-group">{run.group}</h3> : null}
124
128
  <ul className="list">
125
129
  {run.items.map((item) => (
@@ -0,0 +1,109 @@
1
+ import {useEffect, useRef, useState} from "react";
2
+ import {Button, Icon} from "./ui";
3
+ import {useStartSound} from "../settings";
4
+ import {useTheme, type Theme} from "../theme";
5
+
6
+ /**
7
+ * The workspace preferences, behind one control in the header.
8
+ *
9
+ * This replaced a three-button theme switcher. Theme was the only preference
10
+ * Studio had, so spending a permanent row of chrome on it was fine right up
11
+ * until there was a second one; three more buttons for sound would have made
12
+ * the header a settings panel that never closes. A gear says "there are
13
+ * choices here" in the space one of the old buttons used.
14
+ */
15
+ const THEMES: Array<{id: Theme; label: string; hint: string}> = [
16
+ {id: "system", label: "System", hint: "Follow the operating system"},
17
+ {id: "light", label: "Light", hint: "Always light"},
18
+ {id: "dark", label: "Dark", hint: "Always dark"},
19
+ ];
20
+
21
+ const SOUNDS = [
22
+ {id: "on" as const, label: "On", hint: "Play the score with the video"},
23
+ {id: "off" as const, label: "Off", hint: "Open every video muted"},
24
+ ];
25
+
26
+ export const Settings = () => {
27
+ const [open, setOpen] = useState(false);
28
+ const {theme, setTheme} = useTheme();
29
+ const [sound, setSound] = useStartSound();
30
+ const wrapper = useRef<HTMLDivElement>(null);
31
+ const trigger = useRef<HTMLButtonElement>(null);
32
+
33
+ useEffect(() => {
34
+ if (!open) return undefined;
35
+ const onPointerDown = (event: PointerEvent) => {
36
+ if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
37
+ };
38
+ const onKeyDown = (event: KeyboardEvent) => {
39
+ if (event.key !== "Escape") return;
40
+ setOpen(false);
41
+ // Closing with the keyboard has to put the focus somewhere, and the
42
+ // control that opened the menu is the only place that is not a surprise.
43
+ trigger.current?.focus();
44
+ };
45
+ // Capture, so a click that also does something else still closes the menu.
46
+ document.addEventListener("pointerdown", onPointerDown, true);
47
+ document.addEventListener("keydown", onKeyDown);
48
+ return () => {
49
+ document.removeEventListener("pointerdown", onPointerDown, true);
50
+ document.removeEventListener("keydown", onKeyDown);
51
+ };
52
+ }, [open]);
53
+
54
+ return (
55
+ <div className="settings" ref={wrapper}>
56
+ <Button
57
+ ref={trigger}
58
+ icon
59
+ active={open}
60
+ aria-label="Settings"
61
+ aria-expanded={open}
62
+ aria-haspopup="menu"
63
+ title="Settings"
64
+ onClick={() => setOpen((value) => !value)}
65
+ >
66
+ <Icon name="settings" />
67
+ </Button>
68
+
69
+ {open ? (
70
+ <div className="menu settings-menu" role="menu" aria-label="Settings">
71
+ <p className="menu-heading">Theme</p>
72
+ {THEMES.map((option) => (
73
+ <button
74
+ key={option.id}
75
+ type="button"
76
+ role="menuitemradio"
77
+ aria-checked={theme === option.id}
78
+ className="menu-item"
79
+ data-selected={theme === option.id ? "true" : undefined}
80
+ onClick={() => setTheme(option.id)}
81
+ >
82
+ <span>{option.label}</span>
83
+ <span className="menu-hint">{option.hint}</span>
84
+ </button>
85
+ ))}
86
+
87
+ <p className="menu-heading">Sound</p>
88
+ {SOUNDS.map((option) => (
89
+ <button
90
+ key={option.id}
91
+ type="button"
92
+ role="menuitemradio"
93
+ aria-checked={sound === option.id}
94
+ className="menu-item"
95
+ data-selected={sound === option.id ? "true" : undefined}
96
+ onClick={() => setSound(option.id)}
97
+ >
98
+ <span>{option.label}</span>
99
+ <span className="menu-hint">{option.hint}</span>
100
+ </button>
101
+ ))}
102
+ {/* Changing it now would mute or unmute the video being watched,
103
+ which is not what a default is. */}
104
+ <p className="menu-note">Applies to the next video you open.</p>
105
+ </div>
106
+ ) : null}
107
+ </div>
108
+ );
109
+ };
@@ -1,10 +1,20 @@
1
- import {useEffect, useRef} from "react";
1
+ import {useEffect, useRef, useState} from "react";
2
2
  import {formatTimecode, type AudioTrack, type CompiledTimeline, type Playback} from "odori";
3
3
  import {Button, Icon} from "./ui";
4
4
 
5
5
  /**
6
6
  * Transport plus a scene-aware timeline. The track is the scene structure the
7
7
  * runtime compiled, not a decorative bar.
8
+ *
9
+ * The playhead is not draggable. Dragging one is a commitment: the pointer is
10
+ * captured, so a release outside the window can be missed and leave the
11
+ * transport stuck mid-scrub, and every intermediate frame is a real seek, which
12
+ * is what made scrubbing sound like a stuck record. Hovering costs nothing and
13
+ * answers the same question - what is at this moment - so the track previews
14
+ * under the cursor and a click is what actually moves the playhead.
15
+ *
16
+ * Preview is a paused-only affordance. A second marker chasing the cursor while
17
+ * the video plays is two playheads disagreeing about where you are.
8
18
  */
9
19
  export const RATES = [0.25, 0.5, 1, 1.5, 2, 4];
10
20
 
@@ -22,7 +32,7 @@ export const Transport = ({
22
32
  onToggleLoop,
23
33
  onToggleMuted,
24
34
  onSoloCue,
25
- onScrubbing,
35
+ onPreview,
26
36
  rate = 1,
27
37
  onRate,
28
38
  }: {
@@ -41,65 +51,77 @@ export const Transport = ({
41
51
  onToggleLoop: () => void;
42
52
  onToggleMuted?: () => void;
43
53
  onSoloCue?: (id: string | null) => void;
44
- onScrubbing?: (scrubbing: boolean) => void;
54
+ /**
55
+ * The frame under the cursor, or null when the cursor is off the track. The
56
+ * stage renders this instead of the playhead frame, so hovering shows the
57
+ * moment without committing to it.
58
+ */
59
+ onPreview?: (frame: number | null) => void;
45
60
  rate?: number;
46
61
  onRate?: (rate: number) => void;
47
62
  }) => {
48
63
  const trackRef = useRef<HTMLDivElement>(null);
49
- const scrubbing = useRef(false);
50
- // Scrubbing pauses the clock so the drag owns the playhead, then hands
51
- // playback back in the state it was in. Picking a frame is not a request to
52
- // stop.
53
- const resumeAfterScrub = useRef(false);
54
- // A click picks a frame; only a drag auditions. Audio waits for movement so
55
- // choosing a frame while paused stays silent.
56
- const dragOrigin = useRef(0);
57
- const auditioning = useRef(false);
64
+ const [preview, setPreview] = useState<number | null>(null);
65
+ /* How much room the track has, so the ruler can be drawn for it rather than
66
+ for an assumed window. Studio is used docked beside an agent as often as
67
+ it is used full width. */
68
+ const [width, setWidth] = useState(0);
58
69
  const {frame} = playback;
59
70
 
60
- const scrubTo = (clientX: number) => {
71
+ /** The frame under a given client x, or null if the track has no box yet. */
72
+ const frameAt = (clientX: number): number | null => {
61
73
  const element = trackRef.current;
62
- if (!element) return;
74
+ if (!element) return null;
63
75
  const bounds = element.getBoundingClientRect();
76
+ if (bounds.width === 0) return null;
64
77
  const ratio = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width));
65
- playback.seek(Math.round(ratio * (durationInFrames - 1)));
78
+ return Math.round(ratio * (durationInFrames - 1));
66
79
  };
67
80
 
68
81
  useEffect(() => {
69
- const move = (event: PointerEvent) => {
70
- if (!scrubbing.current) return;
71
- if (!auditioning.current && Math.abs(event.clientX - dragOrigin.current) > 3) {
72
- auditioning.current = true;
73
- onScrubbing?.(true);
74
- }
75
- scrubTo(event.clientX);
76
- };
77
- const up = () => {
78
- if (!scrubbing.current) return;
79
- scrubbing.current = false;
80
- if (auditioning.current) {
81
- auditioning.current = false;
82
- onScrubbing?.(false);
83
- }
84
- if (resumeAfterScrub.current) {
85
- resumeAfterScrub.current = false;
86
- playback.play();
87
- }
88
- };
89
- window.addEventListener("pointermove", move);
90
- window.addEventListener("pointerup", up);
91
- return () => {
92
- window.removeEventListener("pointermove", move);
93
- window.removeEventListener("pointerup", up);
94
- };
95
- });
82
+ const element = trackRef.current;
83
+ if (!element) return undefined;
84
+ const observer = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? 0));
85
+ observer.observe(element);
86
+ return () => observer.disconnect();
87
+ }, []);
88
+
89
+ const showPreview = (next: number | null) => {
90
+ setPreview(next);
91
+ onPreview?.(next);
92
+ };
93
+
94
+ /* Playing is the one state with no preview, so starting playback while the
95
+ cursor rests on the track clears the marker it left behind. In an effect,
96
+ not in render: the parent owns the previewed frame and telling it during
97
+ our own render would be a write into somebody else's component. */
98
+ const playing = playback.playing;
99
+ useEffect(() => {
100
+ if (playing) {
101
+ setPreview(null);
102
+ onPreview?.(null);
103
+ }
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [playing]);
96
106
 
107
+ /** What the stage is showing: the previewed frame wins while hovering. */
108
+ const shown = preview ?? frame;
97
109
  const scenes = timeline?.scenes ?? [];
98
- const active = scenes.find((scene) => frame >= scene.start && frame < scene.start + scene.durationInFrames);
110
+ const active = scenes.find((scene) => shown >= scene.start && shown < scene.start + scene.durationInFrames);
111
+
112
+ /*
113
+ * The ruler is drawn to the width it actually has. A fixed twelve marks is
114
+ * fine in a wide window and unreadable in a narrow one, where "10.0s" next
115
+ * to "11.0s" in eighteen pixels is a smear rather than a scale - and where
116
+ * two marks could round to the same frame, which React counted as a
117
+ * duplicate key. Roughly fifty pixels is what one label needs to stand
118
+ * alone.
119
+ */
99
120
  const seconds = Math.max(1, Math.round(durationInFrames / fps));
100
- const ticks = Array.from({length: Math.min(seconds, 12) + 1}, (_, index) =>
101
- Math.round((index * durationInFrames) / Math.min(seconds, 12)),
102
- );
121
+ const divisions = Math.max(1, Math.min(seconds, Math.floor(width / 52) || 1, 12));
122
+ const ticks = [...new Set(
123
+ Array.from({length: divisions + 1}, (_, index) => Math.round((index * durationInFrames) / divisions)),
124
+ )];
103
125
 
104
126
  return (
105
127
  <div className="transport">
@@ -114,12 +136,22 @@ export const Transport = ({
114
136
  <div
115
137
  className="track"
116
138
  ref={trackRef}
117
- onPointerDown={(event) => {
118
- scrubbing.current = true;
119
- dragOrigin.current = event.clientX;
120
- resumeAfterScrub.current = playback.playing;
139
+ data-preview={preview !== null ? "true" : undefined}
140
+ onPointerMove={(event) => {
141
+ // Touch reports a move before its tap, and a marker that appears
142
+ // under a finger and stays there is worse than none.
143
+ if (playing || event.pointerType === "touch") return;
144
+ showPreview(frameAt(event.clientX));
145
+ }}
146
+ onPointerLeave={() => showPreview(null)}
147
+ onClick={(event) => {
148
+ const next = frameAt(event.clientX);
149
+ if (next === null) return;
150
+ // Picking a frame is not a request to stop, but it is a request to
151
+ // look at that frame, and the clock would move off it immediately.
121
152
  playback.pause();
122
- scrubTo(event.clientX);
153
+ playback.seek(next);
154
+ showPreview(null);
123
155
  }}
124
156
  >
125
157
  {scenes.length > 0 ? (
@@ -139,6 +171,14 @@ export const Transport = ({
139
171
  continuous timeline
140
172
  </div>
141
173
  )}
174
+ {preview !== null && preview !== frame ? (
175
+ <div
176
+ className="playhead"
177
+ data-preview="true"
178
+ aria-hidden="true"
179
+ style={{left: `${(preview / Math.max(1, durationInFrames - 1)) * 100}%`}}
180
+ />
181
+ ) : null}
142
182
  <div className="playhead" style={{left: `${(frame / Math.max(1, durationInFrames - 1)) * 100}%`}} />
143
183
  </div>
144
184
 
@@ -224,11 +264,14 @@ export const Transport = ({
224
264
  </Button>
225
265
  ) : null}
226
266
 
227
- <span className="timecode" style={{marginLeft: 8}}>
228
- <b>{formatTimecode(frame, fps)}</b> / {formatTimecode(durationInFrames - 1, fps)}
267
+ {/* The readout follows the picture, not the playhead: while hovering,
268
+ the stage is showing the previewed frame and a counter reporting
269
+ some other number is just wrong. */}
270
+ <span className="timecode" style={{marginLeft: 8}} data-preview={preview !== null ? "true" : undefined}>
271
+ <b>{formatTimecode(shown, fps)}</b> / {formatTimecode(durationInFrames - 1, fps)}
229
272
  </span>
230
- <span className="timecode">
231
- frame <b>{frame}</b> of {durationInFrames - 1}
273
+ <span className="timecode" data-preview={preview !== null ? "true" : undefined}>
274
+ frame <b>{shown}</b> of {durationInFrames - 1}
232
275
  </span>
233
276
  {active ? <span className="timecode">scene <b>{active.name ?? active.id}</b></span> : null}
234
277
  </div>
@@ -1,4 +1,4 @@
1
- import type {ButtonHTMLAttributes, ReactNode} from "react";
1
+ import type {ButtonHTMLAttributes, ReactNode, Ref} from "react";
2
2
 
3
3
  export const Button = ({
4
4
  variant = "ghost",
@@ -10,6 +10,8 @@ export const Button = ({
10
10
  variant?: "ghost" | "outline" | "primary";
11
11
  active?: boolean;
12
12
  icon?: boolean;
13
+ /** React 19 passes ref as an ordinary prop; the type has to say so. */
14
+ ref?: Ref<HTMLButtonElement>;
13
15
  }) => (
14
16
  <button
15
17
  type="button"
@@ -69,10 +71,23 @@ export const Icon = ({
69
71
  | "display"
70
72
  | "external"
71
73
  | "sidebar"
74
+ | "settings"
72
75
  | "search";
73
76
  }) => {
74
77
  const paths: Record<string, ReactNode> = {
75
78
  play: <path d="M4.5 2.8v8.4l7-4.2z" fill="currentColor" />,
79
+ settings: (
80
+ <>
81
+ <circle cx="7" cy="7" r="2.1" fill="none" stroke="currentColor" strokeWidth="1.2" />
82
+ <path
83
+ d="M7 1.3l.85 1.5a4.7 4.7 0 0 1 1.2.5l1.7-.3.85 1.47-1 1.36c.08.2.14.4.18.62l1.53.72v1.7l-1.53.72c-.04.21-.1.42-.18.62l1 1.36-.85 1.47-1.7-.3a4.7 4.7 0 0 1-1.2.5L7 12.7l-.85-1.5a4.7 4.7 0 0 1-1.2-.5l-1.7.3-.85-1.47 1-1.36a4.7 4.7 0 0 1-.18-.62L1.7 6.83v-1.7l1.52-.72c.05-.21.11-.42.19-.62l-1-1.36.85-1.47 1.7.3c.37-.21.78-.38 1.2-.5z"
84
+ fill="none"
85
+ stroke="currentColor"
86
+ strokeWidth="1.1"
87
+ strokeLinejoin="round"
88
+ />
89
+ </>
90
+ ),
76
91
  sun: (
77
92
  <>
78
93
  <circle cx="7" cy="7" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.2" />