From f238ff4f62ba1aea9f04b4541286b772b7eaf9bf Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Sun, 4 Oct 2026 03:08:21 -0400 Subject: [PATCH 1/3] Put the top bar on the surface as icons, and give palettes a real chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top bar: every document command is an icon button in a run — panes, new/open, undo/history/redo, cut/copy/paste/duplicate, snapshots — with the title and status in the middle and export, share and account on the right. Pane toggles are pictures of the window with that pane filled in. Stage bar: tracing, onion and passepartout are all the same toggle-plus-settings split button, followed by one zoom group, with fit inside it. Popovers: one `menu/popover`, measured against its button and dismissed by a scrim, replaces the
popouts that never closed on an outside click. Palette: the strip ends in a palette button whose popover lists every palette with its swatch strip, renames the open one in place, and marks the ones on the stage. An info badge says when the palette being edited is not one the stage draws in at the playhead; both ends of a palette-lane blend count. `clip/palette-at` is the resolver's root palette lookup, pulled out so the strip asks the same question the renderer answers. Co-Authored-By: Claude Opus 5.5 --- frontend/src/arthur/domain/clip.cljs | 99 +++++++------ frontend/src/arthur/subs/render.cljs | 19 +++ frontend/src/arthur/ui/layout.cljs | 115 +++++++++------ frontend/src/arthur/ui/menu.cljs | 26 ++++ frontend/src/arthur/ui/openmenu.cljs | 72 ++++----- frontend/src/arthur/ui/palette.cljs | 128 +++++++++++++--- frontend/src/arthur/ui/snapshots.cljs | 58 ++++---- frontend/src/arthur/ui/stage.cljs | 3 +- frontend/src/arthur/ui/tools.cljs | 25 ++-- frontend/src/arthur/ui/topbar.cljs | 131 +++++++++++------ frontend/src/arthur/ui/undo.cljs | 48 +++--- .../test/arthur/domain/instance_test.cljs | 3 + frontend/test/browser/onion.mjs | 10 +- frontend/test/browser/take.mjs | 3 +- static/arthur/app.css | 137 ++++++++++++++---- 15 files changed, 564 insertions(+), 313 deletions(-) diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index 7f83e45..962d481 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -351,6 +351,58 @@ (active-palette [this] "The palette selected by this resolver's most recently resolved frame.")) +(defn- channel-value [store selection frame] + (cond + (nil? selection) nil + (and (map? selection) (contains? selection :animated?)) + (ch/value-at selection frame store) + :else selection)) + +(defn palette-at + "The palette symbol `owner` draws in at `frame`, as the stage's root does: a + palette id, or `{:from :to :t}` while its palette lane blends from one to the + next. `inherited` is what an unset palette falls back to, then `default`." + [clip store default owner frame inherited] + ;; A palette track has meaningful uncovered time. Ordinary held + ;; channels clamp to their first key before it, but doing that + ;; here would erase the gap before the first palette segment. + (let [channel-value (partial channel-value store) + fallback (or (channel-value (:palette owner) frame) + inherited default) + materialize (fn [choice] + (cond + (= pal/inherit choice) fallback + (map? choice) (-> choice + (update :from #(if (= pal/inherit %) fallback %)) + (update :to #(if (= pal/inherit %) fallback %))) + :else choice)) + track (:palette-channel owner) + track-value (if-let [ks (:keys track)] + (some->> (keys ks) + (filter #(<= % frame)) + sort last + (get ks)) + (channel-value track frame))] + (or (when-let [track-sid (and (keyword? (:palette-track owner)) + (:palette-track owner))] + (let [track-symbol (symbol clip track-sid) + palette-clip (first + (filter (fn [n] + (let [[a b] (node/placed-span n)] + (and a (<= a frame) (< frame b)))) + (symbol/children (:nodes track-symbol)))) + palette-symbol (some-> palette-clip node/source + (#(symbol clip %))) + fallback (when (= :palette (:type palette-symbol)) + (:palette-ref palette-symbol)) + choice (get-in palette-clip [:channels [:palette]]) + start (some-> palette-clip node/placed-span first)] + (or (when (and choice start) + (materialize (channel-value choice (- frame start)))) + fallback))) + track-value + fallback))) + (defn resolver "Resolve an output frame, selecting native content at each symbol boundary. Every instance owns its cursors and buffers. The IResolver queries return @@ -358,51 +410,8 @@ [clip sid store palette opts] (let [context? (and (map? palette) (:palettes palette) (:offsets palette)) active-palette-state (atom (:default palette))] - (letfn [(channel-value [selection frame] - (cond - (nil? selection) nil - (and (map? selection) (contains? selection :animated?)) - (ch/value-at selection frame store) - :else selection)) - (root-selection-at [owner frame inherited] - ;; A palette track has meaningful uncovered time. Ordinary held - ;; channels clamp to their first key before it, but doing that - ;; here would erase the gap before the first palette segment. - (let [fallback (or (channel-value (:palette owner) frame) - inherited (:default palette)) - materialize (fn [choice] - (cond - (= pal/inherit choice) fallback - (map? choice) (-> choice - (update :from #(if (= pal/inherit %) fallback %)) - (update :to #(if (= pal/inherit %) fallback %))) - :else choice)) - track (:palette-channel owner) - track-value (if-let [ks (:keys track)] - (some->> (keys ks) - (filter #(<= % frame)) - sort last - (get ks)) - (channel-value track frame))] - (or (when-let [track-sid (and (keyword? (:palette-track owner)) - (:palette-track owner))] - (let [track-symbol (symbol clip track-sid) - palette-clip (first - (filter (fn [n] - (let [[a b] (node/placed-span n)] - (and a (<= a frame) (< frame b)))) - (symbol/children (:nodes track-symbol)))) - palette-symbol (some-> palette-clip node/source - (#(symbol clip %))) - fallback (when (= :palette (:type palette-symbol)) - (:palette-ref palette-symbol)) - choice (get-in palette-clip [:channels [:palette]]) - start (some-> palette-clip node/placed-span first)] - (or (when (and choice start) - (materialize (channel-value choice (- frame start)))) - fallback))) - track-value - fallback))) + (letfn [(root-selection-at [owner frame inherited] + (palette-at clip store (:default palette) owner frame inherited)) (selection-at [owner frame inherited] (let [selection (:palette owner) chosen (cond diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index c535fe4..8acd478 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -136,6 +136,25 @@ :<- [::palette] (fn [compiled _] (:ramp compiled))) +(rf/reg-sub + ::palettes-shown + :<- [::clip] + :<- [::open] + :<- [::store] + :<- [::open-frame] + :<- [::palette] + ;; The palettes the stage is drawing in at the playhead: the open symbol's own, + ;; BOTH ends while its palette lane blends from one to the next, and any an + ;; instance in it is set to. The palette strip asks it whether the palette it + ;; is editing is one of these. + (fn [[document sid store frame compiled] _] + (when-let [sym (and document (clip/symbol document sid))] + (let [at #(clip/palette-at document store (:default compiled) % (or frame 0) nil) + ends #(if (map? %) [(:from %) (:to %)] [%])] + (into (set (ends (at sym))) + (comp (filter :palette) (mapcat (comp ends at))) + (vals (:nodes sym))))))) + (rf/reg-sub ::store :<- [::clip-id] diff --git a/frontend/src/arthur/ui/layout.cljs b/frontend/src/arthur/ui/layout.cljs index 4cf9c2c..2440881 100644 --- a/frontend/src/arthur/ui/layout.cljs +++ b/frontend/src/arthur/ui/layout.cljs @@ -21,6 +21,7 @@ half of this (see `@media` in app.css), and a drawer that is shut should not be holding video thumbnails live." (:require [arthur.domain.onion :as onion] + [arthur.ui.menu :as menu] [re-frame.core :as rf])) (def ^:private narrow? @@ -120,21 +121,26 @@ :on-pointer-cancel (fn [_] (reset! sizing nil))}])) (defn panes - "Which panes are open, as a segmented control in the top bar. In the top bar - because a shut pane has no head left to carry its own handle, and because on a - narrow screen this is the whole of the navigation." + "Which panes are open, as three switches at the left of the top bar, each a + picture of the window with its pane filled in. In the top bar because a shut + pane has no head left to carry its own handle, and because on a narrow screen + this is the whole of the navigation." [] (let [{:keys [shut]} @(rf/subscribe [::state])] - [:div.seg.panes {:aria-label "panes"} + [:div.icons.panes {:aria-label "panes"} (doall - (for [[k label] [[:pool "pool"] [:params "inspector"] [:time "timeline"]]] + (for [[k label strip] [[:pool "media pool" {:x 1.5 :y 2.5 :width 4 :height 11}] + [:time "timeline" {:x 1.5 :y 9.5 :width 13 :height 4}] + [:params "inspector" {:x 10.5 :y 2.5 :width 4 :height 11}]]] (let [open? (not (contains? shut k))] ^{:key k} - [:button {:class (when open? "on") - :aria-pressed open? - :title (str (if open? "hide the " "show the ") label) - :on-click #(rf/dispatch [::toggle k])} - label])))])) + [:button.ico {:class (when open? "on") + :aria-pressed open? :aria-label label + :title (str (if open? "hide the " "show the ") label) + :on-click #(rf/dispatch [::toggle k])} + [:svg {:view-box "0 0 16 16"} + [:rect.frame {:x 1.5 :y 2.5 :width 13 :height 11 :rx 1}] + [:rect (assoc strip :rx 0.5)]]])))])) (defn- stepped [k z out?] ;; Geometric steps work below 100% as well as above it. @@ -146,8 +152,9 @@ "− + for one zoomable surface. THE READOUT IS THE BUTTON BACK: it says what the zoom is, pressing it returns to normal, and it is accented while it has something to return from — so the quick way back is in the one place - you already look to find out where you are." - [k label] + you already look to find out where you are. `more` joins the group: the + stage's fit." + [k label & more] (let [z @(rf/subscribe [::zoom k]) [lo hi] (limits k) home (k normal)] @@ -162,10 +169,11 @@ (str (js/Math.round (* 100 z)) "%")] [:button {:disabled (>= z hi) :aria-label (str label " zoom in") :title (str "zoom " label " in") - :on-click #(rf/dispatch [::set k (stepped k z false)])} "+"]])) + :on-click #(rf/dispatch [::set k (stepped k z false)])} "+"] + (into [:<>] more)])) -(rf/reg-sub ::mat (fn [db _] (merge {:opacity 0.55} (get-in db [:ui :passepartout])))) +(rf/reg-sub ::mat (fn [db _] (merge {:on? true :opacity 0.55} (get-in db [:ui :passepartout])))) (rf/reg-event-db ::mat (fn [db [_ k v]] (assoc-in db [:ui :passepartout k] v))) (rf/reg-sub ::onion @@ -175,30 +183,42 @@ (fn [db [_ k v]] (assoc-in db [:ui :onion (:clip/current db) (get-in db [:ui :open]) k] v))) +(defn overlay + "Something drawn over the stage that is not the picture: a switch, and its + settings in a popover beside it. Tracing, onion and passepartout are all this + one shape, so they read as three of a kind." + [class label on? tip toggle! settings] + [:div.group.overlay {:class class} + [:button {:class (when on? "on") :aria-pressed (boolean on?) :title tip + :on-click toggle!} + label] + [menu/popover {:label "▾" :title (str label " settings")} settings]]) + +(defn- slider [label lo hi v on-change] + [:label label + [:input {:type "range" :min lo :max hi :step 0.05 :value v + :on-change #(on-change (js/parseFloat (.. % -target -value)))}]]) + (defn onion-controls [] (let [{:keys [on? before after opacity scope]} @(rf/subscribe [::onion])] - [:div.group.onion-controls - [:button {:class (when on? "on") :aria-pressed (boolean on?) - :title "Onion skin — neighboring cels of the selected lane" - :on-click #(rf/dispatch [::onion :on? (not on?)])} "onion"] - [:details - [:summary {:aria-label "Onion skin settings" :title "Onion skin settings"} "▾"] - [:div.view-popout - [:label "Scope" - [:select {:value (name scope) - :on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])} - [:option {:value "selected"} "Selected lane"] - [:option {:value "symbol"} "Whole symbol"]]] - (for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]] - ^{:key k} [:label label - [:input {:type "number" :min 0 :max 5 :value v - :on-change #(let [n (js/parseInt (.. % -target -value) 10)] - (when-not (js/isNaN n) - (rf/dispatch [::onion k (max 0 (min 5 n))])))}]]) - [:label "Opacity" - [:input {:type "range" :min 0.05 :max 0.6 :step 0.05 :value opacity - :on-change #(rf/dispatch [::onion :opacity (js/parseFloat (.. % -target -value))])}]] - [:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]]]])) + [overlay "onion-controls" "onion" on? + "Onion skin — neighboring cels of the selected lane" + #(rf/dispatch [::onion :on? (not on?)]) + (fn [_] + [:div.view-popout + [:label "Scope" + [:select {:value (name scope) + :on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])} + [:option {:value "selected"} "Selected lane"] + [:option {:value "symbol"} "Whole symbol"]]] + (for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]] + ^{:key k} [:label label + [:input {:type "number" :min 0 :max 5 :value v + :on-change #(let [n (js/parseInt (.. % -target -value) 10)] + (when-not (js/isNaN n) + (rf/dispatch [::onion k (max 0 (min 5 n))])))}]]) + [slider "Opacity" 0.05 0.6 opacity #(rf/dispatch [::onion :opacity %])] + [:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]])])) (defn fit-stage! [] (when-let [area (.querySelector js/document ".stage-area")] @@ -209,14 +229,19 @@ (/ (- (.-clientHeight area) 32) h))]) (js/requestAnimationFrame #(do (set! (.-scrollLeft area) 0) (set! (.-scrollTop area) 0))))))) -(defn stage-controls [] - (let [{:keys [opacity]} @(rf/subscribe [::mat])] +(defn stage-controls + "The stage's own view, after the tracing switch: what else is drawn over it, + then how big it is. Fit is in the zoom group because it IS a zoom." + [] + (let [{:keys [on? opacity]} @(rf/subscribe [::mat])] [:<> [onion-controls] - [:button {:on-click fit-stage! :title "Fit stage in view"} "fit"] - [:details.passepartout-controls - [:summary "passepartout"] - [:div.view-popout - [:label "Passepartout" - [:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity - :on-change #(rf/dispatch [::mat :opacity (js/parseFloat (.. % -target -value))])}]]]]])) + [overlay "passepartout-controls" "passepartout" on? + "Darken the desk around the stage" + #(rf/dispatch [::mat :on? (not on?)]) + (fn [_] + [:div.view-popout + [slider "Opacity" 0 1 opacity #(rf/dispatch [::mat :opacity %])]])] + [:span.sep] + [zoomer :stage "the stage" + [:button {:on-click fit-stage! :title "Fit stage in view"} "fit"]]])) diff --git a/frontend/src/arthur/ui/menu.cljs b/frontend/src/arthur/ui/menu.cljs index 93342fe..61ae8a2 100644 --- a/frontend/src/arthur/ui/menu.cljs +++ b/frontend/src/arthur/ui/menu.cljs @@ -79,3 +79,29 @@ :on-click (fn [] (close!) (on-click))} label (when sub [:span.sub sub])]))]])])))) + +(defn popover + "A button that opens a panel of settings rather than a list of commands: the + onion's counts, a slider, the palette's name. Placed and dismissed exactly as + `view` is — measured against its button, shut by a click anywhere else — so + every popover in the window closes the same way. `body` is a function of the + close function, for a panel with a control that should shut it." + [_ _] + (let [at (r/atom nil)] + (fn [{:keys [label title class]} body] + (let [close! #(reset! at nil)] + [:div.menu-wrap {:class class} + [:button {:class (str "pop" (when @at " on")) :title title :aria-label title + :aria-haspopup "dialog" :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.popout + {:role "dialog" :aria-label title + :style (cond-> {:left (str left "px") :max-height (str max-height "px")} + top (assoc :top (str top "px")) + bottom (assoc :bottom (str bottom "px")))} + (body close!)]])])))) diff --git a/frontend/src/arthur/ui/openmenu.cljs b/frontend/src/arthur/ui/openmenu.cljs index 5f10bd3..4450b10 100644 --- a/frontend/src/arthur/ui/openmenu.cljs +++ b/frontend/src/arthur/ui/openmenu.cljs @@ -14,9 +14,7 @@ (:require [arthur.db :as db] [arthur.events.playback :as pb] [arthur.events.project :as project] - [arthur.subs.playback :as playback] - [re-frame.core :as rf] - [reagent.core :as r])) + [re-frame.core :as rf])) (defn- when-said "The ISO stamp the server sends, as a date. Truncated rather than formatted: @@ -35,44 +33,30 @@ label (when sub [:span.sub sub])]) -(defn view [] - (r/with-let [open? (r/atom false)] - (let [{:keys [items loading?]} @(rf/subscribe [::project/listing]) - busy? (:busy? @(rf/subscribe [::playback/project])) - choose! (fn [event] (reset! open? false) (rf/dispatch event))] - [:div.menu-wrap - [:button {:disabled busy? - :class (when @open? "on") - :on-click (fn [] - (when-not @open? (rf/dispatch [::project/list])) - (swap! open? not))} - "open ▾"] - (when @open? - [:<> - ;; A full-page catcher behind the panel, so clicking anywhere else - ;; dismisses it. Cheaper and more predictable than a document-level - ;; listener that has to be added, removed and told to ignore the click - ;; that opened the menu. - [:div.menu-scrim {:on-click #(reset! open? false)}] - [:div.menu - [:h2 "projects"] - (cond - loading? [:div.dim "…"] - (empty? items) [:div.dim "none saved yet"] - :else - (doall - (for [{:keys [id name seq updated]} items] - ^{:key id} - [item {:label (or name "untitled") - :sub (str "r" seq " · " (when-said updated)) - :on-click #(choose! [::project/open id])}]))) - [:h2 "built-in examples"] - [:div.dim "compiled in, not saved documents"] - (doall - (for [[id {:keys [label]}] db/clips] - ^{:key id} - [item {:label label :example? true - :on-click #(choose! [::pb/select-clip id])}])) - [item {:label "8625 stage study" :example? true - :sub "composed from a locally saved project" - :on-click #(choose! [::project/load-stage])}]]])]))) +(defn panel + "The list, under the top bar's file menu. `close!` shuts it." + [close!] + (let [{:keys [items loading?]} @(rf/subscribe [::project/listing]) + choose! (fn [event] (close!) (rf/dispatch event))] + [:div.menu.menu-left + [:h2 "projects"] + (cond + loading? [:div.dim "…"] + (empty? items) [:div.dim "none saved yet"] + :else + (doall + (for [{:keys [id name seq updated]} items] + ^{:key id} + [item {:label (or name "untitled") + :sub (str "r" seq " · " (when-said updated)) + :on-click #(choose! [::project/open id])}]))) + [:h2 "built-in examples"] + [:div.dim "compiled in, not saved documents"] + (doall + (for [[id {:keys [label]}] db/clips] + ^{:key id} + [item {:label label :example? true + :on-click #(choose! [::pb/select-clip id])}])) + [item {:label "8625 stage study" :example? true + :sub "composed from a locally saved project" + :on-click #(choose! [::project/load-stage])}]])) diff --git a/frontend/src/arthur/ui/palette.cljs b/frontend/src/arthur/ui/palette.cljs index a95b038..e034ff6 100644 --- a/frontend/src/arthur/ui/palette.cljs +++ b/frontend/src/arthur/ui/palette.cljs @@ -15,6 +15,7 @@ The footage switch is NOT here. It is a viewing aid rather than something you set before you draw, so it is a section of the inspector. See `ui/params`." (:require [arthur.domain.channel :as channel] + [arthur.domain.clip :as clip] [arthur.domain.node :as node] [arthur.domain.palette :as pal] [arthur.domain.symbol :as symbol] @@ -22,6 +23,8 @@ [arthur.events.ui :as ui] [arthur.subs.render :as render] [arthur.subs.ui :as sub] + [arthur.ui.menu :as menu] + [clojure.string :as str] [re-frame.core :as rf] [reagent.core :as r])) @@ -174,30 +177,107 @@ :on-click #(pick! [:remap {}] placements)} "⇄"]]]])) +(defn- strip + "Every slot of a palette, side by side: what it looks like at a glance." + [{:keys [slots]}] + [:span.strip (for [[i {:keys [hex]}] (map-indexed vector slots)] + ^{:key i} [:i {:style {:background hex}}])]) + +(defn- rename + "The open palette's name, made editable. Committed on Enter or blur, so a + rename is one undo step and not one per letter; Esc leaves it as it was." + [pid palette done!] + [:input.palette-name + {:type "text" :default-value (:name palette) :auto-focus true + :aria-label "palette name" + :on-focus #(.select (.-target %)) + :on-blur (fn [^js e] + (rf/dispatch [::project/palette-name pid (.. e -target -value)]) + (done!)) + :on-key-down (fn [^js e] + (case (.-key e) + "Enter" (.blur (.-target e)) + "Escape" (do (set! (.. e -target -value) (:name palette)) + (.blur (.-target e))) + nil))}]) + +(defn- chooser + "Every palette in the project as a row — its strip and its name — so what you + would switch to is visible before you switch. The open one carries the edit + button that renames it; the ones the stage is drawing in at the playhead carry + a dot." + [clip pid] + (r/with-let [renaming? (r/atom false)] + (let [on-stage @(rf/subscribe [::render/palettes-shown])] + [:div.palette-list {:role "listbox" :aria-label "palettes"} + (doall + (for [[id p] (sort-by (comp str :name val) (pal/palettes clip)) + :let [open? (= id pid)]] + ^{:key (str id)} + [:div.palette-row {:class (when open? "on")} + (if (and open? @renaming?) + [:div.choose [strip p] [rename id p #(reset! renaming? false)]] + [:button.choose {:role "option" :aria-selected open? + :title (if open? (str "editing " (:name p)) (str "edit " (:name p))) + :on-click (fn [] + (when-not open? + (reset! renaming? false) + (rf/dispatch [::ui/set-tone 0]) + (rf/dispatch [::select id])))} + [strip p] + [:span.name (:name p)] + (when (contains? on-stage id) + [:span.on-stage {:title "on the stage at the playhead"}])]) + (when open? + [:button.rename {:title (str "rename " (:name p)) :aria-label "rename palette" + :on-click #(swap! renaming? not)} + "✎"])]))]))) + +(defn- elsewhere + "An info badge on the palette button when the palette being edited is not one + the stage draws in at the playhead — not an error: editing another palette is + how one is made ready for a palette lane. Hovering says where to change it. + The card is placed in the viewport, as `ui/menu` places its panels, because + the tool strip scrolls and clips its overflow." + [pid palette] + (r/with-let [at (r/atom nil)] + (let [on-stage @(rf/subscribe [::render/palettes-shown]) + clip @(rf/subscribe [::render/clip]) + sid @(rf/subscribe [::render/open])] + (when (and (seq on-stage) (not (contains? on-stage pid))) + [:span.palette-info + {:aria-label "palette not on the stage here" + :on-mouse-enter (fn [^js e] + (let [r (.getBoundingClientRect (.-currentTarget e))] + (reset! at [(+ (.-right r) 8) (- (.-top r) 6)]))) + :on-mouse-leave #(reset! at nil)} + "i" + (when-let [[x y] @at] + [:span.info-card {:role "tooltip" :style {:left (str x "px") :top (str y "px")}} + [:strong (str (:name palette) " isn't on the stage here")] + (str (or (clip/symbol-name clip sid) "This symbol") + " draws in " (str/join " and " (map #(:name (get (pal/palettes clip) %)) on-stage)) + " at this frame. To draw in " (:name palette) + ", set the authoring palette in the symbol inspector, or blend to it with a palette lane.")])])))) + (defn assets - "Which palette asset the grid shows, and a new or duplicated one, for the - tool strip, with a popout for the full asset controls." + "The palettes behind a button at the foot of the strip: switch, rename, add. + The name is NOT printed in the strip: 44px holds no palette's name, and a name + broken over three lines is worse than one shown when asked for." [] (let [[clip pid palette] (shown)] - [:details.palette-assets - {:on-toggle (fn [e] - (let [el (.-currentTarget e) - box (.getBoundingClientRect el) - pop (.querySelector el ".palette-popout")] - (set! (.. pop -style -left) (str (+ 4 (.-right box)) "px")) - (set! (.. pop -style -top) (str (max 8 (min (.-top box) (- (.-innerHeight js/window) 160))) "px"))))} - [:summary {:title (:name palette)} [:span (:name palette)]] - [:div.palette-popout - [:strong (:name palette)] - [:select {:value (str pid) - :title "palette asset" - :on-change (fn [e] - (let [v (.. e -target -value) - id (first (filter #(= v (str %)) (keys (pal/palettes clip))))] - (rf/dispatch [::ui/set-tone 0]) - (rf/dispatch [::select id])))} - (for [[id p] (sort-by (comp str :name val) (pal/palettes clip))] - ^{:key (str id)} [:option {:value (str id)} (:name p)])] - [:button {:title "new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "+"] - [:button {:title (str "duplicate " (:name palette) " — a copy you can retone") - :on-click #(rf/dispatch [::project/duplicate-palette pid])} "clone"]]])) + [:div.palette-assets-wrap + [menu/popover {:class "palette-assets" :title (str "palette: " (:name palette)) + :label [:svg {:view-box "0 0 18 18" :width 18 :height 18} + [:path {:d "M9 2C4.6 2 1.5 5.2 1.5 9c0 4 3.4 7 7.3 7 1.4 0 1.9-.9 1.4-1.9-.5-1 0-2.1 1.4-2.1h1.9c1.9 0 3-1.2 3-3.2C16.5 5 13.2 2 9 2Z"}] + [:circle {:cx 5.5 :cy 8 :r 1.3}] + [:circle {:cx 8.5 :cy 5 :r 1.3}] + [:circle {:cx 12.5 :cy 6.5 :r 1.3}]]} + (fn [_] + [:div.palette-popout + [chooser clip pid] + [:div.row + [:button {:title "a new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "new"] + [:button {:title (str "a copy of " (:name palette) " you can retone") + :on-click #(rf/dispatch [::project/duplicate-palette pid])} "duplicate"]]])] + [elsewhere pid palette]])) diff --git a/frontend/src/arthur/ui/snapshots.cljs b/frontend/src/arthur/ui/snapshots.cljs index bb2d64b..c220ba2 100644 --- a/frontend/src/arthur/ui/snapshots.cljs +++ b/frontend/src/arthur/ui/snapshots.cljs @@ -7,38 +7,32 @@ [re-frame.core :as rf] [reagent.core :as r])) -(defn view [] - (r/with-let [open? (r/atom false) - draft (r/atom "")] +(defn panel + "Named versions, under the top bar's file menu. `close!` shuts it." + [close!] + (r/with-let [draft (r/atom "")] (let [{:keys [can-edit?]} @(rf/subscribe [::playback/project]) rows @(rf/subscribe [::collab/snapshot-list]) editable? (not (false? can-edit?))] - [:div.menu-wrap - [:button {:class (when @open? "on") - :on-click (fn [] (when-not @open? (rf/dispatch [::collab/snapshots])) - (swap! open? not))} - "snapshots ▾"] - (when @open? - [:<> [:div.menu-scrim {:on-click #(reset! open? false)}] - [:div.menu.menu-left - (when editable? - [:form.row {:on-submit (fn [e] - (.preventDefault e) - (rf/dispatch [::collab/snapshot - (or (not-empty (str/trim @draft)) "snapshot")]) - (reset! draft ""))} - [:input {:placeholder "name this version" :value @draft :auto-focus true - :on-change #(reset! draft (.. % -target -value))}] - [:button {:type "submit"} "take snapshot"]]) - [:h2 "snapshots"] - (if (empty? rows) - [:div.dim "none yet"] - (doall - (for [{:keys [id name author created] :as row} rows] - ^{:key id} - [:div.menu-item.static - [:span name [:span.sub (str author " · " (subs (str created) 0 16))]] - (when editable? - [:button.link {:on-click (fn [] (reset! open? false) - (rf/dispatch [::collab/restore row]))} - "restore"])])))]])]))) + [:div.menu.menu-left + (when editable? + [:form.row {:on-submit (fn [e] + (.preventDefault e) + (rf/dispatch [::collab/snapshot + (or (not-empty (str/trim @draft)) "snapshot")]) + (reset! draft ""))} + [:input {:placeholder "name this version" :value @draft :auto-focus true + :on-change #(reset! draft (.. % -target -value))}] + [:button {:type "submit"} "take snapshot"]]) + [:h2 "snapshots"] + (if (empty? rows) + [:div.dim "none yet"] + (doall + (for [{:keys [id name author created] :as row} rows] + ^{:key id} + [:div.menu-item.static + [:span name [:span.sub (str author " · " (subs (str created) 0 16))]] + (when editable? + [:button.link {:on-click (fn [] (close!) + (rf/dispatch [::collab/restore row]))} + "restore"])])))]))) diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index d08a324..b57a665 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -755,7 +755,8 @@ destination-name (when-let [sid (:sid destination)] (clip/symbol-name clip sid)) zoom @(rf/subscribe [::layout/zoom :stage]) - {:keys [opacity]} @(rf/subscribe [::layout/mat])] + {:keys [on? opacity]} @(rf/subscribe [::layout/mat]) + opacity (if on? opacity 0)] [:div.stage-area (navigation) [:div.stage-wrap diff --git a/frontend/src/arthur/ui/tools.cljs b/frontend/src/arthur/ui/tools.cljs index 44b8c89..a2a0aab 100644 --- a/frontend/src/arthur/ui/tools.cljs +++ b/frontend/src/arthur/ui/tools.cljs @@ -43,8 +43,8 @@ :title (str name " (" (.toUpperCase key) ") — " tip) :on-click #(rf/dispatch [::ui/set-tool t])} [:svg {:view-box "0 0 18 18" :width 18 :height 18} icon]]))] - [palette/assets] - [palette/grid]])) + [palette/grid] + [palette/assets]])) (defn- size-control [] (let [size @(rf/subscribe [::sub/brush])] @@ -87,17 +87,16 @@ [:span.dim.hint tip])) [:span.spacer] (let [{:keys [on? opacity]} @(rf/subscribe [::render/tracing])] - [:<> - [:button {:class (when on? "on") - :title "show tracing layers over the picture · never exported" - :on-click #(rf/dispatch [::ui/tracing-on (not on?)])} "tracing"] - [:input.trace-opacity - {:type "range" :min 0 :max 1 :step 0.05 :value opacity :disabled (not on?) - :title "how strongly tracing layers draw" :style {:flex "0 0 64px"} - :on-change #(rf/dispatch [::ui/tracing-opacity (js/parseFloat (.. % -target -value))])}]]) - ;; The stage's zoom: a property of the view and not of the document, so it - ;; sits in the view's own chrome rather than in the inspector. - [layout/zoomer :stage "the stage"] + [layout/overlay "tracing-controls" "tracing" on? + "show tracing layers over the picture · never exported" + #(rf/dispatch [::ui/tracing-on (not on?)]) + (fn [_] + [:div.view-popout + [:label "Opacity" + [:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity + :on-change #(rf/dispatch [::ui/tracing-opacity (js/parseFloat (.. % -target -value))])}]]])]) + ;; The stage's zoom and overlays: properties of the view and not of the + ;; document, so they sit in the view's own chrome rather than in the inspector. [layout/stage-controls]])) (defn adjust-last diff --git a/frontend/src/arthur/ui/topbar.cljs b/frontend/src/arthur/ui/topbar.cljs index f1bc2f4..4d6c3b3 100644 --- a/frontend/src/arthur/ui/topbar.cljs +++ b/frontend/src/arthur/ui/topbar.cljs @@ -1,18 +1,20 @@ (ns arthur.ui.topbar - "The strip across the top: what document this is, and the three things you can - do to the whole of it. + "The strip across the top: everything you do to the whole document, on the + surface rather than in menus, and as pictures rather than words so there is + room for all of it. Each group is a run of the same plain icon button — panes, + file, history, clipboard, versions — then the document's name in the middle, + then export and the people on the right. Every icon says what it is on hover. - Save, open and export are here rather than in a pane because none of them is a - property of a selection — they act on the document, and the document is the - window." + Open, snapshots and undo history are lists, so they drop from their button; + only one of them is open at a time." (:require [arthur.events.collab :as collab] [arthur.events.export :as export] + [arthur.events.history :as history] [arthur.events.project :as project] [arthur.events.ui :as ui] [arthur.subs.playback :as playback] [arthur.subs.ui :as sub] [arthur.ui.layout :as layout] - [arthur.ui.menu :as menu] [arthur.ui.openmenu :as openmenu] [arthur.ui.share :as share] [arthur.ui.snapshots :as snapshots] @@ -24,7 +26,6 @@ (let [{:keys [target zoom busy? done total]} @(rf/subscribe [::export/state]) targets @(rf/subscribe [::export/targets])] [:<> - [:button {:disabled busy? :on-click #(reset! open? true)} "export…"] (when @open? [:<> [:div.export-scrim {:on-click #(when-not busy? (reset! open? false))}] [:section.export-dialog {:role "dialog" :aria-modal true :aria-label "Export"} @@ -48,16 +49,53 @@ (if busy? (str "rendering " done "/" total) "start export")] [:button {:disabled busy? :on-click #(reset! open? false)} "close"]]]])])) +(defn- icon + "A 16-unit glyph, stroked in the button's own colour." + [& paths] + (into [:svg {:view-box "0 0 16 16"}] paths)) + +(def ^:private glyphs + {:new (icon [:path {:d "M4 1.5h5.5L12.5 4.5V14.5H4Z"}] [:path {:d "M8.25 7v5M5.75 9.5h5"}]) + :open (icon [:path {:d "M1.5 3.5h4.5l1.5 1.5h7v8.5h-13Z"}]) + :undo (icon [:path {:d "M5.5 3 2.5 6l3 3"}] [:path {:d "M2.5 6h7a4 4 0 0 1 0 8H6"}]) + :redo (icon [:path {:d "M10.5 3l3 3-3 3"}] [:path {:d "M13.5 6h-7a4 4 0 0 0 0 8H10"}]) + :history (icon [:path {:d "M4.5 6.5 8 10l3.5-3.5"}]) + :cut (icon [:circle {:cx 4.5 :cy 12 :r 2}] [:circle {:cx 11.5 :cy 12 :r 2}] + [:path {:d "M5.8 10.5 11.5 2M10.2 10.5 4.5 2"}]) + :copy (icon [:rect {:x 2 :y 2 :width 8 :height 8 :rx 1}] [:rect {:x 6 :y 6 :width 8 :height 8 :rx 1}]) + :paste (icon [:path {:d "M5.5 2.5h-2v12h9v-12h-2"}] [:rect {:x 5.5 :y 1.5 :width 5 :height 2.5 :rx 0.5}]) + :duplicate (icon [:rect.fill {:x 2 :y 2 :width 8 :height 8 :rx 1}] [:rect {:x 6 :y 6 :width 8 :height 8 :rx 1}]) + :unique (icon [:rect.fill {:x 2 :y 2 :width 8 :height 8 :rx 1}] + [:rect {:x 6 :y 6 :width 8 :height 8 :rx 1 :stroke-dasharray "2 1.5"}]) + :snapshots (icon [:path {:d "M4 1.5h8v13l-4-3-4 3Z"}]) + :export (icon [:path {:d "M8 10V1.5M4.5 5 8 1.5 11.5 5"}] [:path {:d "M2 9.5v5h12v-5"}])}) + +(defn- btn [glyph title attrs] + [:button.ico (merge {:title title :aria-label title} attrs) (glyphs glyph)]) + +(defn- dropper + "An icon button with a list hanging from it: projects, snapshots, undo steps." + [panel k glyph title on-open body & [disabled?]] + [:div.menu-wrap + [btn glyph title {:class (when (= k @panel) "on") :disabled disabled? + :on-click (fn [] (if (= k @panel) + (reset! panel nil) + (do (on-open) (reset! panel k))))}] + (when (= k @panel) + [:<> [:div.menu-scrim {:on-click #(reset! panel nil)}] + [body #(reset! panel nil)]])]) + (defn view [] (r/with-let [renaming? (r/atom false) draft (r/atom "") + panel (r/atom nil) export-open? (r/atom false)] (let [{project-name :name project-seq :seq :keys [busy? status]} @(rf/subscribe [::playback/project]) {footage-status :status} @(rf/subscribe [::playback/footage]) - {export-status :status} @(rf/subscribe [::export/state]) - selections @(rf/subscribe [::sub/selections]) + {export-status :status export-busy? :busy?} @(rf/subscribe [::export/state]) + {:keys [done undone]} @(rf/subscribe [::history/steps]) + selected? (boolean (seq @(rf/subscribe [::sub/selections]))) clipboard @(rf/subscribe [::sub/clipboard]) - selected? (boolean (seq selections)) title (or project-name "untitled") commit! (fn [] (rf/dispatch [::project/rename @draft]) @@ -69,37 +107,46 @@ ;; this strip scrolls, and which panes are open is the one control that ;; must not be the thing off the end of it. [layout/panes] - [:button {:disabled busy? :on-click #(rf/dispatch [::collab/create])} "new"] - [openmenu/view] - [undo/view] - [menu/view - {:label "edit" :title "selection and clipboard" - :items [{:label "copy" :sub "Copy selection · ⌘/Ctrl C" - :disabled? (not selected?) :on-click #(rf/dispatch [::ui/copy])} - {:label "cut" :sub "Cut selection · ⌘/Ctrl X" - :disabled? (not selected?) :on-click #(rf/dispatch [::ui/cut])} - {:label "paste" :sub "Paste at playhead into the active row · ⌘/Ctrl V" - :disabled? (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])} - {:label "duplicate" :sub "Duplicate beside selection; lanes repeat forward · ⌘/Ctrl D" - :disabled? (not selected?) :on-click #(rf/dispatch [::ui/duplicate])} - {:label "duplicate unique" - :sub "Duplicate with a private nested symbol graph · ⇧⌘/Ctrl D" - :disabled? (not selected?) - :on-click #(rf/dispatch [::ui/duplicate-unique])}]}] - [snapshots/view] - (if @renaming? - [:input.project-name {:auto-focus true :value @draft - :on-change #(reset! draft (.. % -target -value)) - :on-blur (fn [_] (commit!)) - :on-key-down (fn [e] - (case (.-key e) - "Enter" (do (.preventDefault e) (commit!)) - "Escape" (reset! renaming? false) - nil))}] - [:button.project-title {:disabled busy? :title "click to rename project" - :on-click (fn [] (reset! draft title) - (reset! renaming? true))} - title (when project-seq (str " r" project-seq))]) - [:span.status (or export-status status footage-status)] + [:span.sep] + [:div.icons + [btn :new "new project" {:disabled busy? :on-click #(rf/dispatch [::collab/create])}] + [dropper panel :open :open "open a project" #(rf/dispatch [::project/list]) openmenu/panel]] + [:span.sep] + [:div.icons + [btn :undo (if (seq done) (str "undo " (first done) " (⌘Z)") "nothing to undo") + {:disabled (empty? done) :on-click #(rf/dispatch [::history/undo])}] + [dropper panel :history :history "undo history" identity undo/panel (empty? done)] + [btn :redo (if (seq undone) (str "redo " (first undone) " (⇧⌘Z)") "nothing to redo") + {:disabled (empty? undone) :on-click #(rf/dispatch [::history/redo])}]] + [:span.sep] + [:div.icons + [btn :cut "cut selection (⌘X)" {:disabled (not selected?) :on-click #(rf/dispatch [::ui/cut])}] + [btn :copy "copy selection (⌘C)" {:disabled (not selected?) :on-click #(rf/dispatch [::ui/copy])}] + [btn :paste "paste at the playhead into the active row (⌘V)" + {:disabled (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])}] + [btn :duplicate "duplicate beside the selection; lanes repeat forward (⌘D)" + {:disabled (not selected?) :on-click #(rf/dispatch [::ui/duplicate])}] + [btn :unique "duplicate unique — with a private copy of its nested symbols (⇧⌘D)" + {:disabled (not selected?) :on-click #(rf/dispatch [::ui/duplicate-unique])}]] + [:span.sep] + [dropper panel :snapshots :snapshots "snapshots — name this version, or go back to one" + #(rf/dispatch [::collab/snapshots]) snapshots/panel] + [:div.doc + (if @renaming? + [:input.project-name {:auto-focus true :value @draft + :on-change #(reset! draft (.. % -target -value)) + :on-blur (fn [_] (commit!)) + :on-key-down (fn [e] + (case (.-key e) + "Enter" (do (.preventDefault e) (commit!)) + "Escape" (reset! renaming? false) + nil))}] + [:button.project-title {:disabled busy? :title "click to rename project" + :on-click (fn [] (reset! draft title) + (reset! renaming? true))} + title (when project-seq [:span.dim (str " r" project-seq)])]) + [:span.status (or export-status status footage-status)]] + [btn :export "export a PNG sequence and audio" + {:disabled export-busy? :on-click #(reset! export-open? true)}] [exporter export-open?] [share/view]]))) diff --git a/frontend/src/arthur/ui/undo.cljs b/frontend/src/arthur/ui/undo.cljs index 18ac7bb..821f765 100644 --- a/frontend/src/arthur/ui/undo.cljs +++ b/frontend/src/arthur/ui/undo.cljs @@ -1,32 +1,22 @@ (ns arthur.ui.undo - "Undo, redo, and the list of what undo would take off — newest first, so - choosing the third row undoes three steps." + "The list of what undo would take off — newest first, so choosing the third + row undoes three steps. Undo and redo themselves are the top bar's." (:require [arthur.events.history :as history] - [re-frame.core :as rf] - [reagent.core :as r])) + [re-frame.core :as rf])) -(defn view [] - (r/with-let [open? (r/atom false)] - (let [{:keys [done undone]} @(rf/subscribe [::history/steps])] - [:div.menu-wrap.undo - [:button {:disabled (empty? done) :title (if (seq done) (str "undo " (first done) " (⌘Z)") "nothing to undo") - :on-click #(rf/dispatch [::history/undo])} - "undo"] - [:button.undo-list {:disabled (empty? done) :class (when @open? "on") - :title "undo history" :on-click #(swap! open? not)} - "▾"] - [:button {:disabled (empty? undone) :title (if (seq undone) (str "redo " (first undone) " (⇧⌘Z)") "nothing to redo") - :on-click #(rf/dispatch [::history/redo])} - "redo"] - (when (and @open? (seq done)) - [:<> [:div.menu-scrim {:on-click #(reset! open? false)}] - [:div.menu.menu-left - [:h2 "undo"] - (doall - (map-indexed - (fn [i label] - ^{:key i} - [:button.menu-item {:on-click (fn [] (reset! open? false) - (rf/dispatch [::history/undo (inc i)]))} - label (when (pos? i) [:span.sub (str (inc i) " steps")])]) - (take 30 done)))]])]))) +(defn panel + "What undo would take off, newest first, under the edit menu. `close!` shuts it." + [close!] + (let [{:keys [done]} @(rf/subscribe [::history/steps])] + [:div.menu.menu-left + [:h2 "undo history"] + (if (empty? done) + [:div.dim "nothing to undo"] + (doall + (map-indexed + (fn [i label] + ^{:key i} + [:button.menu-item {:on-click (fn [] (close!) + (rf/dispatch [::history/undo (inc i)]))} + label (when (pos? i) [:span.sub (str (inc i) " steps")])]) + (take 30 done))))])) diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index 32b2f68..d425c20 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -441,6 +441,9 @@ extended (nest/resize-out document :palettes [:change] 1 false)] (resolve 2) (is (= {:from :day :to :night :t 0.5} (clip/active-palette resolve))) + (is (= (clip/active-palette resolve) + (clip/palette-at document nil :day (clip/symbol document :main) 2 nil)) + "the palette strip asks the same question the resolver answers: both ends of a blend") (is (= [128 128 128] (nth (pal/effective-ramp context (clip/active-palette resolve)) (pal/render-index context :day 1)))) diff --git a/frontend/test/browser/onion.mjs b/frontend/test/browser/onion.mjs index 9c8b81c..bacdbf3 100644 --- a/frontend/test/browser/onion.mjs +++ b/frontend/test/browser/onion.mjs @@ -72,14 +72,16 @@ try { })()`); await sleep(250); - assert.equal(await evaluate(`getComputedStyle(document.querySelector('.onion-controls summary')).listStyleType`), 'none'); - assert.equal(await evaluate(`document.querySelector('.onion-controls summary').textContent`), '▾'); + assert.equal(await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').textContent`), '▾'); await evaluate(`document.querySelector('.onion-controls > button').click()`); await sleep(150); assert.equal(await evaluate(`document.querySelector('.onion-controls > button').getAttribute('aria-pressed')`), 'true'); - await evaluate(`document.querySelector('.onion-controls summary').click()`); + await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').click()`); await sleep(100); - assert.equal(await evaluate(`document.querySelector('.onion-controls details').open`), true); + assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), true); + await evaluate(`document.querySelector('.menu-scrim').click()`); + await sleep(100); + assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), false, 'a click outside shuts the onion settings'); await evaluate(`(() => { const c = cljs.core, k = c.keyword, m = (...xs) => c.hash_map(...xs), v = (...xs) => c.vector(...xs); const db = c.deref(re_frame.db.app_db); diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index 039c147..95a38c6 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -251,8 +251,7 @@ const CLICK = (label) => `(() => { // turn of the event loop between them, as it is for anybody using the app. const MENU_OPEN = `(() => { if (document.querySelector('.menu')) return true; - const b = [...document.querySelectorAll('button')] - .find((b) => b.textContent.trim() === 'open \u25be'); + const b = document.querySelector('.top button[aria-label="open a project"]'); if (!b || b.disabled) return false; b.click(); return true; diff --git a/static/arthur/app.css b/static/arthur/app.css index 8498c04..16d9dd3 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -155,8 +155,6 @@ audio { display: none; } /* Which panes are open, in the top bar. Lower-case words rather than icons: the three panes have names and the names are what the rest of the chrome calls them. */ -.panes > button { color: var(--dim); } -.panes > button.on { color: var(--fg); } /* A zoom readout that is also the way back to 1:1, so it is a button that looks like the number it shows. Tabular figures, because the + beside it must not @@ -313,8 +311,29 @@ button.retry { border-color: var(--sel); color: var(--sel); } } .top .brand { color: var(--dim); letter-spacing: .14em; text-transform: uppercase; } -.top .status { +/* The top bar's icon runs: plain glyphs until pointed at, accented when on. + A run is one group; `.sep` divides one run from the next. */ +.icons { display: flex; align-items: center; gap: 1px; } +.top button.ico { border-color: transparent; background: none; padding: 3px 5px; } +.top button.ico:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); } +.top button.ico.on { background: var(--sel-bg); border-color: var(--sel); color: var(--sel); } +.top button.ico > svg { + width: 16px; height: 16px; + fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; +} +.top button.ico svg .fill, .panes svg rect:not(.frame) { fill: currentColor; stroke: none; } +.top .sep { height: 16px; } + +/* What this is and what it is doing: the middle of the bar, and the room in it. */ +.top .doc { flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; +} +.top .status { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -322,7 +341,13 @@ button.retry { border-color: var(--sel); color: var(--sel); } color: var(--dim); } -.project-title { max-width: 22vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.project-title { + max-width: 28vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + border-color: transparent; background: none; font-weight: 600; +} +.project-title:hover:not(:disabled) { border-color: var(--line); } + +.panes button.ico:not(.on) { color: var(--dim); } .project-name { width: 180px; } .export-scrim { position: fixed; inset: 0; z-index: 50; background: rgba(0, 0, 0, .45); } .export-dialog { @@ -417,11 +442,6 @@ button.link { border: 0; background: none; padding: 0; color: var(--sel); cursor .index-list a:hover { color: var(--sel); } a.brand { text-decoration: none; } -/* Undo, its list, redo: one group. */ -.undo { display: flex; gap: 0; } -.undo > button { border-radius: 0; } -.undo > button:first-child { border-radius: 2px 0 0 2px; } -.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 @@ -1650,7 +1670,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } background-size: 8px 8px; background-position: 0 0, 4px 4px; } -.palette .cell.clear { grid-column: span 2; width: 36px; } /* -------------------------------------------------------------------------- on the stage: the tools' marks, in stage pixels (the SVG's viewBox), and their @@ -1697,7 +1716,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } background: linear-gradient(135deg, #3a3f52 0 50%, #e8d9a8 50% 100%); color: #fff; font-size: 11px; line-height: 1; text-shadow: 0 1px 1px rgba(0, 0, 0, .6); } -.palette .cell.map { grid-column: span 2; width: 36px; } .palette .cell { position: relative; } .palette .cell .badge { position: absolute; right: -2px; bottom: -2px; @@ -1726,32 +1744,87 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } border-radius: 2px; } -/* Palette assets stay in the existing narrow tool strip. */ -.toolbox .palette-assets { display: block; width: 44px; flex: 0 0 auto; } -.palette-assets summary { cursor: pointer; font-size: 10px; line-height: 1.25; overflow-wrap: anywhere; padding: 3px; } -.palette-popout, .view-popout { position: fixed; z-index: 100; background: var(--pane); color: var(--fg); border: 1px solid var(--line); padding: 10px; box-shadow: 0 4px 16px #0008; line-height: 1.4; } -.palette-popout { left: 52px; width: 230px; display: flex; flex-wrap: wrap; gap: 7px; } -.palette-popout strong { width: 100%; overflow-wrap: anywhere; } -.palette-popout select { width: 100%; } -.view-popout { right: 16px; width: 240px; } +/* The palette's own settings, at the foot of the strip: drawn as a tool button, + because it is one more thing in the strip you press. */ +.palette-assets > button { + width: 34px; height: 30px; padding: 0; + display: grid; place-items: center; + border: 1px solid transparent; border-radius: 4px; background: none; +} +.palette-assets > button:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); } +.palette-assets > button.on { background: var(--sel-bg); border-color: var(--sel); } +.palette-assets svg path { fill: var(--fg); } +.palette-assets svg circle { fill: var(--chrome); } +.palette-assets > button.on svg circle { fill: var(--sel-bg); } + +/* A popover's panel: settings, not commands, so narrower than a menu and laid + out as a form. Placed and dismissed by `ui/menu`. */ +.menu.popout { width: 240px; min-width: 0; padding: 10px; line-height: 1.4; } +.menu.popout:has(.palette-popout) { width: 280px; } +.palette-popout { display: grid; gap: 8px; } +.palette-popout .row { margin: 0; } + +/* Every palette as its strip and its name: what you would switch to, seen + before you switch. The open one is the selected row. */ +.palette-list { display: grid; gap: 1px; max-height: 50vh; overflow: auto; } +.palette-row { display: flex; align-items: center; gap: 2px; border-radius: 3px; } +.palette-row.on { background: var(--sel-bg); } +.palette-row .choose { + flex: 1; min-width: 0; + display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 3px 8px; + padding: 5px 6px; border: 1px solid transparent; border-radius: 3px; background: none; text-align: left; +} +.palette-row button.choose:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); } +.palette-row.on button.choose { border-color: var(--sel); } +.palette-row.on button.choose:hover { background: var(--sel-bg); } +.palette-row .strip { grid-column: 1 / -1; } +.palette-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.palette-row.on .name { font-weight: 600; color: #14395f; } +.palette-row .palette-name { grid-column: 1 / -1; width: 100%; min-width: 0; } +.palette-row .rename { align-self: stretch; padding: 0 7px; border-color: transparent; background: none; } +.palette-row .rename:hover:not(:disabled) { border-color: var(--sel); background: #fff; } + +.strip { display: flex; height: 14px; border: 1px solid rgba(0, 0, 0, .35); border-radius: 2px; overflow: hidden; } +.strip i { flex: 1; } + +/* On the stage at the playhead. */ +.on-stage { width: 7px; height: 7px; border-radius: 50%; background: var(--live); } + +/* A palette being edited that the stage is not drawing in here. Information, + not alarm: the accent of a selection, not of a warning. */ +.palette-assets-wrap { position: relative; } +.palette-info { + position: absolute; top: -3px; right: -5px; + width: 13px; height: 13px; border-radius: 50%; + display: grid; place-items: center; + background: var(--sel); color: #fff; + font: italic 600 9px/1 Georgia, serif; + cursor: help; + box-shadow: 0 0 0 1.5px var(--chrome); +} +.info-card { + position: fixed; z-index: 60; + width: 240px; padding: 8px 10px; + display: grid; gap: 3px; + background: var(--pane); color: var(--fg); + font: 11px/1.45 "Lucida Grande", "Segoe UI", system-ui, sans-serif; + border: 1px solid var(--sel); border-left-width: 3px; border-radius: 3px; + box-shadow: 0 3px 10px rgba(0, 0, 0, .25); + cursor: default; +} +.info-card strong { color: #14395f; } .view-popout label { display: block; margin-bottom: 8px; } -.view-popout input { width: 100%; } -.passepartout-controls summary { cursor: pointer; } +.view-popout label:last-child { margin-bottom: 0; } +.view-popout input, .view-popout select { width: 100%; } + +/* An overlay switch and its settings: one object, two parts. */ +.overlay > .menu-wrap > button { margin-left: -1px; padding: 1px 5px; border-radius: 0 2px 2px 0; } +.overlay > button { border-radius: 2px 0 0 2px; } .stage-wrap { flex: 0 0 auto; } .onion-skin { position: absolute; inset: 0; pointer-events: none; image-rendering: pixelated; } -.onion-controls { display: inline-flex; align-items: center; } -.onion-controls > button { border-radius: 2px 0 0 2px; } -.onion-controls summary { - display: block; cursor: pointer; list-style: none; - color: var(--fg); background: var(--pane); border: 1px solid var(--line); - border-radius: 0 2px 2px 0; margin-left: -1px; padding: 1px 5px; -} -.onion-controls summary:hover, .onion-controls details[open] > summary { background: #fff; } -.onion-controls summary::-webkit-details-marker { display: none; } -.onion-controls .view-popout { line-height: 1.4; } -.onion-controls select { width: 100%; } + /* Expanded automation lanes expose editable keyframe dots. */ button.tl-key { pointer-events: auto; padding: 0; border: 0; width: 11px; height: 11px; margin: -5.5px; cursor: ew-resize; z-index: 4; opacity: 1; border-radius: 50%; } From 208dddee07eb5928e4d79bde543181abf9070676 Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Sun, 4 Oct 2026 03:16:09 -0400 Subject: [PATCH 2/3] Put the top bar back to words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon run in the top bar read worse than what it replaced. Restore the earlier top bar — pane words, new, open ▾, undo ▾ redo, the edit menu, snapshots ▾, title, status, export… — and its CSS. The stage bar's overlay switches, the palette chooser and `menu/popover` stay. Co-Authored-By: Claude Opus 5.5 --- frontend/src/arthur/ui/layout.cljs | 25 ++--- frontend/src/arthur/ui/openmenu.cljs | 72 ++++++++------ frontend/src/arthur/ui/snapshots.cljs | 58 +++++++----- frontend/src/arthur/ui/topbar.cljs | 131 +++++++++----------------- frontend/src/arthur/ui/undo.cljs | 48 ++++++---- frontend/test/browser/take.mjs | 3 +- static/arthur/app.css | 38 ++------ 7 files changed, 168 insertions(+), 207 deletions(-) diff --git a/frontend/src/arthur/ui/layout.cljs b/frontend/src/arthur/ui/layout.cljs index 2440881..027367c 100644 --- a/frontend/src/arthur/ui/layout.cljs +++ b/frontend/src/arthur/ui/layout.cljs @@ -121,26 +121,21 @@ :on-pointer-cancel (fn [_] (reset! sizing nil))}])) (defn panes - "Which panes are open, as three switches at the left of the top bar, each a - picture of the window with its pane filled in. In the top bar because a shut - pane has no head left to carry its own handle, and because on a narrow screen - this is the whole of the navigation." + "Which panes are open, as a segmented control in the top bar. In the top bar + because a shut pane has no head left to carry its own handle, and because on a + narrow screen this is the whole of the navigation." [] (let [{:keys [shut]} @(rf/subscribe [::state])] - [:div.icons.panes {:aria-label "panes"} + [:div.seg.panes {:aria-label "panes"} (doall - (for [[k label strip] [[:pool "media pool" {:x 1.5 :y 2.5 :width 4 :height 11}] - [:time "timeline" {:x 1.5 :y 9.5 :width 13 :height 4}] - [:params "inspector" {:x 10.5 :y 2.5 :width 4 :height 11}]]] + (for [[k label] [[:pool "pool"] [:params "inspector"] [:time "timeline"]]] (let [open? (not (contains? shut k))] ^{:key k} - [:button.ico {:class (when open? "on") - :aria-pressed open? :aria-label label - :title (str (if open? "hide the " "show the ") label) - :on-click #(rf/dispatch [::toggle k])} - [:svg {:view-box "0 0 16 16"} - [:rect.frame {:x 1.5 :y 2.5 :width 13 :height 11 :rx 1}] - [:rect (assoc strip :rx 0.5)]]])))])) + [:button {:class (when open? "on") + :aria-pressed open? + :title (str (if open? "hide the " "show the ") label) + :on-click #(rf/dispatch [::toggle k])} + label])))])) (defn- stepped [k z out?] ;; Geometric steps work below 100% as well as above it. diff --git a/frontend/src/arthur/ui/openmenu.cljs b/frontend/src/arthur/ui/openmenu.cljs index 4450b10..5f10bd3 100644 --- a/frontend/src/arthur/ui/openmenu.cljs +++ b/frontend/src/arthur/ui/openmenu.cljs @@ -14,7 +14,9 @@ (:require [arthur.db :as db] [arthur.events.playback :as pb] [arthur.events.project :as project] - [re-frame.core :as rf])) + [arthur.subs.playback :as playback] + [re-frame.core :as rf] + [reagent.core :as r])) (defn- when-said "The ISO stamp the server sends, as a date. Truncated rather than formatted: @@ -33,30 +35,44 @@ label (when sub [:span.sub sub])]) -(defn panel - "The list, under the top bar's file menu. `close!` shuts it." - [close!] - (let [{:keys [items loading?]} @(rf/subscribe [::project/listing]) - choose! (fn [event] (close!) (rf/dispatch event))] - [:div.menu.menu-left - [:h2 "projects"] - (cond - loading? [:div.dim "…"] - (empty? items) [:div.dim "none saved yet"] - :else - (doall - (for [{:keys [id name seq updated]} items] - ^{:key id} - [item {:label (or name "untitled") - :sub (str "r" seq " · " (when-said updated)) - :on-click #(choose! [::project/open id])}]))) - [:h2 "built-in examples"] - [:div.dim "compiled in, not saved documents"] - (doall - (for [[id {:keys [label]}] db/clips] - ^{:key id} - [item {:label label :example? true - :on-click #(choose! [::pb/select-clip id])}])) - [item {:label "8625 stage study" :example? true - :sub "composed from a locally saved project" - :on-click #(choose! [::project/load-stage])}]])) +(defn view [] + (r/with-let [open? (r/atom false)] + (let [{:keys [items loading?]} @(rf/subscribe [::project/listing]) + busy? (:busy? @(rf/subscribe [::playback/project])) + choose! (fn [event] (reset! open? false) (rf/dispatch event))] + [:div.menu-wrap + [:button {:disabled busy? + :class (when @open? "on") + :on-click (fn [] + (when-not @open? (rf/dispatch [::project/list])) + (swap! open? not))} + "open ▾"] + (when @open? + [:<> + ;; A full-page catcher behind the panel, so clicking anywhere else + ;; dismisses it. Cheaper and more predictable than a document-level + ;; listener that has to be added, removed and told to ignore the click + ;; that opened the menu. + [:div.menu-scrim {:on-click #(reset! open? false)}] + [:div.menu + [:h2 "projects"] + (cond + loading? [:div.dim "…"] + (empty? items) [:div.dim "none saved yet"] + :else + (doall + (for [{:keys [id name seq updated]} items] + ^{:key id} + [item {:label (or name "untitled") + :sub (str "r" seq " · " (when-said updated)) + :on-click #(choose! [::project/open id])}]))) + [:h2 "built-in examples"] + [:div.dim "compiled in, not saved documents"] + (doall + (for [[id {:keys [label]}] db/clips] + ^{:key id} + [item {:label label :example? true + :on-click #(choose! [::pb/select-clip id])}])) + [item {:label "8625 stage study" :example? true + :sub "composed from a locally saved project" + :on-click #(choose! [::project/load-stage])}]]])]))) diff --git a/frontend/src/arthur/ui/snapshots.cljs b/frontend/src/arthur/ui/snapshots.cljs index c220ba2..bb2d64b 100644 --- a/frontend/src/arthur/ui/snapshots.cljs +++ b/frontend/src/arthur/ui/snapshots.cljs @@ -7,32 +7,38 @@ [re-frame.core :as rf] [reagent.core :as r])) -(defn panel - "Named versions, under the top bar's file menu. `close!` shuts it." - [close!] - (r/with-let [draft (r/atom "")] +(defn view [] + (r/with-let [open? (r/atom false) + draft (r/atom "")] (let [{:keys [can-edit?]} @(rf/subscribe [::playback/project]) rows @(rf/subscribe [::collab/snapshot-list]) editable? (not (false? can-edit?))] - [:div.menu.menu-left - (when editable? - [:form.row {:on-submit (fn [e] - (.preventDefault e) - (rf/dispatch [::collab/snapshot - (or (not-empty (str/trim @draft)) "snapshot")]) - (reset! draft ""))} - [:input {:placeholder "name this version" :value @draft :auto-focus true - :on-change #(reset! draft (.. % -target -value))}] - [:button {:type "submit"} "take snapshot"]]) - [:h2 "snapshots"] - (if (empty? rows) - [:div.dim "none yet"] - (doall - (for [{:keys [id name author created] :as row} rows] - ^{:key id} - [:div.menu-item.static - [:span name [:span.sub (str author " · " (subs (str created) 0 16))]] - (when editable? - [:button.link {:on-click (fn [] (close!) - (rf/dispatch [::collab/restore row]))} - "restore"])])))]))) + [:div.menu-wrap + [:button {:class (when @open? "on") + :on-click (fn [] (when-not @open? (rf/dispatch [::collab/snapshots])) + (swap! open? not))} + "snapshots ▾"] + (when @open? + [:<> [:div.menu-scrim {:on-click #(reset! open? false)}] + [:div.menu.menu-left + (when editable? + [:form.row {:on-submit (fn [e] + (.preventDefault e) + (rf/dispatch [::collab/snapshot + (or (not-empty (str/trim @draft)) "snapshot")]) + (reset! draft ""))} + [:input {:placeholder "name this version" :value @draft :auto-focus true + :on-change #(reset! draft (.. % -target -value))}] + [:button {:type "submit"} "take snapshot"]]) + [:h2 "snapshots"] + (if (empty? rows) + [:div.dim "none yet"] + (doall + (for [{:keys [id name author created] :as row} rows] + ^{:key id} + [:div.menu-item.static + [:span name [:span.sub (str author " · " (subs (str created) 0 16))]] + (when editable? + [:button.link {:on-click (fn [] (reset! open? false) + (rf/dispatch [::collab/restore row]))} + "restore"])])))]])]))) diff --git a/frontend/src/arthur/ui/topbar.cljs b/frontend/src/arthur/ui/topbar.cljs index 4d6c3b3..f1bc2f4 100644 --- a/frontend/src/arthur/ui/topbar.cljs +++ b/frontend/src/arthur/ui/topbar.cljs @@ -1,20 +1,18 @@ (ns arthur.ui.topbar - "The strip across the top: everything you do to the whole document, on the - surface rather than in menus, and as pictures rather than words so there is - room for all of it. Each group is a run of the same plain icon button — panes, - file, history, clipboard, versions — then the document's name in the middle, - then export and the people on the right. Every icon says what it is on hover. + "The strip across the top: what document this is, and the three things you can + do to the whole of it. - Open, snapshots and undo history are lists, so they drop from their button; - only one of them is open at a time." + Save, open and export are here rather than in a pane because none of them is a + property of a selection — they act on the document, and the document is the + window." (:require [arthur.events.collab :as collab] [arthur.events.export :as export] - [arthur.events.history :as history] [arthur.events.project :as project] [arthur.events.ui :as ui] [arthur.subs.playback :as playback] [arthur.subs.ui :as sub] [arthur.ui.layout :as layout] + [arthur.ui.menu :as menu] [arthur.ui.openmenu :as openmenu] [arthur.ui.share :as share] [arthur.ui.snapshots :as snapshots] @@ -26,6 +24,7 @@ (let [{:keys [target zoom busy? done total]} @(rf/subscribe [::export/state]) targets @(rf/subscribe [::export/targets])] [:<> + [:button {:disabled busy? :on-click #(reset! open? true)} "export…"] (when @open? [:<> [:div.export-scrim {:on-click #(when-not busy? (reset! open? false))}] [:section.export-dialog {:role "dialog" :aria-modal true :aria-label "Export"} @@ -49,53 +48,16 @@ (if busy? (str "rendering " done "/" total) "start export")] [:button {:disabled busy? :on-click #(reset! open? false)} "close"]]]])])) -(defn- icon - "A 16-unit glyph, stroked in the button's own colour." - [& paths] - (into [:svg {:view-box "0 0 16 16"}] paths)) - -(def ^:private glyphs - {:new (icon [:path {:d "M4 1.5h5.5L12.5 4.5V14.5H4Z"}] [:path {:d "M8.25 7v5M5.75 9.5h5"}]) - :open (icon [:path {:d "M1.5 3.5h4.5l1.5 1.5h7v8.5h-13Z"}]) - :undo (icon [:path {:d "M5.5 3 2.5 6l3 3"}] [:path {:d "M2.5 6h7a4 4 0 0 1 0 8H6"}]) - :redo (icon [:path {:d "M10.5 3l3 3-3 3"}] [:path {:d "M13.5 6h-7a4 4 0 0 0 0 8H10"}]) - :history (icon [:path {:d "M4.5 6.5 8 10l3.5-3.5"}]) - :cut (icon [:circle {:cx 4.5 :cy 12 :r 2}] [:circle {:cx 11.5 :cy 12 :r 2}] - [:path {:d "M5.8 10.5 11.5 2M10.2 10.5 4.5 2"}]) - :copy (icon [:rect {:x 2 :y 2 :width 8 :height 8 :rx 1}] [:rect {:x 6 :y 6 :width 8 :height 8 :rx 1}]) - :paste (icon [:path {:d "M5.5 2.5h-2v12h9v-12h-2"}] [:rect {:x 5.5 :y 1.5 :width 5 :height 2.5 :rx 0.5}]) - :duplicate (icon [:rect.fill {:x 2 :y 2 :width 8 :height 8 :rx 1}] [:rect {:x 6 :y 6 :width 8 :height 8 :rx 1}]) - :unique (icon [:rect.fill {:x 2 :y 2 :width 8 :height 8 :rx 1}] - [:rect {:x 6 :y 6 :width 8 :height 8 :rx 1 :stroke-dasharray "2 1.5"}]) - :snapshots (icon [:path {:d "M4 1.5h8v13l-4-3-4 3Z"}]) - :export (icon [:path {:d "M8 10V1.5M4.5 5 8 1.5 11.5 5"}] [:path {:d "M2 9.5v5h12v-5"}])}) - -(defn- btn [glyph title attrs] - [:button.ico (merge {:title title :aria-label title} attrs) (glyphs glyph)]) - -(defn- dropper - "An icon button with a list hanging from it: projects, snapshots, undo steps." - [panel k glyph title on-open body & [disabled?]] - [:div.menu-wrap - [btn glyph title {:class (when (= k @panel) "on") :disabled disabled? - :on-click (fn [] (if (= k @panel) - (reset! panel nil) - (do (on-open) (reset! panel k))))}] - (when (= k @panel) - [:<> [:div.menu-scrim {:on-click #(reset! panel nil)}] - [body #(reset! panel nil)]])]) - (defn view [] (r/with-let [renaming? (r/atom false) draft (r/atom "") - panel (r/atom nil) export-open? (r/atom false)] (let [{project-name :name project-seq :seq :keys [busy? status]} @(rf/subscribe [::playback/project]) {footage-status :status} @(rf/subscribe [::playback/footage]) - {export-status :status export-busy? :busy?} @(rf/subscribe [::export/state]) - {:keys [done undone]} @(rf/subscribe [::history/steps]) - selected? (boolean (seq @(rf/subscribe [::sub/selections]))) + {export-status :status} @(rf/subscribe [::export/state]) + selections @(rf/subscribe [::sub/selections]) clipboard @(rf/subscribe [::sub/clipboard]) + selected? (boolean (seq selections)) title (or project-name "untitled") commit! (fn [] (rf/dispatch [::project/rename @draft]) @@ -107,46 +69,37 @@ ;; this strip scrolls, and which panes are open is the one control that ;; must not be the thing off the end of it. [layout/panes] - [:span.sep] - [:div.icons - [btn :new "new project" {:disabled busy? :on-click #(rf/dispatch [::collab/create])}] - [dropper panel :open :open "open a project" #(rf/dispatch [::project/list]) openmenu/panel]] - [:span.sep] - [:div.icons - [btn :undo (if (seq done) (str "undo " (first done) " (⌘Z)") "nothing to undo") - {:disabled (empty? done) :on-click #(rf/dispatch [::history/undo])}] - [dropper panel :history :history "undo history" identity undo/panel (empty? done)] - [btn :redo (if (seq undone) (str "redo " (first undone) " (⇧⌘Z)") "nothing to redo") - {:disabled (empty? undone) :on-click #(rf/dispatch [::history/redo])}]] - [:span.sep] - [:div.icons - [btn :cut "cut selection (⌘X)" {:disabled (not selected?) :on-click #(rf/dispatch [::ui/cut])}] - [btn :copy "copy selection (⌘C)" {:disabled (not selected?) :on-click #(rf/dispatch [::ui/copy])}] - [btn :paste "paste at the playhead into the active row (⌘V)" - {:disabled (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])}] - [btn :duplicate "duplicate beside the selection; lanes repeat forward (⌘D)" - {:disabled (not selected?) :on-click #(rf/dispatch [::ui/duplicate])}] - [btn :unique "duplicate unique — with a private copy of its nested symbols (⇧⌘D)" - {:disabled (not selected?) :on-click #(rf/dispatch [::ui/duplicate-unique])}]] - [:span.sep] - [dropper panel :snapshots :snapshots "snapshots — name this version, or go back to one" - #(rf/dispatch [::collab/snapshots]) snapshots/panel] - [:div.doc - (if @renaming? - [:input.project-name {:auto-focus true :value @draft - :on-change #(reset! draft (.. % -target -value)) - :on-blur (fn [_] (commit!)) - :on-key-down (fn [e] - (case (.-key e) - "Enter" (do (.preventDefault e) (commit!)) - "Escape" (reset! renaming? false) - nil))}] - [:button.project-title {:disabled busy? :title "click to rename project" - :on-click (fn [] (reset! draft title) - (reset! renaming? true))} - title (when project-seq [:span.dim (str " r" project-seq)])]) - [:span.status (or export-status status footage-status)]] - [btn :export "export a PNG sequence and audio" - {:disabled export-busy? :on-click #(reset! export-open? true)}] + [:button {:disabled busy? :on-click #(rf/dispatch [::collab/create])} "new"] + [openmenu/view] + [undo/view] + [menu/view + {:label "edit" :title "selection and clipboard" + :items [{:label "copy" :sub "Copy selection · ⌘/Ctrl C" + :disabled? (not selected?) :on-click #(rf/dispatch [::ui/copy])} + {:label "cut" :sub "Cut selection · ⌘/Ctrl X" + :disabled? (not selected?) :on-click #(rf/dispatch [::ui/cut])} + {:label "paste" :sub "Paste at playhead into the active row · ⌘/Ctrl V" + :disabled? (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])} + {:label "duplicate" :sub "Duplicate beside selection; lanes repeat forward · ⌘/Ctrl D" + :disabled? (not selected?) :on-click #(rf/dispatch [::ui/duplicate])} + {:label "duplicate unique" + :sub "Duplicate with a private nested symbol graph · ⇧⌘/Ctrl D" + :disabled? (not selected?) + :on-click #(rf/dispatch [::ui/duplicate-unique])}]}] + [snapshots/view] + (if @renaming? + [:input.project-name {:auto-focus true :value @draft + :on-change #(reset! draft (.. % -target -value)) + :on-blur (fn [_] (commit!)) + :on-key-down (fn [e] + (case (.-key e) + "Enter" (do (.preventDefault e) (commit!)) + "Escape" (reset! renaming? false) + nil))}] + [:button.project-title {:disabled busy? :title "click to rename project" + :on-click (fn [] (reset! draft title) + (reset! renaming? true))} + title (when project-seq (str " r" project-seq))]) + [:span.status (or export-status status footage-status)] [exporter export-open?] [share/view]]))) diff --git a/frontend/src/arthur/ui/undo.cljs b/frontend/src/arthur/ui/undo.cljs index 821f765..18ac7bb 100644 --- a/frontend/src/arthur/ui/undo.cljs +++ b/frontend/src/arthur/ui/undo.cljs @@ -1,22 +1,32 @@ (ns arthur.ui.undo - "The list of what undo would take off — newest first, so choosing the third - row undoes three steps. Undo and redo themselves are the top bar's." + "Undo, redo, and the list of what undo would take off — newest first, so + choosing the third row undoes three steps." (:require [arthur.events.history :as history] - [re-frame.core :as rf])) + [re-frame.core :as rf] + [reagent.core :as r])) -(defn panel - "What undo would take off, newest first, under the edit menu. `close!` shuts it." - [close!] - (let [{:keys [done]} @(rf/subscribe [::history/steps])] - [:div.menu.menu-left - [:h2 "undo history"] - (if (empty? done) - [:div.dim "nothing to undo"] - (doall - (map-indexed - (fn [i label] - ^{:key i} - [:button.menu-item {:on-click (fn [] (close!) - (rf/dispatch [::history/undo (inc i)]))} - label (when (pos? i) [:span.sub (str (inc i) " steps")])]) - (take 30 done))))])) +(defn view [] + (r/with-let [open? (r/atom false)] + (let [{:keys [done undone]} @(rf/subscribe [::history/steps])] + [:div.menu-wrap.undo + [:button {:disabled (empty? done) :title (if (seq done) (str "undo " (first done) " (⌘Z)") "nothing to undo") + :on-click #(rf/dispatch [::history/undo])} + "undo"] + [:button.undo-list {:disabled (empty? done) :class (when @open? "on") + :title "undo history" :on-click #(swap! open? not)} + "▾"] + [:button {:disabled (empty? undone) :title (if (seq undone) (str "redo " (first undone) " (⇧⌘Z)") "nothing to redo") + :on-click #(rf/dispatch [::history/redo])} + "redo"] + (when (and @open? (seq done)) + [:<> [:div.menu-scrim {:on-click #(reset! open? false)}] + [:div.menu.menu-left + [:h2 "undo"] + (doall + (map-indexed + (fn [i label] + ^{:key i} + [:button.menu-item {:on-click (fn [] (reset! open? false) + (rf/dispatch [::history/undo (inc i)]))} + label (when (pos? i) [:span.sub (str (inc i) " steps")])]) + (take 30 done)))]])]))) diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index 95a38c6..039c147 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -251,7 +251,8 @@ const CLICK = (label) => `(() => { // turn of the event loop between them, as it is for anybody using the app. const MENU_OPEN = `(() => { if (document.querySelector('.menu')) return true; - const b = document.querySelector('.top button[aria-label="open a project"]'); + const b = [...document.querySelectorAll('button')] + .find((b) => b.textContent.trim() === 'open \u25be'); if (!b || b.disabled) return false; b.click(); return true; diff --git a/static/arthur/app.css b/static/arthur/app.css index 16d9dd3..4ae29f5 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -155,6 +155,8 @@ audio { display: none; } /* Which panes are open, in the top bar. Lower-case words rather than icons: the three panes have names and the names are what the rest of the chrome calls them. */ +.panes > button { color: var(--dim); } +.panes > button.on { color: var(--fg); } /* A zoom readout that is also the way back to 1:1, so it is a button that looks like the number it shows. Tabular figures, because the + beside it must not @@ -311,29 +313,8 @@ button.retry { border-color: var(--sel); color: var(--sel); } } .top .brand { color: var(--dim); letter-spacing: .14em; text-transform: uppercase; } -/* The top bar's icon runs: plain glyphs until pointed at, accented when on. - A run is one group; `.sep` divides one run from the next. */ -.icons { display: flex; align-items: center; gap: 1px; } -.top button.ico { border-color: transparent; background: none; padding: 3px 5px; } -.top button.ico:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); } -.top button.ico.on { background: var(--sel-bg); border-color: var(--sel); color: var(--sel); } -.top button.ico > svg { - width: 16px; height: 16px; - fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; -} -.top button.ico svg .fill, .panes svg rect:not(.frame) { fill: currentColor; stroke: none; } -.top .sep { height: 16px; } - -/* What this is and what it is doing: the middle of the bar, and the room in it. */ -.top .doc { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - justify-content: center; - gap: 9px; -} .top .status { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -341,13 +322,7 @@ button.retry { border-color: var(--sel); color: var(--sel); } color: var(--dim); } -.project-title { - max-width: 28vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - border-color: transparent; background: none; font-weight: 600; -} -.project-title:hover:not(:disabled) { border-color: var(--line); } - -.panes button.ico:not(.on) { color: var(--dim); } +.project-title { max-width: 22vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .project-name { width: 180px; } .export-scrim { position: fixed; inset: 0; z-index: 50; background: rgba(0, 0, 0, .45); } .export-dialog { @@ -442,6 +417,11 @@ button.link { border: 0; background: none; padding: 0; color: var(--sel); cursor .index-list a:hover { color: var(--sel); } a.brand { text-decoration: none; } +/* Undo, its list, redo: one group. */ +.undo { display: flex; gap: 0; } +.undo > button { border-radius: 0; } +.undo > button:first-child { border-radius: 2px 0 0 2px; } +.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 From 78fb120edd711c7f13e965573b916544324c25ad Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Sun, 4 Oct 2026 18:14:05 -0400 Subject: [PATCH 3/3] ikd mn --- .claude/worktrees/tracing-layers | 1 + frontend/src/arthur/db.cljs | 5 +- frontend/src/arthur/domain/clip.cljs | 58 ++++++++++++++----- frontend/src/arthur/domain/leaf.cljs | 5 +- frontend/src/arthur/events/project.cljs | 4 +- frontend/src/arthur/flow/freeze.cljs | 1 + frontend/src/arthur/ui/timeline.cljs | 5 +- .../test/arthur/domain/instance_test.cljs | 15 ++++- frontend/test/browser/take.mjs | 56 +++++++++++------- 9 files changed, 102 insertions(+), 48 deletions(-) create mode 160000 .claude/worktrees/tracing-layers diff --git a/.claude/worktrees/tracing-layers b/.claude/worktrees/tracing-layers new file mode 160000 index 0000000..f4dd047 --- /dev/null +++ b/.claude/worktrees/tracing-layers @@ -0,0 +1 @@ +Subproject commit f4dd04764204506fc275180364d0366d693036f4 diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index 6bd341b..c61f6af 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -57,8 +57,9 @@ THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at the stage, like solo and zoom: not an undo step, not sent to collaborators, and it cannot reach an export. A layer is shown unless it is in `:hidden`, so - footage brought in shows without being found and switched on first." - {:on? true :opacity 0.5 :hidden #{}}) + footage brought in shows without being found and switched on first — once + tracing itself is on, which it is not until asked for." + {:on? false :opacity 0.5 :hidden #{}}) (def default {;; --- the document --- diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index 962d481..04df444 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -25,13 +25,14 @@ another clip is `arthur.domain.bring`. This namespace is the document and the operations that only need the document. - NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which - symbol is on screen is the editor's state, not the document's, and every - function here that needs a symbol is told which. A new document has one symbol - called `:main` because it has to be called something, and that is all the name - means — it can be renamed, placed inside another symbol or deleted like any of - them. `unplaced` answers the question a reserved root used to: which symbols - nothing else places, and so which ones a person opening the document wants. + NO SYMBOL IS SPECIAL. There is no reserved root id: which symbol is on screen + is the editor's state, not the document's, and every function here that needs + a symbol is told which. A new document has one symbol called `:main` because + it has to be called something, and that is all the name means — it can be + renamed or placed inside another symbol like any of them. What the document + does say is `:root`, which symbol it opens on: a pointer, not a kind of + symbol, the way a Flash file names its scene. See `opens-on` for why that + cannot be worked out instead. :fps is the output grid. A symbol's optional :fps names the native grid its frames were authored or measured on; absent means the document's grid." @@ -52,7 +53,7 @@ layer must not be able to have. Add the field here and to `leaf/leaves` and `leaf/clip` in the same commit." #{:name :fps :analyses :subjects :features :groups :width :height :symbols - :palettes :default-palette}) + :palettes :default-palette :root}) (defn symbol "One of the clip's symbols, by id." @@ -201,18 +202,39 @@ (let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))] (vec (sort-by str (remove placed (keys (:symbols clip))))))) -(defn opens-on - "The symbol a document opens on: the longest one nothing else places, ties - broken by id. The symbol that contains everything else is the longest of the - unplaced ones in every document made so far, and a reserved name is what this - replaces. - - Never a tracing symbol: it is footage nobody has placed yet, and as long as - the take it came from, so it would otherwise win." +(defn- longest-unplaced + "The longest symbol nothing else places, ties broken by id, and never a + tracing symbol: that is footage nobody has placed yet, as long as its take." [clip] (first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)]) (remove #(trace? (symbol clip %)) (unplaced clip))))) +(defn opens-on + "The symbol a document opens on: its `:root`. + + IT IS STORED, NOT WORKED OUT. It used to be the longest unplaced symbol, on + the theory that the one containing everything else is always that. A take + disproves it: imported footage is a symbol a thousand frames long, and the + moment its instance is deleted, or the drop lands somewhere other than the + root, nothing places it and it outranks a 120-frame `:main`. The document then + opened on the take, and `set-root-fps` rewrote the take's rate to the + project's on the way in. + + A document with no `:root` — a demo, a fixture — still gets the old answer." + [clip] + (let [root (:root clip)] + (if (contains? (:symbols clip) root) root (longest-unplaced clip)))) + +(defn pin-root + "Give a document saved before `:root` existed the root it was made with. + Every such document started as `blank`, whose root is `:main`, and ids never + change, so `:main` is the answer wherever it survives; the old rule is only + for documents that never had one." + [clip] + (cond-> clip + (not (:root clip)) + (assoc :root (if (contains? (:symbols clip) :main) :main (longest-unplaced clip))))) + (defn set-root-fps "Set the document/output rate and the root symbol's editing rate together. @@ -250,6 +272,7 @@ :width 320 :height 200 :palettes {pal/default-id pal/default-palette} :default-palette pal/default-id + :root :main ;; No native fps yet: an untouched canvas follows the project grid. Imported ;; and generated symbols carry their own rate explicitly. :symbols {:main {:id :main :frames blank-frames :nodes {}}}}) @@ -723,6 +746,9 @@ (when (and (contains? clip :default-palette) (map? (:palettes clip)) (not (contains? (:palettes clip) (:default-palette clip)))) [":default-palette must name a project palette"]) + (when (and (contains? clip :root) (map? (:symbols clip)) + (not (contains? (:symbols clip) (:root clip)))) + [(str ":root names missing symbol " (pr-str (:root clip)))]) (for [[id p] (:palettes clip) :when (or (not= id (:id p)) (not (pal/valid-palette? p)))] (str "palette " (pr-str id) " is invalid or has a different :id")) diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index 822294d..02a28f9 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -12,6 +12,7 @@ clip//stage width, height clip//palette/ a named indexed palette asset clip//palette-default the project fallback palette id + clip//root the symbol the document opens on clip//source the analysis record this came out of clip//subject/ a tracked subject and its params clip//feature/ one feature: area, nodes, params @@ -138,6 +139,7 @@ (some-leaf (at "timing") (select-keys clip [:fps])) (some-leaf (at "stage") (select-keys clip [:width :height])) (some-leaf (at "palette-default") (select-keys clip [:default-palette])) + (some-leaf (at "root") (select-keys clip [:root])) (some-leaf (at "analyses") (:analyses clip)) (concat (for [[id v] (:subjects clip)] {(at "subject" (segment id)) v}) @@ -198,6 +200,7 @@ "stage" (merge acc v) "analyses" (assoc acc :analyses v) "palette-default" (merge acc v) + "root" (merge acc v) "palette" (assoc-in acc [:palettes (unsegment a)] v) "subject" (assoc-in acc [:subjects (unsegment a)] v) "feature" (assoc-in acc [:features (unsegment a)] v) @@ -239,7 +242,7 @@ false) (case (count p) ;; The clip's own facts carry no id. - 3 (#{"name" "timing" "stage" "analyses" "palette-default"} (nth p 2)) + 3 (#{"name" "timing" "stage" "analyses" "palette-default" "root"} (nth p 2)) 4 (#{"subject" "feature" "group" "palette"} (nth p 2)) false))))] (vec diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index e6f133f..1b29bf4 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -113,7 +113,9 @@ ;; also normalizes documents saved by the earlier model, ;; where changing project FPS left the root on its old ;; editing grid. - built (clip/set-root-fps (:clip loaded) (:fps (:clip loaded)))] + built (-> (:clip loaded) + clip/pin-root + (clip/set-root-fps (:fps (:clip loaded))))] (let [entry (merge (select-keys built [:fps :width :height]) {:label (str (or (.-name clip-json) cid) " (saved)") :cid cid diff --git a/frontend/src/arthur/flow/freeze.cljs b/frontend/src/arthur/flow/freeze.cljs index 2f2641b..18a201e 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -841,6 +841,7 @@ :width (first stage) :height (second stage) :palettes {pal/default-id pal/default-palette} :default-palette pal/default-id + :root :main :subjects (into {} (map (fn [[id _]] [id {:id id :params {} :analysis (:id analysis) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 091ad2e..654444a 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -1241,10 +1241,9 @@ (let [by-frame (group-by :at (concat key-items (mapcat :key-items cels)))] (for [[index f] (map-indexed vector (sort (distinct (concat keys (mapcat :keys cels))))) :when (and (<= 0 f) (< f frames))] - ^{:key index} (if-let [items (and (= :channel kind) (seq (get by-frame f)))] - [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements] - [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))])) + ^{:key index} [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements] + ^{:key index} [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))])) (defn- cursor-hint "What the drag in flight would do, beside the pointer. diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index d425c20..abe3d8f 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -216,10 +216,19 @@ (deftest no-symbol-is-special (let [c (nested)] - (testing "a document opens on the longest symbol nothing places" + (testing "a document opens on its root, however long its unplaced symbols are" (is (= [:loose :main :outer] (clip/unplaced c))) - (is (= :outer (clip/opens-on c))) - (is (= :main (clip/opens-on (clip/blank))))) + (is (= :main (clip/opens-on c))) + (is (= :main (clip/opens-on (clip/blank)))) + (is (= :loose (clip/opens-on (assoc c :root :loose)))) + (is (= c (leaf/clip "u" (leaf/leaves "u" c)))) + (is (seq (clip/problems (assoc c :root :gone))))) + (testing "without a root, the longest symbol nothing places" + (is (= :outer (clip/opens-on (dissoc c :root))))) + (testing "a document saved before roots opens on :main, not a longer take" + (is (= :main (:root (clip/pin-root (dissoc c :root))))) + (is (= :outer (:root (clip/pin-root (-> c (dissoc :root) (update :symbols dissoc :main)))))) + (is (= :loose (:root (clip/pin-root (assoc c :root :loose)))))) (testing "an instance can go into any symbol, and spans that symbol's frames" (let [[n] (vals (get-in c [:symbols :outer :nodes]))] (is (= #{:inner} (node/sources n))) diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index 039c147..93d3721 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -523,32 +523,32 @@ async function main() { // same synchronous block arrives while the tool is still unset and is dropped. // A person cannot click twice inside one microtask; a test should not either. const armed = await page.eval(`(() => { - const button = [...document.querySelectorAll('.palette-bar button')] - .find(b => b.textContent === 'pen'); + const button = document.querySelector('.toolbox button.tool[title^="Pen"]'); if (!button) return false; button.click(); return true; })()`); await sleep(100); - const painted = armed && await page.eval(`(() => { - const svg = document.querySelector('.paint-overlay'); - const box = svg.getBoundingClientRect(); - for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) { - svg.dispatchEvent(new PointerEvent('pointerdown', { - bubbles: true, clientX: box.left + x * box.width / 320, - clientY: box.top + y * box.height / 200, - })); - } + // The overlay reaches past the stage on every side, so a stage pixel is + // found through the SVG's own transform, as the stage finds it coming back. + // Each point is its own eval: a click is a re-frame event, and the next one + // reads the draft it added to. + const PEN_AT = (x, y) => `(() => { + const svg = document.querySelector('.paint-overlay.tool-pen'); + if (!svg) return false; + const p = new DOMPoint(${x}, ${y}).matrixTransform(svg.getScreenCTM()); + svg.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, clientX: p.x, clientY: p.y, + })); return true; - })()`); - await sleep(100); - const finished = await page.eval(`(() => { - const button = [...document.querySelectorAll('.palette-bar button')] - .find(b => b.textContent === 'finish'); - if (!button || button.disabled) return false; - button.click(); - return true; - })()`); + })()`; + let painted = armed; + for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) { + painted = painted && await page.eval(PEN_AT(x, y)); + await sleep(60); + } + // Closed as a person closes it: on the first point. + const finished = painted && await page.eval(PEN_AT(10, 10)); await sleep(250); const afterPaint = await page.eval(`(() => { const c = document.querySelector('canvas.stage'); @@ -556,7 +556,8 @@ async function main() { return [...d]; })()`); check(painted && finished && beforePaint.join(',') !== afterPaint.join(','), - 'a polygon drawn with the paint controls appears on the canvas'); + 'a polygon drawn with the paint controls appears on the canvas', + `${beforePaint} -> ${afterPaint}`); for (const f of [8, 16]) { await page.eval(SEEK(f)); await sleep(100); @@ -597,7 +598,18 @@ async function main() { return [...c.getContext('2d').getImageData(25, 20, 1, 1).data]; })()`); check(afterPaint.join(',') === reopenedPaint.join(','), - 'the painted polygon survives the project round trip'); + 'the painted polygon survives the project round trip', `${afterPaint} vs ${reopenedPaint}`); + // A reopened document has nothing selected, so the shape's keys are not in + // the inspector until it is selected again, as anybody would. + await page.eval(`(() => { + const c = cljs.core, k = c.keyword; + const db = c.deref(re_frame.db.app_db); + const nodes = c.get_in(c.get(arthur.footage.store.entry(c.get(db, k('clip/current'))), k('clip')), + c.vector(k('symbols'), k('main'), k('nodes'))); + const id = c.first(c.filter((id) => c.name(id).startsWith('paint-'), c.keys(nodes))); + re_frame.core.dispatch_sync(c.vector(k('arthur.events.ui/select'), + c.vector(k('node'), k('main'), id, c.vector(id)))); + })()`); await page.eval(SEEK(8)); await sleep(100); check(await page.eval(`(() => {