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.ui :as ui]
[arthur.footage.store :as store]
[arthur.ui.icon :as icon]
[arthur.ui.menu :as menu]
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
@ -223,6 +225,9 @@
(defn- transport []
(let [playing? @(rf/subscribe [::playback/playing?])
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])
frames @(rf/subscribe [::render/frames])
{:keys [fps drop]} @player/meter
@ -237,7 +242,7 @@
lane? (node/lane? lane)
cel? (and lane? (= :instance (:kind n)) (some? (node/source 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.
shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip)
[_ other] (:nodes sym)
@ -250,82 +255,141 @@
(lane/lane-frame clip sid (:id lane) owner-frame))
insertable? (and lane? (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
[:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")]
[:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"]
[:button {:on-click #(rf/dispatch [::pb/step -1])} "-1"]
[:button {:on-click #(rf/dispatch [::pb/step 1])} "+1"]
[:button {:class (when @(rf/subscribe [::playback/loop?]) "on")
:on-click #(rf/dispatch [::pb/toggle-loop])} "loop"]
[:button {:class (when @(rf/subscribe [::playback/muted?]) "on")
:on-click #(rf/dispatch [::pb/toggle-mute])} "mute"]
[:button {:class (when (= :timeline @(rf/subscribe [::sub/time-view])) "on")
:on-click #(rf/dispatch [::ui/set-time-view :timeline])} "timeline"]
[:button {:class (when (= :cel-sheet @(rf/subscribe [::sub/time-view])) "on")
:on-click #(rf/dispatch [::ui/set-time-view :cel-sheet])} "cel sheet"]
(doall
(for [r [0.25 0.5 1.0 2.0 4.0]]
^{:key r}
;; playbackRate on the audio element and nothing else: the sound slows,
;; currentTime advances proportionally, and the derived frame follows. Slow
;; motion cannot desync by construction.
[:button {:class (when (== r rate) "on")
:on-click #(rf/dispatch [::pb/set-rate r])}
(case r 1.0 "1x" 0.5 "½" 0.25 "¼" 2.0 "2x" 4.0 "4x" (str r))]))
[:button {:title "a new empty symbol inside the selected instance, or beside the selected node, or in the open symbol"
:on-click #(rf/dispatch [::ui/new-symbol])}
"+ symbol"]
[:button {:on-click #(rf/dispatch [::ui/new-lane])} "+ lane"]
[:button {:disabled (not lane?)
:title "append a new independent drawing to the selected lane"
:on-click #(rf/dispatch [::ui/append-drawing])} "new drawing"]
[:button {:disabled (not insertable?)
:title "a new drawing at the playhead; later drawings ripple later"
:on-click #(rf/dispatch [::ui/insert-drawing])} "insert"]
[:button {:disabled (not insertable?)
:title "replace the drawing at the playhead; later drawings stay put"
:on-click #(rf/dispatch [::ui/overwrite-drawing])} "overwrite"]
[:button {:disabled (not splittable?)
:title "cut this cel in two at the playhead; the picture does not change"
:on-click #(rf/dispatch [::ui/split-cel])} "split"]
[:button {:disabled (not cel?)
:title "expose this same drawing again — one drawing, two cels"
:on-click #(rf/dispatch [::ui/reuse-drawing])} "reuse"]
[:button {:disabled (not cel?)
:title "append a copy of this drawing, to draw the next one over it"
:on-click #(rf/dispatch [::ui/duplicate-drawing])} "duplicate"]
[:button {:disabled (not shared?)
:title "give this cel its own copy; other cels keep sharing"
:on-click #(rf/dispatch [::ui/make-unique])} "make unique"]
[:button {:disabled (not splittable?)
:title "start this cel at the playhead; nothing else moves"
:on-click #(rf/dispatch [::ui/trim-cel :in])} "trim in"]
[:button {:disabled (not splittable?)
:title "end this cel at the playhead; nothing else moves"
:on-click #(rf/dispatch [::ui/trim-cel :out])} "trim out"]
[:button {:disabled (not (and cel? (integer? at)))
:title "put this cel at the playhead; refused if something is there"
:on-click #(rf/dispatch [::ui/move-cel])} "move here"]
[:button {:disabled (not cel?)
:title "clear this cel's frames, leaving a gap; later drawings stay put"
:on-click #(rf/dispatch [::ui/blank-cel])} "blank"]
[:button {:disabled (not held?)
:title "shorten this cel; ripple later drawings, keeping lane keys fixed"
:on-click #(rf/dispatch [::ui/extend-hold -1])} "hold −"]
[:button {:disabled (not held?)
:title "extend this cel; ripple later drawings, keeping lane keys fixed"
:on-click #(rf/dispatch [::ui/extend-hold 1])} "hold +"]
(when @(rf/subscribe [::sub/lane-retry])
[:button {:on-click #(rf/dispatch [::ui/lane-retry])} "extend shot and apply"])
;; ----------------------------------------------------------------- time
;; Where the playhead is and how it moves. Nothing here depends on a
;; selection, which is why it is the one group that is never greyed.
[:div.group
[:button.ico {:aria-label "go to start" :title "go to the first frame"
:on-click (act [::pb/seek 0])} [icon/view :start]]
[:button {:aria-label "−1" :title "step back one frame"
:on-click (act [::pb/step -1])} "−1"]
[:button.ico.ico-play {:aria-label (if playing? "pause" "play")
:title (if playing? "pause" "play")
:on-click (act [::pb/toggle])}
[icon/view (if playing? :pause :play)]]
[:button {:aria-label "+1" :title "step forward one frame"
:on-click (act [::pb/step 1])} "+1"]]
[: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,
;; currentTime advances proportionally, and the derived frame follows. Slow
;; motion cannot desync by construction.
[:span.pick
[:select {:value rate :aria-label "playback speed" :title "playback speed"
:on-change #(rf/dispatch [::pb/set-rate
(js/parseFloat (.. % -target -value))])}
(doall (for [[r label] [[0.25 "¼×"] [0.5 "½×"] [1.0 "1×"] [2.0 "2×"] [4.0 "4×"]]]
^{:key r} [:option {:value r} label]))]]
[:span.sep]
;; ----------------------------------------------------------------- view
;; TWO VIEWS OF ONE THING, and you are always in exactly one. Two separate
;; toggles said neither half of that; joined, with the one in force filled,
;; the control is the statement.
[:div.seg {:role "radiogroup" :aria-label "time view"}
(doall
(for [[k label title] [[:timeline "timeline" "lanes across, frames left to right"]
[:cel-sheet "cel sheet" "frames down, one column per lane"]]]
^{:key k}
[:button {:class (when (= k view) "on") :role "radio"
:aria-checked (= k view) :title title
:on-click (act [::ui/set-time-view k])}
label]))]
[:span.sep]
;; ------------------------------------------------------------- commands
;; Fourteen of these, and all but two mean nothing without a selected lane
;; or cel — so as buttons they were a permanent grey hedge across the strip
;; with their explanations hidden in `title`. Grouped by what they change,
;; with the explanation on the row, they cost three slots and read as a
;; vocabulary. There is also room here for the correction commands, which
;; `docs/lane-handoff.md` says are next.
[menu/view
{:label "new" :title "add to the document"
:items [{:label "symbol"
:sub "empty, inside the selected instance or beside the selected node"
:on-click (act [::ui/new-symbol])}
{:label "lane"
:sub "a row that holds one drawing after another"
:on-click (act [::ui/new-lane])}]}]
[menu/view
{:label "drawing" :title "what the lane exposes"
:note "select a lane, or a cel in one"
:items [{:label "new drawing" :disabled? (not lane?)
: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"
:on-click (act [::ui/extend-hold -1])} "−"]
[:button {:disabled (not held?) :aria-label "hold +"
:title "extend this cel; ripple later drawings, keeping lane keys fixed"
:on-click (act [::ui/extend-hold 1])} "+"]]]
[: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
;; while profiling is whether the painting keeps up with the clock, and a
;; number computed FROM the clock would answer itself.
[:span {:class (if (and drop (> drop 1.35)) "warn" "dim")}
(str (.toFixed (or fps 0) 1) " paint/s"
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]]))
;; number computed FROM the clock would answer itself. Shown only while it
;; has something to say: paused, it read "0.0 paint/s" for ever.
(when (pos? (or fps 0))
[: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?
"Whether the row at `target`, a `target-kind` node, can take the row being

View file

@ -67,18 +67,53 @@ try {
return true;
})()`);
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 where = await reveal(label);
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;
b.click(); return true;
})()`), `enabled button: ${label}`);
})()`), `enabled control: ${label}`);
await sleep(180);
await shut();
};
const shot = async () => (await evaluate('laneSnapshot()'));
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance')
.sort((a, b) => a.time.at - b.time.at);
await click('+ lane');
await click('lane');
await click('new drawing');
await click('hold +');
await click('hold +');
@ -123,10 +158,15 @@ try {
document.querySelector('.tl-cel').click();
})()`);
await sleep(200);
const enabled = async label => await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)});
return !!b && !b.disabled;
})()`);
const enabled = async label => {
const where = await reveal(label);
const yes = await evaluate(`(() => {
const b = [...document.querySelectorAll('${where}')].find(${named(label)});
return !!b && !b.disabled;
})()`);
await shut();
return yes;
};
assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet');
await click('reuse');
s = await shot();

View file

@ -185,7 +185,7 @@ const PROBE = `(() => {
w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones],
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
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
// 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".
@ -202,7 +202,7 @@ const PROBE = `(() => {
// 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.
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
// 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.
@ -216,7 +216,7 @@ const SEEK = (f) => `(() => {
clientY: box.top + box.height / 2,
}));
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
@ -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.
//
// `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 want = ${JSON.stringify(label)};
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;
b.click();
return true;
@ -626,7 +631,7 @@ async function main() {
check(irisSlider !== null, 'the stage eye has an iris-size slider');
if (irisSlider) {
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', {
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(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'),
'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}`);
await page.eval(CLICK('pause'));
}