@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.
- package/dist/{chunk-RXLB2CXH.js → chunk-NYXWEZU2.js} +399 -226
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +34 -5
- 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/cli.ts +12 -9
- package/src/commands/add.ts +25 -0
- package/src/commands/dev.ts +28 -1
- package/src/commands/doctor.ts +47 -2
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/discovery.ts +63 -2
- package/src/index.ts +1 -1
- package/src/registry-snapshot.json +1529 -327
- package/src/registry-source.ts +37 -2
- package/src/server.ts +7 -1
- package/studio/src/components/Inspector.tsx +101 -1
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +254 -7
- package/studio/src/views/BrandsView.tsx +18 -1
- package/studio/src/views/ComponentsView.tsx +191 -26
- package/studio/src/views/HomeView.tsx +7 -4
- package/studio/src/views/VideosView.tsx +21 -1
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-BDP6PVYB.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,19 +497,93 @@ select.input {
|
|
|
485
497
|
|
|
486
498
|
.main {
|
|
487
499
|
display: grid;
|
|
488
|
-
/* A detail is
|
|
489
|
-
column. The
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
{
|
|
129
|
-
<
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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">{
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
);
|