@odori/cli 0.0.2

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 (70) hide show
  1. package/LICENSE +22 -0
  2. package/bin/odori.mjs +39 -0
  3. package/dist/chunk-7XJL2BYO.js +3552 -0
  4. package/dist/cli.d.ts +10 -0
  5. package/dist/cli.js +10 -0
  6. package/dist/index.d.ts +622 -0
  7. package/dist/index.js +156 -0
  8. package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
  9. package/package.json +50 -0
  10. package/src/audio-mix.ts +133 -0
  11. package/src/binaries.ts +241 -0
  12. package/src/brand-file.ts +94 -0
  13. package/src/chunk-cache.ts +85 -0
  14. package/src/chunks.ts +78 -0
  15. package/src/cli.ts +319 -0
  16. package/src/commands/add.ts +151 -0
  17. package/src/commands/dev.ts +160 -0
  18. package/src/commands/doctor.ts +162 -0
  19. package/src/commands/exportVideo.ts +198 -0
  20. package/src/commands/init.ts +56 -0
  21. package/src/commands/inspect.ts +72 -0
  22. package/src/commands/list.ts +22 -0
  23. package/src/commands/new.ts +126 -0
  24. package/src/commands/shared.ts +96 -0
  25. package/src/commands/still.ts +40 -0
  26. package/src/commands/test.ts +265 -0
  27. package/src/commands/update.ts +183 -0
  28. package/src/config.ts +84 -0
  29. package/src/contracts.ts +159 -0
  30. package/src/cues.ts +141 -0
  31. package/src/determinism.ts +82 -0
  32. package/src/diff.ts +71 -0
  33. package/src/discovery.ts +216 -0
  34. package/src/formats.ts +119 -0
  35. package/src/index.ts +58 -0
  36. package/src/integrity.ts +101 -0
  37. package/src/jobs.ts +151 -0
  38. package/src/log.ts +17 -0
  39. package/src/open.ts +32 -0
  40. package/src/paths.ts +12 -0
  41. package/src/prepare-cache.ts +58 -0
  42. package/src/project.ts +196 -0
  43. package/src/registry-snapshot.json +3431 -0
  44. package/src/registry-source.ts +269 -0
  45. package/src/render.ts +627 -0
  46. package/src/server.ts +307 -0
  47. package/studio/index.html +41 -0
  48. package/studio/src/Studio.tsx +192 -0
  49. package/studio/src/components/AudioClip.tsx +64 -0
  50. package/studio/src/components/CanvasStage.tsx +79 -0
  51. package/studio/src/components/CommandPalette.tsx +129 -0
  52. package/studio/src/components/Diagnostics.tsx +93 -0
  53. package/studio/src/components/ExportPanel.tsx +234 -0
  54. package/studio/src/components/InputControls.tsx +110 -0
  55. package/studio/src/components/Thumbnail.tsx +71 -0
  56. package/studio/src/components/Transport.tsx +237 -0
  57. package/studio/src/components/Waveform.tsx +114 -0
  58. package/studio/src/components/Wordmark.tsx +449 -0
  59. package/studio/src/components/ui.tsx +138 -0
  60. package/studio/src/lib/mix-loudness.ts +52 -0
  61. package/studio/src/main.tsx +34 -0
  62. package/studio/src/shortcuts.ts +27 -0
  63. package/studio/src/studio.css +1232 -0
  64. package/studio/src/theme.ts +61 -0
  65. package/studio/src/views/AssetsView.tsx +111 -0
  66. package/studio/src/views/BrandsView.tsx +139 -0
  67. package/studio/src/views/ComponentsView.tsx +285 -0
  68. package/studio/src/views/HomeView.tsx +122 -0
  69. package/studio/src/views/VideosView.tsx +343 -0
  70. package/studio/src/virtual.d.ts +25 -0
@@ -0,0 +1,79 @@
1
+ import {useEffect, useRef, useState, type ReactNode, type RefObject} from "react";
2
+
3
+ /** Fit a fixed composition size into whatever space the container has. */
4
+ export const useFitScale = (width: number, height: number): [RefObject<HTMLDivElement | null>, number] => {
5
+ const container = useRef<HTMLDivElement>(null);
6
+ const [scale, setScale] = useState(0.25);
7
+
8
+ useEffect(() => {
9
+ const element = container.current;
10
+ if (!element) return;
11
+ const fit = () => {
12
+ const bounds = element.getBoundingClientRect();
13
+ if (bounds.width === 0 || bounds.height === 0) return;
14
+ setScale(Math.min(bounds.width / width, bounds.height / height));
15
+ };
16
+ fit();
17
+ const observer = new ResizeObserver(fit);
18
+ observer.observe(element);
19
+ return () => observer.disconnect();
20
+ }, [height, width]);
21
+
22
+ return [container, scale];
23
+ };
24
+
25
+ /**
26
+ * Scales the composition to fit the viewport without changing its pixel
27
+ * dimensions, so what Studio shows is the render resolution scaled, not a
28
+ * different layout.
29
+ */
30
+ export const CanvasStage = ({
31
+ width,
32
+ height,
33
+ safeArea,
34
+ showSafeArea,
35
+ children,
36
+ }: {
37
+ width: number;
38
+ height: number;
39
+ safeArea?: {x: number; y: number};
40
+ showSafeArea?: boolean;
41
+ children: ReactNode;
42
+ }) => {
43
+ const [container, scale] = useFitScale(width, height);
44
+
45
+ return (
46
+ <div className="viewport">
47
+ {/* The measured box is absolutely positioned, so the composition it
48
+ contains can never feed its own size back into the fit calculation. */}
49
+ <div className="viewport-inner" ref={container}>
50
+ <div
51
+ className="frame"
52
+ data-safe={showSafeArea ? "true" : undefined}
53
+ style={
54
+ {
55
+ height: Math.round(height * scale),
56
+ width: Math.round(width * scale),
57
+ "--safe-x": `${(safeArea?.x ?? 0) * scale}px`,
58
+ "--safe-y": `${(safeArea?.y ?? 0) * scale}px`,
59
+ } as React.CSSProperties
60
+ }
61
+ >
62
+ <div
63
+ style={{
64
+ height,
65
+ left: 0,
66
+ position: "absolute",
67
+ top: 0,
68
+ transform: `scale(${scale})`,
69
+ transformOrigin: "top left",
70
+ width,
71
+ }}
72
+ >
73
+ {children}
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ );
79
+ };
@@ -0,0 +1,129 @@
1
+ import {useEffect, useMemo, useState} from "react";
2
+ import {componentPreviews, videos} from "virtual:odori-project";
3
+
4
+ export type Command = {id: string; label: string; type: string; keywords?: string; run: () => void};
5
+
6
+ /** One keystroke to anything in the project, like a code editor. */
7
+ export const CommandPalette = ({
8
+ open,
9
+ initialQuery = "",
10
+ onClose,
11
+ onOpenVideo,
12
+ onOpenComponent,
13
+ onView,
14
+ }: {
15
+ open: boolean;
16
+ /** What was typed in the top bar before the palette took over. */
17
+ initialQuery?: string;
18
+ onClose: () => void;
19
+ onOpenVideo: (id: string) => void;
20
+ onOpenComponent: (id: string) => void;
21
+ onView: (view: string) => void;
22
+ }) => {
23
+ const [query, setQuery] = useState(initialQuery);
24
+ const [index, setIndex] = useState(0);
25
+
26
+ const commands = useMemo<Command[]>(
27
+ () => [
28
+ ...videos.map((video) => ({
29
+ id: `video:${video.metadata.id}`,
30
+ label: video.metadata.title,
31
+ keywords: `${video.metadata.id} ${(video.metadata.tags ?? []).join(" ")}`,
32
+ type: "video",
33
+ run: () => onOpenVideo(video.metadata.id),
34
+ })),
35
+ ...componentPreviews.map((preview) => ({
36
+ id: `component:${preview.id}`,
37
+ label: preview.preview.title,
38
+ keywords: `${preview.id} ${preview.preview.category}`,
39
+ type: "component",
40
+ run: () => onOpenComponent(preview.id),
41
+ })),
42
+ ...["videos", "components", "brands", "assets"].map((view) => ({
43
+ id: `view:${view}`,
44
+ label: `Go to ${view}`,
45
+ type: "view",
46
+ run: () => onView(view),
47
+ })),
48
+ ],
49
+ [onOpenComponent, onOpenVideo, onView],
50
+ );
51
+
52
+ const needle = query.trim().toLowerCase();
53
+ const matches = commands.filter((command) =>
54
+ `${command.label} ${command.keywords ?? ""}`.toLowerCase().includes(needle),
55
+ );
56
+
57
+ useEffect(() => {
58
+ if (open) {
59
+ setQuery(initialQuery);
60
+ setIndex(0);
61
+ }
62
+ }, [initialQuery, open]);
63
+
64
+ useEffect(() => {
65
+ if (!open) return;
66
+ const onKeyDown = (event: KeyboardEvent) => {
67
+ if (event.key === "Escape") onClose();
68
+ if (event.key === "ArrowDown") {
69
+ event.preventDefault();
70
+ setIndex((value) => Math.min(value + 1, matches.length - 1));
71
+ }
72
+ if (event.key === "ArrowUp") {
73
+ event.preventDefault();
74
+ setIndex((value) => Math.max(value - 1, 0));
75
+ }
76
+ if (event.key === "Enter") {
77
+ event.preventDefault();
78
+ matches[index]?.run();
79
+ onClose();
80
+ }
81
+ };
82
+ window.addEventListener("keydown", onKeyDown);
83
+ return () => window.removeEventListener("keydown", onKeyDown);
84
+ }, [index, matches, onClose, open]);
85
+
86
+ if (!open) return null;
87
+
88
+ return (
89
+ <div className="overlay" onPointerDown={onClose}>
90
+ <div className="palette" onPointerDown={(event) => event.stopPropagation()}>
91
+ <input
92
+ className="input"
93
+ autoFocus
94
+ placeholder="Search videos, components, and views"
95
+ value={query}
96
+ onChange={(event) => {
97
+ setQuery(event.currentTarget.value);
98
+ setIndex(0);
99
+ }}
100
+ />
101
+ <ul>
102
+ {matches.map((command, position) => (
103
+ <li key={command.id}>
104
+ <button
105
+ type="button"
106
+ data-active={position === index ? "true" : undefined}
107
+ onPointerEnter={() => setIndex(position)}
108
+ onClick={() => {
109
+ command.run();
110
+ onClose();
111
+ }}
112
+ >
113
+ {command.label}
114
+ <span className="type">{command.type}</span>
115
+ </button>
116
+ </li>
117
+ ))}
118
+ {matches.length === 0 ? (
119
+ <li>
120
+ <button type="button" disabled>
121
+ No matches
122
+ </button>
123
+ </li>
124
+ ) : null}
125
+ </ul>
126
+ </div>
127
+ </div>
128
+ );
129
+ };
@@ -0,0 +1,93 @@
1
+ import {
2
+ entryDurationInFrames,
3
+ resolveEntryLayout,
4
+ type AudioTrack,
5
+ type CompiledTimeline,
6
+ type VideoEntry,
7
+ } from "odori";
8
+ import {SectionTitle, Separator} from "./ui";
9
+
10
+ export type Issue = {level: "error" | "warning"; message: string};
11
+
12
+ /**
13
+ * Validation the framework promises: a declared duration is authoritative, and
14
+ * compiled scene totals must agree with it.
15
+ */
16
+ export const collectDiagnostics = (
17
+ entry: VideoEntry,
18
+ timeline: CompiledTimeline | null,
19
+ track?: AudioTrack | null,
20
+ ): Issue[] => {
21
+ const layout = resolveEntryLayout(entry);
22
+ const declared = entryDurationInFrames(entry, layout);
23
+ const issues: Issue[] = [];
24
+
25
+ if (!declared && !timeline?.durationInFrames) {
26
+ issues.push({level: "error", message: "No duration. Set metadata.duration or add scenes."});
27
+ }
28
+ if (declared && timeline && timeline.durationInFrames && timeline.durationInFrames !== declared) {
29
+ issues.push({
30
+ level: "warning",
31
+ message: `Scenes total ${timeline.durationInFrames} frames but metadata.duration is ${declared} frames.`,
32
+ });
33
+ }
34
+
35
+ const seen = new Set<string>();
36
+ for (const scene of timeline?.scenes ?? []) {
37
+ if (seen.has(scene.id)) issues.push({level: "error", message: `Duplicate scene id: ${scene.id}`});
38
+ seen.add(scene.id);
39
+ if (scene.durationInFrames < layout.format.fps / 2) {
40
+ issues.push({level: "warning", message: `Scene ${scene.id} is shorter than half a second.`});
41
+ }
42
+ // An overlap that eats most of a scene is a scene nobody sees on its own,
43
+ // which is almost always a duration that was meant to be longer.
44
+ if (scene.overlap > 0 && scene.overlap > scene.durationInFrames / 2) {
45
+ issues.push({
46
+ level: "warning",
47
+ message: `Scene ${scene.id} overlaps ${scene.overlap} of its ${scene.durationInFrames} frames, so it is mostly transition.`,
48
+ });
49
+ }
50
+ }
51
+ const declaredTotal = declared || timeline?.durationInFrames || 0;
52
+ for (const cue of track?.cues ?? []) {
53
+ if (declaredTotal && cue.fromFrame + cue.durationInFrames > declaredTotal) {
54
+ issues.push({
55
+ level: "warning",
56
+ message: `Audio ${cue.src.split("/").pop()} runs ${cue.fromFrame + cue.durationInFrames - declaredTotal} frames past the end.`,
57
+ });
58
+ }
59
+ if (cue.gain > 1) {
60
+ issues.push({level: "warning", message: `Audio ${cue.src.split("/").pop()} has gain above 1 and may clip.`});
61
+ }
62
+ }
63
+ return issues;
64
+ };
65
+
66
+ export const Diagnostics = ({
67
+ entry,
68
+ timeline,
69
+ track,
70
+ }: {
71
+ entry: VideoEntry;
72
+ timeline: CompiledTimeline | null;
73
+ track?: AudioTrack | null;
74
+ }) => {
75
+ const issues = collectDiagnostics(entry, timeline, track);
76
+ // A permanent green badge is a section that never says anything. The panel
77
+ // appears when a contract is actually broken, so its absence is the pass.
78
+ if (issues.length === 0) return null;
79
+
80
+ return (
81
+ <div>
82
+ <Separator />
83
+ <SectionTitle>Diagnostics</SectionTitle>
84
+ <ul className="issues">
85
+ {issues.map((issue) => (
86
+ <li key={issue.message} className="issue" data-level={issue.level}>
87
+ {issue.message}
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ </div>
92
+ );
93
+ };
@@ -0,0 +1,234 @@
1
+ import {useCallback, useEffect, useRef, useState} from "react";
2
+ import {Badge, Button, Icon, SectionTitle} from "./ui";
3
+
4
+ type JobState = {
5
+ id: string;
6
+ videoId?: string;
7
+ status: string;
8
+ progress: number;
9
+ attempts?: number;
10
+ output?: string;
11
+ error?: string;
12
+ createdAt?: string;
13
+ };
14
+
15
+ const tone = (status: string) => (status === "failed" ? "danger" : status === "ready" ? "success" : undefined);
16
+
17
+ /** Jobs are identified by what they wrote, not by their manifest hash. */
18
+ const fileName = (path?: string) => path?.split(/[\\/]/).pop();
19
+
20
+ const clockTime = (value?: string) => {
21
+ if (!value) return undefined;
22
+ const parsed = new Date(value);
23
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed.toLocaleTimeString();
24
+ };
25
+
26
+ type Mode = "video" | "frame" | "clipboard";
27
+
28
+ /** Ids are paths under videos/, output files are flat. */
29
+ const outputName = (videoId: string) => videoId.split("/").join("-");
30
+
31
+ /**
32
+ * Studio never encodes to preview. Export is an explicit action that hands a
33
+ * frozen manifest to the same queue and render worker the CLI uses, so a job
34
+ * started here can be retried from either surface.
35
+ */
36
+ export const ExportPanel = ({
37
+ videoId,
38
+ input,
39
+ frame,
40
+ exportDir,
41
+ }: {
42
+ videoId: string;
43
+ input: Record<string, unknown>;
44
+ frame: number;
45
+ exportDir: string;
46
+ }) => {
47
+ const [job, setJob] = useState<JobState | null>(null);
48
+ const [busy, setBusy] = useState(false);
49
+ const [mode, setMode] = useState<Mode>("video");
50
+ const [menuOpen, setMenuOpen] = useState(false);
51
+ const [dropUp, setDropUp] = useState(false);
52
+ const [notice, setNotice] = useState<string | null>(null);
53
+ const menuRef = useRef<HTMLDivElement | null>(null);
54
+
55
+ useEffect(() => {
56
+ if (!menuOpen) return undefined;
57
+ const dismiss = (event: MouseEvent) => {
58
+ if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false);
59
+ };
60
+ const onKey = (event: KeyboardEvent) => {
61
+ if (event.key === "Escape") setMenuOpen(false);
62
+ };
63
+ document.addEventListener("mousedown", dismiss);
64
+ document.addEventListener("keydown", onKey);
65
+ return () => {
66
+ document.removeEventListener("mousedown", dismiss);
67
+ document.removeEventListener("keydown", onKey);
68
+ };
69
+ }, [menuOpen]);
70
+
71
+ const poll = async (id: string) => {
72
+ for (let attempt = 0; attempt < 900; attempt += 1) {
73
+ await new Promise((wait) => setTimeout(wait, 1000));
74
+ const response = await fetch(`/__odori/jobs/${id}`);
75
+ if (!response.ok) continue;
76
+ const payload = (await response.json()) as JobState;
77
+ setJob(payload);
78
+ if (payload.status === "ready" || payload.status === "failed") return;
79
+ }
80
+ };
81
+
82
+ const post = async (path: string, body?: unknown) => {
83
+ setBusy(true);
84
+ setJob({id: "pending", status: "queued", progress: 0});
85
+ try {
86
+ const response = await fetch(path, {
87
+ method: "POST",
88
+ headers: {"content-type": "application/json"},
89
+ body: body === undefined ? undefined : JSON.stringify(body),
90
+ });
91
+ const payload = (await response.json()) as JobState;
92
+ setJob(payload);
93
+ if (payload.id && payload.status !== "ready" && payload.status !== "failed") await poll(payload.id);
94
+ } catch (error) {
95
+ setJob({id: "error", status: "failed", progress: 0, error: String(error)});
96
+ } finally {
97
+ setBusy(false);
98
+ }
99
+ };
100
+
101
+ /**
102
+ * The write is handed a promise rather than a resolved blob so the copy stays
103
+ * inside the click that started it. Rendering a frame takes longer than the
104
+ * user activation a bare `await` would spend.
105
+ */
106
+ const copyFrame = async () => {
107
+ setBusy(true);
108
+ setNotice(null);
109
+ setJob(null);
110
+ const png = fetch("/__odori/still", {
111
+ method: "POST",
112
+ headers: {"content-type": "application/json"},
113
+ body: JSON.stringify({videoId, input, frame, inline: true}),
114
+ }).then(async (response) => {
115
+ if (!response.ok) throw new Error(await response.text());
116
+ return response.blob();
117
+ });
118
+ try {
119
+ await navigator.clipboard.write([new ClipboardItem({"image/png": png})]);
120
+ setNotice(`Frame ${frame} copied to the clipboard.`);
121
+ } catch {
122
+ try {
123
+ await navigator.clipboard.write([new ClipboardItem({"image/png": await png})]);
124
+ setNotice(`Frame ${frame} copied to the clipboard.`);
125
+ } catch (error) {
126
+ setNotice(`Could not copy frame ${frame}: ${error instanceof Error ? error.message : String(error)}`);
127
+ }
128
+ } finally {
129
+ setBusy(false);
130
+ }
131
+ };
132
+
133
+ const run = async (next: Mode) => {
134
+ setMenuOpen(false);
135
+ setMode(next);
136
+ setNotice(null);
137
+ if (next === "clipboard") return copyFrame();
138
+ if (next === "frame") return post("/__odori/still", {videoId, input, frame});
139
+ return post("/__odori/exports", {videoId, input});
140
+ };
141
+
142
+ const file = outputName(videoId);
143
+ // Destinations name a pattern, never the live frame: the transport already
144
+ // reports the playhead, and a number that moves 30 times a second here reads
145
+ // as activity rather than as a label.
146
+ const modes: Array<{id: Mode; label: string; hint: string}> = [
147
+ {id: "video", label: "MP4 video", hint: `Encode the timeline to ${exportDir}/${file}.mp4`},
148
+ {id: "frame", label: "PNG frame", hint: `Write the current frame to ${exportDir}/${file}-<frame>.png`},
149
+ {id: "clipboard", label: "Frame to clipboard", hint: "Copy the current frame as an image, writing no file"},
150
+ ];
151
+
152
+ const actionLabel = mode === "video" ? "Export MP4" : mode === "clipboard" ? "Copy frame" : "Export frame";
153
+
154
+ return (
155
+ <div>
156
+ <SectionTitle>Export</SectionTitle>
157
+ <div className="split" ref={menuRef}>
158
+ <Button
159
+ variant="primary"
160
+ disabled={busy}
161
+ onClick={() => void run(mode)}
162
+ title={modes.find((item) => item.id === mode)?.hint}
163
+ >
164
+ {actionLabel}
165
+ </Button>
166
+ <Button
167
+ variant="primary"
168
+ disabled={busy}
169
+ icon
170
+ aria-haspopup="menu"
171
+ aria-expanded={menuOpen}
172
+ aria-label="Choose what to export"
173
+ onClick={() => {
174
+ // The panel sits low in the sidebar, so the menu opens toward
175
+ // whichever edge has room for it.
176
+ const bounds = menuRef.current?.getBoundingClientRect();
177
+ if (bounds) setDropUp(window.innerHeight - bounds.bottom < 180);
178
+ setMenuOpen((open) => !open);
179
+ }}
180
+ >
181
+ <Icon name="caret" />
182
+ </Button>
183
+ {menuOpen ? (
184
+ <div className="menu" role="menu" data-placement={dropUp ? "up" : "down"}>
185
+ {modes.map((item) => (
186
+ <button
187
+ key={item.id}
188
+ type="button"
189
+ role="menuitemradio"
190
+ aria-checked={item.id === mode}
191
+ className="menu-item"
192
+ data-active={item.id === mode ? "true" : undefined}
193
+ onClick={() => void run(item.id)}
194
+ >
195
+ <span>{item.label}</span>
196
+ <span className="hint">{item.hint}</span>
197
+ </button>
198
+ ))}
199
+ </div>
200
+ ) : null}
201
+ </div>
202
+
203
+ {notice ? (
204
+ <p className="hint" style={{marginTop: 8}}>
205
+ {notice}
206
+ </p>
207
+ ) : null}
208
+
209
+ {job ? (
210
+ <>
211
+ <div className="status">
212
+ <Badge tone={tone(job.status)}>{job.status}</Badge>
213
+ <span
214
+ style={{color: "var(--subtle)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}
215
+ title={job.output ?? job.error}
216
+ >
217
+ {fileName(job.output) ?? job.error ?? `${Math.round((job.progress ?? 0) * 100)}%`}
218
+ </span>
219
+ </div>
220
+ {job.status === "rendering" || job.status === "encoding" ? (
221
+ <div className="progress">
222
+ <span style={{width: `${Math.round((job.progress ?? 0) * 100)}%`}} />
223
+ </div>
224
+ ) : null}
225
+ </>
226
+ ) : (
227
+ <p className="hint" style={{marginTop: 8}}>
228
+ Preview needs no encode. Export writes {exportDir}/{file}.mp4 from the frozen manifest.
229
+ </p>
230
+ )}
231
+
232
+ </div>
233
+ );
234
+ };
@@ -0,0 +1,110 @@
1
+ import type {FieldDescriptor} from "odori";
2
+
3
+ export const InputControls = ({
4
+ fields,
5
+ value,
6
+ onChange,
7
+ }: {
8
+ fields: Record<string, FieldDescriptor>;
9
+ value: Record<string, unknown>;
10
+ onChange: (next: Record<string, unknown>) => void;
11
+ }) => {
12
+ const set = (name: string, next: unknown) => onChange({...value, [name]: next});
13
+
14
+ return (
15
+ <div>
16
+ {Object.entries(fields).map(([name, field]) => {
17
+ const current = value[name] ?? field.defaultValue;
18
+ return (
19
+ <div key={name} className="field">
20
+ <label htmlFor={`control-${name}`}>
21
+ {name}
22
+ <span className="hint">
23
+ {field.type}
24
+ {field.type === "text" && field.maxLength ? ` · ${String(current).length}/${field.maxLength}` : ""}
25
+ </span>
26
+ </label>
27
+ {field.type === "text" ? (
28
+ field.multiline ? (
29
+ <textarea
30
+ id={`control-${name}`}
31
+ className="input"
32
+ rows={3}
33
+ value={String(current)}
34
+ onChange={(event) => set(name, event.currentTarget.value)}
35
+ />
36
+ ) : (
37
+ <input
38
+ id={`control-${name}`}
39
+ className="input"
40
+ type="text"
41
+ maxLength={field.maxLength}
42
+ value={String(current)}
43
+ onChange={(event) => set(name, event.currentTarget.value)}
44
+ />
45
+ )
46
+ ) : null}
47
+ {field.type === "number" ? (
48
+ <input
49
+ id={`control-${name}`}
50
+ className="input"
51
+ type="number"
52
+ min={field.min}
53
+ max={field.max}
54
+ step={field.step ?? 1}
55
+ value={Number(current)}
56
+ onChange={(event) => set(name, Number(event.currentTarget.value))}
57
+ />
58
+ ) : null}
59
+ {field.type === "boolean" ? (
60
+ <input
61
+ id={`control-${name}`}
62
+ type="checkbox"
63
+ checked={Boolean(current)}
64
+ onChange={(event) => set(name, event.currentTarget.checked)}
65
+ />
66
+ ) : null}
67
+ {field.type === "color" ? (
68
+ <input
69
+ id={`control-${name}`}
70
+ className="input"
71
+ type="color"
72
+ value={String(current)}
73
+ onChange={(event) => set(name, event.currentTarget.value)}
74
+ />
75
+ ) : null}
76
+ {field.type === "select" ? (
77
+ <select
78
+ id={`control-${name}`}
79
+ className="input"
80
+ value={String(current)}
81
+ onChange={(event) => set(name, event.currentTarget.value)}
82
+ >
83
+ {field.options.map((option) => (
84
+ <option key={option} value={option}>
85
+ {option}
86
+ </option>
87
+ ))}
88
+ </select>
89
+ ) : null}
90
+ {field.type === "json" ? (
91
+ <textarea
92
+ id={`control-${name}`}
93
+ className="input"
94
+ rows={4}
95
+ defaultValue={JSON.stringify(current, null, 2)}
96
+ onBlur={(event) => {
97
+ try {
98
+ set(name, JSON.parse(event.currentTarget.value));
99
+ } catch {
100
+ // Keep the previous value until the JSON parses.
101
+ }
102
+ }}
103
+ />
104
+ ) : null}
105
+ </div>
106
+ );
107
+ })}
108
+ </div>
109
+ );
110
+ };