@odori/cli 0.0.4 → 0.0.6

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.
@@ -201,9 +201,21 @@ a.button {
201
201
  border-color: var(--border);
202
202
  color: var(--foreground);
203
203
  }
204
- .button[data-variant="outline"]:hover {
205
- border-color: var(--border-strong);
206
- background: var(--panel);
204
+ /*
205
+ * Hover says a control is live. Selected says what the setting is. They have
206
+ * to differ in kind, not in degree.
207
+ *
208
+ * Hovering an unselected option used to darken its border and fill it, which
209
+ * is what being chosen looks like, so passing the cursor over 2x read as
210
+ * having clicked 2x. And this rule outranked the selected one - a pseudo-class
211
+ * beats an attribute - so hovering the option that *was* chosen replaced its
212
+ * fill with this one and the choice appeared to move under the cursor.
213
+ *
214
+ * Now hover only lifts the surface a step, the border never moves, and
215
+ * selected always wins.
216
+ */
217
+ .button[data-variant="outline"]:hover:not([data-active="true"]) {
218
+ background: var(--hover);
207
219
  }
208
220
  .button[data-variant="primary"] {
209
221
  background: var(--foreground);
@@ -217,6 +229,25 @@ a.button {
217
229
  background: var(--active);
218
230
  color: var(--foreground);
219
231
  }
232
+ /*
233
+ * A chosen option in a group: filled and outlined, which is how the source
234
+ * tabs and the catalog filter already say the same thing.
235
+ *
236
+ * Fill alone was not enough of a difference. Hover is #f2f2f2 and chosen is
237
+ * #ebebeb, seven levels apart on a #fafafa panel, so the cursor moving across
238
+ * 0.5x / 1x / 2x looked like the choice following it. The border is the part
239
+ * hover never touches, so it is the part that carries the state.
240
+ *
241
+ * Both listed, and last, so the chosen option keeps looking chosen under the
242
+ * cursor: a pseudo-class outranks an attribute, and the plain hover rule was
243
+ * winning and taking the fill away.
244
+ */
245
+ .button[data-variant="outline"][data-active="true"],
246
+ .button[data-variant="outline"][data-active="true"]:hover {
247
+ background: var(--active);
248
+ border-color: var(--border-strong);
249
+ color: var(--foreground);
250
+ }
220
251
  .button:disabled {
221
252
  cursor: not-allowed;
222
253
  opacity: 0.45;
@@ -306,6 +337,66 @@ a.button {
306
337
  font-size: 11px;
307
338
  }
308
339
 
340
+ /* ---------- settings ---------- */
341
+
342
+ .settings {
343
+ position: relative;
344
+ }
345
+ /* Right-aligned: the trigger is the last thing in the header, so a menu
346
+ growing rightward from its left edge would leave the window. */
347
+ .settings-menu {
348
+ left: auto;
349
+ right: 0;
350
+ }
351
+ .menu-heading {
352
+ color: var(--subtle);
353
+ font-size: 11px;
354
+ letter-spacing: 0.04em;
355
+ margin: 6px 0 2px;
356
+ padding: 0 9px;
357
+ text-transform: uppercase;
358
+ }
359
+ .menu-heading:first-child {
360
+ margin-top: 2px;
361
+ }
362
+ .menu-hint {
363
+ color: var(--subtle);
364
+ font-size: 11px;
365
+ }
366
+ /* A tick in a reserved column, so choosing an option never reflows the row
367
+ or nudges the one under the cursor. */
368
+ .menu-item {
369
+ display: grid;
370
+ grid-template-columns: 1fr 14px;
371
+ }
372
+ .menu-item > span:first-child {
373
+ grid-column: 1;
374
+ }
375
+ .menu-hint {
376
+ grid-column: 1;
377
+ }
378
+ .menu-item[data-selected="true"]::after {
379
+ align-self: center;
380
+ color: var(--foreground);
381
+ content: "✓";
382
+ font-size: 11px;
383
+ grid-column: 2;
384
+ grid-row: 1 / span 2;
385
+ justify-self: end;
386
+ }
387
+ /* A stalled bar stops looking like a working one. */
388
+ .progress[data-stalled="true"] > span {
389
+ opacity: 0.5;
390
+ }
391
+
392
+ .menu-note {
393
+ border-top: 1px solid var(--border);
394
+ color: var(--subtle);
395
+ font-size: 11px;
396
+ margin: 4px 0 0;
397
+ padding: 7px 9px 3px;
398
+ }
399
+
309
400
  /* Matches the transport's icon buttons rather than the host select. */
310
401
  .rate {
311
402
  appearance: none;
@@ -503,12 +594,77 @@ select.input {
503
594
  without a rule per combination of what is open. The inspector's width is
504
595
  a root variable so its drag handle can resize it without React re-laying
505
596
  anything out. */
506
- grid-template-columns: auto minmax(0, 1fr) auto;
597
+ /*
598
+ * The side columns are stated rather than measured from their contents.
599
+ *
600
+ * A track that is `auto` collapses because the pane inside it left the DOM,
601
+ * and that is a used-value change, which no transition fires on: the stage
602
+ * jumped to its new width a frame after the click. Stating both widths, and
603
+ * letting the attributes the panes already set drive them, makes the track
604
+ * list itself change, which is a computed value and animatable. The
605
+ * inspector's is the same variable its drag handle writes.
606
+ */
607
+ grid-template-columns:
608
+ var(--navigator-column, 232px)
609
+ minmax(0, 1fr)
610
+ clamp(240px, var(--inspector-width, 320px), 50vw);
507
611
  overflow: hidden;
508
612
  }
613
+ html[data-navigator="collapsed"] .main {
614
+ --navigator-column: 0px;
615
+ }
616
+ /* Zero, not `auto`: the pane is gone and the column has to say so in a length
617
+ the track list can interpolate towards. */
618
+ html[data-inspector="collapsed"] .main {
619
+ --inspector-width: 0px;
620
+ }
621
+ html[data-inspector="collapsed"] .main:not([data-single="true"]) {
622
+ grid-template-columns: var(--navigator-column, 232px) minmax(0, 1fr) 0px;
623
+ }
509
624
  .main[data-single="true"] {
510
625
  grid-template-columns: 1fr;
511
626
  }
627
+ /*
628
+ * Each pane owns its column by number rather than by arriving in order.
629
+ *
630
+ * A pane that is put away renders nothing but its reopen button, and that
631
+ * button is fixed, so it is not a grid item. Auto-placement then slid every
632
+ * remaining pane one column to the left: closing the list handed the stage the
633
+ * sidebar's auto column, which sizes to content rather than to the space, and
634
+ * pushed the inspector into the middle column and its 273px out through the
635
+ * right edge of the grid. Naming the column makes where a pane sits
636
+ * independent of how many panes happen to exist, so any of them can be put
637
+ * away without moving the others.
638
+ */
639
+ .main:not([data-single="true"]) > .navigator {
640
+ grid-column: 1;
641
+ }
642
+ .main:not([data-single="true"]) > .stage {
643
+ grid-column: 2;
644
+ }
645
+ .main:not([data-single="true"]) > .inspector {
646
+ grid-column: 3;
647
+ }
648
+ /*
649
+ * Putting a pane away is a move, so it should read as one.
650
+ *
651
+ * The columns are what actually change, so they are what animates: the stage
652
+ * grows into the space as the list leaves rather than snapping to a new width
653
+ * a frame later. Only the track list is transitioned, not `all`, because the
654
+ * inspector's drag handle writes its width continuously and easing that would
655
+ * make the pane lag the cursor.
656
+ */
657
+ @media (prefers-reduced-motion: no-preference) {
658
+ .main:not([data-single="true"]) {
659
+ transition: grid-template-columns 220ms cubic-bezier(0.32, 0.72, 0, 1);
660
+ }
661
+ /* Except while a divider is being dragged, when the column has to track the
662
+ pointer exactly. A pane follows its column instantly and the column would
663
+ not, so the two would part company and show the page between them. */
664
+ html[data-resizing] .main {
665
+ transition: none;
666
+ }
667
+ }
512
668
 
513
669
  /* ---------- navigator ---------- */
514
670
 
@@ -518,14 +674,39 @@ select.input {
518
674
  display: flex;
519
675
  flex-direction: column;
520
676
  overflow: hidden;
521
- width: 232px;
677
+ /* The column carries the width now, so the pane fills whatever it is given
678
+ and the drag has one number to move rather than two to keep in step. */
679
+ min-width: 0;
680
+ position: relative;
681
+ width: 100%;
682
+ }
683
+ .navigator-resize {
684
+ cursor: col-resize;
685
+ /* Fully inside the pane, like the inspector's: the pane clips overflow, and
686
+ a handle hung across the border loses the clipped half of its hit area. */
687
+ inset: 0 0 0 auto;
688
+ position: absolute;
689
+ touch-action: none;
690
+ width: 8px;
691
+ z-index: 2;
522
692
  }
523
- .navigator-head {
693
+ /*
694
+ * Both pane headers, from one rule, because they are the same thing on either
695
+ * side of the workspace and describing them separately is how they drifted:
696
+ * one sat at a 10px gutter and the other at 16px, so the two sides of the top
697
+ * of the window did not line up with each other.
698
+ *
699
+ * 49px is 28px of control with ten above and below and the rule underneath.
700
+ */
701
+ .navigator-head,
702
+ .inspector-head {
524
703
  align-items: center;
525
704
  border-bottom: 1px solid var(--border);
526
705
  display: flex;
527
- gap: 6px;
528
- padding: 10px;
706
+ flex: none;
707
+ gap: 8px;
708
+ min-height: 49px;
709
+ padding: 10px 16px;
529
710
  }
530
711
  .navigator-search {
531
712
  height: 28px;
@@ -534,7 +715,9 @@ select.input {
534
715
  }
535
716
  .navigator-scroll {
536
717
  overflow-y: auto;
537
- padding: 10px;
718
+ /* The header's gutter, so a row lines up with the title above it and with
719
+ the inspector's content across the window. */
720
+ padding: 16px;
538
721
  scrollbar-gutter: stable;
539
722
  }
540
723
  .navigator-group {
@@ -570,11 +753,12 @@ select.input {
570
753
  background: var(--hover);
571
754
  color: var(--foreground);
572
755
  }
573
- /* Away, the way back floats where the list was. */
756
+ /* Away, the way back sits exactly where the way out was: the header's own
757
+ 16px gutter, and its top edge plus 10px of padding. */
574
758
  .navigator-reopen {
575
759
  background: var(--panel);
576
760
  border-color: var(--border);
577
- left: 10px;
761
+ left: 16px;
578
762
  position: fixed;
579
763
  top: 58px;
580
764
  z-index: 5;
@@ -586,6 +770,8 @@ select.input {
586
770
  width: clamp(240px, var(--inspector-width, 320px), 50vw);
587
771
  container-type: inline-size;
588
772
  display: flex;
773
+ /* A header above a scroller, which is what the navigator is. */
774
+ flex-direction: column;
589
775
  overflow: hidden;
590
776
  position: relative;
591
777
  }
@@ -601,12 +787,19 @@ select.input {
601
787
  }
602
788
 
603
789
 
790
+ /*
791
+ * The pane's header, matched to the navigator's across the workspace.
792
+ *
793
+ * It used to be the first thing inside the scroller, so the title and the tabs
794
+ * slid away as you read and there was no line to say where the pane stopped
795
+ * and its contents started. Out here it stays put and carries the same rule
796
+ * the list's header does. Ten pixels top and bottom is what makes the two the
797
+ * same height, since both hold a 28px control; sixteen at the sides is what
798
+ * puts the title over the content underneath it rather than six pixels to its
799
+ * left.
800
+ */
604
801
  .inspector-head {
605
- align-items: center;
606
- display: flex;
607
- gap: 8px;
608
802
  justify-content: space-between;
609
- margin-bottom: 14px;
610
803
  }
611
804
  .inspector-title {
612
805
  color: var(--foreground);
@@ -738,6 +931,8 @@ select.input {
738
931
  white-space: pre;
739
932
  }
740
933
 
934
+ /* 28px, the same as the list's pair: two controls doing the same job either
935
+ side of the workspace should not be different sizes. */
741
936
  .inspector-toggle,
742
937
  .inspector-reopen {
743
938
  align-items: center;
@@ -748,21 +943,28 @@ select.input {
748
943
  cursor: pointer;
749
944
  display: inline-flex;
750
945
  flex: none;
751
- height: 26px;
946
+ height: 28px;
752
947
  justify-content: center;
753
- width: 26px;
948
+ width: 28px;
754
949
  }
755
950
  .inspector-toggle:hover,
756
951
  .inspector-reopen:hover {
757
952
  background: var(--hover);
758
953
  color: var(--foreground);
759
954
  }
760
- /* When the pane is away, the way back floats where the pane was. */
955
+ /*
956
+ * Away, the way back sits exactly where the way out was.
957
+ *
958
+ * These are the same control in two states, so it must not jump between them.
959
+ * 16px is the header's own side padding, which is where the toggle sits while
960
+ * the pane is open; it was 10px, and the button hopped six pixels right on
961
+ * every close. 58px is the header's top edge plus its 10px of padding.
962
+ */
761
963
  .inspector-reopen {
762
964
  background: var(--panel);
763
965
  border-color: var(--border);
764
966
  position: fixed;
765
- right: 10px;
967
+ right: 16px;
766
968
  top: 58px;
767
969
  z-index: 5;
768
970
  }
@@ -951,6 +1153,13 @@ select.input {
951
1153
 
952
1154
  .stage {
953
1155
  display: grid;
1156
+ /* The column has to be named. An implicit grid column is auto, which means
1157
+ max-content, so the transport's row of controls set the width and the
1158
+ timeline was laid out at its natural size and then clipped: in a pane this
1159
+ narrow the right half of every video sat under the inspector, unclickable,
1160
+ and the playhead walked off the edge on its way there. min-width on .stage
1161
+ does not help, because the overflow is the track inside it. */
1162
+ grid-template-columns: minmax(0, 1fr);
954
1163
  grid-template-rows: minmax(0, 1fr) auto;
955
1164
  min-width: 0;
956
1165
  overflow: hidden;
@@ -993,13 +1202,19 @@ select.input {
993
1202
  border-top: 1px solid var(--border);
994
1203
  display: grid;
995
1204
  gap: 10px;
1205
+ min-width: 0;
996
1206
  padding: 10px 14px 14px;
997
1207
  }
998
1208
 
999
1209
  .transport-row {
1000
1210
  align-items: center;
1001
1211
  display: flex;
1212
+ /* Wrapping is what lets the transport be narrower than its controls. Without
1213
+ it the row is a single min-content block and it is the row, not the
1214
+ timeline, that decides how wide the stage has to be. */
1215
+ flex-wrap: wrap;
1002
1216
  gap: 8px;
1217
+ min-width: 0;
1003
1218
  }
1004
1219
 
1005
1220
  .timecode {
@@ -1014,8 +1229,13 @@ select.input {
1014
1229
  }
1015
1230
 
1016
1231
  .timeline {
1017
- cursor: pointer;
1232
+ /* Only the track is clickable, so only the track shows a pointer. */
1018
1233
  display: grid;
1234
+ /* Named for the same reason .stage is: an implicit auto column would take
1235
+ its width from the ruler and the scene labels rather than from the space
1236
+ available, and push the track out from under the cursor. */
1237
+ grid-template-columns: minmax(0, 1fr);
1238
+ min-width: 0;
1019
1239
  gap: 4px;
1020
1240
  position: relative;
1021
1241
  user-select: none;
@@ -1029,6 +1249,8 @@ select.input {
1029
1249
  justify-content: space-between;
1030
1250
  }
1031
1251
  .track {
1252
+ /* The track is a click target, not something to drag. */
1253
+ cursor: pointer;
1032
1254
  background: var(--field);
1033
1255
  border: 1px solid var(--border);
1034
1256
  border-radius: var(--radius-sm);
@@ -1069,6 +1291,18 @@ select.input {
1069
1291
  height: 20px;
1070
1292
  position: relative;
1071
1293
  }
1294
+ /* Empty until the runtime has reported, then labelled. Same height either
1295
+ way, so nothing above it moves. */
1296
+ .audio-track[data-empty="true"] {
1297
+ align-items: center;
1298
+ border: 1px dashed var(--border);
1299
+ border-radius: var(--radius-sm);
1300
+ box-sizing: border-box;
1301
+ color: var(--subtle);
1302
+ display: flex;
1303
+ font-size: 11px;
1304
+ padding: 0 8px;
1305
+ }
1072
1306
  .audio-track .cue {
1073
1307
  align-items: center;
1074
1308
  background: var(--hover);
@@ -1140,6 +1374,21 @@ select.input {
1140
1374
  width: 7.5px;
1141
1375
  }
1142
1376
 
1377
+ /* The frame under the cursor, which is a suggestion until it is clicked. It
1378
+ reads as the same instrument as the playhead, turned down. */
1379
+ .playhead[data-preview="true"],
1380
+ .playhead[data-preview="true"]::before {
1381
+ background: var(--subtle);
1382
+ }
1383
+ .playhead[data-preview="true"] {
1384
+ opacity: 0.75;
1385
+ }
1386
+ /* Muted rather than moved: the readout must not change width when it starts
1387
+ reporting the hovered frame, or the whole row shifts under the cursor. */
1388
+ .timecode[data-preview="true"] {
1389
+ color: var(--subtle);
1390
+ }
1391
+
1143
1392
  /* ---------- inspector ---------- */
1144
1393
 
1145
1394
  .facts {
@@ -23,6 +23,7 @@ import {Inspector} from "../components/Inspector";
23
23
  import {Navigator} from "../components/Navigator";
24
24
  import {Badge, Empty, Fact, SectionTitle, Separator} from "../components/ui";
25
25
  import {measureTrack} from "../lib/mix-loudness";
26
+ import {readStartSound} from "../settings";
26
27
  import {useShortcuts} from "../shortcuts";
27
28
 
28
29
  /**
@@ -51,9 +52,15 @@ export const VideosView = ({
51
52
  const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
52
53
  const [track, setTrack] = useState<AudioTrack | null>(null);
53
54
  const [loop, setLoop] = useState(true);
54
- const [muted, setMuted] = useState(false);
55
+ /* Read once per mount rather than watched: flipping the default while a
56
+ video is open would mute the thing being listened to, and a default is a
57
+ statement about what happens next. */
58
+ const [muted, setMuted] = useState(() => readStartSound() === "off");
55
59
  const [soloCue, setSoloCue] = useState<string | null>(null);
56
- const [scrubbing, setScrubbing] = useState(false);
60
+ /* The frame the cursor is over, which the stage shows instead of the
61
+ playhead frame. Preview never moves the playhead and never sounds: it
62
+ answers "what is here" and nothing else. */
63
+ const [preview, setPreview] = useState<number | null>(null);
57
64
  const [rate, setRate] = useState(1);
58
65
  const [loudness, setLoudness] = useState<number | null>(null);
59
66
  const [audioBlocked, setAudioBlocked] = useState(false);
@@ -108,7 +115,7 @@ export const VideosView = ({
108
115
  playing: playback.playing,
109
116
  muted,
110
117
  soloCue,
111
- scrubbing,
118
+ scrubbing: preview !== null,
112
119
  rate,
113
120
  onBlocked: setAudioBlocked,
114
121
  onFailed: setAudioFailed,
@@ -238,7 +245,7 @@ export const VideosView = ({
238
245
  <PreviewBoundary resetKey={selected.metadata.id}>
239
246
  <OdoriRuntime
240
247
  entry={selected}
241
- frame={playback.frame}
248
+ frame={preview ?? playback.frame}
242
249
  input={input}
243
250
  assets={project.assets}
244
251
  onTimeline={handleTimeline}
@@ -258,7 +265,7 @@ export const VideosView = ({
258
265
  audioBlocked={audioBlocked}
259
266
  onEnableAudio={() => setMuted(false)}
260
267
  onSoloCue={setSoloCue}
261
- onScrubbing={setScrubbing}
268
+ onPreview={setPreview}
262
269
  rate={rate}
263
270
  onRate={setRate}
264
271
  onToggleLoop={() => setLoop((value) => !value)}