@odori/cli 0.0.3 → 0.0.4

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.
@@ -153,6 +153,18 @@ body.render-mode {
153
153
  background-clip: content-box;
154
154
  }
155
155
 
156
+ /* Anything you click says so, once, rather than per control. */
157
+ button:not(:disabled),
158
+ [role="button"]:not([aria-disabled="true"]),
159
+ summary {
160
+ cursor: pointer;
161
+ }
162
+
163
+ button:disabled,
164
+ [role="button"][aria-disabled="true"] {
165
+ cursor: not-allowed;
166
+ }
167
+
156
168
  /* ---------- primitives ---------- */
157
169
 
158
170
  .button {
@@ -485,19 +497,93 @@ select.input {
485
497
 
486
498
  .main {
487
499
  display: grid;
488
- /* A detail is the stage and the inspector; galleries and pages are one
489
- column. The inspector's column is a root variable so its drag handle can
490
- resize it without React re-laying anything out. */
491
- grid-template-columns: minmax(0, 1fr) clamp(240px, var(--inspector-width, 320px), 50vw);
500
+ /* A detail is a list, a stage, and an inspector; galleries and pages are one
501
+ column. The side columns size to their contents, so a panel that puts
502
+ itself away takes its column with it and the stage grows into the gap
503
+ without a rule per combination of what is open. The inspector's width is
504
+ a root variable so its drag handle can resize it without React re-laying
505
+ anything out. */
506
+ grid-template-columns: auto minmax(0, 1fr) auto;
492
507
  overflow: hidden;
493
508
  }
494
509
  .main[data-single="true"] {
495
510
  grid-template-columns: 1fr;
496
511
  }
497
512
 
513
+ /* ---------- navigator ---------- */
514
+
515
+ .navigator {
516
+ background: var(--panel);
517
+ border-right: 1px solid var(--border);
518
+ display: flex;
519
+ flex-direction: column;
520
+ overflow: hidden;
521
+ width: 232px;
522
+ }
523
+ .navigator-head {
524
+ align-items: center;
525
+ border-bottom: 1px solid var(--border);
526
+ display: flex;
527
+ gap: 6px;
528
+ padding: 10px;
529
+ }
530
+ .navigator-search {
531
+ height: 28px;
532
+ font-size: 12px;
533
+ min-width: 0;
534
+ }
535
+ .navigator-scroll {
536
+ overflow-y: auto;
537
+ padding: 10px;
538
+ scrollbar-gutter: stable;
539
+ }
540
+ .navigator-group {
541
+ color: var(--subtle);
542
+ font-size: 11px;
543
+ font-weight: 500;
544
+ letter-spacing: 0.04em;
545
+ margin: 10px 0 4px 2px;
546
+ text-transform: uppercase;
547
+ }
548
+ .navigator-scroll > div:first-child .navigator-group {
549
+ margin-top: 0;
550
+ }
551
+ .navigator-empty {
552
+ padding: 4px 2px;
553
+ }
554
+ .navigator-toggle,
555
+ .navigator-reopen {
556
+ align-items: center;
557
+ background: transparent;
558
+ border: 1px solid transparent;
559
+ border-radius: var(--radius-sm);
560
+ color: var(--muted);
561
+ cursor: pointer;
562
+ display: inline-flex;
563
+ flex: none;
564
+ height: 28px;
565
+ justify-content: center;
566
+ width: 28px;
567
+ }
568
+ .navigator-toggle:hover,
569
+ .navigator-reopen:hover {
570
+ background: var(--hover);
571
+ color: var(--foreground);
572
+ }
573
+ /* Away, the way back floats where the list was. */
574
+ .navigator-reopen {
575
+ background: var(--panel);
576
+ border-color: var(--border);
577
+ left: 10px;
578
+ position: fixed;
579
+ top: 58px;
580
+ z-index: 5;
581
+ }
582
+
498
583
  .inspector {
499
584
  background: var(--panel);
500
585
  border-left: 1px solid var(--border);
586
+ width: clamp(240px, var(--inspector-width, 320px), 50vw);
501
587
  container-type: inline-size;
502
588
  display: flex;
503
589
  overflow: hidden;
@@ -507,6 +593,11 @@ select.input {
507
593
  flex: 1;
508
594
  overflow-y: auto;
509
595
  padding: 16px;
596
+ /* The gutter is reserved whether or not it is used. Code is taller than the
597
+ facts it replaces, so switching to it summoned a scrollbar, and the ten
598
+ pixels that took came out of the content: the header's controls jumped
599
+ sideways every time you changed face. */
600
+ scrollbar-gutter: stable;
510
601
  }
511
602
 
512
603
 
@@ -526,6 +617,127 @@ select.input {
526
617
  text-overflow: ellipsis;
527
618
  white-space: nowrap;
528
619
  }
620
+ /* Two views of one selection, not two modes: the segmented control sits in
621
+ the pane's own header rather than above the whole app. */
622
+ .inspector-views {
623
+ background: var(--hover);
624
+ border-radius: var(--radius-sm);
625
+ display: flex;
626
+ flex: none;
627
+ gap: 2px;
628
+ margin-left: auto;
629
+ padding: 2px;
630
+ }
631
+ .inspector-views button {
632
+ background: transparent;
633
+ border: 0;
634
+ border-radius: calc(var(--radius-sm) - 1px);
635
+ color: var(--muted);
636
+ cursor: pointer;
637
+ font: inherit;
638
+ font-size: 11px;
639
+ padding: 3px 9px;
640
+ }
641
+ .inspector-views button:hover {
642
+ color: var(--foreground);
643
+ }
644
+ /* A lift off the track it sits in, and nothing else. The weight and the
645
+ border this used to add both changed the button's size, so selecting one
646
+ nudged the other sideways: emphasis has to come from colour alone, or the
647
+ control moves while you read it. */
648
+ .inspector-views button[data-active="true"] {
649
+ background: color-mix(in srgb, var(--foreground) 10%, var(--hover));
650
+ color: var(--foreground);
651
+ }
652
+
653
+ /* Which of a component's files is being read. */
654
+ .source-files {
655
+ display: flex;
656
+ flex-wrap: wrap;
657
+ gap: 6px;
658
+ margin-bottom: 10px;
659
+ }
660
+ .source-files button {
661
+ background: transparent;
662
+ border: 1px solid var(--border);
663
+ border-radius: 999px;
664
+ color: var(--muted);
665
+ font-family: var(--font-mono);
666
+ font-size: 11px;
667
+ padding: 3px 10px;
668
+ }
669
+ .source-files button:hover {
670
+ color: var(--foreground);
671
+ }
672
+ .source-files button[data-active="true"] {
673
+ background: var(--hover);
674
+ border-color: var(--border-strong);
675
+ color: var(--foreground);
676
+ }
677
+
678
+ /*
679
+ * Token colours, taken from the same GitHub themes the docs site highlights
680
+ * with, so a component's source reads the same in both places.
681
+ */
682
+ :root {
683
+ --token-comment: #6e7781;
684
+ --token-string: #0a3069;
685
+ --token-keyword: #cf222e;
686
+ --token-number: #0550ae;
687
+ --token-tag: #116329;
688
+ --token-attr: #953800;
689
+ --token-fn: #8250df;
690
+ }
691
+ :root[data-theme="dark"] {
692
+ --token-comment: #8b949e;
693
+ --token-string: #a5d6ff;
694
+ --token-keyword: #ff7b72;
695
+ --token-number: #79c0ff;
696
+ --token-tag: #7ee787;
697
+ --token-attr: #ffa657;
698
+ --token-fn: #d2a8ff;
699
+ }
700
+ @media (prefers-color-scheme: dark) {
701
+ :root:not([data-theme="light"]) {
702
+ --token-comment: #8b949e;
703
+ --token-string: #a5d6ff;
704
+ --token-keyword: #ff7b72;
705
+ --token-number: #79c0ff;
706
+ --token-tag: #7ee787;
707
+ --token-attr: #ffa657;
708
+ --token-fn: #d2a8ff;
709
+ }
710
+ }
711
+
712
+ .source [data-token="comment"] { color: var(--token-comment); font-style: italic; }
713
+ .source [data-token="string"] { color: var(--token-string); }
714
+ .source [data-token="keyword"] { color: var(--token-keyword); }
715
+ .source [data-token="number"] { color: var(--token-number); }
716
+ .source [data-token="tag"] { color: var(--token-tag); }
717
+ .source [data-token="attr"] { color: var(--token-attr); }
718
+ .source [data-token="fn"] { color: var(--token-fn); }
719
+ .source [data-token="punct"] { color: var(--muted); }
720
+
721
+ /*
722
+ * Code scrolls the full width of the pane. The inspector pads its content by
723
+ * sixteen, which is right for prose and wrong for a scrolling region: it left
724
+ * a gutter the code could not reach, so a long line appeared to stop short of
725
+ * an edge it had not reached. The negative margin gives the scroller the whole
726
+ * pane and the padding puts the text back where it was.
727
+ */
728
+ .source {
729
+ color: var(--foreground);
730
+ font-family: var(--font-mono);
731
+ font-size: 11px;
732
+ line-height: 1.65;
733
+ margin: 0 -16px;
734
+ overflow-x: auto;
735
+ overscroll-behavior-x: contain;
736
+ padding: 0 16px;
737
+ tab-size: 2;
738
+ white-space: pre;
739
+ }
740
+
529
741
  .inspector-toggle,
530
742
  .inspector-reopen {
531
743
  align-items: center;
@@ -556,9 +768,7 @@ select.input {
556
768
  }
557
769
 
558
770
  /* A collapsed inspector hands its column to the stage. */
559
- html[data-inspector="collapsed"] .main:not([data-single="true"]) {
560
- grid-template-columns: minmax(0, 1fr);
561
- }
771
+ /* Panels carry their own width now, so collapsing one needs no grid rule. */
562
772
  .inspector-resize {
563
773
  cursor: col-resize;
564
774
  /* Fully inside the pane: the inspector clips overflow, and a handle hung
@@ -630,6 +840,33 @@ html[data-inspector="collapsed"] .main:not([data-single="true"]) {
630
840
  }
631
841
  }
632
842
 
843
+ /* Filter the catalog by the video a component is actually used in. */
844
+ .catalog-filter {
845
+ display: flex;
846
+ flex-wrap: wrap;
847
+ gap: 6px;
848
+ padding: 16px 20px 0;
849
+ }
850
+ .catalog-filter button {
851
+ background: transparent;
852
+ border: 1px solid var(--border);
853
+ border-radius: 999px;
854
+ color: var(--muted);
855
+ cursor: pointer;
856
+ font: inherit;
857
+ font-size: 12px;
858
+ padding: 4px 12px;
859
+ }
860
+ .catalog-filter button:hover {
861
+ border-color: var(--border-strong);
862
+ color: var(--foreground);
863
+ }
864
+ .catalog-filter button[data-active="true"] {
865
+ background: var(--hover);
866
+ border-color: var(--border-strong);
867
+ color: var(--foreground);
868
+ }
869
+
633
870
  /* A gallery page or an explainer page: one scrolling column with a readable
634
871
  lead-in. */
635
872
  .page {
@@ -1263,6 +1500,16 @@ html[data-inspector="collapsed"] .main:not([data-single="true"]) {
1263
1500
  color: var(--foreground);
1264
1501
  }
1265
1502
 
1503
+ /* A drawer inside a family. Quieter than the family it sits under, and
1504
+ indented enough that the nesting reads without a rule to draw it. */
1505
+ .group-title {
1506
+ color: var(--muted);
1507
+ font-size: 12px;
1508
+ font-weight: 500;
1509
+ letter-spacing: -0.01em;
1510
+ margin: 2px 0 8px;
1511
+ }
1512
+
1266
1513
  /* ---------- home ---------- */
1267
1514
 
1268
1515
  .home {
@@ -13,6 +13,7 @@ import {CanvasStage} from "../components/CanvasStage";
13
13
  import {Transport} from "../components/Transport";
14
14
  import {Empty, Fact, SectionTitle, Separator} from "../components/ui";
15
15
  import {Inspector} from "../components/Inspector";
16
+ import {Navigator} from "../components/Navigator";
16
17
  import {useShortcuts} from "../shortcuts";
17
18
 
18
19
  /** Brands come from videos/**\/brands modules and from every resolved layout. */
@@ -101,9 +102,21 @@ export const BrandsView = ({
101
102
  }
102
103
 
103
104
  const layout = defineVideoLayout({extends: base, brand});
105
+ const brandFile = project.files.brands.find((item) => item.id === brand.name)?.file;
104
106
 
105
107
  return (
106
108
  <>
109
+ <Navigator
110
+ label="Brands"
111
+ selected={brand.name}
112
+ onSelect={onSelect}
113
+ items={brands.map((item) => ({
114
+ id: item.name,
115
+ title: item.name,
116
+ detail: `${Object.keys(item.audio.cues).length} cues · ${item.audio.targetLufs} LUFS`,
117
+ }))}
118
+ />
119
+
107
120
  <section className="stage">
108
121
  <CanvasStage width={layout.format.width} height={layout.format.height}>
109
122
  <OdoriRuntime
@@ -125,7 +138,11 @@ export const BrandsView = ({
125
138
  />
126
139
  </section>
127
140
 
128
- <Inspector title={brand.name} hint={project.files.brands.find((item) => item.id === brand.name)?.file}>
141
+ <Inspector
142
+ title={brand.name}
143
+ hint={brandFile}
144
+ source={brandFile}
145
+ >
129
146
  <SectionTitle>Preview with</SectionTitle>
130
147
  <ul className="list">
131
148
  {videos.map((video) => (
@@ -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
  );