better toolbar

This commit is contained in:
Your Name 2026-10-01 00:11:16 -04:00
parent 2f1c9b9c02
commit 5fb04f6a7d
6 changed files with 433 additions and 87 deletions

View file

@ -0,0 +1,55 @@
(ns arthur.ui.icon
"The handful of glyphs the chrome uses, drawn here rather than imported.
NOT an icon library, on purpose. Three reasons, in order of weight:
1. SIZE. These render at 11px against 11px type. Lucide, Feather and the rest
are drawn on a 24px grid with a 2px stroke and sub-pixel curves; scaled to
11 they go soft and land off the pixel grid, in a tool whose entire premise
is that you can see individual pixels. These are drawn on a 12px grid with
whole and half coordinates, so the bars and edges fall where the renderer
wants them.
2. REGISTER. `app.css` argues the chrome is Macromedia-era — flat, hairline,
no bevels, one accent. A modern rounded icon set is a second visual voice
in a strip 21px tall.
3. COST. Six glyphs is forty lines. The alternative is a dependency, a build
step's worth of tree-shaking, and a license file, for forty lines.
Icons are used ONLY where the word is worse than the picture: the transport,
where `|<` was ASCII pretending to be a glyph, and the two playback toggles,
which are state rather than actions. Everything else in the strip keeps its
word, because `insert` and `overwrite` have no pictures and inventing some
would be a puzzle rather than a shorthand. Every one carries an `aria-label`
at the call site; nothing here is the only statement of what a control does."
(:require [clojure.string :as str]))
;; A glyph is its paths on a 12x12 grid. Solid shapes are filled; the two
;; drawn with a line are stroked, which `kind` says.
(def ^:private glyphs
{:start {:fill ["M2 2.5h1.3v7H2z" ; the bar it stops against
"M10 2.5v7L4.2 6z"]} ; and the triangle into it
:play {:fill ["M3.4 2.3L10 6 3.4 9.7z"]}
:pause {:fill ["M3.3 2.4h1.9v7.2H3.3z"
"M6.8 2.4h1.9v7.2H6.8z"]}
;; Two arrows round a rectangle: the clip's end rejoining its start.
:loop {:stroke ["M3 5.4V4.6A1.2 1.2 0 0 1 4.2 3.4h5"
"M7.8 1.8 9.4 3.4 7.8 5"
"M9 6.6v.8A1.2 1.2 0 0 1 7.8 8.6h-5"
"M4.2 7 2.6 8.6 4.2 10.2"]}
;; One speaker, with or without the sound coming out of it.
:sound {:fill ["M2.4 4.6h1.8L6.4 2.6v6.8L4.2 7.4H2.4z"]
:stroke ["M8 4.1a2.7 2.7 0 0 1 0 3.8"]}
:muted {:fill ["M2.4 4.6h1.8L6.4 2.6v6.8L4.2 7.4H2.4z"]
:stroke ["M7.9 4.4 10.7 7.6" "M10.7 4.4 7.9 7.6"]}})
(defn view
"The glyph named `k`, sized by CSS and inked in `currentColor` — so a button's
`.on` state colours its icon without this knowing the palette exists."
[k]
(let [{:keys [fill stroke]} (glyphs k)]
[:svg {:viewBox "0 0 12 12" :aria-hidden true :focusable false}
(when (seq fill)
[:path {:d (str/join " " fill) :fill "currentColor"}])
(when (seq stroke)
[:path {:d (str/join " " stroke) :fill "none" :stroke "currentColor"
:stroke-width 1.2 :stroke-linecap "round" :stroke-linejoin "round"}])]))

View file

@ -0,0 +1,81 @@
(ns arthur.ui.menu
"A labelled drop of commands, for a pane head that has more commands than room.
THE POINT IS NOT SPACE, IT IS THE EXPLANATION. A command that applies only to
a selected cel spends most of its life disabled, and a disabled button is a
grey word with its reason hidden in a `title` nobody hovers. The same command
as a menu row gets a second line saying what it does, visible at the moment
somebody is looking for it, which is the moment the menu is open.
So a menu stays openable even when every row in it is disabled, and says what
is missing instead. Hiding the rows would answer \"where did split go\" with
silence.
IT IS PLACED IN THE VIEWPORT, NOT IN THE PANE. A pane clips its own overflow —
`.time` must, or the timeline would push the window taller — so a panel laid
out inside one is cut off at its edge. Measured once per opening against the
button it hangs under, which is also what lets it flip above the button when
there is more room there. The measurement is of the BUTTON and not of the
panel, so it does not need the panel to exist yet.
The scrim is `ui/openmenu`'s, for the reason given there: a catcher behind the
panel dismisses on any outside click without a document listener that has to
be added, removed, and told to ignore the click that opened the menu."
(:require [reagent.core :as r]))
(def ^:private gap 2)
(def ^:private margin 8)
(defn- placement
"Where to put the panel, given the button it belongs to. `:bottom` rather than
`:top` when it opens upward, so the panel grows away from the button in both
directions without anything measuring the panel."
[^js button]
(let [r (.getBoundingClientRect button)
vh (.-innerHeight js/window)
vw (.-innerWidth js/window)
below (- vh (.-bottom r) gap margin)
above (- (.-top r) gap margin)
up? (> above below)]
(merge {:left (max margin (min (.-left r) (- vw 280 margin)))
:max-height (max 120 (if up? above below))}
(if up?
{:bottom (+ (- vh (.-top r)) gap)}
{:top (+ (.-bottom r) gap)}))))
(defn view
"`{:label :title :note :items}`. An item is
`{:label :sub :disabled? :on-click}`; a nil item is dropped, so a caller can
put a row behind a `when`. `note` is shown when nothing in the menu applies."
[_]
(let [at (r/atom nil)]
(fn [{:keys [label title note items]}]
(let [items (remove nil? items)
dead? (every? :disabled? items)
close! #(reset! at nil)]
[:div.menu-wrap
[:button {:class (when @at "on") :title title
:aria-haspopup "menu" :aria-expanded (boolean @at)
:on-click (fn [^js e]
(if @at (close!) (reset! at (placement (.-currentTarget e)))))}
label " ▾"]
(when-let [{:keys [left top bottom max-height]} @at]
[:<>
[:div.menu-scrim {:on-click close!}]
[:div.menu.menu-drop
{:role "menu"
:style (cond-> {:left (str left "px") :max-height (str max-height "px")}
top (assoc :top (str top "px"))
bottom (assoc :bottom (str bottom "px")))}
(when (and dead? note) [:div.dim.menu-note note])
(doall
(for [{:keys [label sub disabled? on-click]} items]
^{:key label}
;; The row's accessible name is the COMMAND, not the command
;; followed by its explanation — which is what `textContent` is
;; here, and what a screen reader would otherwise read out.
[:button.menu-item {:role "menuitem" :disabled (boolean disabled?)
:aria-label label :title sub
:on-click (fn [] (close!) (on-click))}
label
(when sub [:span.sub sub])]))]])]))))

View file

@ -29,6 +29,8 @@
[arthur.events.playback :as pb] [arthur.events.playback :as pb]
[arthur.events.ui :as ui] [arthur.events.ui :as ui]
[arthur.footage.store :as store] [arthur.footage.store :as store]
[arthur.ui.icon :as icon]
[arthur.ui.menu :as menu]
[arthur.subs.playback :as playback] [arthur.subs.playback :as playback]
[arthur.subs.render :as render] [arthur.subs.render :as render]
[arthur.subs.ui :as sub] [arthur.subs.ui :as sub]
@ -223,6 +225,9 @@
(defn- transport [] (defn- transport []
(let [playing? @(rf/subscribe [::playback/playing?]) (let [playing? @(rf/subscribe [::playback/playing?])
rate @(rf/subscribe [::playback/rate]) rate @(rf/subscribe [::playback/rate])
loop? @(rf/subscribe [::playback/loop?])
muted? @(rf/subscribe [::playback/muted?])
view @(rf/subscribe [::sub/time-view])
frame @(rf/subscribe [::playback/frame]) frame @(rf/subscribe [::playback/frame])
frames @(rf/subscribe [::render/frames]) frames @(rf/subscribe [::render/frames])
{:keys [fps drop]} @player/meter {:keys [fps drop]} @player/meter
@ -237,7 +242,7 @@
lane? (node/lane? lane) lane? (node/lane? lane)
cel? (and lane? (= :instance (:kind n)) (some? (node/source n))) cel? (and lane? (= :instance (:kind n)) (some? (node/source n)))
held? (and cel? (zero? (:speed (node/playback-of n)))) held? (and cel? (zero? (:speed (node/playback-of n))))
;; Shared use is shown rather than discovered: the button that decouples ;; Shared use is shown rather than discovered: the row that decouples
;; a cel is only offered where there is something to decouple from. ;; a cel is only offered where there is something to decouple from.
shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip) shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip)
[_ other] (:nodes sym) [_ other] (:nodes sym)
@ -250,82 +255,141 @@
(lane/lane-frame clip sid (:id lane) owner-frame)) (lane/lane-frame clip sid (:id lane) owner-frame))
insertable? (and lane? (integer? at)) insertable? (and lane? (integer? at))
splittable? (and cel? (integer? at) splittable? (and cel? (integer? at)
(let [[lo hi] (node/placed-span n)] (< lo at hi)))] (let [[lo hi] (node/placed-span n)] (< lo at hi)))
act (fn [event] #(rf/dispatch event))]
[:div.pane-head [:div.pane-head
[:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")] ;; ----------------------------------------------------------------- time
[:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"] ;; Where the playhead is and how it moves. Nothing here depends on a
[:button {:on-click #(rf/dispatch [::pb/step -1])} "-1"] ;; selection, which is why it is the one group that is never greyed.
[:button {:on-click #(rf/dispatch [::pb/step 1])} "+1"] [:div.group
[:button {:class (when @(rf/subscribe [::playback/loop?]) "on") [:button.ico {:aria-label "go to start" :title "go to the first frame"
:on-click #(rf/dispatch [::pb/toggle-loop])} "loop"] :on-click (act [::pb/seek 0])} [icon/view :start]]
[:button {:class (when @(rf/subscribe [::playback/muted?]) "on") [:button {:aria-label "−1" :title "step back one frame"
:on-click #(rf/dispatch [::pb/toggle-mute])} "mute"] :on-click (act [::pb/step -1])} "−1"]
[:button {:class (when (= :timeline @(rf/subscribe [::sub/time-view])) "on") [:button.ico.ico-play {:aria-label (if playing? "pause" "play")
:on-click #(rf/dispatch [::ui/set-time-view :timeline])} "timeline"] :title (if playing? "pause" "play")
[:button {:class (when (= :cel-sheet @(rf/subscribe [::sub/time-view])) "on") :on-click (act [::pb/toggle])}
:on-click #(rf/dispatch [::ui/set-time-view :cel-sheet])} "cel sheet"] [icon/view (if playing? :pause :play)]]
(doall [:button {:aria-label "+1" :title "step forward one frame"
(for [r [0.25 0.5 1.0 2.0 4.0]] :on-click (act [::pb/step 1])} "+1"]]
^{:key r} [:div.group
[:button.ico {:class (when loop? "on") :aria-label "loop"
:title "loop playback" :on-click (act [::pb/toggle-loop])}
[icon/view :loop]]
[:button.ico {:class (when muted? "on") :aria-label (if muted? "unmute" "mute")
:title (if muted? "unmute" "mute") :on-click (act [::pb/toggle-mute])}
[icon/view (if muted? :muted :sound)]]]
;; Beside the transport rather than off at the right margin: it is what you
;; read WHILE scrubbing, and tabular figures keep it from twitching as it
;; counts.
[:span.readout.at-frame (str frame " / " frames)]
;; Five buttons for one mutually exclusive choice that is 1× almost always.
;; A select says "pick one of these" in the width of the answer.
;; playbackRate on the audio element and nothing else: the sound slows, ;; playbackRate on the audio element and nothing else: the sound slows,
;; currentTime advances proportionally, and the derived frame follows. Slow ;; currentTime advances proportionally, and the derived frame follows. Slow
;; motion cannot desync by construction. ;; motion cannot desync by construction.
[:button {:class (when (== r rate) "on") [:span.pick
:on-click #(rf/dispatch [::pb/set-rate r])} [:select {:value rate :aria-label "playback speed" :title "playback speed"
(case r 1.0 "1x" 0.5 "½" 0.25 "¼" 2.0 "2x" 4.0 "4x" (str r))])) :on-change #(rf/dispatch [::pb/set-rate
[:button {:title "a new empty symbol inside the selected instance, or beside the selected node, or in the open symbol" (js/parseFloat (.. % -target -value))])}
:on-click #(rf/dispatch [::ui/new-symbol])} (doall (for [[r label] [[0.25 "¼×"] [0.5 "½×"] [1.0 "1×"] [2.0 "2×"] [4.0 "4×"]]]
"+ symbol"] ^{:key r} [:option {:value r} label]))]]
[:button {:on-click #(rf/dispatch [::ui/new-lane])} "+ lane"] [:span.sep]
[:button {:disabled (not lane?) ;; ----------------------------------------------------------------- view
:title "append a new independent drawing to the selected lane" ;; TWO VIEWS OF ONE THING, and you are always in exactly one. Two separate
:on-click #(rf/dispatch [::ui/append-drawing])} "new drawing"] ;; toggles said neither half of that; joined, with the one in force filled,
[:button {:disabled (not insertable?) ;; the control is the statement.
:title "a new drawing at the playhead; later drawings ripple later" [:div.seg {:role "radiogroup" :aria-label "time view"}
:on-click #(rf/dispatch [::ui/insert-drawing])} "insert"] (doall
[:button {:disabled (not insertable?) (for [[k label title] [[:timeline "timeline" "lanes across, frames left to right"]
:title "replace the drawing at the playhead; later drawings stay put" [:cel-sheet "cel sheet" "frames down, one column per lane"]]]
:on-click #(rf/dispatch [::ui/overwrite-drawing])} "overwrite"] ^{:key k}
[:button {:disabled (not splittable?) [:button {:class (when (= k view) "on") :role "radio"
:title "cut this cel in two at the playhead; the picture does not change" :aria-checked (= k view) :title title
:on-click #(rf/dispatch [::ui/split-cel])} "split"] :on-click (act [::ui/set-time-view k])}
[:button {:disabled (not cel?) label]))]
:title "expose this same drawing again — one drawing, two cels" [:span.sep]
:on-click #(rf/dispatch [::ui/reuse-drawing])} "reuse"] ;; ------------------------------------------------------------- commands
[:button {:disabled (not cel?) ;; Fourteen of these, and all but two mean nothing without a selected lane
:title "append a copy of this drawing, to draw the next one over it" ;; or cel — so as buttons they were a permanent grey hedge across the strip
:on-click #(rf/dispatch [::ui/duplicate-drawing])} "duplicate"] ;; with their explanations hidden in `title`. Grouped by what they change,
[:button {:disabled (not shared?) ;; with the explanation on the row, they cost three slots and read as a
:title "give this cel its own copy; other cels keep sharing" ;; vocabulary. There is also room here for the correction commands, which
:on-click #(rf/dispatch [::ui/make-unique])} "make unique"] ;; `docs/lane-handoff.md` says are next.
[:button {:disabled (not splittable?) [menu/view
:title "start this cel at the playhead; nothing else moves" {:label "new" :title "add to the document"
:on-click #(rf/dispatch [::ui/trim-cel :in])} "trim in"] :items [{:label "symbol"
[:button {:disabled (not splittable?) :sub "empty, inside the selected instance or beside the selected node"
:title "end this cel at the playhead; nothing else moves" :on-click (act [::ui/new-symbol])}
:on-click #(rf/dispatch [::ui/trim-cel :out])} "trim out"] {:label "lane"
[:button {:disabled (not (and cel? (integer? at))) :sub "a row that holds one drawing after another"
:title "put this cel at the playhead; refused if something is there" :on-click (act [::ui/new-lane])}]}]
:on-click #(rf/dispatch [::ui/move-cel])} "move here"] [menu/view
[:button {:disabled (not cel?) {:label "drawing" :title "what the lane exposes"
:title "clear this cel's frames, leaving a gap; later drawings stay put" :note "select a lane, or a cel in one"
:on-click #(rf/dispatch [::ui/blank-cel])} "blank"] :items [{:label "new drawing" :disabled? (not lane?)
[:button {:disabled (not held?) :sub "append a new independent drawing to the selected lane"
:on-click (act [::ui/append-drawing])}
{:label "insert" :disabled? (not insertable?)
:sub "a new drawing at the playhead; later drawings ripple later"
:on-click (act [::ui/insert-drawing])}
{:label "overwrite" :disabled? (not insertable?)
:sub "replace the drawing at the playhead; later drawings stay put"
:on-click (act [::ui/overwrite-drawing])}
{:label "reuse" :disabled? (not cel?)
:sub "expose this same drawing again — one drawing, two cels"
:on-click (act [::ui/reuse-drawing])}
{:label "duplicate" :disabled? (not cel?)
:sub "append a copy of this drawing, to draw the next one over it"
:on-click (act [::ui/duplicate-drawing])}
{:label "make unique" :disabled? (not shared?)
:sub "give this cel its own copy; other cels keep sharing"
:on-click (act [::ui/make-unique])}]}]
[menu/view
{:label "cel" :title "where this cel sits and how long it lasts"
:note "select a cel in the timeline or the cel sheet"
:items [{:label "split" :disabled? (not splittable?)
:sub "cut this cel in two at the playhead; the picture does not change"
:on-click (act [::ui/split-cel])}
{:label "trim in" :disabled? (not splittable?)
:sub "start this cel at the playhead; nothing else moves"
:on-click (act [::ui/trim-cel :in])}
{:label "trim out" :disabled? (not splittable?)
:sub "end this cel at the playhead; nothing else moves"
:on-click (act [::ui/trim-cel :out])}
{:label "move here" :disabled? (not (and cel? (integer? at)))
:sub "put this cel at the playhead; refused if something is there"
:on-click (act [::ui/move-cel])}
{:label "blank" :disabled? (not cel?)
:sub "clear this cel's frames, leaving a gap; later drawings stay put"
:on-click (act [::ui/blank-cel])}]}]
;; THE ONE PAIR THAT STAYS A BUTTON. Deciding how many frames a drawing is
;; held for is shooting on ones or twos — the most repeated edit in the
;; list, done by eye, a frame at a time. Two clicks into a menu per frame
;; would be the one place this consolidation made the tool worse.
[:span.stepper
[:span.stepper-label "hold"]
[:span.group
[:button {:disabled (not held?) :aria-label "hold −"
:title "shorten this cel; ripple later drawings, keeping lane keys fixed" :title "shorten this cel; ripple later drawings, keeping lane keys fixed"
:on-click #(rf/dispatch [::ui/extend-hold -1])} "hold −"] :on-click (act [::ui/extend-hold -1])} "−"]
[:button {:disabled (not held?) [:button {:disabled (not held?) :aria-label "hold +"
:title "extend this cel; ripple later drawings, keeping lane keys fixed" :title "extend this cel; ripple later drawings, keeping lane keys fixed"
:on-click #(rf/dispatch [::ui/extend-hold 1])} "hold +"] :on-click (act [::ui/extend-hold 1])} "+"]]]
(when @(rf/subscribe [::sub/lane-retry])
[:button {:on-click #(rf/dispatch [::ui/lane-retry])} "extend shot and apply"])
[:span.spacer] [:span.spacer]
[:span.dim (str frame " / " frames)] ;; After the spacer, both of them: an offer that appears and a reading that
;; comes and goes must not shove the fixed controls sideways when they do.
(when @(rf/subscribe [::sub/lane-retry])
[:button.retry {:title "the command was refused because the shot is too short"
:on-click (act [::ui/lane-retry])} "extend shot and apply"])
;; Measured in the loop, not derived from the clock — the whole question ;; Measured in the loop, not derived from the clock — the whole question
;; while profiling is whether the painting keeps up with the clock, and a ;; while profiling is whether the painting keeps up with the clock, and a
;; number computed FROM the clock would answer itself. ;; number computed FROM the clock would answer itself. Shown only while it
[:span {:class (if (and drop (> drop 1.35)) "warn" "dim")} ;; has something to say: paused, it read "0.0 paint/s" for ever.
(str (.toFixed (or fps 0) 1) " paint/s" (when (pos? (or fps 0))
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]])) [:span.readout {:class (if (and drop (> drop 1.35)) "warn" "dim")}
(str (.toFixed fps 1) " paint/s"
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))])]))
(defn- takes? (defn- takes?
"Whether the row at `target`, a `target-kind` node, can take the row being "Whether the row at `target`, a `target-kind` node, can take the row being

View file

@ -67,18 +67,53 @@ try {
return true; return true;
})()`); })()`);
await sleep(250); await sleep(250);
// A command is named the same wherever it is drawn, and since the transport
// strip was consolidated it is drawn in one of two places: as a button in the
// strip, or as a row in one of the strip's menus. So the test asks for it by
// name and this finds it — opening each menu in turn to look — rather than the
// test knowing which menu anything ended up in. An icon button is matched on
// its `aria-label`, which is also what a screen reader is told it is.
const strip = '.pane.time .pane-head';
const named = label =>
`(b => b.textContent.trim() === ${JSON.stringify(label)}` +
` || b.getAttribute('aria-label') === ${JSON.stringify(label)})`;
const shut = async () => {
await evaluate(`(() => { document.querySelectorAll('.menu-scrim').forEach(s => s.click()); return true })()`);
await sleep(120);
};
// Leaves the control on screen and returns what to select it with.
const reveal = async label => {
await shut();
if (await evaluate(`![...document.querySelectorAll('${strip} button')].find(${named(label)})`)) {
const menus = await evaluate(
`[...document.querySelectorAll('${strip} .menu-wrap > button')].map(b => b.textContent.trim())`);
let found = false;
for (const menu of menus) {
await evaluate(`(() => { [...document.querySelectorAll('${strip} .menu-wrap > button')]
.find(b => b.textContent.trim() === ${JSON.stringify(menu)}).click(); return true })()`);
await sleep(180);
if (await evaluate(`!![...document.querySelectorAll('.menu-item')].find(${named(label)})`)) { found = true; break; }
await shut();
}
assert(found, `a control named: ${label}`);
return '.menu-item';
}
return `${strip} button`;
};
const click = async label => { const click = async label => {
const where = await reveal(label);
assert(await evaluate(`(() => { assert(await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)}); const b = [...document.querySelectorAll('${where}')].find(${named(label)});
if (!b || b.disabled) return false; if (!b || b.disabled) return false;
b.click(); return true; b.click(); return true;
})()`), `enabled button: ${label}`); })()`), `enabled control: ${label}`);
await sleep(180); await sleep(180);
await shut();
}; };
const shot = async () => (await evaluate('laneSnapshot()')); const shot = async () => (await evaluate('laneSnapshot()'));
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance') const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance')
.sort((a, b) => a.time.at - b.time.at); .sort((a, b) => a.time.at - b.time.at);
await click('+ lane'); await click('lane');
await click('new drawing'); await click('new drawing');
await click('hold +'); await click('hold +');
await click('hold +'); await click('hold +');
@ -123,10 +158,15 @@ try {
document.querySelector('.tl-cel').click(); document.querySelector('.tl-cel').click();
})()`); })()`);
await sleep(200); await sleep(200);
const enabled = async label => await evaluate(`(() => { const enabled = async label => {
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)}); const where = await reveal(label);
const yes = await evaluate(`(() => {
const b = [...document.querySelectorAll('${where}')].find(${named(label)});
return !!b && !b.disabled; return !!b && !b.disabled;
})()`); })()`);
await shut();
return yes;
};
assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet'); assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet');
await click('reuse'); await click('reuse');
s = await shot(); s = await shot();

View file

@ -185,7 +185,7 @@ const PROBE = `(() => {
w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones], w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones],
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null, cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
hash: h >>> 0, hash: h >>> 0,
frame: document.querySelector('.time .pane-head .dim')?.textContent ?? '', frame: document.querySelector('.time .pane-head .at-frame')?.textContent ?? '',
// What the top bar says about the document. The media pool holds the OPEN // What the top bar says about the document. The media pool holds the OPEN
// document's library now and no longer names documents at all, so the status // document's library now and no longer names documents at all, so the status
// line is the page's own answer to "what am I looking at". // line is the page's own answer to "what am I looking at".
@ -202,7 +202,7 @@ const PROBE = `(() => {
// knows which frame it wants, not how long the clip it is looking at happens to // knows which frame it wants, not how long the clip it is looking at happens to
// be, and reading it here is one place instead of every call site. // be, and reading it here is one place instead of every call site.
const SEEK = (f) => `(() => { const SEEK = (f) => `(() => {
const read = document.querySelector('.time .pane-head .dim').textContent; const read = document.querySelector('.time .pane-head .at-frame').textContent;
// Split, not a regex: this is inside a template literal, where an escaped // Split, not a regex: this is inside a template literal, where an escaped
// slash collapses to a bare one and the two together open a line comment that // slash collapses to a bare one and the two together open a line comment that
// eats the rest of the statement. The readout is "12 / 229" and nothing else. // eats the rest of the statement. The readout is "12 / 229" and nothing else.
@ -216,7 +216,7 @@ const SEEK = (f) => `(() => {
clientY: box.top + box.height / 2, clientY: box.top + box.height / 2,
})); }));
ruler.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 })); ruler.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
return document.querySelector('.time .pane-head .dim').textContent; return document.querySelector('.time .pane-head .at-frame').textContent;
})()`; })()`;
// Everything the page has to say about loading, saving and opening. Read off the // Everything the page has to say about loading, saving and opening. Read off the
@ -231,10 +231,15 @@ const STATUS = `[...document.querySelectorAll('.top .status, .pool .pane-body >
// pane each one lived in would be a second copy of the layout. // pane each one lived in would be a second copy of the layout.
// //
// `firstChild` is the label: a media-pool row has a second line in a child span, // `firstChild` is the label: a media-pool row has a second line in a child span,
// so matching on textContent would never find "take". // so matching on textContent would never find "take". `aria-label` is the same
// label for a control whose face is a glyph — the transport's play and pause —
// and it is the right fallback rather than a test hook, because it is already
// what that button is called for anybody not reading it with their eyes.
const CLICK = (label) => `(() => { const CLICK = (label) => `(() => {
const want = ${JSON.stringify(label)};
const b = [...document.querySelectorAll('button')] const b = [...document.querySelectorAll('button')]
.find((b) => (b.firstChild?.textContent ?? '').trim() === ${JSON.stringify(label)}); .find((b) => (b.firstChild?.textContent ?? '').trim() === want
|| b.getAttribute('aria-label') === want);
if (!b || b.disabled) return false; if (!b || b.disabled) return false;
b.click(); b.click();
return true; return true;
@ -626,7 +631,7 @@ async function main() {
check(irisSlider !== null, 'the stage eye has an iris-size slider'); check(irisSlider !== null, 'the stage eye has an iris-size slider');
if (irisSlider) { if (irisSlider) {
check(await page.eval(CLICK('play')), 'the stage starts playing'); check(await page.eval(CLICK('play')), 'the stage starts playing');
const startFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`); const startFrame = await page.eval(`Number(document.querySelector('.time .pane-head .at-frame').textContent.match(/\\d+/)[0])`);
await page.send('Input.dispatchMouseEvent', { await page.send('Input.dispatchMouseEvent', {
type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1, type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
}); });
@ -638,7 +643,7 @@ async function main() {
check(preview !== null, 'the slider updates the stage preview', preview ?? debug); check(preview !== null, 'the slider updates the stage preview', preview ?? debug);
check(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'), check(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'),
'the panel reports the affected feature and tier', debug); 'the panel reports the affected feature and tier', debug);
const endFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`); const endFrame = await page.eval(`Number(document.querySelector('.time .pane-head .at-frame').textContent.match(/\\d+/)[0])`);
check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`); check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`);
await page.eval(CLICK('pause')); await page.eval(CLICK('pause'));
} }

View file

@ -123,6 +123,33 @@ audio { display: none; }
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
color: var(--dim); color: var(--dim);
letter-spacing: .03em; letter-spacing: .03em;
/* One line, always. A strip of controls that reflows to two lines resizes the
pane under it, and the pane under this one is the timeline. */
white-space: nowrap;
}
.pane-head .readout { color: var(--fg); }
/* A pick-one that looks like the other pick-ones. A native select in this strip
draws a tall white well and a chevron of the browser's choosing, which reads
as a form field among buttons; the app's own idiom for "choose one of these"
is `label ▾`, and this IS one of those. So: the select keeps the behaviour and
the keyboard, and borrows the look. The arrow is the wrapper's, because an
option list cannot carry one. */
.pick { position: relative; display: inline-flex; align-items: center; }
.pick > select {
appearance: none;
background: var(--pane);
padding: 1px 15px 1px 6px;
cursor: pointer;
}
.pick > select:hover { background: #fff; }
.pick::after {
content: "▾";
position: absolute;
right: 5px;
color: var(--dim);
pointer-events: none;
} }
.pane-head .spacer { flex: 1; } .pane-head .spacer { flex: 1; }
@ -173,6 +200,47 @@ input[type="range"] { width: 100%; accent-color: var(--sel); }
.dim { color: var(--dim); } .dim { color: var(--dim); }
.warn { color: var(--warn); } .warn { color: var(--warn); }
/* Buttons that are one control: a transport, a stepper, a mode picker. They
share their borders, so the group reads as a single object with parts rather
than as several things that happen to be adjacent — which is the whole claim
a segmented control makes, and the reason `timeline` and `cel sheet` are one
of these. `.seg` is `.group` with that meaning; they are drawn the same
because the difference is what the buttons do, not how they look.
The negative margin collapses the doubled border between two buttons into
one hairline. A pressed button then has to be raised, or the neighbour's
plain border covers half of its accent one. */
.group, .seg { display: flex; align-items: center; }
.group > button, .seg > button { border-radius: 0; margin-left: -1px; }
.group > button:first-child, .seg > button:first-child { margin-left: 0; border-radius: 2px 0 0 2px; }
.group > button:last-child, .seg > button:last-child { border-radius: 0 2px 2px 0; }
.group > button.on, .seg > button.on { position: relative; z-index: 1; }
/* The rule between two groups of unrelated controls. Shorter than the strip, so
it reads as a division of the row rather than as a wall across it. */
.sep { flex: 0 0 1px; width: 1px; height: 13px; background: var(--line); }
/* An icon button. The glyph is inked in `currentColor`, so `.on` colours it
with the same rule that colours a word. 11px to sit on 11px type. */
button.ico { padding: 2px 5px; line-height: 0; }
button.ico > svg { display: block; width: 11px; height: 11px; }
/* Play is the one control in the strip you aim at without looking. */
button.ico-play { padding-left: 8px; padding-right: 8px; }
/* `hold −` / `hold +`: one label over two steppers, because the word is shared
and repeating it in both buttons was most of their width. */
.stepper { display: flex; align-items: center; gap: 4px; }
.stepper-label { color: var(--dim); }
.stepper button { padding: 1px 6px; }
/* A number that changes every frame. Tabular figures stop it twitching, and
stop the controls after it being nudged about as the count passes 9 and 99. */
.readout { font-variant-numeric: tabular-nums; }
/* The answer to a refusal rather than a standing command — `lane-retry` only
exists because something was declined and named this as the way through. */
button.retry { border-color: var(--sel); color: var(--sel); }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
top bar */ top bar */
@ -296,6 +364,39 @@ a.brand { text-decoration: none; }
.undo > .undo-list { padding: 0 4px; margin-right: 4px; border-left: 0; border-radius: 0 2px 2px 0; } .undo > .undo-list { padding: 0 4px; margin-right: 4px; border-left: 0; border-radius: 0 2px 2px 0; }
.menu.menu-left { left: 0; right: auto; } .menu.menu-left { left: 0; right: auto; }
/* A menu dropping out of a PANE HEAD. Fixed and placed by `ui/menu` against the
button, because a pane clips its own overflow and an absolutely positioned
panel would be cut off at the pane's edge — invisibly, and more of it the
longer the menu gets. `top`/`bottom`/`left`/`max-height` all come from there.
Wide enough that a command's one-line explanation is one line. At 215px every
`.sub` in the cel menu wrapped, which doubled the panel's height for no gain:
the explanations are the reason these are menu rows and not buttons, so they
get the width to be read in. */
.menu.menu-drop {
position: fixed;
/* BOTH offsets on each axis are reset, and `ui/menu` sets exactly one of each
inline. Leaving `.menu`'s own `top` standing alongside an inline `bottom`
over-constrains the box, and a fixed box given both offsets takes its HEIGHT
from them — which, from a toolbar near the foot of the window, computes
negative and collapses the panel to its padding with the commands still
inside it. */
top: auto;
bottom: auto;
left: auto;
right: auto;
width: 272px;
overflow: auto;
}
.menu-note { padding: 1px 6px 5px; }
/* A disabled row still says what the command is FOR. That is the whole trade:
a greyed button hides its reason in a `title`, a greyed row prints it. */
.menu-item:disabled { opacity: .5; cursor: default; }
.menu-item:disabled:hover { background: none; }
.menu-item { padding: 3px 6px; }
.menu-item .sub { font-size: 10px; line-height: 1.3; white-space: normal; }
/* The one way to hand the document to somebody else, so it looks like one. */ /* The one way to hand the document to somebody else, so it looks like one. */
button.share-button { background: var(--sel); color: #fff; border-color: var(--sel); font-weight: 600; padding: 1px 12px; } button.share-button { background: var(--sel); color: #fff; border-color: var(--sel); font-weight: 600; padding: 1px 12px; }
button.share-button:hover, button.share-button.on { filter: brightness(1.1); } button.share-button:hover, button.share-button.on { filter: brightness(1.1); }