@odori/cli 0.0.8 → 0.0.10

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.
@@ -4035,7 +4035,7 @@
4035
4035
  "files": [
4036
4036
  {
4037
4037
  "path": "components/statement/statement.tsx",
4038
- "content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StatementChip = {\n /** The chip's text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: \"characters\" | \"words\" | \"none\";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: \"rise\" | \"cut\";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: \"left\" | \"center\";\n /** Light page or dark page. Chips and text follow it. */\n theme?: \"light\" | \"dark\";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== \"string\";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = \"words\",\n motion = \"rise\",\n stagger,\n align = \"left\",\n theme = \"light\",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === \"dark\";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === \"characters\" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === \"characters\" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === \"none\") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === \"cut\") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === \"cut\"\n ? {display: \"inline-block\", opacity: progress}\n : {\n display: \"inline-block\",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip[\"tone\"]) => {\n if (tone === \"accent\") return {background: brand.colors.accent, color: paper, border: \"transparent\"};\n if (tone === \"dark\") return {background: ink, color: paper, border: \"transparent\"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: \"inline-flex\",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n background: dark ? ink : \"transparent\",\n justifyContent: \"center\",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n color: ink,\n columnGap: 16 * scale,\n display: \"flex\",\n flexWrap: \"wrap\",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === \"center\" ? \"center\" : \"flex-start\",\n letterSpacing: \"-0.03em\",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === \"cut\" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === \"characters\") {\n return (\n <span key={`${part}-${index}`} style={{display: \"inline-block\", whiteSpace: \"pre\"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: \"inline-block\", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === \"cut\"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: \"inline-grid\"}}>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: 1 - swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
4038
+ "content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StatementChip = {\n /** The chip's text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: \"characters\" | \"words\" | \"none\";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: \"rise\" | \"cut\";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: \"left\" | \"center\";\n /** Light page or dark page. Chips and text follow it. */\n theme?: \"light\" | \"dark\";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== \"string\";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = \"words\",\n motion = \"rise\",\n stagger,\n align = \"left\",\n theme = \"light\",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === \"dark\";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === \"characters\" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === \"characters\" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === \"none\") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === \"cut\") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === \"cut\"\n ? {display: \"inline-block\", opacity: progress}\n : {\n display: \"inline-block\",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip[\"tone\"]) => {\n if (tone === \"accent\") return {background: brand.colors.accent, color: paper, border: \"transparent\"};\n if (tone === \"dark\") return {background: ink, color: paper, border: \"transparent\"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: \"inline-flex\",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n // paper, not ink. ink is the type colour, and painting the ground\n // with it put light text on a light ground: the dark theme rendered\n // the sentence invisible, including in this component's own fixture.\n background: dark ? paper : \"transparent\",\n justifyContent: \"center\",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n color: ink,\n columnGap: 16 * scale,\n display: \"flex\",\n flexWrap: \"wrap\",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === \"center\" ? \"center\" : \"flex-start\",\n letterSpacing: \"-0.03em\",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === \"cut\" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === \"characters\") {\n return (\n <span key={`${part}-${index}`} style={{display: \"inline-block\", whiteSpace: \"pre\"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: \"inline-block\", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === \"cut\"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: \"inline-grid\"}}>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: 1 - swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
4039
4039
  "target": "videos/components/statement/statement.tsx"
4040
4040
  },
4041
4041
  {
package/src/server.ts CHANGED
@@ -42,6 +42,15 @@ const RESOLVED_ID = `\0${VIRTUAL_ID}`;
42
42
  * the aliases below are simply not installed — Vite then resolves `odori`
43
43
  * through its own exports map, which is what should happen.
44
44
  */
45
+ /** The CLI's own published version, for the status bar. */
46
+ const cliVersion = (): string => {
47
+ try {
48
+ return (createRequire(import.meta.url)("../package.json") as {version: string}).version;
49
+ } catch {
50
+ return "dev";
51
+ }
52
+ };
53
+
45
54
  const runtimeSource = (root: string): string | null => {
46
55
  for (const from of [resolve(root, "package.json"), import.meta.url]) {
47
56
  try {
@@ -99,6 +108,7 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
99
108
  audioDir: config.audioDir,
100
109
  docsUrl: config.docsUrl,
101
110
  audio: graph.audio,
111
+ version: cliVersion(),
102
112
  sourceHash: graph.sourceHash,
103
113
  assets: config.assets ?? [],
104
114
  files: {
@@ -4,7 +4,6 @@ import {VideosView} from "./views/VideosView";
4
4
  import {ComponentsView} from "./views/ComponentsView";
5
5
  import {BrandsView} from "./views/BrandsView";
6
6
  import {AssetsView} from "./views/AssetsView";
7
- import {IntegrationsView} from "./views/IntegrationsView";
8
7
  import {HomeView} from "./views/HomeView";
9
8
  import {CommandPalette} from "./components/CommandPalette";
10
9
  import {Wordmark} from "./components/Wordmark";
@@ -12,7 +11,7 @@ import {Button, Icon, Kbd} from "./components/ui";
12
11
  import {Settings} from "./components/Settings";
13
12
  import {useTheme} from "./theme";
14
13
 
15
- const VIEWS = ["videos", "components", "brands", "assets", "integrations"] as const;
14
+ const VIEWS = ["videos", "components", "brands", "assets"] as const;
16
15
 
17
16
  export type StudioView = (typeof VIEWS)[number] | "home";
18
17
 
@@ -146,12 +145,11 @@ export const Studio = () => {
146
145
  <BrandsView selection={route.selection} onSelect={(name) => navigate("brands", name)} />
147
146
  ) : null}
148
147
  {route.view === "assets" ? <AssetsView /> : null}
149
- {route.view === "integrations" ? <IntegrationsView /> : null}
150
148
  {route.view === "home" ? <HomeView onOpen={(view, selection) => navigate(view, selection)} /> : null}
151
149
  </main>
152
150
 
153
151
  <footer className="statusbar">
154
- <span>source {project.sourceHash.slice(0, 10)}</span>
152
+ <span>odori {project.version}</span>
155
153
  <a className="statusbar-link" href={project.docsUrl} target="_blank" rel="noreferrer">
156
154
  Documentation
157
155
  <Icon name="external" />
@@ -0,0 +1,148 @@
1
+ import {useEffect, useState} from "react";
2
+ import {Button} from "./ui";
3
+ import {INTEGRATIONS_CHANGED, loadProviders} from "../integrations";
4
+
5
+ /**
6
+ * Prompt to registered role, without a terminal. The server walks the same
7
+ * path the CLI does — provider, prepare, register — and the numbers it prints
8
+ * there render here, warnings included, with the result playable in place.
9
+ *
10
+ * This lives with the audio library because that is where the result lands:
11
+ * a generated bed is an asset the moment the pipeline finishes with it. Keys
12
+ * are configured in Settings, under Audio; the panel gates on a provider
13
+ * being connected and unlocks in place when one is stored, announced over a
14
+ * window event so no reload sits between pasting a key and using it.
15
+ */
16
+ export const GenerateBed = () => {
17
+ const [connected, setConnected] = useState<boolean | null>(null);
18
+
19
+ useEffect(() => {
20
+ const load = () => {
21
+ loadProviders()
22
+ .then((providers) => setConnected(providers.some((provider) => provider.source !== null)))
23
+ .catch(() => setConnected(null));
24
+ };
25
+ load();
26
+ window.addEventListener(INTEGRATIONS_CHANGED, load);
27
+ return () => window.removeEventListener(INTEGRATIONS_CHANGED, load);
28
+ }, []);
29
+
30
+ if (connected === null) return null;
31
+ if (!connected) {
32
+ return (
33
+ <p className="hint">
34
+ A provider can compose a bed from a prompt, prepared and levelled like any file you drop in. Open Settings
35
+ from the gear in the header and paste an API key under Audio; the panel unlocks here the moment one is
36
+ stored.
37
+ </p>
38
+ );
39
+ }
40
+
41
+ return <GeneratePanel />;
42
+ };
43
+
44
+ type GenerateReport = {
45
+ destination: string;
46
+ role: string;
47
+ url: string | null;
48
+ registered: {file: string; already: boolean} | null;
49
+ before: {lufs: number; peak: number; range: number};
50
+ after: {lufs: number; peak: number};
51
+ warnings: string[];
52
+ };
53
+
54
+ const GeneratePanel = () => {
55
+ const [prompt, setPrompt] = useState("");
56
+ const [seconds, setSeconds] = useState(60);
57
+ const [role, setRole] = useState("");
58
+ const [busy, setBusy] = useState(false);
59
+ const [error, setError] = useState<string | null>(null);
60
+ const [report, setReport] = useState<GenerateReport | null>(null);
61
+
62
+ const generate = async () => {
63
+ setBusy(true);
64
+ setError(null);
65
+ setReport(null);
66
+ try {
67
+ const response = await fetch("/__odori/generate", {
68
+ method: "POST",
69
+ headers: {"content-type": "application/json"},
70
+ body: JSON.stringify({prompt: prompt.trim(), seconds, role: role.trim() || undefined}),
71
+ });
72
+ const body = (await response.json()) as GenerateReport & {error?: string};
73
+ if (!response.ok) {
74
+ setError(body.error ?? "Generation failed.");
75
+ return;
76
+ }
77
+ setReport(body);
78
+ } catch {
79
+ setError("Studio could not reach its own server. Is odori dev still running?");
80
+ } finally {
81
+ setBusy(false);
82
+ }
83
+ };
84
+
85
+ return (
86
+ <div className="integration generate-panel">
87
+ <textarea
88
+ className="generate-prompt"
89
+ rows={2}
90
+ placeholder="steady ambient bed, no drums, warm"
91
+ value={prompt}
92
+ onChange={(event) => setPrompt(event.target.value)}
93
+ disabled={busy}
94
+ aria-label="Prompt"
95
+ />
96
+ <div className="generate-controls">
97
+ <label className="generate-field">
98
+ seconds
99
+ <input
100
+ type="number"
101
+ min={5}
102
+ max={300}
103
+ value={seconds}
104
+ onChange={(event) => setSeconds(Number(event.target.value) || 60)}
105
+ disabled={busy}
106
+ />
107
+ </label>
108
+ <label className="generate-field">
109
+ role
110
+ <input
111
+ type="text"
112
+ placeholder="bed.main"
113
+ value={role}
114
+ onChange={(event) => setRole(event.target.value)}
115
+ disabled={busy}
116
+ />
117
+ </label>
118
+ <Button variant="primary" disabled={busy || !prompt.trim()} onClick={() => void generate()}>
119
+ {busy ? "Generating…" : "Generate"}
120
+ </Button>
121
+ </div>
122
+ {busy ? <p className="hint">The provider is composing, then the track is levelled to the stem target. A minute is normal.</p> : null}
123
+ {error ? <p className="hint">{error}</p> : null}
124
+ {report ? (
125
+ <div className="generate-report">
126
+ {report.url ? <audio controls src={report.url} style={{width: "100%"}} /> : null}
127
+ <p className="hint">
128
+ {report.destination} · {report.before.lufs.toFixed(1)} → {report.after.lufs.toFixed(1)} LUFS · range{" "}
129
+ {report.before.range.toFixed(1)} LU
130
+ {report.registered
131
+ ? report.registered.already
132
+ ? ` · "${report.role}" was already registered`
133
+ : ` · registered "${report.role}" in ${report.registered.file}`
134
+ : ""}
135
+ </p>
136
+ {report.warnings.map((warning) => (
137
+ <p key={warning} className="hint">
138
+ {warning}
139
+ </p>
140
+ ))}
141
+ <p className="integration-usage">
142
+ <code>{`<Audio src="${report.role}" fadeIn="1s" fadeOut="1.5s" duckUnder />`}</code>
143
+ </p>
144
+ </div>
145
+ ) : null}
146
+ </div>
147
+ );
148
+ };
@@ -2,15 +2,24 @@ import {useEffect, useRef, useState} from "react";
2
2
  import {Button, Icon} from "./ui";
3
3
  import {useStartSound} from "../settings";
4
4
  import {useTheme, type Theme} from "../theme";
5
+ import {INTEGRATIONS_CHANGED, loadProviders, type ProviderStatus} from "../integrations";
5
6
 
6
7
  /**
7
8
  * The workspace preferences, behind one control in the header.
8
9
  *
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.
10
+ * A dialog with pages rather than a popover: the menu was right when the
11
+ * choices were two radio groups, and wrong the moment a page's worth of
12
+ * audio configuration the start-sound default and the provider keys
13
+ * had to fold into a strip beside the gear. A dialog gives each area a page
14
+ * and room to grow one setting at a time, the way every tool this one sits
15
+ * beside does it.
16
+ *
17
+ * Provider keys live here because a key is configuration of the machine,
18
+ * not material of the project. The form is one-way about secrets: a key can
19
+ * be typed and sent to the local server, but it never comes back — status
20
+ * is a source ("environment", "stored") and nothing else. A key from the
21
+ * environment wins over a stored one and cannot be edited here, because
22
+ * the place to change an environment is the environment.
14
23
  */
15
24
  const THEMES: Array<{id: Theme; label: string; hint: string}> = [
16
25
  {id: "system", label: "System", hint: "Follow the operating system"},
@@ -23,87 +32,294 @@ const SOUNDS = [
23
32
  {id: "off" as const, label: "Off", hint: "Open every video muted"},
24
33
  ];
25
34
 
35
+ const PAGES = [
36
+ {id: "appearance" as const, label: "Appearance"},
37
+ {id: "audio" as const, label: "Audio"},
38
+ ];
39
+
40
+ type Page = (typeof PAGES)[number]["id"];
41
+
26
42
  export const Settings = () => {
27
43
  const [open, setOpen] = useState(false);
28
- const {theme, setTheme} = useTheme();
29
- const [sound, setSound] = useStartSound();
30
- const wrapper = useRef<HTMLDivElement>(null);
31
44
  const trigger = useRef<HTMLButtonElement>(null);
32
45
 
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
46
  return (
55
- <div className="settings" ref={wrapper}>
47
+ <div className="settings">
56
48
  <Button
57
49
  ref={trigger}
58
50
  icon
59
51
  active={open}
60
52
  aria-label="Settings"
61
53
  aria-expanded={open}
62
- aria-haspopup="menu"
54
+ aria-haspopup="dialog"
63
55
  title="Settings"
64
- onClick={() => setOpen((value) => !value)}
56
+ onClick={() => setOpen(true)}
65
57
  >
66
58
  <Icon name="settings" />
67
59
  </Button>
68
60
 
69
61
  {open ? (
70
- <div className="menu settings-menu" role="menu" aria-label="Settings">
71
- <p className="menu-heading">Theme</p>
72
- {THEMES.map((option) => (
62
+ <SettingsDialog
63
+ onClose={() => {
64
+ setOpen(false);
65
+ // Closing has to put the focus somewhere, and the control that
66
+ // opened the dialog is the only place that is not a surprise.
67
+ trigger.current?.focus();
68
+ }}
69
+ />
70
+ ) : null}
71
+ </div>
72
+ );
73
+ };
74
+
75
+ const SettingsDialog = ({onClose}: {onClose: () => void}) => {
76
+ const [page, setPage] = useState<Page>("appearance");
77
+
78
+ useEffect(() => {
79
+ const onKeyDown = (event: KeyboardEvent) => {
80
+ if (event.key === "Escape") onClose();
81
+ };
82
+ window.addEventListener("keydown", onKeyDown);
83
+ return () => window.removeEventListener("keydown", onKeyDown);
84
+ }, [onClose]);
85
+
86
+ return (
87
+ <div className="overlay" onPointerDown={onClose}>
88
+ <div
89
+ className="settings-dialog"
90
+ role="dialog"
91
+ aria-modal="true"
92
+ aria-label="Settings"
93
+ onPointerDown={(event) => event.stopPropagation()}
94
+ >
95
+ <nav className="settings-nav" aria-label="Settings pages">
96
+ <p className="settings-nav-title">Settings</p>
97
+ {PAGES.map((item) => (
73
98
  <button
74
- key={option.id}
99
+ key={item.id}
75
100
  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)}
101
+ className="settings-nav-item"
102
+ data-selected={page === item.id ? "true" : undefined}
103
+ aria-current={page === item.id ? "page" : undefined}
104
+ onClick={() => setPage(item.id)}
81
105
  >
82
- <span>{option.label}</span>
83
- <span className="menu-hint">{option.hint}</span>
106
+ {item.label}
84
107
  </button>
85
108
  ))}
109
+ </nav>
86
110
 
87
- <p className="menu-heading">Sound</p>
111
+ <div className="settings-body">
112
+ <div className="settings-body-head">
113
+ <h2 className="settings-body-title">{PAGES.find((item) => item.id === page)?.label}</h2>
114
+ <Button icon aria-label="Close settings" title="Close" onClick={onClose}>
115
+ <Icon name="close" />
116
+ </Button>
117
+ </div>
118
+ {page === "appearance" ? <AppearancePage /> : null}
119
+ {page === "audio" ? <AudioPage /> : null}
120
+ </div>
121
+ </div>
122
+ </div>
123
+ );
124
+ };
125
+
126
+ const AppearancePage = () => {
127
+ const {theme, setTheme} = useTheme();
128
+
129
+ return (
130
+ <section className="settings-group" aria-label="Theme">
131
+ <p className="settings-group-title">Theme</p>
132
+ <div className="settings-options" role="radiogroup" aria-label="Theme">
133
+ {THEMES.map((option) => (
134
+ <button
135
+ key={option.id}
136
+ type="button"
137
+ role="radio"
138
+ aria-checked={theme === option.id}
139
+ className="settings-option"
140
+ data-selected={theme === option.id ? "true" : undefined}
141
+ onClick={() => setTheme(option.id)}
142
+ >
143
+ <span>{option.label}</span>
144
+ <span className="settings-option-hint">{option.hint}</span>
145
+ </button>
146
+ ))}
147
+ </div>
148
+ </section>
149
+ );
150
+ };
151
+
152
+ const AudioPage = () => {
153
+ const [sound, setSound] = useStartSound();
154
+ const [providers, setProviders] = useState<ProviderStatus[] | null>(null);
155
+
156
+ /* Fetched when the page shows rather than with Studio: the status can
157
+ change underneath a long session (a key exported in another shell), and
158
+ opening settings is the moment it has to be right. */
159
+ useEffect(() => {
160
+ loadProviders()
161
+ .then(setProviders)
162
+ .catch(() => setProviders(null));
163
+ }, []);
164
+
165
+ return (
166
+ <>
167
+ <section className="settings-group" aria-label="Sound">
168
+ <p className="settings-group-title">Sound</p>
169
+ <div className="settings-options" role="radiogroup" aria-label="Sound">
88
170
  {SOUNDS.map((option) => (
89
171
  <button
90
172
  key={option.id}
91
173
  type="button"
92
- role="menuitemradio"
174
+ role="radio"
93
175
  aria-checked={sound === option.id}
94
- className="menu-item"
176
+ className="settings-option"
95
177
  data-selected={sound === option.id ? "true" : undefined}
96
178
  onClick={() => setSound(option.id)}
97
179
  >
98
180
  <span>{option.label}</span>
99
- <span className="menu-hint">{option.hint}</span>
181
+ <span className="settings-option-hint">{option.hint}</span>
100
182
  </button>
101
183
  ))}
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
184
  </div>
185
+ {/* Changing it now would mute or unmute the video being watched,
186
+ which is not what a default is. */}
187
+ <p className="settings-note">Applies to the next video you open.</p>
188
+ </section>
189
+
190
+ <section className="settings-group" aria-label="Integrations">
191
+ <p className="settings-group-title">Integrations</p>
192
+ {(providers ?? []).map((provider) => (
193
+ <ProviderKeyRow
194
+ key={provider.name}
195
+ provider={provider}
196
+ onChanged={async () => {
197
+ setProviders(await loadProviders());
198
+ window.dispatchEvent(new Event(INTEGRATIONS_CHANGED));
199
+ }}
200
+ />
201
+ ))}
202
+ </section>
203
+ </>
204
+ );
205
+ };
206
+
207
+ const ProviderKeyRow = ({provider, onChanged}: {provider: ProviderStatus; onChanged: () => Promise<void>}) => {
208
+ const [editing, setEditing] = useState(false);
209
+ const [draft, setDraft] = useState("");
210
+ const [busy, setBusy] = useState(false);
211
+ const [message, setMessage] = useState<string | null>(null);
212
+
213
+ const submit = async (key: string) => {
214
+ setBusy(true);
215
+ setMessage(null);
216
+ try {
217
+ const response = await fetch("/__odori/integrations", {
218
+ method: "POST",
219
+ headers: {"content-type": "application/json"},
220
+ body: JSON.stringify({provider: provider.name, key}),
221
+ });
222
+ const body = (await response.json()) as {error?: string; verified?: boolean | null};
223
+ if (!response.ok) {
224
+ setMessage(body.error ?? "The key was not accepted.");
225
+ return;
226
+ }
227
+ setDraft("");
228
+ setEditing(false);
229
+ setMessage(
230
+ key === ""
231
+ ? "Key removed."
232
+ : body.verified
233
+ ? "Checked against the provider and stored on this machine."
234
+ : "Stored. The provider could not be reached to check it, so the first generation will tell.",
235
+ );
236
+ await onChanged();
237
+ } catch {
238
+ setMessage("Studio could not reach its own server.");
239
+ } finally {
240
+ setBusy(false);
241
+ }
242
+ };
243
+
244
+ const connected = provider.source !== null;
245
+ const fromEnvironment = provider.source === "environment";
246
+ const status = fromEnvironment
247
+ ? `Connected from ${provider.keyVariable}`
248
+ : connected
249
+ ? "Connected"
250
+ : "Not connected";
251
+
252
+ return (
253
+ <div className="settings-provider">
254
+ <div className="settings-provider-head">
255
+ <div className="settings-provider-id">
256
+ <span className="settings-provider-name">{provider.title}</span>
257
+ <span className="settings-provider-sub" data-tone={connected ? "ok" : undefined}>
258
+ <span className="settings-provider-dot" aria-hidden="true" />
259
+ {status}
260
+ {" · "}
261
+ <a href={provider.docsUrl} target="_blank" rel="noreferrer">
262
+ docs
263
+ </a>
264
+ </span>
265
+ </div>
266
+ {/* One action at a time. The environment case has none: the place to
267
+ change an environment is the environment. */}
268
+ {fromEnvironment ? null : connected ? (
269
+ <Button disabled={busy} onClick={() => void submit("")}>
270
+ Disconnect
271
+ </Button>
272
+ ) : editing ? null : (
273
+ <Button
274
+ onClick={() => {
275
+ setEditing(true);
276
+ setMessage(null);
277
+ }}
278
+ >
279
+ Connect
280
+ </Button>
281
+ )}
282
+ </div>
283
+ {editing && !connected ? (
284
+ <>
285
+ <form
286
+ className="settings-provider-form"
287
+ onSubmit={(event) => {
288
+ event.preventDefault();
289
+ if (draft.trim()) void submit(draft.trim());
290
+ }}
291
+ >
292
+ <input
293
+ className="settings-integration-input"
294
+ type="password"
295
+ autoFocus
296
+ autoComplete="off"
297
+ placeholder={provider.keyVariable}
298
+ value={draft}
299
+ onChange={(event) => setDraft(event.target.value)}
300
+ disabled={busy}
301
+ aria-label={`${provider.title} API key`}
302
+ />
303
+ <Button variant="primary" disabled={busy || !draft.trim()} onClick={() => void submit(draft.trim())}>
304
+ {busy ? "Checking…" : "Save"}
305
+ </Button>
306
+ <Button
307
+ disabled={busy}
308
+ onClick={() => {
309
+ setEditing(false);
310
+ setDraft("");
311
+ }}
312
+ >
313
+ Cancel
314
+ </Button>
315
+ </form>
316
+ <p className="settings-note">
317
+ Checked against the provider, then stored in <code>~/.config/odori</code> on this machine — never shown
318
+ again. A key in the environment wins over a stored one.
319
+ </p>
320
+ </>
106
321
  ) : null}
322
+ {message ? <p className="settings-note">{message}</p> : null}
107
323
  </div>
108
324
  );
109
325
  };
@@ -72,7 +72,8 @@ export const Icon = ({
72
72
  | "external"
73
73
  | "sidebar"
74
74
  | "settings"
75
- | "search";
75
+ | "search"
76
+ | "close";
76
77
  }) => {
77
78
  const paths: Record<string, ReactNode> = {
78
79
  play: <path d="M4.5 2.8v8.4l7-4.2z" fill="currentColor" />,
@@ -92,6 +93,15 @@ export const Icon = ({
92
93
  <circle cx="12" cy="12" r="3" />
93
94
  </g>
94
95
  ),
96
+ close: (
97
+ <path
98
+ d="M3.6 3.6l6.8 6.8M10.4 3.6l-6.8 6.8"
99
+ fill="none"
100
+ stroke="currentColor"
101
+ strokeLinecap="round"
102
+ strokeWidth="1.2"
103
+ />
104
+ ),
95
105
  sun: (
96
106
  <>
97
107
  <circle cx="7" cy="7" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.2" />