@odori/cli 0.0.2 → 0.0.3

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.
@@ -485,30 +485,182 @@ select.input {
485
485
 
486
486
  .main {
487
487
  display: grid;
488
- grid-template-columns: 260px minmax(0, 1fr) 320px;
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);
489
492
  overflow: hidden;
490
493
  }
491
494
  .main[data-single="true"] {
492
495
  grid-template-columns: 1fr;
493
496
  }
494
497
 
495
- .sidebar {
498
+ .inspector {
496
499
  background: var(--panel);
497
- border-right: 1px solid var(--border);
500
+ border-left: 1px solid var(--border);
501
+ container-type: inline-size;
498
502
  display: flex;
499
- flex-direction: column;
500
503
  overflow: hidden;
504
+ position: relative;
501
505
  }
502
- .sidebar-scroll {
506
+ .inspector-scroll {
507
+ flex: 1;
503
508
  overflow-y: auto;
504
- padding: 10px;
509
+ padding: 16px;
505
510
  }
506
511
 
507
- .inspector {
512
+
513
+ .inspector-head {
514
+ align-items: center;
515
+ display: flex;
516
+ gap: 8px;
517
+ justify-content: space-between;
518
+ margin-bottom: 14px;
519
+ }
520
+ .inspector-title {
521
+ color: var(--foreground);
522
+ font-size: 13px;
523
+ font-weight: 500;
524
+ letter-spacing: -0.01em;
525
+ overflow: hidden;
526
+ text-overflow: ellipsis;
527
+ white-space: nowrap;
528
+ }
529
+ .inspector-toggle,
530
+ .inspector-reopen {
531
+ align-items: center;
532
+ background: transparent;
533
+ border: 1px solid transparent;
534
+ border-radius: var(--radius-sm);
535
+ color: var(--muted);
536
+ cursor: pointer;
537
+ display: inline-flex;
538
+ flex: none;
539
+ height: 26px;
540
+ justify-content: center;
541
+ width: 26px;
542
+ }
543
+ .inspector-toggle:hover,
544
+ .inspector-reopen:hover {
545
+ background: var(--hover);
546
+ color: var(--foreground);
547
+ }
548
+ /* When the pane is away, the way back floats where the pane was. */
549
+ .inspector-reopen {
508
550
  background: var(--panel);
509
- border-left: 1px solid var(--border);
551
+ border-color: var(--border);
552
+ position: fixed;
553
+ right: 10px;
554
+ top: 58px;
555
+ z-index: 5;
556
+ }
557
+
558
+ /* 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
+ }
562
+ .inspector-resize {
563
+ cursor: col-resize;
564
+ /* Fully inside the pane: the inspector clips overflow, and a handle hung
565
+ across the border loses the clipped half of its hit area. */
566
+ inset: 0 auto 0 0;
567
+ position: absolute;
568
+ touch-action: none;
569
+ width: 8px;
570
+ z-index: 2;
571
+ }
572
+ /* The hit area is eight pixels; the drawn feedback is a two-pixel line on
573
+ the pane's edge. Filling the whole strip read as a fat bar mid-drag. */
574
+ .inspector-resize::before {
575
+ content: "";
576
+ inset: 0 auto 0 0;
577
+ position: absolute;
578
+ width: 2px;
579
+ }
580
+ .inspector-resize:hover::before,
581
+ .inspector-resize:active::before {
582
+ background: color-mix(in srgb, var(--accent, #7c8cff) 55%, transparent);
583
+ }
584
+
585
+ /* The export block: what to make, how big, how compressed, then one
586
+ full-width action named after the file it writes. */
587
+ .export-panel {
588
+ display: grid;
589
+ gap: 10px;
590
+ }
591
+ .export-row {
592
+ display: grid;
593
+ gap: 5px;
594
+ }
595
+ .export-label {
596
+ color: var(--muted);
597
+ font-size: 12px;
598
+ }
599
+ .export-options {
600
+ align-items: center;
601
+ display: flex;
602
+ flex-wrap: wrap;
603
+ gap: 6px;
604
+ }
605
+ .export-dimensions {
606
+ color: var(--subtle);
607
+ font-family: var(--font-mono);
608
+ font-size: 11px;
609
+ }
610
+ /* The action names a file, and file names outgrow narrow panes. */
611
+ .export-panel .button {
612
+ min-width: 0;
613
+ }
614
+ .export-panel .button > span {
615
+ overflow: hidden;
616
+ text-overflow: ellipsis;
617
+ white-space: nowrap;
618
+ }
619
+ /* Stacked while narrow, side by side once the pane can afford it. */
620
+ .export-actions {
621
+ display: grid;
622
+ gap: 8px;
623
+ }
624
+ .export-actions .button {
625
+ width: 100%;
626
+ }
627
+ @container (min-width: 340px) {
628
+ .export-actions {
629
+ grid-template-columns: 1fr 1fr;
630
+ }
631
+ }
632
+
633
+ /* A gallery page or an explainer page: one scrolling column with a readable
634
+ lead-in. */
635
+ .page {
510
636
  overflow-y: auto;
511
- padding: 16px;
637
+ padding: 16px 20px;
638
+ }
639
+ .page-hint {
640
+ color: var(--muted);
641
+ font-size: 13px;
642
+ line-height: 1.6;
643
+ margin: 0 0 16px;
644
+ max-width: 64ch;
645
+ }
646
+
647
+ /* A section heading with an action on the same line, for the home page's
648
+ "View all" links. */
649
+ .section-head {
650
+ align-items: baseline;
651
+ display: flex;
652
+ justify-content: space-between;
653
+ }
654
+ .view-all {
655
+ background: none;
656
+ border: 0;
657
+ color: var(--muted);
658
+ cursor: pointer;
659
+ font-size: 12px;
660
+ padding: 0;
661
+ }
662
+ .view-all:hover {
663
+ color: var(--foreground);
512
664
  }
513
665
 
514
666
  .section-title {
@@ -767,6 +919,9 @@ select.input {
767
919
  }
768
920
  .fact dt {
769
921
  color: var(--subtle);
922
+ /* A label longer than its column wraps inside it. Without this a long
923
+ token walks into the value column and reads as two facts overprinted. */
924
+ overflow-wrap: anywhere;
770
925
  }
771
926
  .fact dd {
772
927
  color: var(--foreground);
@@ -775,6 +930,16 @@ select.input {
775
930
  overflow-wrap: anywhere;
776
931
  }
777
932
 
933
+ /* Facts read as a table while there is room and as a list when there is not.
934
+ Two columns in a narrow pane wrap both halves and read as neither. After
935
+ the base rule, because a container query adds no specificity of its own. */
936
+ @container (max-width: 300px) {
937
+ .fact {
938
+ gap: 2px;
939
+ grid-template-columns: 1fr;
940
+ }
941
+ }
942
+
778
943
  .field {
779
944
  display: grid;
780
945
  gap: 5px;
@@ -883,6 +1048,23 @@ select.input {
883
1048
  .card[data-active="true"] {
884
1049
  border-color: var(--border-strong);
885
1050
  }
1051
+ /* A quiet corner chip: this composition has sound. */
1052
+ .card-audio {
1053
+ align-items: center;
1054
+ background: color-mix(in srgb, #000 55%, transparent);
1055
+ border-radius: 999px;
1056
+ color: #fff;
1057
+ display: inline-flex;
1058
+ height: 22px;
1059
+ justify-content: center;
1060
+ opacity: 0.85;
1061
+ position: absolute;
1062
+ right: 8px;
1063
+ top: 8px;
1064
+ width: 22px;
1065
+ z-index: 2;
1066
+ }
1067
+
886
1068
  .card-canvas {
887
1069
  aspect-ratio: 16 / 9;
888
1070
  background: #000;
@@ -936,6 +1118,9 @@ select.input {
936
1118
  }
937
1119
 
938
1120
  .assets-view {
1121
+ /* Rows pack at the top. Stretched rows made a near-empty page read as
1122
+ three stranded lines with a screen of black between them. */
1123
+ align-content: start;
939
1124
  display: grid;
940
1125
  gap: 26px;
941
1126
  overflow-y: auto;
@@ -1122,23 +1307,6 @@ select.input {
1122
1307
  .brand-card .card-meta {
1123
1308
  padding: 0 12px;
1124
1309
  }
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
1310
 
1143
1311
  .statusbar {
1144
1312
  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,7 @@ 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";
15
16
  import {useShortcuts} from "../shortcuts";
16
17
 
17
18
  /** Brands come from videos/**\/brands modules and from every resolved layout. */
@@ -25,12 +26,23 @@ export const collectBrands = (): Brand[] => {
25
26
  return [...seen.values()];
26
27
  };
27
28
 
28
- export const BrandsView = () => {
29
+ /**
30
+ * A brand is the project's design system as one object — colors, type, motion
31
+ * policy, and named sounds — declared once and inherited by every video. This
32
+ * view exists to answer "what does this brand make everything look like":
33
+ * pick a brand, and watch a real video re-render under it.
34
+ */
35
+ export const BrandsView = ({
36
+ selection,
37
+ onSelect,
38
+ }: {
39
+ selection: string | null;
40
+ onSelect: (name: string) => void;
41
+ }) => {
29
42
  const brands = collectBrands();
30
- const [brandName, setBrandName] = useState(brands[0]?.name ?? "");
31
43
  const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
32
44
  const [videoId, setVideoId] = useState(videos[0]?.metadata.id ?? "");
33
- const brand = brands.find((item) => item.name === brandName) ?? brands[0];
45
+ const brand = selection ? (brands.find((item) => item.name === selection) ?? null) : null;
34
46
  const entry = videos.find((video) => video.metadata.id === videoId) ?? videos[0];
35
47
  const base = entry ? resolveEntryLayout(entry) : null;
36
48
  const durationInFrames = entry && base ? Math.max(1, entryDurationInFrames(entry, base)) : 1;
@@ -46,33 +58,52 @@ export const BrandsView = () => {
46
58
  End: () => playback.seek(durationInFrames - 1),
47
59
  });
48
60
 
49
- if (!brand || !entry || !base) {
50
- return <Empty title="No brands resolved">Attach a brand through defineVideoLayout().</Empty>;
61
+ if (brands.length === 0 || !entry || !base) {
62
+ return (
63
+ <Empty title="No brands resolved">
64
+ A brand is the design system every video inherits: colors, type, motion, and named sounds, declared with{" "}
65
+ <code>defineBrand()</code> and attached through <code>defineVideoLayout()</code>. The scaffold puts one in{" "}
66
+ <code>videos/layout.tsx</code>.
67
+ </Empty>
68
+ );
69
+ }
70
+
71
+ if (!brand) {
72
+ return (
73
+ <div className="page">
74
+ <p className="page-hint">
75
+ A brand is the project's design system as one object: colors, type, motion policy, and named sounds,
76
+ declared once in source and inherited by every video. Open one to preview a real video re-rendered under it.
77
+ </p>
78
+ <div className="home-swatches">
79
+ {brands.map((item) => (
80
+ <button key={item.name} type="button" className="brand-card" onClick={() => onSelect(item.name)}>
81
+ <div className="swatch-colors">
82
+ {/* Keyed by position: two tokens can resolve to one color,
83
+ and a color used twice is not one child twice. */}
84
+ {[item.colors.background, item.colors.surface, item.colors.foreground, item.colors.accent, item.colors.border].map(
85
+ (color, position) => (
86
+ <span key={position} style={{background: color}} />
87
+ ),
88
+ )}
89
+ </div>
90
+ <div className="card-meta">
91
+ <strong>{item.name}</strong>
92
+ <span>
93
+ {item.typography.sans.split(",")[0].replace(/"/g, "")} · {item.audio.targetLufs} LUFS
94
+ </span>
95
+ </div>
96
+ </button>
97
+ ))}
98
+ </div>
99
+ </div>
100
+ );
51
101
  }
52
102
 
53
103
  const layout = defineVideoLayout({extends: base, brand});
54
104
 
55
105
  return (
56
106
  <>
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>
75
-
76
107
  <section className="stage">
77
108
  <CanvasStage width={layout.format.width} height={layout.format.height}>
78
109
  <OdoriRuntime
@@ -94,8 +125,8 @@ export const BrandsView = () => {
94
125
  />
95
126
  </section>
96
127
 
97
- <aside className="inspector">
98
- <SectionTitle>Preview with</SectionTitle>
128
+ <Inspector title={brand.name} hint={project.files.brands.find((item) => item.id === brand.name)?.file}>
129
+ <SectionTitle>Preview with</SectionTitle>
99
130
  <ul className="list">
100
131
  {videos.map((video) => (
101
132
  <li key={video.metadata.id}>
@@ -133,7 +164,7 @@ export const BrandsView = () => {
133
164
  </Fact>
134
165
  <Fact label="fonts">{brand.fonts.length ? brand.fonts.map((font) => font.family).join(", ") : "system"}</Fact>
135
166
  </dl>
136
- </aside>
167
+ </Inspector>
137
168
  </>
138
169
  );
139
170
  };
@@ -17,6 +17,7 @@ import {Transport} from "../components/Transport";
17
17
  import {Thumbnail} from "../components/Thumbnail";
18
18
  import {InputControls} from "../components/InputControls";
19
19
  import {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
20
+ import {Inspector} from "../components/Inspector";
20
21
  import {useShortcuts} from "../shortcuts";
21
22
 
22
23
  type PreviewEntry = (typeof componentPreviews)[number];
@@ -69,15 +70,14 @@ export const entryFor = (
69
70
  export const ComponentsView = ({
70
71
  selection,
71
72
  onSelect,
72
- mode,
73
73
  }: {
74
74
  selection: string | null;
75
75
  onSelect: (id: string) => void;
76
- mode: "player" | "gallery";
77
76
  }) => {
78
- // Finding one by name is ⌘K; this list is for browsing them all.
77
+ // Finding one by name is ⌘K; the gallery is for browsing them all. A route
78
+ // with no selection is the gallery; a selection is that component's player.
79
79
  const filtered = useMemo(() => [...componentPreviews].sort(byFamily), []);
80
- const selected = filtered.find((entry) => entry.id === selection) ?? filtered[0];
80
+ const selected = selection ? (filtered.find((entry) => entry.id === selection) ?? null) : null;
81
81
  const brands = projectBrands();
82
82
  const [brandName, setBrandName] = useState(brands[0]?.name ?? defaultBrand.name);
83
83
  const [formatLabel, setFormatLabel] = useState(FORMATS[0].label);
@@ -110,23 +110,15 @@ export const ComponentsView = ({
110
110
  l: () => setLoop((value) => !value),
111
111
  });
112
112
 
113
- if (!selected) {
113
+ if (filtered.length === 0) {
114
114
  return (
115
115
  <Empty title="No component previews">
116
- Add a sibling <code>*.preview.tsx</code> file, or run <code>odori add @odori/title-reveal</code>.
116
+ Add a sibling <code>*.preview.tsx</code> file, or run <code>odori add title-reveal</code>.
117
117
  </Empty>
118
118
  );
119
119
  }
120
120
 
121
- const preview = selected.preview;
122
- const example = preview.examples.find((item) => item.name === exampleName) ?? preview.examples[0];
123
- const controlDefaults = Object.fromEntries(
124
- Object.entries(preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
125
- );
126
- const props = {...controlDefaults, ...(example?.props ?? {}), ...overrides};
127
- const entry = entryFor(selected, props, brand, format);
128
-
129
- if (mode === "gallery") {
121
+ if (!selected) {
130
122
  const grouped = filtered.reduce<Record<string, PreviewEntry[]>>((groups, item) => {
131
123
  groups[item.preview.category] = [...(groups[item.preview.category] ?? []), item];
132
124
  return groups;
@@ -167,28 +159,16 @@ export const ComponentsView = ({
167
159
  );
168
160
  }
169
161
 
162
+ const preview = selected.preview;
163
+ const example = preview.examples.find((item) => item.name === exampleName) ?? preview.examples[0];
164
+ const controlDefaults = Object.fromEntries(
165
+ Object.entries(preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
166
+ );
167
+ const props = {...controlDefaults, ...(example?.props ?? {}), ...overrides};
168
+ const entry = entryFor(selected, props, brand, format);
169
+
170
170
  return (
171
171
  <>
172
- <aside className="sidebar">
173
- <div className="sidebar-scroll">
174
- <ul className="list">
175
- {filtered.map((item) => (
176
- <li key={item.id}>
177
- <button
178
- type="button"
179
- className="list-item"
180
- data-active={item.id === selected.id ? "true" : undefined}
181
- onClick={() => onSelect(item.id)}
182
- >
183
- <strong>{item.preview.title}</strong>
184
- </button>
185
- </li>
186
- ))}
187
- </ul>
188
-
189
- </div>
190
- </aside>
191
-
192
172
  <section className="stage">
193
173
  <CanvasStage width={format.width} height={format.height}>
194
174
  <PreviewBoundary resetKey={`${selected.id}-${format.label}-${brand.name}`} label="This component threw">
@@ -210,7 +190,13 @@ export const ComponentsView = ({
210
190
  />
211
191
  </section>
212
192
 
213
- <aside className="inspector">
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
+ >
214
200
  <SectionTitle>Examples</SectionTitle>
215
201
  <ul className="list">
216
202
  {preview.examples.map((item) => (
@@ -279,7 +265,7 @@ export const ComponentsView = ({
279
265
  ) : (
280
266
  <p className="hint">This preview declares no controls. Add them to vary props in Studio.</p>
281
267
  )}
282
- </aside>
268
+ </Inspector>
283
269
  </>
284
270
  );
285
271
  };
@@ -14,19 +14,26 @@ import type {StudioView} from "../Studio";
14
14
  * is in here", which is the question you have on arrival, so each card plays a
15
15
  * frame of the real composition and opens the view that owns it.
16
16
  */
17
+ /** Cards per section here. Home is an overview; each tab shows everything. */
18
+ const SHOWN = 6;
19
+
17
20
  export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: string) => void}) => {
18
21
  const brands = collectBrands();
19
22
  const brand = brands[0];
20
- const fonts = [
21
- ...new Map(videos.flatMap((video) => resolveEntryLayout(video).brand.fonts).map((font) => [font.url, font])).values(),
22
- ];
23
23
 
24
24
  return (
25
25
  <div className="home">
26
26
  <section>
27
- <SectionTitle>Videos</SectionTitle>
27
+ <div className="section-head">
28
+ <SectionTitle>Videos</SectionTitle>
29
+ {videos.length > SHOWN ? (
30
+ <button type="button" className="view-all" onClick={() => onOpen("videos")}>
31
+ View all {videos.length} →
32
+ </button>
33
+ ) : null}
34
+ </div>
28
35
  <div className="gallery">
29
- {videos.map((video) => {
36
+ {videos.slice(0, SHOWN).map((video) => {
30
37
  const layout = resolveEntryLayout(video);
31
38
  return (
32
39
  <button key={video.metadata.id} type="button" className="card" onClick={() => onOpen("videos", video.metadata.id)}>
@@ -34,12 +41,11 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
34
41
  entry={video}
35
42
  frame={video.metadata.thumbnailFrame ?? Math.round(entryDurationInFrames(video, layout) * 0.4)}
36
43
  durationInFrames={entryDurationInFrames(video, layout)}
44
+ audioBadge
37
45
  />
38
46
  <div className="card-meta">
39
47
  <strong>{video.metadata.title}</strong>
40
- <span>
41
- {layout.format.width}x{layout.format.height} · {video.metadata.id}
42
- </span>
48
+ <span>{video.metadata.id}</span>
43
49
  </div>
44
50
  </button>
45
51
  );
@@ -48,9 +54,16 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
48
54
  </section>
49
55
 
50
56
  <section>
51
- <SectionTitle>Components</SectionTitle>
57
+ <div className="section-head">
58
+ <SectionTitle>Components</SectionTitle>
59
+ {componentPreviews.length > SHOWN ? (
60
+ <button type="button" className="view-all" onClick={() => onOpen("components")}>
61
+ View all {componentPreviews.length} →
62
+ </button>
63
+ ) : null}
64
+ </div>
52
65
  <div className="gallery">
53
- {componentPreviews.map((item) => {
66
+ {componentPreviews.slice(0, SHOWN).map((item) => {
54
67
  const defaults = Object.fromEntries(
55
68
  Object.entries(item.preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
56
69
  );
@@ -78,14 +91,21 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
78
91
  </section>
79
92
 
80
93
  <section>
81
- <SectionTitle>Brands</SectionTitle>
94
+ <div className="section-head">
95
+ <SectionTitle>Brands</SectionTitle>
96
+ <button type="button" className="view-all" onClick={() => onOpen("brands")}>
97
+ View all →
98
+ </button>
99
+ </div>
82
100
  <div className="home-swatches">
83
101
  {brands.map((item) => (
84
102
  <button key={item.name} type="button" className="brand-card" onClick={() => onOpen("brands", item.name)}>
85
103
  <div className="swatch-colors">
104
+ {/* Keyed by position: two tokens can resolve to one color,
105
+ and a color used twice is not one child twice. */}
86
106
  {[item.colors.background, item.colors.surface, item.colors.foreground, item.colors.accent, item.colors.border].map(
87
- (color) => (
88
- <span key={color} style={{background: color}} />
107
+ (color, position) => (
108
+ <span key={position} style={{background: color}} />
89
109
  ),
90
110
  )}
91
111
  </div>
@@ -101,7 +121,12 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
101
121
  </section>
102
122
 
103
123
  <section>
104
- <SectionTitle>Assets</SectionTitle>
124
+ <div className="section-head">
125
+ <SectionTitle>Assets</SectionTitle>
126
+ <button type="button" className="view-all" onClick={() => onOpen("assets")}>
127
+ View all →
128
+ </button>
129
+ </div>
105
130
  <ul className="asset-list">
106
131
  {project.audio.slice(0, 4).map((entry) => (
107
132
  <li key={entry.url}>
@@ -113,9 +138,6 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
113
138
  </li>
114
139
  ))}
115
140
  </ul>
116
- <button type="button" className="home-more" onClick={() => onOpen("assets")}>
117
- {project.assets.length} declared assets · {project.audio.length} sounds · {fonts.length} fonts
118
- </button>
119
141
  </section>
120
142
  </div>
121
143
  );