@odori/cli 0.0.8 → 0.0.9

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.
@@ -344,9 +344,176 @@ a.button {
344
344
  }
345
345
  /* Right-aligned: the trigger is the last thing in the header, so a menu
346
346
  growing rightward from its left edge would leave the window. */
347
- .settings-menu {
348
- left: auto;
349
- right: 0;
347
+ /* The settings dialog: pages down the left, one page's controls on the right. */
348
+ .settings-dialog {
349
+ background: var(--elevated);
350
+ border: 1px solid var(--border-strong);
351
+ border-radius: var(--radius-lg);
352
+ box-shadow: var(--shadow);
353
+ display: flex;
354
+ /* One size regardless of the page shown: a dialog that grows and shrinks
355
+ as you switch pages reads as two dialogs. The body scrolls instead. */
356
+ height: min(480px, 82vh);
357
+ overflow: hidden;
358
+ width: min(640px, 92vw);
359
+ }
360
+ .settings-nav {
361
+ border-right: 1px solid var(--border);
362
+ display: flex;
363
+ flex-direction: column;
364
+ flex: none;
365
+ gap: 2px;
366
+ padding: 14px 10px;
367
+ width: 168px;
368
+ }
369
+ .settings-nav-title {
370
+ color: var(--subtle);
371
+ font-size: 11px;
372
+ letter-spacing: 0.04em;
373
+ margin: 0 0 8px;
374
+ padding: 0 8px;
375
+ text-transform: uppercase;
376
+ }
377
+ .settings-nav-item {
378
+ background: transparent;
379
+ border: none;
380
+ border-radius: var(--radius-sm);
381
+ color: var(--muted);
382
+ cursor: pointer;
383
+ font: inherit;
384
+ font-size: 13px;
385
+ padding: 7px 8px;
386
+ text-align: left;
387
+ }
388
+ .settings-nav-item:hover {
389
+ color: var(--foreground);
390
+ }
391
+ .settings-nav-item[data-selected="true"] {
392
+ background: var(--active);
393
+ color: var(--foreground);
394
+ }
395
+ .settings-body {
396
+ display: flex;
397
+ flex: 1;
398
+ flex-direction: column;
399
+ gap: 18px;
400
+ min-width: 0;
401
+ overflow-y: auto;
402
+ padding: 14px 18px 20px;
403
+ }
404
+ .settings-body-head {
405
+ align-items: center;
406
+ display: flex;
407
+ justify-content: space-between;
408
+ }
409
+ .settings-body-title {
410
+ font-size: 15px;
411
+ font-weight: 600;
412
+ margin: 0;
413
+ }
414
+ .settings-group {
415
+ display: grid;
416
+ gap: 8px;
417
+ }
418
+ .settings-group-title {
419
+ color: var(--subtle);
420
+ font-size: 11px;
421
+ letter-spacing: 0.04em;
422
+ margin: 0;
423
+ text-transform: uppercase;
424
+ }
425
+ .settings-options {
426
+ display: grid;
427
+ gap: 2px;
428
+ }
429
+ .settings-option {
430
+ align-items: baseline;
431
+ background: transparent;
432
+ border: none;
433
+ border-radius: var(--radius-sm);
434
+ color: var(--muted);
435
+ cursor: pointer;
436
+ display: flex;
437
+ font: inherit;
438
+ font-size: 13px;
439
+ gap: 10px;
440
+ justify-content: space-between;
441
+ padding: 7px 9px;
442
+ text-align: left;
443
+ }
444
+ .settings-option:hover {
445
+ color: var(--foreground);
446
+ }
447
+ .settings-option[data-selected="true"] {
448
+ background: var(--active);
449
+ color: var(--foreground);
450
+ }
451
+ .settings-option-hint {
452
+ color: var(--subtle);
453
+ font-size: 11px;
454
+ }
455
+ .settings-note {
456
+ color: var(--subtle);
457
+ font-size: 12px;
458
+ line-height: 1.5;
459
+ margin: 0;
460
+ }
461
+ .settings-provider {
462
+ border: 1px solid var(--border);
463
+ border-radius: var(--radius);
464
+ display: grid;
465
+ gap: 10px;
466
+ padding: 12px 14px;
467
+ }
468
+ .settings-provider-head {
469
+ align-items: center;
470
+ display: flex;
471
+ gap: 12px;
472
+ justify-content: space-between;
473
+ }
474
+ .settings-provider-id {
475
+ display: grid;
476
+ gap: 3px;
477
+ min-width: 0;
478
+ }
479
+ .settings-provider-name {
480
+ font-size: 13px;
481
+ font-weight: 600;
482
+ }
483
+ .settings-provider-sub {
484
+ align-items: center;
485
+ color: var(--subtle);
486
+ display: flex;
487
+ font-size: 12px;
488
+ gap: 6px;
489
+ }
490
+ .settings-provider-sub a {
491
+ color: inherit;
492
+ }
493
+ .settings-provider-dot {
494
+ background: var(--subtle);
495
+ border-radius: 999px;
496
+ flex: none;
497
+ height: 6px;
498
+ width: 6px;
499
+ }
500
+ .settings-provider-sub[data-tone="ok"] .settings-provider-dot {
501
+ background: var(--success);
502
+ }
503
+ .settings-provider-form {
504
+ display: flex;
505
+ gap: 8px;
506
+ }
507
+ .settings-integration-input {
508
+ flex: 1;
509
+ min-width: 0;
510
+ background: var(--field, transparent);
511
+ border: 1px solid var(--border);
512
+ border-radius: 6px;
513
+ color: inherit;
514
+ font: inherit;
515
+ font-size: 12px;
516
+ padding: 6px 10px;
350
517
  }
351
518
  .menu-heading {
352
519
  color: var(--subtle);
@@ -2,6 +2,7 @@ import {resolveEntryLayout} from "odori";
2
2
  import {project, videos} from "virtual:odori-project";
3
3
  import {AudioClip} from "../components/AudioClip";
4
4
  import {Empty, SectionTitle} from "../components/ui";
5
+ import {GenerateBed} from "../components/GenerateBed";
5
6
 
6
7
  const isImage = (url: string) => /\.(png|jpe?g|gif|svg|webp|avif)$/i.test(url);
7
8
  const isAudio = (url: string) => /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i.test(url);
@@ -88,6 +89,11 @@ export const AssetsView = () => {
88
89
  )}
89
90
  </section>
90
91
 
92
+ <section>
93
+ <SectionTitle>Generate a bed</SectionTitle>
94
+ <GenerateBed />
95
+ </section>
96
+
91
97
  <section>
92
98
  <SectionTitle>Fonts</SectionTitle>
93
99
  {/* Studio's own chrome never loaded these faces, so a name rendered in
@@ -17,6 +17,7 @@ declare module "virtual:odori-project" {
17
17
  exportDir: string;
18
18
  audioDir: string;
19
19
  audio: Array<{name: string; url: string; relativeFile: string; bytes: number}>;
20
+ version: string;
20
21
  sourceHash: string;
21
22
  assets: Array<{reference: string; url: string}>;
22
23
  files: {
@@ -1,270 +0,0 @@
1
- import {useEffect, useState} from "react";
2
- import {Badge, Button, Empty, SectionTitle} from "../components/ui";
3
-
4
- type ProviderStatus = {
5
- name: string;
6
- title: string;
7
- kind: string;
8
- keyVariable: string;
9
- docsUrl: string;
10
- source: "environment" | "stored" | null;
11
- };
12
-
13
- /**
14
- * Where generation providers are connected.
15
- *
16
- * The page is deliberately one-way about secrets: a key can be typed here and
17
- * sent to the local server once, but it never comes back — status is a source
18
- * ("environment", "stored") and nothing else. A key from the environment wins
19
- * over a stored one and cannot be edited here, because the place to change an
20
- * environment is the environment.
21
- */
22
- export const IntegrationsView = () => {
23
- const [providers, setProviders] = useState<ProviderStatus[] | null>(null);
24
- const [error, setError] = useState<string | null>(null);
25
-
26
- const load = async () => {
27
- try {
28
- const response = await fetch("/__odori/integrations");
29
- if (!response.ok) throw new Error(`${response.status}`);
30
- const body = (await response.json()) as {providers: ProviderStatus[]};
31
- setProviders(body.providers);
32
- } catch {
33
- setError("Studio could not reach its own server. Is odori dev still running?");
34
- }
35
- };
36
-
37
- useEffect(() => {
38
- void load();
39
- }, []);
40
-
41
- if (error) return <Empty title="Integrations unavailable">{error}</Empty>;
42
- if (!providers) return <Empty title="Loading" />;
43
-
44
- return (
45
- <div className="integrations-view">
46
- <section>
47
- <SectionTitle>Music</SectionTitle>
48
- <p className="hint">
49
- A provider generates at author time only: the track lands in <code>public/audio</code>, prepared and
50
- committed, and the render never calls a network. Keys live in <code>~/.config/odori</code> on this machine —
51
- never in the project, and never readable from this page.
52
- </p>
53
- <ul className="integration-list">
54
- {providers.map((provider) => (
55
- <IntegrationRow key={provider.name} provider={provider} onChanged={load} />
56
- ))}
57
- </ul>
58
- </section>
59
- {providers.some((provider) => provider.source !== null) ? (
60
- <section>
61
- <SectionTitle>Generate a bed</SectionTitle>
62
- <GeneratePanel />
63
- </section>
64
- ) : null}
65
- </div>
66
- );
67
- };
68
-
69
- type GenerateReport = {
70
- destination: string;
71
- role: string;
72
- url: string | null;
73
- registered: {file: string; already: boolean} | null;
74
- before: {lufs: number; peak: number; range: number};
75
- after: {lufs: number; peak: number};
76
- warnings: string[];
77
- };
78
-
79
- /**
80
- * Prompt to registered role, without a terminal. The server walks the same
81
- * path the CLI does — provider, prepare, register — and the numbers it prints
82
- * there render here, warnings included, with the result playable in place.
83
- */
84
- const GeneratePanel = () => {
85
- const [prompt, setPrompt] = useState("");
86
- const [seconds, setSeconds] = useState(60);
87
- const [role, setRole] = useState("");
88
- const [busy, setBusy] = useState(false);
89
- const [error, setError] = useState<string | null>(null);
90
- const [report, setReport] = useState<GenerateReport | null>(null);
91
-
92
- const generate = async () => {
93
- setBusy(true);
94
- setError(null);
95
- setReport(null);
96
- try {
97
- const response = await fetch("/__odori/generate", {
98
- method: "POST",
99
- headers: {"content-type": "application/json"},
100
- body: JSON.stringify({prompt: prompt.trim(), seconds, role: role.trim() || undefined}),
101
- });
102
- const body = (await response.json()) as GenerateReport & {error?: string};
103
- if (!response.ok) {
104
- setError(body.error ?? "Generation failed.");
105
- return;
106
- }
107
- setReport(body);
108
- } catch {
109
- setError("Studio could not reach its own server. Is odori dev still running?");
110
- } finally {
111
- setBusy(false);
112
- }
113
- };
114
-
115
- return (
116
- <div className="integration generate-panel">
117
- <textarea
118
- className="generate-prompt"
119
- rows={2}
120
- placeholder="steady ambient bed, no drums, warm"
121
- value={prompt}
122
- onChange={(event) => setPrompt(event.target.value)}
123
- disabled={busy}
124
- aria-label="Prompt"
125
- />
126
- <div className="generate-controls">
127
- <label className="generate-field">
128
- seconds
129
- <input
130
- type="number"
131
- min={5}
132
- max={300}
133
- value={seconds}
134
- onChange={(event) => setSeconds(Number(event.target.value) || 60)}
135
- disabled={busy}
136
- />
137
- </label>
138
- <label className="generate-field">
139
- role
140
- <input
141
- type="text"
142
- placeholder="bed.main"
143
- value={role}
144
- onChange={(event) => setRole(event.target.value)}
145
- disabled={busy}
146
- />
147
- </label>
148
- <Button variant="primary" disabled={busy || !prompt.trim()} onClick={() => void generate()}>
149
- {busy ? "Generating…" : "Generate"}
150
- </Button>
151
- </div>
152
- {busy ? <p className="hint">The provider is composing, then the track is levelled to the stem target. A minute is normal.</p> : null}
153
- {error ? <p className="hint">{error}</p> : null}
154
- {report ? (
155
- <div className="generate-report">
156
- {report.url ? <audio controls src={report.url} style={{width: "100%"}} /> : null}
157
- <p className="hint">
158
- {report.destination} · {report.before.lufs.toFixed(1)} → {report.after.lufs.toFixed(1)} LUFS · range{" "}
159
- {report.before.range.toFixed(1)} LU
160
- {report.registered
161
- ? report.registered.already
162
- ? ` · "${report.role}" was already registered`
163
- : ` · registered "${report.role}" in ${report.registered.file}`
164
- : ""}
165
- </p>
166
- {report.warnings.map((warning) => (
167
- <p key={warning} className="hint">
168
- {warning}
169
- </p>
170
- ))}
171
- <p className="integration-usage">
172
- <code>{`<Audio src="${report.role}" fadeIn="1s" fadeOut="1.5s" duckUnder />`}</code>
173
- </p>
174
- </div>
175
- ) : null}
176
- </div>
177
- );
178
- };
179
-
180
- const IntegrationRow = ({provider, onChanged}: {provider: ProviderStatus; onChanged: () => Promise<void>}) => {
181
- const [draft, setDraft] = useState("");
182
- const [busy, setBusy] = useState(false);
183
- const [message, setMessage] = useState<string | null>(null);
184
-
185
- const submit = async (key: string) => {
186
- setBusy(true);
187
- setMessage(null);
188
- try {
189
- const response = await fetch("/__odori/integrations", {
190
- method: "POST",
191
- headers: {"content-type": "application/json"},
192
- body: JSON.stringify({provider: provider.name, key}),
193
- });
194
- const body = (await response.json()) as {error?: string; verified?: boolean | null};
195
- if (!response.ok) {
196
- setMessage(body.error ?? "The key was not accepted.");
197
- return;
198
- }
199
- setDraft("");
200
- setMessage(
201
- key === ""
202
- ? "Key removed."
203
- : body.verified
204
- ? "Connected. The key was checked against the provider and stored on this machine."
205
- : "Stored. The provider could not be reached to check it, so the first generation will tell.",
206
- );
207
- await onChanged();
208
- } catch {
209
- setMessage("Studio could not reach its own server.");
210
- } finally {
211
- setBusy(false);
212
- }
213
- };
214
-
215
- const connected = provider.source !== null;
216
- const fromEnvironment = provider.source === "environment";
217
-
218
- return (
219
- <li className="integration">
220
- <div className="integration-head">
221
- <span className="integration-title">{provider.title}</span>
222
- {connected ? (
223
- <Badge tone="success">{fromEnvironment ? `connected · ${provider.keyVariable}` : "connected"}</Badge>
224
- ) : (
225
- <Badge tone="warning">not configured</Badge>
226
- )}
227
- <a className="integration-docs" href={provider.docsUrl} target="_blank" rel="noreferrer">
228
- docs
229
- </a>
230
- </div>
231
- {fromEnvironment ? (
232
- <p className="hint">
233
- The key comes from <code>{provider.keyVariable}</code> in the environment, which always wins over a stored
234
- one. Change it where the environment is set.
235
- </p>
236
- ) : (
237
- <form
238
- className="integration-form"
239
- onSubmit={(event) => {
240
- event.preventDefault();
241
- if (draft.trim()) void submit(draft.trim());
242
- }}
243
- >
244
- <input
245
- className="integration-input"
246
- type="password"
247
- autoComplete="off"
248
- placeholder={connected ? "Replace the stored key" : `Paste an API key (${provider.keyVariable})`}
249
- value={draft}
250
- onChange={(event) => setDraft(event.target.value)}
251
- disabled={busy}
252
- aria-label={`${provider.title} API key`}
253
- />
254
- <Button disabled={busy || !draft.trim()} onClick={() => void submit(draft.trim())}>
255
- {connected ? "Replace" : "Connect"}
256
- </Button>
257
- {connected ? (
258
- <Button disabled={busy} onClick={() => void submit("")}>
259
- Disconnect
260
- </Button>
261
- ) : null}
262
- </form>
263
- )}
264
- {message ? <p className="hint">{message}</p> : null}
265
- <p className="integration-usage">
266
- <code>odori bed "steady ambient, no drums" --generate --role bed.main</code>
267
- </p>
268
- </li>
269
- );
270
- };