@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.
- package/dist/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +63 -8
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-MSH2EA36.js +4867 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +22 -12
- package/src/commands/add.ts +33 -8
- package/src/commands/dev.ts +114 -12
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +85 -5
- package/src/formats.ts +67 -8
- package/src/index.ts +1 -1
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +1530 -328
- package/src/registry-source.ts +87 -5
- package/src/render.ts +48 -13
- package/src/server.ts +55 -13
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +221 -0
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +435 -20
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +74 -26
- package/studio/src/views/ComponentsView.tsx +206 -55
- package/studio/src/views/HomeView.tsx +44 -19
- package/studio/src/views/VideosView.tsx +53 -48
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
package/studio/src/studio.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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 ===
|
|
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 (
|
|
50
|
-
return
|
|
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
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
<
|
|
98
|
-
|
|
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
|
-
</
|
|
184
|
+
</Inspector>
|
|
137
185
|
</>
|
|
138
186
|
);
|
|
139
187
|
};
|