@odori/cli 0.0.2 → 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.
Files changed (41) hide show
  1. package/dist/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
  2. package/dist/cli.js +1 -1
  3. package/dist/index.d.ts +63 -8
  4. package/dist/index.js +3 -3
  5. package/dist/registry-snapshot-MSH2EA36.js +4867 -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/chunk-cache.ts +6 -0
  10. package/src/cli.ts +22 -12
  11. package/src/commands/add.ts +33 -8
  12. package/src/commands/dev.ts +114 -12
  13. package/src/commands/doctor.ts +47 -2
  14. package/src/commands/exportVideo.ts +39 -5
  15. package/src/commands/{still.ts → frame.ts} +23 -9
  16. package/src/commands/init.ts +1 -1
  17. package/src/commands/new.ts +1 -1
  18. package/src/cues.ts +34 -21
  19. package/src/discovery.ts +85 -5
  20. package/src/formats.ts +67 -8
  21. package/src/index.ts +1 -1
  22. package/src/jobs.ts +6 -2
  23. package/src/registry-snapshot.json +1530 -328
  24. package/src/registry-source.ts +87 -5
  25. package/src/render.ts +48 -13
  26. package/src/server.ts +55 -13
  27. package/studio/src/Studio.tsx +19 -22
  28. package/studio/src/components/ExportPanel.tsx +124 -90
  29. package/studio/src/components/Inspector.tsx +221 -0
  30. package/studio/src/components/Navigator.tsx +145 -0
  31. package/studio/src/components/Thumbnail.tsx +65 -23
  32. package/studio/src/components/ui.tsx +9 -2
  33. package/studio/src/lib/highlight.ts +85 -0
  34. package/studio/src/studio.css +435 -20
  35. package/studio/src/views/AssetsView.tsx +14 -1
  36. package/studio/src/views/BrandsView.tsx +74 -26
  37. package/studio/src/views/ComponentsView.tsx +206 -55
  38. package/studio/src/views/HomeView.tsx +44 -19
  39. package/studio/src/views/VideosView.tsx +53 -48
  40. package/studio/src/virtual.d.ts +4 -1
  41. package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
@@ -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,30 +497,407 @@ select.input {
485
497
 
486
498
  .main {
487
499
  display: grid;
488
- grid-template-columns: 260px minmax(0, 1fr) 320px;
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;
489
507
  overflow: hidden;
490
508
  }
491
509
  .main[data-single="true"] {
492
510
  grid-template-columns: 1fr;
493
511
  }
494
512
 
495
- .sidebar {
513
+ /* ---------- navigator ---------- */
514
+
515
+ .navigator {
496
516
  background: var(--panel);
497
517
  border-right: 1px solid var(--border);
498
518
  display: flex;
499
519
  flex-direction: column;
500
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;
501
534
  }
502
- .sidebar-scroll {
535
+ .navigator-scroll {
503
536
  overflow-y: auto;
504
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;
505
581
  }
506
582
 
507
583
  .inspector {
508
584
  background: var(--panel);
509
585
  border-left: 1px solid var(--border);
586
+ width: clamp(240px, var(--inspector-width, 320px), 50vw);
587
+ container-type: inline-size;
588
+ display: flex;
589
+ overflow: hidden;
590
+ position: relative;
591
+ }
592
+ .inspector-scroll {
593
+ flex: 1;
510
594
  overflow-y: auto;
511
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;
601
+ }
602
+
603
+
604
+ .inspector-head {
605
+ align-items: center;
606
+ display: flex;
607
+ gap: 8px;
608
+ justify-content: space-between;
609
+ margin-bottom: 14px;
610
+ }
611
+ .inspector-title {
612
+ color: var(--foreground);
613
+ font-size: 13px;
614
+ font-weight: 500;
615
+ letter-spacing: -0.01em;
616
+ overflow: hidden;
617
+ text-overflow: ellipsis;
618
+ white-space: nowrap;
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
+
741
+ .inspector-toggle,
742
+ .inspector-reopen {
743
+ align-items: center;
744
+ background: transparent;
745
+ border: 1px solid transparent;
746
+ border-radius: var(--radius-sm);
747
+ color: var(--muted);
748
+ cursor: pointer;
749
+ display: inline-flex;
750
+ flex: none;
751
+ height: 26px;
752
+ justify-content: center;
753
+ width: 26px;
754
+ }
755
+ .inspector-toggle:hover,
756
+ .inspector-reopen:hover {
757
+ background: var(--hover);
758
+ color: var(--foreground);
759
+ }
760
+ /* When the pane is away, the way back floats where the pane was. */
761
+ .inspector-reopen {
762
+ background: var(--panel);
763
+ border-color: var(--border);
764
+ position: fixed;
765
+ right: 10px;
766
+ top: 58px;
767
+ z-index: 5;
768
+ }
769
+
770
+ /* A collapsed inspector hands its column to the stage. */
771
+ /* Panels carry their own width now, so collapsing one needs no grid rule. */
772
+ .inspector-resize {
773
+ cursor: col-resize;
774
+ /* Fully inside the pane: the inspector clips overflow, and a handle hung
775
+ across the border loses the clipped half of its hit area. */
776
+ inset: 0 auto 0 0;
777
+ position: absolute;
778
+ touch-action: none;
779
+ width: 8px;
780
+ z-index: 2;
781
+ }
782
+ /* The hit area is eight pixels; the drawn feedback is a two-pixel line on
783
+ the pane's edge. Filling the whole strip read as a fat bar mid-drag. */
784
+ .inspector-resize::before {
785
+ content: "";
786
+ inset: 0 auto 0 0;
787
+ position: absolute;
788
+ width: 2px;
789
+ }
790
+ .inspector-resize:hover::before,
791
+ .inspector-resize:active::before {
792
+ background: color-mix(in srgb, var(--accent, #7c8cff) 55%, transparent);
793
+ }
794
+
795
+ /* The export block: what to make, how big, how compressed, then one
796
+ full-width action named after the file it writes. */
797
+ .export-panel {
798
+ display: grid;
799
+ gap: 10px;
800
+ }
801
+ .export-row {
802
+ display: grid;
803
+ gap: 5px;
804
+ }
805
+ .export-label {
806
+ color: var(--muted);
807
+ font-size: 12px;
808
+ }
809
+ .export-options {
810
+ align-items: center;
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 6px;
814
+ }
815
+ .export-dimensions {
816
+ color: var(--subtle);
817
+ font-family: var(--font-mono);
818
+ font-size: 11px;
819
+ }
820
+ /* The action names a file, and file names outgrow narrow panes. */
821
+ .export-panel .button {
822
+ min-width: 0;
823
+ }
824
+ .export-panel .button > span {
825
+ overflow: hidden;
826
+ text-overflow: ellipsis;
827
+ white-space: nowrap;
828
+ }
829
+ /* Stacked while narrow, side by side once the pane can afford it. */
830
+ .export-actions {
831
+ display: grid;
832
+ gap: 8px;
833
+ }
834
+ .export-actions .button {
835
+ width: 100%;
836
+ }
837
+ @container (min-width: 340px) {
838
+ .export-actions {
839
+ grid-template-columns: 1fr 1fr;
840
+ }
841
+ }
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
+
870
+ /* A gallery page or an explainer page: one scrolling column with a readable
871
+ lead-in. */
872
+ .page {
873
+ overflow-y: auto;
874
+ padding: 16px 20px;
875
+ }
876
+ .page-hint {
877
+ color: var(--muted);
878
+ font-size: 13px;
879
+ line-height: 1.6;
880
+ margin: 0 0 16px;
881
+ max-width: 64ch;
882
+ }
883
+
884
+ /* A section heading with an action on the same line, for the home page's
885
+ "View all" links. */
886
+ .section-head {
887
+ align-items: baseline;
888
+ display: flex;
889
+ justify-content: space-between;
890
+ }
891
+ .view-all {
892
+ background: none;
893
+ border: 0;
894
+ color: var(--muted);
895
+ cursor: pointer;
896
+ font-size: 12px;
897
+ padding: 0;
898
+ }
899
+ .view-all:hover {
900
+ color: var(--foreground);
512
901
  }
513
902
 
514
903
  .section-title {
@@ -767,6 +1156,9 @@ select.input {
767
1156
  }
768
1157
  .fact dt {
769
1158
  color: var(--subtle);
1159
+ /* A label longer than its column wraps inside it. Without this a long
1160
+ token walks into the value column and reads as two facts overprinted. */
1161
+ overflow-wrap: anywhere;
770
1162
  }
771
1163
  .fact dd {
772
1164
  color: var(--foreground);
@@ -775,6 +1167,16 @@ select.input {
775
1167
  overflow-wrap: anywhere;
776
1168
  }
777
1169
 
1170
+ /* Facts read as a table while there is room and as a list when there is not.
1171
+ Two columns in a narrow pane wrap both halves and read as neither. After
1172
+ the base rule, because a container query adds no specificity of its own. */
1173
+ @container (max-width: 300px) {
1174
+ .fact {
1175
+ gap: 2px;
1176
+ grid-template-columns: 1fr;
1177
+ }
1178
+ }
1179
+
778
1180
  .field {
779
1181
  display: grid;
780
1182
  gap: 5px;
@@ -883,6 +1285,23 @@ select.input {
883
1285
  .card[data-active="true"] {
884
1286
  border-color: var(--border-strong);
885
1287
  }
1288
+ /* A quiet corner chip: this composition has sound. */
1289
+ .card-audio {
1290
+ align-items: center;
1291
+ background: color-mix(in srgb, #000 55%, transparent);
1292
+ border-radius: 999px;
1293
+ color: #fff;
1294
+ display: inline-flex;
1295
+ height: 22px;
1296
+ justify-content: center;
1297
+ opacity: 0.85;
1298
+ position: absolute;
1299
+ right: 8px;
1300
+ top: 8px;
1301
+ width: 22px;
1302
+ z-index: 2;
1303
+ }
1304
+
886
1305
  .card-canvas {
887
1306
  aspect-ratio: 16 / 9;
888
1307
  background: #000;
@@ -936,6 +1355,9 @@ select.input {
936
1355
  }
937
1356
 
938
1357
  .assets-view {
1358
+ /* Rows pack at the top. Stretched rows made a near-empty page read as
1359
+ three stranded lines with a screen of black between them. */
1360
+ align-content: start;
939
1361
  display: grid;
940
1362
  gap: 26px;
941
1363
  overflow-y: auto;
@@ -1078,6 +1500,16 @@ select.input {
1078
1500
  color: var(--foreground);
1079
1501
  }
1080
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
+
1081
1513
  /* ---------- home ---------- */
1082
1514
 
1083
1515
  .home {
@@ -1122,23 +1554,6 @@ select.input {
1122
1554
  .brand-card .card-meta {
1123
1555
  padding: 0 12px;
1124
1556
  }
1125
- .home-more {
1126
- background: transparent;
1127
- border: 1px solid var(--border);
1128
- border-radius: var(--radius);
1129
- color: var(--muted);
1130
- cursor: pointer;
1131
- font-family: var(--font-mono);
1132
- font-size: 11px;
1133
- margin-top: 10px;
1134
- padding: 9px 12px;
1135
- text-align: left;
1136
- width: 100%;
1137
- }
1138
- .home-more:hover {
1139
- border-color: var(--border-strong);
1140
- color: var(--foreground);
1141
- }
1142
1557
 
1143
1558
  .statusbar {
1144
1559
  align-items: center;
@@ -1,7 +1,7 @@
1
1
  import {resolveEntryLayout} from "odori";
2
2
  import {project, videos} from "virtual:odori-project";
3
3
  import {AudioClip} from "../components/AudioClip";
4
- import {SectionTitle} from "../components/ui";
4
+ import {Empty, SectionTitle} from "../components/ui";
5
5
 
6
6
  const isImage = (url: string) => /\.(png|jpe?g|gif|svg|webp|avif)$/i.test(url);
7
7
  const isAudio = (url: string) => /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i.test(url);
@@ -24,6 +24,19 @@ export const AssetsView = () => {
24
24
 
25
25
  const fonts = [...new Map(videos.flatMap((video) => resolveEntryLayout(video).brand.fonts).map((font) => [font.url, font])).values()];
26
26
 
27
+ // Three sections of nothing is a page that looks broken. One empty state
28
+ // says what belongs here and where each kind of file goes.
29
+ if (visualAssets.length === 0 && library.length === 0 && fonts.length === 0) {
30
+ return (
31
+ <Empty title="No assets yet">
32
+ Assets are the files videos reference beyond code: images and footage declared in{" "}
33
+ <code>odori.config.ts</code>, sounds dropped in <code>{project.audioDir}/</code>, and fonts a brand declares
34
+ with <code>defineBrand({"{fonts: [...]}"})</code>. Everything lives under <code>public/</code>, the one path
35
+ that means the same thing in preview and render. Add a file and it appears here.
36
+ </Empty>
37
+ );
38
+ }
39
+
27
40
  return (
28
41
  <div className="assets-view">
29
42
  <section>
@@ -12,6 +12,8 @@ import {brands as discoveredBrands, project, videos} from "virtual:odori-project
12
12
  import {CanvasStage} from "../components/CanvasStage";
13
13
  import {Transport} from "../components/Transport";
14
14
  import {Empty, Fact, SectionTitle, Separator} from "../components/ui";
15
+ import {Inspector} from "../components/Inspector";
16
+ import {Navigator} from "../components/Navigator";
15
17
  import {useShortcuts} from "../shortcuts";
16
18
 
17
19
  /** Brands come from videos/**\/brands modules and from every resolved layout. */
@@ -25,12 +27,23 @@ export const collectBrands = (): Brand[] => {
25
27
  return [...seen.values()];
26
28
  };
27
29
 
28
- export const BrandsView = () => {
30
+ /**
31
+ * A brand is the project's design system as one object — colors, type, motion
32
+ * policy, and named sounds — declared once and inherited by every video. This
33
+ * view exists to answer "what does this brand make everything look like":
34
+ * pick a brand, and watch a real video re-render under it.
35
+ */
36
+ export const BrandsView = ({
37
+ selection,
38
+ onSelect,
39
+ }: {
40
+ selection: string | null;
41
+ onSelect: (name: string) => void;
42
+ }) => {
29
43
  const brands = collectBrands();
30
- const [brandName, setBrandName] = useState(brands[0]?.name ?? "");
31
44
  const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
32
45
  const [videoId, setVideoId] = useState(videos[0]?.metadata.id ?? "");
33
- const brand = brands.find((item) => item.name === brandName) ?? brands[0];
46
+ const brand = selection ? (brands.find((item) => item.name === selection) ?? null) : null;
34
47
  const entry = videos.find((video) => video.metadata.id === videoId) ?? videos[0];
35
48
  const base = entry ? resolveEntryLayout(entry) : null;
36
49
  const durationInFrames = entry && base ? Math.max(1, entryDurationInFrames(entry, base)) : 1;
@@ -46,32 +59,63 @@ export const BrandsView = () => {
46
59
  End: () => playback.seek(durationInFrames - 1),
47
60
  });
48
61
 
49
- if (!brand || !entry || !base) {
50
- return <Empty title="No brands resolved">Attach a brand through defineVideoLayout().</Empty>;
62
+ if (brands.length === 0 || !entry || !base) {
63
+ return (
64
+ <Empty title="No brands resolved">
65
+ A brand is the design system every video inherits: colors, type, motion, and named sounds, declared with{" "}
66
+ <code>defineBrand()</code> and attached through <code>defineVideoLayout()</code>. The scaffold puts one in{" "}
67
+ <code>videos/layout.tsx</code>.
68
+ </Empty>
69
+ );
70
+ }
71
+
72
+ if (!brand) {
73
+ return (
74
+ <div className="page">
75
+ <p className="page-hint">
76
+ A brand is the project's design system as one object: colors, type, motion policy, and named sounds,
77
+ declared once in source and inherited by every video. Open one to preview a real video re-rendered under it.
78
+ </p>
79
+ <div className="home-swatches">
80
+ {brands.map((item) => (
81
+ <button key={item.name} type="button" className="brand-card" onClick={() => onSelect(item.name)}>
82
+ <div className="swatch-colors">
83
+ {/* Keyed by position: two tokens can resolve to one color,
84
+ and a color used twice is not one child twice. */}
85
+ {[item.colors.background, item.colors.surface, item.colors.foreground, item.colors.accent, item.colors.border].map(
86
+ (color, position) => (
87
+ <span key={position} style={{background: color}} />
88
+ ),
89
+ )}
90
+ </div>
91
+ <div className="card-meta">
92
+ <strong>{item.name}</strong>
93
+ <span>
94
+ {item.typography.sans.split(",")[0].replace(/"/g, "")} · {item.audio.targetLufs} LUFS
95
+ </span>
96
+ </div>
97
+ </button>
98
+ ))}
99
+ </div>
100
+ </div>
101
+ );
51
102
  }
52
103
 
53
104
  const layout = defineVideoLayout({extends: base, brand});
105
+ const brandFile = project.files.brands.find((item) => item.id === brand.name)?.file;
54
106
 
55
107
  return (
56
108
  <>
57
- <aside className="sidebar">
58
- <div className="sidebar-scroll">
59
- <ul className="list">
60
- {brands.map((item) => (
61
- <li key={item.name}>
62
- <button
63
- type="button"
64
- className="list-item"
65
- data-active={item.name === brand.name ? "true" : undefined}
66
- onClick={() => setBrandName(item.name)}
67
- >
68
- <strong>{item.name}</strong>
69
- </button>
70
- </li>
71
- ))}
72
- </ul>
73
- </div>
74
- </aside>
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
+ />
75
119
 
76
120
  <section className="stage">
77
121
  <CanvasStage width={layout.format.width} height={layout.format.height}>
@@ -94,8 +138,12 @@ export const BrandsView = () => {
94
138
  />
95
139
  </section>
96
140
 
97
- <aside className="inspector">
98
- <SectionTitle>Preview with</SectionTitle>
141
+ <Inspector
142
+ title={brand.name}
143
+ hint={brandFile}
144
+ source={brandFile}
145
+ >
146
+ <SectionTitle>Preview with</SectionTitle>
99
147
  <ul className="list">
100
148
  {videos.map((video) => (
101
149
  <li key={video.metadata.id}>
@@ -133,7 +181,7 @@ export const BrandsView = () => {
133
181
  </Fact>
134
182
  <Fact label="fonts">{brand.fonts.length ? brand.fonts.map((font) => font.family).join(", ") : "system"}</Fact>
135
183
  </dl>
136
- </aside>
184
+ </Inspector>
137
185
  </>
138
186
  );
139
187
  };