@odori/cli 0.0.5 → 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.
@@ -95,12 +95,18 @@ export const Inspector = ({
95
95
  event.preventDefault();
96
96
  document.body.style.userSelect = "none";
97
97
  document.body.style.cursor = "col-resize";
98
+ /* The column eases when a pane is put away, and a drag writes that
99
+ same column many times a second. Easing it then means the track
100
+ is always behind the pointer while the pane's own width is not,
101
+ and the difference between them is a strip of empty page. */
102
+ document.documentElement.dataset.resizing = "";
98
103
  const onMove = (move: PointerEvent) => applyWidth(window.innerWidth - move.clientX);
99
104
  const onUp = () => {
100
105
  window.removeEventListener("pointermove", onMove);
101
106
  window.removeEventListener("pointerup", onUp);
102
107
  document.body.style.userSelect = "";
103
108
  document.body.style.cursor = "";
109
+ delete document.documentElement.dataset.resizing;
104
110
  const width = getComputedStyle(document.documentElement).getPropertyValue(WIDTH_VAR).trim();
105
111
  if (width) window.localStorage.setItem(STORAGE_KEY, String(Number.parseInt(width, 10)));
106
112
  };
@@ -113,37 +119,37 @@ export const Inspector = ({
113
119
  window.localStorage.removeItem(STORAGE_KEY);
114
120
  }}
115
121
  />
122
+ <div className="inspector-head">
123
+ <span className="inspector-title" title={hint ?? title}>
124
+ {title}
125
+ </span>
126
+ {files.length > 0 ? (
127
+ <div className="inspector-views" role="tablist">
128
+ {(["inspect", "code"] as const).map((view) => (
129
+ <button
130
+ key={view}
131
+ type="button"
132
+ role="tab"
133
+ aria-selected={showing === view}
134
+ data-active={showing === view ? "true" : undefined}
135
+ onClick={() => setShowing(view)}
136
+ >
137
+ {view === "inspect" ? "Inspect" : "Code"}
138
+ </button>
139
+ ))}
140
+ </div>
141
+ ) : null}
142
+ <button
143
+ type="button"
144
+ className="inspector-toggle"
145
+ aria-label="Hide inspector"
146
+ title="Hide inspector"
147
+ onClick={() => setCollapsed(true)}
148
+ >
149
+ <Icon name="sidebar" />
150
+ </button>
151
+ </div>
116
152
  <div className="inspector-scroll">
117
- <div className="inspector-head">
118
- <span className="inspector-title" title={hint ?? title}>
119
- {title}
120
- </span>
121
- {files.length > 0 ? (
122
- <div className="inspector-views" role="tablist">
123
- {(["inspect", "code"] as const).map((view) => (
124
- <button
125
- key={view}
126
- type="button"
127
- role="tab"
128
- aria-selected={showing === view}
129
- data-active={showing === view ? "true" : undefined}
130
- onClick={() => setShowing(view)}
131
- >
132
- {view === "inspect" ? "Inspect" : "Code"}
133
- </button>
134
- ))}
135
- </div>
136
- ) : null}
137
- <button
138
- type="button"
139
- className="inspector-toggle"
140
- aria-label="Hide inspector"
141
- title="Hide inspector"
142
- onClick={() => setCollapsed(true)}
143
- >
144
- <Icon name="sidebar" />
145
- </button>
146
- </div>
147
153
  {files.length > 0 && showing === "code" ? (
148
154
  <>
149
155
  {/* One file is the file; several want naming, so the pane says
@@ -15,6 +15,19 @@ import {Icon} from "./ui";
15
15
  * is the part a gallery cannot do at all.
16
16
  */
17
17
  const COLLAPSED_KEY = "odori-navigator-collapsed";
18
+ const WIDTH_VAR = "--navigator-column";
19
+ const WIDTH_KEY = "odori-navigator-width";
20
+ /* Narrow enough for a filter box and a title, wide enough for a nested
21
+ category path without truncating every row. */
22
+ const MIN = 180;
23
+ const MAX = 420;
24
+
25
+ const clampWidth = (value: number): number =>
26
+ Math.round(Math.min(Math.max(value, MIN), Math.min(MAX, window.innerWidth * 0.4)));
27
+
28
+ const applyWidth = (value: number) => {
29
+ document.documentElement.style.setProperty(WIDTH_VAR, `${clampWidth(value)}px`);
30
+ };
18
31
 
19
32
  export type NavigatorItem = {
20
33
  id: string;
@@ -37,7 +50,15 @@ export const Navigator = ({
37
50
  selected: string | null;
38
51
  onSelect: (id: string) => void;
39
52
  }) => {
40
- const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) === "true");
53
+ /* Closed until asked for. Studio is most often docked beside something else,
54
+ where the stage wants every pixel, and the list is one click and a
55
+ remembered choice away. Anyone who opens it keeps it open. */
56
+ const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) !== "false");
57
+
58
+ useEffect(() => {
59
+ const stored = Number(window.localStorage.getItem(WIDTH_KEY));
60
+ if (Number.isFinite(stored) && stored > 0) applyWidth(stored);
61
+ }, []);
41
62
  const [query, setQuery] = useState("");
42
63
  const field = useRef<HTMLInputElement>(null);
43
64
 
@@ -86,6 +107,43 @@ export const Navigator = ({
86
107
 
87
108
  return (
88
109
  <aside className="navigator" aria-label={label}>
110
+ {/* The mirror of the inspector's handle, on the inside edge for the same
111
+ reason: the pane clips its overflow, so a divider hung across the
112
+ border loses the clipped half of its hit area. */}
113
+ <div
114
+ className="navigator-resize"
115
+ role="separator"
116
+ aria-orientation="vertical"
117
+ aria-label="Resize list"
118
+ onPointerDown={(event) => {
119
+ // Eight pixels wide, and a fast drag leaves it between events, so
120
+ // the window owns the move for the duration rather than the handle.
121
+ event.preventDefault();
122
+ document.body.style.userSelect = "none";
123
+ document.body.style.cursor = "col-resize";
124
+ /* The column eases when a pane is put away, and a drag writes that
125
+ same column many times a second. Easing it then means the track
126
+ is always behind the pointer while the pane's own width is not,
127
+ and the difference between them is a strip of empty page. */
128
+ document.documentElement.dataset.resizing = "";
129
+ const onMove = (move: PointerEvent) => applyWidth(move.clientX);
130
+ const onUp = () => {
131
+ window.removeEventListener("pointermove", onMove);
132
+ window.removeEventListener("pointerup", onUp);
133
+ document.body.style.userSelect = "";
134
+ document.body.style.cursor = "";
135
+ delete document.documentElement.dataset.resizing;
136
+ const width = getComputedStyle(document.documentElement).getPropertyValue(WIDTH_VAR).trim();
137
+ if (width) window.localStorage.setItem(WIDTH_KEY, String(Number.parseInt(width, 10)));
138
+ };
139
+ window.addEventListener("pointermove", onMove);
140
+ window.addEventListener("pointerup", onUp);
141
+ }}
142
+ onDoubleClick={() => {
143
+ document.documentElement.style.removeProperty(WIDTH_VAR);
144
+ window.localStorage.removeItem(WIDTH_KEY);
145
+ }}
146
+ />
89
147
  <div className="navigator-head">
90
148
  <button
91
149
  type="button"
@@ -109,6 +109,16 @@ export const Transport = ({
109
109
  const scenes = timeline?.scenes ?? [];
110
110
  const active = scenes.find((scene) => shown >= scene.start && shown < scene.start + scene.durationInFrames);
111
111
 
112
+ /* The widest each readout will get, in characters. The transport is set in
113
+ the mono face, so a `ch` is exactly one column and this is a measurement
114
+ rather than an estimate. */
115
+ const digits = String(Math.max(0, durationInFrames - 1)).length;
116
+ const frameChars = "frame of ".length + digits * 2;
117
+ const sceneChars =
118
+ scenes.length > 0
119
+ ? "scene ".length + Math.max(...scenes.map((scene) => (scene.name ?? scene.id).length))
120
+ : 0;
121
+
112
122
  /*
113
123
  * The ruler is drawn to the width it actually has. A fixed twelve marks is
114
124
  * fine in a wide window and unreadable in a narrow one, where "10.0s" next
@@ -147,9 +157,10 @@ export const Transport = ({
147
157
  onClick={(event) => {
148
158
  const next = frameAt(event.clientX);
149
159
  if (next === null) return;
150
- // Picking a frame is not a request to stop, but it is a request to
151
- // look at that frame, and the clock would move off it immediately.
152
- playback.pause();
160
+ /* Picking a frame is not a request to stop. A cut that is running
161
+ keeps running from where you pointed, which is what every player
162
+ does and what the audio guide already promised; a paused one
163
+ stays where you put it. */
153
164
  playback.seek(next);
154
165
  showPreview(null);
155
166
  }}
@@ -182,6 +193,11 @@ export const Transport = ({
182
193
  <div className="playhead" style={{left: `${(frame / Math.max(1, durationInFrames - 1)) * 100}%`}} />
183
194
  </div>
184
195
 
196
+ {/* Always present, the way the scene track always is.
197
+ It used to appear only once the runtime reported cues, which is
198
+ after the first paint, so the transport grew 26px and took them off
199
+ the stage on every load. A lane that is there and empty also says
200
+ something a missing one cannot: this video is silent. */}
185
201
  {track && track.cues.length > 0 ? (
186
202
  <div className="audio-track">
187
203
  {track.cues.map((cue) => {
@@ -211,7 +227,11 @@ export const Transport = ({
211
227
  );
212
228
  })}
213
229
  </div>
214
- ) : null}
230
+ ) : (
231
+ <div className="audio-track" data-empty="true">
232
+ <span>{track ? "no audio" : "\u00a0"}</span>
233
+ </div>
234
+ )}
215
235
  </div>
216
236
 
217
237
  <div className="transport-row">
@@ -270,10 +290,25 @@ export const Transport = ({
270
290
  <span className="timecode" style={{marginLeft: 8}} data-preview={preview !== null ? "true" : undefined}>
271
291
  <b>{formatTimecode(shown, fps)}</b> / {formatTimecode(durationInFrames - 1, fps)}
272
292
  </span>
273
- <span className="timecode" data-preview={preview !== null ? "true" : undefined}>
293
+ {/* Both of these change width as the cursor sweeps: the frame number
294
+ grows a digit at a time, and a scene called Resolution is half
295
+ again as wide as one called Proof. Tabular figures fix the digits
296
+ and do nothing for the count or the word, so each reserves the
297
+ widest it will ever need. Without that the row reflows under the
298
+ cursor and, in a pane narrow enough for it to wrap, takes a line
299
+ back from the stage while you are looking at it. */}
300
+ <span
301
+ className="timecode"
302
+ style={{minWidth: `${frameChars}ch`}}
303
+ data-preview={preview !== null ? "true" : undefined}
304
+ >
274
305
  frame <b>{shown}</b> of {durationInFrames - 1}
275
306
  </span>
276
- {active ? <span className="timecode">scene <b>{active.name ?? active.id}</b></span> : null}
307
+ {/* Rendered even with no scene under the cursor, so a gap in the
308
+ timeline does not collapse the box and shuffle the row. */}
309
+ <span className="timecode" style={{minWidth: `${sceneChars}ch`}}>
310
+ {active ? <>scene <b>{active.name ?? active.id}</b></> : null}
311
+ </span>
277
312
  </div>
278
313
  </div>
279
314
  );
@@ -76,17 +76,21 @@ export const Icon = ({
76
76
  }) => {
77
77
  const paths: Record<string, ReactNode> = {
78
78
  play: <path d="M4.5 2.8v8.4l7-4.2z" fill="currentColor" />,
79
+ /* The ordinary gear, on Lucide's 24 grid scaled to this 14 one. Drawing a
80
+ new one by eye is how you get a gear that reads as almost right; the
81
+ stroke is doubled so it comes out at the 1.2 everything else uses. */
79
82
  settings: (
80
- <>
81
- <circle cx="7" cy="7" r="2.1" fill="none" stroke="currentColor" strokeWidth="1.2" />
82
- <path
83
- d="M7 1.3l.85 1.5a4.7 4.7 0 0 1 1.2.5l1.7-.3.85 1.47-1 1.36c.08.2.14.4.18.62l1.53.72v1.7l-1.53.72c-.04.21-.1.42-.18.62l1 1.36-.85 1.47-1.7-.3a4.7 4.7 0 0 1-1.2.5L7 12.7l-.85-1.5a4.7 4.7 0 0 1-1.2-.5l-1.7.3-.85-1.47 1-1.36a4.7 4.7 0 0 1-.18-.62L1.7 6.83v-1.7l1.52-.72c.05-.21.11-.42.19-.62l-1-1.36.85-1.47 1.7.3c.37-.21.78-.38 1.2-.5z"
84
- fill="none"
85
- stroke="currentColor"
86
- strokeWidth="1.1"
87
- strokeLinejoin="round"
88
- />
89
- </>
83
+ <g
84
+ transform="scale(0.5833)"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ strokeWidth="2"
88
+ strokeLinecap="round"
89
+ strokeLinejoin="round"
90
+ >
91
+ <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
92
+ <circle cx="12" cy="12" r="3" />
93
+ </g>
90
94
  ),
91
95
  sun: (
92
96
  <>
@@ -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;
@@ -563,12 +594,77 @@ select.input {
563
594
  without a rule per combination of what is open. The inspector's width is
564
595
  a root variable so its drag handle can resize it without React re-laying
565
596
  anything out. */
566
- 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);
567
611
  overflow: hidden;
568
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
+ }
569
624
  .main[data-single="true"] {
570
625
  grid-template-columns: 1fr;
571
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
+ }
572
668
 
573
669
  /* ---------- navigator ---------- */
574
670
 
@@ -578,14 +674,39 @@ select.input {
578
674
  display: flex;
579
675
  flex-direction: column;
580
676
  overflow: hidden;
581
- 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%;
582
682
  }
583
- .navigator-head {
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;
692
+ }
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 {
584
703
  align-items: center;
585
704
  border-bottom: 1px solid var(--border);
586
705
  display: flex;
587
- gap: 6px;
588
- padding: 10px;
706
+ flex: none;
707
+ gap: 8px;
708
+ min-height: 49px;
709
+ padding: 10px 16px;
589
710
  }
590
711
  .navigator-search {
591
712
  height: 28px;
@@ -594,7 +715,9 @@ select.input {
594
715
  }
595
716
  .navigator-scroll {
596
717
  overflow-y: auto;
597
- 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;
598
721
  scrollbar-gutter: stable;
599
722
  }
600
723
  .navigator-group {
@@ -630,11 +753,12 @@ select.input {
630
753
  background: var(--hover);
631
754
  color: var(--foreground);
632
755
  }
633
- /* 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. */
634
758
  .navigator-reopen {
635
759
  background: var(--panel);
636
760
  border-color: var(--border);
637
- left: 10px;
761
+ left: 16px;
638
762
  position: fixed;
639
763
  top: 58px;
640
764
  z-index: 5;
@@ -646,6 +770,8 @@ select.input {
646
770
  width: clamp(240px, var(--inspector-width, 320px), 50vw);
647
771
  container-type: inline-size;
648
772
  display: flex;
773
+ /* A header above a scroller, which is what the navigator is. */
774
+ flex-direction: column;
649
775
  overflow: hidden;
650
776
  position: relative;
651
777
  }
@@ -661,12 +787,19 @@ select.input {
661
787
  }
662
788
 
663
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
+ */
664
801
  .inspector-head {
665
- align-items: center;
666
- display: flex;
667
- gap: 8px;
668
802
  justify-content: space-between;
669
- margin-bottom: 14px;
670
803
  }
671
804
  .inspector-title {
672
805
  color: var(--foreground);
@@ -798,6 +931,8 @@ select.input {
798
931
  white-space: pre;
799
932
  }
800
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. */
801
936
  .inspector-toggle,
802
937
  .inspector-reopen {
803
938
  align-items: center;
@@ -808,21 +943,28 @@ select.input {
808
943
  cursor: pointer;
809
944
  display: inline-flex;
810
945
  flex: none;
811
- height: 26px;
946
+ height: 28px;
812
947
  justify-content: center;
813
- width: 26px;
948
+ width: 28px;
814
949
  }
815
950
  .inspector-toggle:hover,
816
951
  .inspector-reopen:hover {
817
952
  background: var(--hover);
818
953
  color: var(--foreground);
819
954
  }
820
- /* 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
+ */
821
963
  .inspector-reopen {
822
964
  background: var(--panel);
823
965
  border-color: var(--border);
824
966
  position: fixed;
825
- right: 10px;
967
+ right: 16px;
826
968
  top: 58px;
827
969
  z-index: 5;
828
970
  }
@@ -1149,6 +1291,18 @@ select.input {
1149
1291
  height: 20px;
1150
1292
  position: relative;
1151
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
+ }
1152
1306
  .audio-track .cue {
1153
1307
  align-items: center;
1154
1308
  background: var(--hover);