From 5fb04f6a7d0c7760b1e8679a4ed6630929679d09 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 1 Oct 2026 00:11:16 -0400 Subject: [PATCH] better toolbar --- frontend/src/arthur/ui/icon.cljs | 55 +++++++ frontend/src/arthur/ui/menu.cljs | 81 +++++++++++ frontend/src/arthur/ui/timeline.cljs | 210 +++++++++++++++++---------- frontend/test/browser/lane.mjs | 54 ++++++- frontend/test/browser/take.mjs | 19 ++- static/arthur/app.css | 101 +++++++++++++ 6 files changed, 433 insertions(+), 87 deletions(-) create mode 100644 frontend/src/arthur/ui/icon.cljs create mode 100644 frontend/src/arthur/ui/menu.cljs diff --git a/frontend/src/arthur/ui/icon.cljs b/frontend/src/arthur/ui/icon.cljs new file mode 100644 index 0000000..c91f827 --- /dev/null +++ b/frontend/src/arthur/ui/icon.cljs @@ -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"}])])) diff --git a/frontend/src/arthur/ui/menu.cljs b/frontend/src/arthur/ui/menu.cljs new file mode 100644 index 0000000..93342fe --- /dev/null +++ b/frontend/src/arthur/ui/menu.cljs @@ -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])]))]])])))) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index a589d5d..04b9011 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -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 diff --git a/frontend/test/browser/lane.mjs b/frontend/test/browser/lane.mjs index 5b08e68..f9c64f3 100644 --- a/frontend/test/browser/lane.mjs +++ b/frontend/test/browser/lane.mjs @@ -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(); diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index e8f3205..4c866f0 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -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')); } diff --git a/static/arthur/app.css b/static/arthur/app.css index b044afe..6958fcd 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -123,6 +123,33 @@ audio { display: none; } border-bottom: 1px solid var(--line); color: var(--dim); 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; } @@ -173,6 +200,47 @@ input[type="range"] { width: 100%; accent-color: var(--sel); } .dim { color: var(--dim); } .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 */ @@ -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; } .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. */ 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); }