@odori/cli 0.0.3 → 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.
Files changed (38) hide show
  1. package/dist/{chunk-RXLB2CXH.js → chunk-RHG23EWW.js} +457 -241
  2. package/dist/cli.js +1 -1
  3. package/dist/index.d.ts +45 -5
  4. package/dist/index.js +3 -3
  5. package/dist/registry-snapshot-JEVXYGS2.js +4868 -0
  6. package/package.json +3 -3
  7. package/src/assets.ts +90 -0
  8. package/src/brand-file.ts +16 -4
  9. package/src/cli.ts +38 -13
  10. package/src/commands/add.ts +37 -1
  11. package/src/commands/dev.ts +32 -2
  12. package/src/commands/doctor.ts +47 -2
  13. package/src/commands/exportVideo.ts +6 -0
  14. package/src/commands/{still.ts → frame.ts} +23 -9
  15. package/src/commands/update.ts +58 -7
  16. package/src/discovery.ts +63 -2
  17. package/src/index.ts +1 -1
  18. package/src/jobs.ts +1 -1
  19. package/src/registry-snapshot.json +1529 -327
  20. package/src/registry-source.ts +37 -2
  21. package/src/render.ts +8 -1
  22. package/src/server.ts +7 -1
  23. package/studio/src/Studio.tsx +6 -22
  24. package/studio/src/components/ExportPanel.tsx +90 -6
  25. package/studio/src/components/Inspector.tsx +101 -1
  26. package/studio/src/components/Navigator.tsx +149 -0
  27. package/studio/src/components/Settings.tsx +109 -0
  28. package/studio/src/components/Transport.tsx +98 -55
  29. package/studio/src/components/ui.tsx +16 -1
  30. package/studio/src/lib/highlight.ts +85 -0
  31. package/studio/src/settings.ts +87 -0
  32. package/studio/src/studio.css +350 -8
  33. package/studio/src/views/BrandsView.tsx +18 -1
  34. package/studio/src/views/ComponentsView.tsx +191 -26
  35. package/studio/src/views/HomeView.tsx +7 -4
  36. package/studio/src/views/VideosView.tsx +33 -6
  37. package/studio/src/virtual.d.ts +4 -1
  38. package/dist/registry-snapshot-BDP6PVYB.js +0 -3559
@@ -10,14 +10,15 @@ import {
10
10
  type Brand,
11
11
  type VideoEntry,
12
12
  } from "odori";
13
- import {PreviewBoundary} from "odori/preview";
13
+ import {PreviewBoundary, categoryFromPath, categoryPath, familyOf, groupOf} from "odori/preview";
14
14
  import {componentPreviews, project, videos} from "virtual:odori-project";
15
15
  import {CanvasStage} from "../components/CanvasStage";
16
16
  import {Transport} from "../components/Transport";
17
17
  import {Thumbnail} from "../components/Thumbnail";
18
18
  import {InputControls} from "../components/InputControls";
19
- import {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
19
+ import {Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
20
20
  import {Inspector} from "../components/Inspector";
21
+ import {Navigator} from "../components/Navigator";
21
22
  import {useShortcuts} from "../shortcuts";
22
23
 
23
24
  type PreviewEntry = (typeof componentPreviews)[number];
@@ -32,11 +33,93 @@ const projectBrands = (): Brand[] => {
32
33
  return [...seen.values()];
33
34
  };
34
35
 
35
- const FAMILY_ORDER = ["Typography", "Developer proof", "Product UI", "Narrative", "Brand", "Media", "Audio"];
36
+ // The reading order of the catalog: what you author, then what you show, then
37
+ // what carries it. A family missing here sorts last, alphabetically.
38
+ /**
39
+ * The reading order of the catalog. A category nests, so this lists families
40
+ * and the groups inside them sort alphabetically underneath: the order that
41
+ * matters is which family you meet first, not which of its drawers.
42
+ */
43
+ const FAMILY_ORDER = [
44
+ "Typography",
45
+ "Developer proof",
46
+ "Interface",
47
+ "Agents",
48
+ "Products",
49
+ "Narrative",
50
+ "Data",
51
+ "Media",
52
+ "Motion",
53
+ "Brand",
54
+ "Foundation",
55
+ "Sound",
56
+ ];
57
+
58
+ /**
59
+ * Where a family sorts.
60
+ *
61
+ * The listed ones are the registry's, in reading order. A project's own
62
+ * families are not in that list and must not all collapse to the same rank:
63
+ * they sort alphabetically after, so somebody who writes their own categories
64
+ * gets a stable order rather than whatever the file walk happened to produce.
65
+ */
66
+ /**
67
+ * Where a component belongs.
68
+ *
69
+ * The fixture wins when it says, because a registry component is copied into
70
+ * somebody's tree, lands flat, and has to keep the family it was written for.
71
+ * Otherwise the directory answers, which is how a project organises its own
72
+ * work: move the folder and it moves in Studio, with nothing to keep in sync
73
+ * and no group that exists only because somebody mistyped one. A directory
74
+ * names itself in a `category.json` beside its components, which is the one
75
+ * thing a filesystem cannot say.
76
+ */
77
+ /** What a directory calls itself, from its own `category.json`. */
78
+ const declared = new Map(project.categories.map((entry) => [entry.path, entry]));
79
+
80
+ export const categoryOf = (entry: PreviewEntry): string => {
81
+ if (entry.preview.category) return entry.preview.category;
82
+ const file = project.files.previews.find((item) => item.id === entry.id)?.file;
83
+ const derived = file
84
+ ? categoryFromPath(file, project.componentsDir.split("/").pop(), (path) => declared.get(path)?.name)
85
+ : "";
86
+ return derived || "Uncategorised";
87
+ };
88
+
89
+ /**
90
+ * Where a family sorts, when a project has said so.
91
+ *
92
+ * `category.json` carries an order, which is the only way a project can put
93
+ * its own families in a sensible sequence: the list above is ours, and a
94
+ * hardcoded list of somebody else's families is no use to them.
95
+ */
96
+ const declaredOrder = (family: string): number | undefined => {
97
+ for (const entry of project.categories) {
98
+ if (entry.order === undefined || entry.path.includes("/")) continue;
99
+ const label = entry.name ?? entry.path.replace(/-/g, " ").replace(/^./, (letter) => letter.toUpperCase());
100
+ if (label === family) return entry.order;
101
+ }
102
+ return undefined;
103
+ };
104
+
105
+ const familyRank = (family: string): number => {
106
+ // A project's own order leads, then ours, then whatever is left, which
107
+ // sorts alphabetically. Declared orders go negative so they rank ahead.
108
+ const own = declaredOrder(family);
109
+ if (own !== undefined) return own - 1000;
110
+ const known = FAMILY_ORDER.indexOf(family);
111
+ return known === -1 ? FAMILY_ORDER.length : known;
112
+ };
36
113
 
37
114
  const byFamily = (left: PreviewEntry, right: PreviewEntry) => {
38
- const delta = FAMILY_ORDER.indexOf(left.preview.category) - FAMILY_ORDER.indexOf(right.preview.category);
39
- return delta !== 0 ? delta : left.preview.title.localeCompare(right.preview.title);
115
+ const leftFamily = familyOf(categoryOf(left));
116
+ const rightFamily = familyOf(categoryOf(right));
117
+ const delta = familyRank(leftFamily) - familyRank(rightFamily);
118
+ if (delta !== 0) return delta;
119
+ const family = leftFamily.localeCompare(rightFamily);
120
+ if (family !== 0) return family;
121
+ const group = groupOf(categoryOf(left)).localeCompare(groupOf(categoryOf(right)));
122
+ return group !== 0 ? group : left.preview.title.localeCompare(right.preview.title);
40
123
  };
41
124
 
42
125
  const FORMATS = [
@@ -76,7 +159,26 @@ export const ComponentsView = ({
76
159
  }) => {
77
160
  // Finding one by name is ⌘K; the gallery is for browsing them all. A route
78
161
  // with no selection is the gallery; a selection is that component's player.
79
- const filtered = useMemo(() => [...componentPreviews].sort(byFamily), []);
162
+ const [usedIn, setUsedIn] = useState<string | null>(null);
163
+
164
+ /**
165
+ * Which video a component appears in, derived from the imports rather than
166
+ * declared, so it cannot disagree with the composition. A project of a
167
+ * hundred components is otherwise a wall with no way in.
168
+ */
169
+ const usage = useMemo(
170
+ () => Object.fromEntries(project.files.previews.map((entry) => [entry.id, entry.usedBy])),
171
+ [],
172
+ );
173
+ const videosUsing = useMemo(
174
+ () => [...new Set(Object.values(usage).flat())].sort(),
175
+ [usage],
176
+ );
177
+
178
+ const filtered = useMemo(() => {
179
+ const all = [...componentPreviews].sort(byFamily);
180
+ return usedIn ? all.filter((item) => (usage[item.id] ?? []).includes(usedIn)) : all;
181
+ }, [usedIn, usage]);
80
182
  const selected = selection ? (filtered.find((entry) => entry.id === selection) ?? null) : null;
81
183
  const brands = projectBrands();
82
184
  const [brandName, setBrandName] = useState(brands[0]?.name ?? defaultBrand.name);
@@ -119,17 +221,62 @@ export const ComponentsView = ({
119
221
  }
120
222
 
121
223
  if (!selected) {
122
- const grouped = filtered.reduce<Record<string, PreviewEntry[]>>((groups, item) => {
123
- groups[item.preview.category] = [...(groups[item.preview.category] ?? []), item];
124
- return groups;
125
- }, {});
224
+ /*
225
+ * Two levels, because a category is a path. Rendering one section per
226
+ * full path printed the family again for every drawer inside it -
227
+ * Interface, Interface, Interface - which is a list pretending to be an
228
+ * outline. `filtered` is already sorted by family, then group, then
229
+ * title, so walking it in order is enough to build the tree.
230
+ */
231
+ const families: Array<{name: string; groups: Array<{name: string; entries: PreviewEntry[]}>}> = [];
232
+ for (const item of filtered) {
233
+ const familyName = familyOf(categoryOf(item));
234
+ const groupName = groupOf(categoryOf(item));
235
+ let family = families.at(-1);
236
+ if (!family || family.name !== familyName) {
237
+ family = {name: familyName, groups: []};
238
+ families.push(family);
239
+ }
240
+ let group = family.groups.at(-1);
241
+ if (!group || group.name !== groupName) {
242
+ group = {name: groupName, entries: []};
243
+ family.groups.push(group);
244
+ }
245
+ group.entries.push(item);
246
+ }
126
247
  return (
127
248
  <div style={{overflowY: "auto", width: "100%"}}>
128
- {Object.entries(grouped).map(([category, entries]) => (
129
- <section key={category} style={{padding: "16px 20px 0"}}>
130
- <SectionTitle>{category}</SectionTitle>
131
- <div className="gallery" style={{padding: "0 0 8px"}}>
132
- {entries.map((item) => (
249
+ {videosUsing.length > 0 ? (
250
+ <div className="catalog-filter">
251
+ <button type="button" data-active={usedIn === null ? "true" : undefined} onClick={() => setUsedIn(null)}>
252
+ All
253
+ </button>
254
+ {videosUsing.map((video) => (
255
+ <button
256
+ key={video}
257
+ type="button"
258
+ data-active={usedIn === video ? "true" : undefined}
259
+ onClick={() => setUsedIn(video)}
260
+ >
261
+ {video}
262
+ </button>
263
+ ))}
264
+ </div>
265
+ ) : null}
266
+ {families.map((family) => (
267
+ <section key={family.name} style={{padding: "16px 20px 0"}}>
268
+ <SectionTitle>{family.name}</SectionTitle>
269
+ {family.groups.map((group) => (
270
+ <div key={group.name || "."}>
271
+ {/* A family whose components sit at its top level has one
272
+ unnamed group, and no heading to draw for it. */}
273
+ {/* Any depth renders: the family is the heading, and whatever
274
+ a project nests below it reads as one path. Two levels is
275
+ our own editorial rule for our registry, not a limit the
276
+ framework puts on anybody else's. */}
277
+ {group.name ? <h3 className="group-title">{group.name.split("/").join(" · ")}</h3> : null}
278
+ <div className="gallery" style={{padding: "0 0 8px"}}>
279
+ {group.entries.map((item) => (
133
280
  <button key={item.id} type="button" className="card" onClick={() => onSelect(item.id)}>
134
281
  <Thumbnail
135
282
  entry={entryFor(
@@ -151,8 +298,10 @@ export const ComponentsView = ({
151
298
  <span>{item.id}</span>
152
299
  </div>
153
300
  </button>
154
- ))}
155
- </div>
301
+ ))}
302
+ </div>
303
+ </div>
304
+ ))}
156
305
  </section>
157
306
  ))}
158
307
  </div>
@@ -167,8 +316,30 @@ export const ComponentsView = ({
167
316
  const props = {...controlDefaults, ...(example?.props ?? {}), ...overrides};
168
317
  const entry = entryFor(selected, props, brand, format);
169
318
 
319
+ const previewFile =
320
+ project.files.previews.find((entry) => entry.id === selected.id)?.file ??
321
+ `videos/components/${selected.id}/${selected.id}.preview.tsx`;
322
+ // What it draws comes before the fixture that plays it: the fixture is the
323
+ // harness, and reading only the harness answers the wrong question.
324
+ const componentFile = previewFile.replace(/\.preview\.tsx$/, ".tsx");
325
+ const sources = componentFile === previewFile ? [previewFile] : [componentFile, previewFile];
326
+
170
327
  return (
171
328
  <>
329
+ {/* `filtered` is already sorted family, group, title, so the list reads
330
+ in the same order the gallery does. */}
331
+ <Navigator
332
+ label="Components"
333
+ selected={selected.id}
334
+ onSelect={onSelect}
335
+ items={filtered.map((item) => ({
336
+ id: item.id,
337
+ title: item.preview.title,
338
+ detail: item.id,
339
+ group: categoryPath(categoryOf(item)).join(" · "),
340
+ }))}
341
+ />
342
+
172
343
  <section className="stage">
173
344
  <CanvasStage width={format.width} height={format.height}>
174
345
  <PreviewBoundary resetKey={`${selected.id}-${format.label}-${brand.name}`} label="This component threw">
@@ -190,13 +361,7 @@ export const ComponentsView = ({
190
361
  />
191
362
  </section>
192
363
 
193
- <Inspector
194
- title={preview.title}
195
- hint={
196
- project.files.previews.find((entry) => entry.id === selected.id)?.file ??
197
- `videos/components/${selected.id}/${selected.id}.preview.tsx`
198
- }
199
- >
364
+ <Inspector title={preview.title} hint={componentFile} source={sources}>
200
365
  <SectionTitle>Examples</SectionTitle>
201
366
  <ul className="list">
202
367
  {preview.examples.map((item) => (
@@ -219,7 +384,7 @@ export const ComponentsView = ({
219
384
 
220
385
  <SectionTitle>Component</SectionTitle>
221
386
  <dl className="facts">
222
- <Fact label="category">{preview.category}</Fact>
387
+ <Fact label="category">{categoryPath(categoryOf(selected)).join(" · ")}</Fact>
223
388
  <Fact label="canvas">
224
389
  {preview.canvas.width}x{preview.canvas.height} · {String(preview.canvas.duration)}
225
390
  </Fact>
@@ -255,7 +420,7 @@ export const ComponentsView = ({
255
420
  {item.name}
256
421
  </Button>
257
422
  ))}
258
- <Badge>{Object.keys(preview.controls ?? {}).length} controls</Badge>
423
+
259
424
  </div>
260
425
 
261
426
  <Separator />
@@ -4,7 +4,8 @@ import {Thumbnail} from "../components/Thumbnail";
4
4
  import {AudioClip} from "../components/AudioClip";
5
5
  import {SectionTitle} from "../components/ui";
6
6
  import {collectBrands} from "./BrandsView";
7
- import {entryFor} from "./ComponentsView";
7
+ import {categoryOf, entryFor} from "./ComponentsView";
8
+ import {familyOf} from "odori/preview";
8
9
  import type {StudioView} from "../Studio";
9
10
 
10
11
  /**
@@ -28,7 +29,7 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
28
29
  <SectionTitle>Videos</SectionTitle>
29
30
  {videos.length > SHOWN ? (
30
31
  <button type="button" className="view-all" onClick={() => onOpen("videos")}>
31
- View all {videos.length}
32
+ View all →
32
33
  </button>
33
34
  ) : null}
34
35
  </div>
@@ -58,7 +59,7 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
58
59
  <SectionTitle>Components</SectionTitle>
59
60
  {componentPreviews.length > SHOWN ? (
60
61
  <button type="button" className="view-all" onClick={() => onOpen("components")}>
61
- View all {componentPreviews.length}
62
+ View all →
62
63
  </button>
63
64
  ) : null}
64
65
  </div>
@@ -82,7 +83,9 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
82
83
  />
83
84
  <div className="card-meta">
84
85
  <strong>{item.preview.title}</strong>
85
- <span>{item.preview.category.toLowerCase()}</span>
86
+ {/* The home card names the family, not the whole path: it
87
+ is a glance, not a filing cabinet. */}
88
+ <span>{familyOf(categoryOf(item)).toLowerCase() || "component"}</span>
86
89
  </div>
87
90
  </button>
88
91
  );
@@ -20,8 +20,10 @@ import {InputControls} from "../components/InputControls";
20
20
  import {ExportPanel} from "../components/ExportPanel";
21
21
  import {Diagnostics} from "../components/Diagnostics";
22
22
  import {Inspector} from "../components/Inspector";
23
+ import {Navigator} from "../components/Navigator";
23
24
  import {Badge, Empty, Fact, SectionTitle, Separator} from "../components/ui";
24
25
  import {measureTrack} from "../lib/mix-loudness";
26
+ import {readStartSound} from "../settings";
25
27
  import {useShortcuts} from "../shortcuts";
26
28
 
27
29
  /**
@@ -50,9 +52,15 @@ export const VideosView = ({
50
52
  const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
51
53
  const [track, setTrack] = useState<AudioTrack | null>(null);
52
54
  const [loop, setLoop] = useState(true);
53
- const [muted, setMuted] = useState(false);
55
+ /* Read once per mount rather than watched: flipping the default while a
56
+ video is open would mute the thing being listened to, and a default is a
57
+ statement about what happens next. */
58
+ const [muted, setMuted] = useState(() => readStartSound() === "off");
54
59
  const [soloCue, setSoloCue] = useState<string | null>(null);
55
- const [scrubbing, setScrubbing] = useState(false);
60
+ /* The frame the cursor is over, which the stage shows instead of the
61
+ playhead frame. Preview never moves the playhead and never sounds: it
62
+ answers "what is here" and nothing else. */
63
+ const [preview, setPreview] = useState<number | null>(null);
56
64
  const [rate, setRate] = useState(1);
57
65
  const [loudness, setLoudness] = useState<number | null>(null);
58
66
  const [audioBlocked, setAudioBlocked] = useState(false);
@@ -107,7 +115,7 @@ export const VideosView = ({
107
115
  playing: playback.playing,
108
116
  muted,
109
117
  soloCue,
110
- scrubbing,
118
+ scrubbing: preview !== null,
111
119
  rate,
112
120
  onBlocked: setAudioBlocked,
113
121
  onFailed: setAudioFailed,
@@ -205,6 +213,25 @@ export const VideosView = ({
205
213
 
206
214
  return (
207
215
  <>
216
+ {/* A video id is a path under videos/, so the folder is the grouping a
217
+ project already chose: social/announcement sits under social. */}
218
+ <Navigator
219
+ label="Videos"
220
+ selected={selected.metadata.id}
221
+ onSelect={onSelect}
222
+ items={[...filtered]
223
+ .sort((left, right) => left.metadata.id.localeCompare(right.metadata.id))
224
+ .map((video) => {
225
+ const parts = video.metadata.id.split("/");
226
+ return {
227
+ id: video.metadata.id,
228
+ title: video.metadata.title,
229
+ detail: String(video.metadata.duration ?? "auto"),
230
+ group: parts.length > 1 ? parts.slice(0, -1).join(" · ") : undefined,
231
+ };
232
+ })}
233
+ />
234
+
208
235
  <section className="stage">
209
236
  <CanvasStage
210
237
  width={layout.format.width}
@@ -218,7 +245,7 @@ export const VideosView = ({
218
245
  <PreviewBoundary resetKey={selected.metadata.id}>
219
246
  <OdoriRuntime
220
247
  entry={selected}
221
- frame={playback.frame}
248
+ frame={preview ?? playback.frame}
222
249
  input={input}
223
250
  assets={project.assets}
224
251
  onTimeline={handleTimeline}
@@ -238,7 +265,7 @@ export const VideosView = ({
238
265
  audioBlocked={audioBlocked}
239
266
  onEnableAudio={() => setMuted(false)}
240
267
  onSoloCue={setSoloCue}
241
- onScrubbing={setScrubbing}
268
+ onPreview={setPreview}
242
269
  rate={rate}
243
270
  onRate={setRate}
244
271
  onToggleLoop={() => setLoop((value) => !value)}
@@ -246,7 +273,7 @@ export const VideosView = ({
246
273
  />
247
274
  </section>
248
275
 
249
- <Inspector title={selected.metadata.title} hint={file}>
276
+ <Inspector title={selected.metadata.title} hint={file} source={file}>
250
277
  {/* Export sits first: it is the pane's one action, and the header
251
278
  names what is being worked on right above it. */}
252
279
  <ExportPanel
@@ -10,6 +10,9 @@ declare module "virtual:odori-project" {
10
10
  export const project: {
11
11
  root: string;
12
12
  videosDir: string;
13
+ componentsDir: string;
14
+ /** Directories that named themselves, from `category.json`. */
15
+ categories: Array<{path: string; name?: string; order?: number}>;
13
16
  docsUrl: string;
14
17
  exportDir: string;
15
18
  audioDir: string;
@@ -18,7 +21,7 @@ declare module "virtual:odori-project" {
18
21
  assets: Array<{reference: string; url: string}>;
19
22
  files: {
20
23
  videos: Array<{id: string; file: string}>;
21
- previews: Array<{id: string; file: string}>;
24
+ previews: Array<{id: string; file: string; usedBy: string[]}>;
22
25
  brands: Array<{id: string; file: string}>;
23
26
  };
24
27
  };