Put the top bar on the surface as icons, and give palettes a real chooser

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 <details> 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 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-10-04 03:08:21 -04:00
parent eca1a96b82
commit f238ff4f62
15 changed files with 564 additions and 313 deletions

View file

@ -351,25 +351,24 @@
(active-palette [this] (active-palette [this]
"The palette selected by this resolver's most recently resolved frame.")) "The palette selected by this resolver's most recently resolved frame."))
(defn resolver (defn- channel-value [store selection frame]
"Resolve an output frame, selecting native content at each symbol boundary.
Every instance owns its cursors and buffers. The IResolver queries return
native node frames and world matrices for the last rendered output frame."
[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 (cond
(nil? selection) nil (nil? selection) nil
(and (map? selection) (contains? selection :animated?)) (and (map? selection) (contains? selection :animated?))
(ch/value-at selection frame store) (ch/value-at selection frame store)
:else selection)) :else selection))
(root-selection-at [owner frame inherited]
(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 ;; A palette track has meaningful uncovered time. Ordinary held
;; channels clamp to their first key before it, but doing that ;; channels clamp to their first key before it, but doing that
;; here would erase the gap before the first palette segment. ;; here would erase the gap before the first palette segment.
(let [fallback (or (channel-value (:palette owner) frame) (let [channel-value (partial channel-value store)
inherited (:default palette)) fallback (or (channel-value (:palette owner) frame)
inherited default)
materialize (fn [choice] materialize (fn [choice]
(cond (cond
(= pal/inherit choice) fallback (= pal/inherit choice) fallback
@ -403,6 +402,16 @@
fallback))) fallback)))
track-value track-value
fallback))) 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
native node frames and world matrices for the last rendered output frame."
[clip sid store palette opts]
(let [context? (and (map? palette) (:palettes palette) (:offsets palette))
active-palette-state (atom (:default palette))]
(letfn [(root-selection-at [owner frame inherited]
(palette-at clip store (:default palette) owner frame inherited))
(selection-at [owner frame inherited] (selection-at [owner frame inherited]
(let [selection (:palette owner) (let [selection (:palette owner)
chosen (cond chosen (cond

View file

@ -136,6 +136,25 @@
:<- [::palette] :<- [::palette]
(fn [compiled _] (:ramp compiled))) (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 (rf/reg-sub
::store ::store
:<- [::clip-id] :<- [::clip-id]

View file

@ -21,6 +21,7 @@
half of this (see `@media` in app.css), and a drawer that is shut should not be half of this (see `@media` in app.css), and a drawer that is shut should not be
holding video thumbnails live." holding video thumbnails live."
(:require [arthur.domain.onion :as onion] (:require [arthur.domain.onion :as onion]
[arthur.ui.menu :as menu]
[re-frame.core :as rf])) [re-frame.core :as rf]))
(def ^:private narrow? (def ^:private narrow?
@ -120,21 +121,26 @@
:on-pointer-cancel (fn [_] (reset! sizing nil))}])) :on-pointer-cancel (fn [_] (reset! sizing nil))}]))
(defn panes (defn panes
"Which panes are open, as a segmented control in the top bar. In the top bar "Which panes are open, as three switches at the left of the top bar, each a
because a shut pane has no head left to carry its own handle, and because on a picture of the window with its pane filled in. In the top bar because a shut
narrow screen this is the whole of the navigation." 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])] (let [{:keys [shut]} @(rf/subscribe [::state])]
[:div.seg.panes {:aria-label "panes"} [:div.icons.panes {:aria-label "panes"}
(doall (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))] (let [open? (not (contains? shut k))]
^{:key k} ^{:key k}
[:button {:class (when open? "on") [:button.ico {:class (when open? "on")
:aria-pressed open? :aria-pressed open? :aria-label label
:title (str (if open? "hide the " "show the ") label) :title (str (if open? "hide the " "show the ") label)
:on-click #(rf/dispatch [::toggle k])} :on-click #(rf/dispatch [::toggle k])}
label])))])) [: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?] (defn- stepped [k z out?]
;; Geometric steps work below 100% as well as above it. ;; Geometric steps work below 100% as well as above it.
@ -146,8 +152,9 @@
"− <readout> + for one zoomable surface. THE READOUT IS THE BUTTON BACK: it "− <readout> + 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 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 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." you already look to find out where you are. `more` joins the group: the
[k label] stage's fit."
[k label & more]
(let [z @(rf/subscribe [::zoom k]) (let [z @(rf/subscribe [::zoom k])
[lo hi] (limits k) [lo hi] (limits k)
home (k normal)] home (k normal)]
@ -162,10 +169,11 @@
(str (js/Math.round (* 100 z)) "%")] (str (js/Math.round (* 100 z)) "%")]
[:button {:disabled (>= z hi) :aria-label (str label " zoom in") [:button {:disabled (>= z hi) :aria-label (str label " zoom in")
:title (str "zoom " label " 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-event-db ::mat (fn [db [_ k v]] (assoc-in db [:ui :passepartout k] v)))
(rf/reg-sub ::onion (rf/reg-sub ::onion
@ -175,14 +183,28 @@
(fn [db [_ k v]] (fn [db [_ k v]]
(assoc-in db [:ui :onion (:clip/current db) (get-in db [:ui :open]) 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 [] (defn onion-controls []
(let [{:keys [on? before after opacity scope]} @(rf/subscribe [::onion])] (let [{:keys [on? before after opacity scope]} @(rf/subscribe [::onion])]
[:div.group.onion-controls [overlay "onion-controls" "onion" on?
[:button {:class (when on? "on") :aria-pressed (boolean on?) "Onion skin — neighboring cels of the selected lane"
:title "Onion skin — neighboring cels of the selected lane" #(rf/dispatch [::onion :on? (not on?)])
:on-click #(rf/dispatch [::onion :on? (not on?)])} "onion"] (fn [_]
[:details
[:summary {:aria-label "Onion skin settings" :title "Onion skin settings"} "▾"]
[:div.view-popout [:div.view-popout
[:label "Scope" [:label "Scope"
[:select {:value (name scope) [:select {:value (name scope)
@ -195,10 +217,8 @@
:on-change #(let [n (js/parseInt (.. % -target -value) 10)] :on-change #(let [n (js/parseInt (.. % -target -value) 10)]
(when-not (js/isNaN n) (when-not (js/isNaN n)
(rf/dispatch [::onion k (max 0 (min 5 n))])))}]]) (rf/dispatch [::onion k (max 0 (min 5 n))])))}]])
[:label "Opacity" [slider "Opacity" 0.05 0.6 opacity #(rf/dispatch [::onion :opacity %])]
[:input {:type "range" :min 0.05 :max 0.6 :step 0.05 :value opacity [:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]])]))
: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."]]]]))
(defn fit-stage! [] (defn fit-stage! []
(when-let [area (.querySelector js/document ".stage-area")] (when-let [area (.querySelector js/document ".stage-area")]
@ -209,14 +229,19 @@
(/ (- (.-clientHeight area) 32) h))]) (/ (- (.-clientHeight area) 32) h))])
(js/requestAnimationFrame #(do (set! (.-scrollLeft area) 0) (set! (.-scrollTop area) 0))))))) (js/requestAnimationFrame #(do (set! (.-scrollLeft area) 0) (set! (.-scrollTop area) 0)))))))
(defn stage-controls [] (defn stage-controls
(let [{:keys [opacity]} @(rf/subscribe [::mat])] "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] [onion-controls]
[:button {:on-click fit-stage! :title "Fit stage in view"} "fit"] [overlay "passepartout-controls" "passepartout" on?
[:details.passepartout-controls "Darken the desk around the stage"
[:summary "passepartout"] #(rf/dispatch [::mat :on? (not on?)])
(fn [_]
[:div.view-popout [:div.view-popout
[:label "Passepartout" [slider "Opacity" 0 1 opacity #(rf/dispatch [::mat :opacity %])]])]
[:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity [:span.sep]
:on-change #(rf/dispatch [::mat :opacity (js/parseFloat (.. % -target -value))])}]]]]])) [zoomer :stage "the stage"
[:button {:on-click fit-stage! :title "Fit stage in view"} "fit"]]]))

View file

@ -79,3 +79,29 @@
:on-click (fn [] (close!) (on-click))} :on-click (fn [] (close!) (on-click))}
label label
(when sub [:span.sub sub])]))]])])))) (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!)]])]))))

View file

@ -14,9 +14,7 @@
(:require [arthur.db :as db] (:require [arthur.db :as db]
[arthur.events.playback :as pb] [arthur.events.playback :as pb]
[arthur.events.project :as project] [arthur.events.project :as project]
[arthur.subs.playback :as playback] [re-frame.core :as rf]))
[re-frame.core :as rf]
[reagent.core :as r]))
(defn- when-said (defn- when-said
"The ISO stamp the server sends, as a date. Truncated rather than formatted: "The ISO stamp the server sends, as a date. Truncated rather than formatted:
@ -35,26 +33,12 @@
label label
(when sub [:span.sub sub])]) (when sub [:span.sub sub])])
(defn view [] (defn panel
(r/with-let [open? (r/atom false)] "The list, under the top bar's file menu. `close!` shuts it."
[close!]
(let [{:keys [items loading?]} @(rf/subscribe [::project/listing]) (let [{:keys [items loading?]} @(rf/subscribe [::project/listing])
busy? (:busy? @(rf/subscribe [::playback/project])) choose! (fn [event] (close!) (rf/dispatch event))]
choose! (fn [event] (reset! open? false) (rf/dispatch event))] [:div.menu.menu-left
[: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"] [:h2 "projects"]
(cond (cond
loading? [:div.dim "…"] loading? [:div.dim "…"]
@ -75,4 +59,4 @@
:on-click #(choose! [::pb/select-clip id])}])) :on-click #(choose! [::pb/select-clip id])}]))
[item {:label "8625 stage study" :example? true [item {:label "8625 stage study" :example? true
:sub "composed from a locally saved project" :sub "composed from a locally saved project"
:on-click #(choose! [::project/load-stage])}]]])]))) :on-click #(choose! [::project/load-stage])}]]))

View file

@ -15,6 +15,7 @@
The footage switch is NOT here. It is a viewing aid rather than something you 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`." set before you draw, so it is a section of the inspector. See `ui/params`."
(:require [arthur.domain.channel :as channel] (:require [arthur.domain.channel :as channel]
[arthur.domain.clip :as clip]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol] [arthur.domain.symbol :as symbol]
@ -22,6 +23,8 @@
[arthur.events.ui :as ui] [arthur.events.ui :as ui]
[arthur.subs.render :as render] [arthur.subs.render :as render]
[arthur.subs.ui :as sub] [arthur.subs.ui :as sub]
[arthur.ui.menu :as menu]
[clojure.string :as str]
[re-frame.core :as rf] [re-frame.core :as rf]
[reagent.core :as r])) [reagent.core :as r]))
@ -174,30 +177,107 @@
:on-click #(pick! [:remap {}] placements)} :on-click #(pick! [:remap {}] placements)}
"⇄"]]]])) "⇄"]]]]))
(defn assets (defn- strip
"Which palette asset the grid shows, and a new or duplicated one, for the "Every slot of a palette, side by side: what it looks like at a glance."
tool strip, with a popout for the full asset controls." [{:keys [slots]}]
[] [:span.strip (for [[i {:keys [hex]}] (map-indexed vector slots)]
(let [[clip pid palette] (shown)] ^{:key i} [:i {:style {:background hex}}])])
[:details.palette-assets
{:on-toggle (fn [e] (defn- rename
(let [el (.-currentTarget e) "The open palette's name, made editable. Committed on Enter or blur, so a
box (.getBoundingClientRect el) rename is one undo step and not one per letter; Esc leaves it as it was."
pop (.querySelector el ".palette-popout")] [pid palette done!]
(set! (.. pop -style -left) (str (+ 4 (.-right box)) "px")) [:input.palette-name
(set! (.. pop -style -top) (str (max 8 (min (.-top box) (- (.-innerHeight js/window) 160))) "px"))))} {:type "text" :default-value (:name palette) :auto-focus true
[:summary {:title (:name palette)} [:span (:name palette)]] :aria-label "palette name"
[:div.palette-popout :on-focus #(.select (.-target %))
[:strong (:name palette)] :on-blur (fn [^js e]
[:select {:value (str pid) (rf/dispatch [::project/palette-name pid (.. e -target -value)])
:title "palette asset" (done!))
:on-change (fn [e] :on-key-down (fn [^js e]
(let [v (.. e -target -value) (case (.-key e)
id (first (filter #(= v (str %)) (keys (pal/palettes clip))))] "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 [::ui/set-tone 0])
(rf/dispatch [::select id])))} (rf/dispatch [::select id])))}
(for [[id p] (sort-by (comp str :name val) (pal/palettes clip))] [strip p]
^{:key (str id)} [:option {:value (str id)} (:name p)])] [:span.name (:name p)]
[:button {:title "new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "+"] (when (contains? on-stage id)
[:button {:title (str "duplicate " (:name palette) " — a copy you can retone") [:span.on-stage {:title "on the stage at the playhead"}])])
:on-click #(rf/dispatch [::project/duplicate-palette pid])} "clone"]]])) (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
"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)]
[: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]]))

View file

@ -7,19 +7,13 @@
[re-frame.core :as rf] [re-frame.core :as rf]
[reagent.core :as r])) [reagent.core :as r]))
(defn view [] (defn panel
(r/with-let [open? (r/atom false) "Named versions, under the top bar's file menu. `close!` shuts it."
draft (r/atom "")] [close!]
(r/with-let [draft (r/atom "")]
(let [{:keys [can-edit?]} @(rf/subscribe [::playback/project]) (let [{:keys [can-edit?]} @(rf/subscribe [::playback/project])
rows @(rf/subscribe [::collab/snapshot-list]) rows @(rf/subscribe [::collab/snapshot-list])
editable? (not (false? can-edit?))] 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 [:div.menu.menu-left
(when editable? (when editable?
[:form.row {:on-submit (fn [e] [:form.row {:on-submit (fn [e]
@ -39,6 +33,6 @@
[:div.menu-item.static [:div.menu-item.static
[:span name [:span.sub (str author " · " (subs (str created) 0 16))]] [:span name [:span.sub (str author " · " (subs (str created) 0 16))]]
(when editable? (when editable?
[:button.link {:on-click (fn [] (reset! open? false) [:button.link {:on-click (fn [] (close!)
(rf/dispatch [::collab/restore row]))} (rf/dispatch [::collab/restore row]))}
"restore"])])))]])]))) "restore"])])))])))

View file

@ -755,7 +755,8 @@
destination-name (when-let [sid (:sid destination)] destination-name (when-let [sid (:sid destination)]
(clip/symbol-name clip sid)) (clip/symbol-name clip sid))
zoom @(rf/subscribe [::layout/zoom :stage]) 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 [:div.stage-area
(navigation) (navigation)
[:div.stage-wrap [:div.stage-wrap

View file

@ -43,8 +43,8 @@
:title (str name " (" (.toUpperCase key) ") — " tip) :title (str name " (" (.toUpperCase key) ") — " tip)
:on-click #(rf/dispatch [::ui/set-tool t])} :on-click #(rf/dispatch [::ui/set-tool t])}
[:svg {:view-box "0 0 18 18" :width 18 :height 18} icon]]))] [:svg {:view-box "0 0 18 18" :width 18 :height 18} icon]]))]
[palette/assets] [palette/grid]
[palette/grid]])) [palette/assets]]))
(defn- size-control [] (defn- size-control []
(let [size @(rf/subscribe [::sub/brush])] (let [size @(rf/subscribe [::sub/brush])]
@ -87,17 +87,16 @@
[:span.dim.hint tip])) [:span.dim.hint tip]))
[:span.spacer] [:span.spacer]
(let [{:keys [on? opacity]} @(rf/subscribe [::render/tracing])] (let [{:keys [on? opacity]} @(rf/subscribe [::render/tracing])]
[:<> [layout/overlay "tracing-controls" "tracing" on?
[:button {:class (when on? "on") "show tracing layers over the picture · never exported"
:title "show tracing layers over the picture · never exported" #(rf/dispatch [::ui/tracing-on (not on?)])
:on-click #(rf/dispatch [::ui/tracing-on (not on?)])} "tracing"] (fn [_]
[:input.trace-opacity [:div.view-popout
{:type "range" :min 0 :max 1 :step 0.05 :value opacity :disabled (not on?) [:label "Opacity"
:title "how strongly tracing layers draw" :style {:flex "0 0 64px"} [:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity
:on-change #(rf/dispatch [::ui/tracing-opacity (js/parseFloat (.. % -target -value))])}]]) :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 ;; The stage's zoom and overlays: properties of the view and not of the
;; sits in the view's own chrome rather than in the inspector. ;; document, so they sit in the view's own chrome rather than in the inspector.
[layout/zoomer :stage "the stage"]
[layout/stage-controls]])) [layout/stage-controls]]))
(defn adjust-last (defn adjust-last

View file

@ -1,18 +1,20 @@
(ns arthur.ui.topbar (ns arthur.ui.topbar
"The strip across the top: what document this is, and the three things you can "The strip across the top: everything you do to the whole document, on the
do to the whole of it. 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 Open, snapshots and undo history are lists, so they drop from their button;
property of a selection — they act on the document, and the document is the only one of them is open at a time."
window."
(:require [arthur.events.collab :as collab] (:require [arthur.events.collab :as collab]
[arthur.events.export :as export] [arthur.events.export :as export]
[arthur.events.history :as history]
[arthur.events.project :as project] [arthur.events.project :as project]
[arthur.events.ui :as ui] [arthur.events.ui :as ui]
[arthur.subs.playback :as playback] [arthur.subs.playback :as playback]
[arthur.subs.ui :as sub] [arthur.subs.ui :as sub]
[arthur.ui.layout :as layout] [arthur.ui.layout :as layout]
[arthur.ui.menu :as menu]
[arthur.ui.openmenu :as openmenu] [arthur.ui.openmenu :as openmenu]
[arthur.ui.share :as share] [arthur.ui.share :as share]
[arthur.ui.snapshots :as snapshots] [arthur.ui.snapshots :as snapshots]
@ -24,7 +26,6 @@
(let [{:keys [target zoom busy? done total]} @(rf/subscribe [::export/state]) (let [{:keys [target zoom busy? done total]} @(rf/subscribe [::export/state])
targets @(rf/subscribe [::export/targets])] targets @(rf/subscribe [::export/targets])]
[:<> [:<>
[:button {:disabled busy? :on-click #(reset! open? true)} "export…"]
(when @open? (when @open?
[:<> [:div.export-scrim {:on-click #(when-not busy? (reset! open? false))}] [:<> [:div.export-scrim {:on-click #(when-not busy? (reset! open? false))}]
[:section.export-dialog {:role "dialog" :aria-modal true :aria-label "Export"} [:section.export-dialog {:role "dialog" :aria-modal true :aria-label "Export"}
@ -48,16 +49,53 @@
(if busy? (str "rendering " done "/" total) "start export")] (if busy? (str "rendering " done "/" total) "start export")]
[:button {:disabled busy? :on-click #(reset! open? false)} "close"]]]])])) [: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 [] (defn view []
(r/with-let [renaming? (r/atom false) (r/with-let [renaming? (r/atom false)
draft (r/atom "") draft (r/atom "")
panel (r/atom nil)
export-open? (r/atom false)] export-open? (r/atom false)]
(let [{project-name :name project-seq :seq :keys [busy? status]} @(rf/subscribe [::playback/project]) (let [{project-name :name project-seq :seq :keys [busy? status]} @(rf/subscribe [::playback/project])
{footage-status :status} @(rf/subscribe [::playback/footage]) {footage-status :status} @(rf/subscribe [::playback/footage])
{export-status :status} @(rf/subscribe [::export/state]) {export-status :status export-busy? :busy?} @(rf/subscribe [::export/state])
selections @(rf/subscribe [::sub/selections]) {:keys [done undone]} @(rf/subscribe [::history/steps])
selected? (boolean (seq @(rf/subscribe [::sub/selections])))
clipboard @(rf/subscribe [::sub/clipboard]) clipboard @(rf/subscribe [::sub/clipboard])
selected? (boolean (seq selections))
title (or project-name "untitled") title (or project-name "untitled")
commit! (fn [] commit! (fn []
(rf/dispatch [::project/rename @draft]) (rf/dispatch [::project/rename @draft])
@ -69,24 +107,31 @@
;; this strip scrolls, and which panes are open is the one control that ;; this strip scrolls, and which panes are open is the one control that
;; must not be the thing off the end of it. ;; must not be the thing off the end of it.
[layout/panes] [layout/panes]
[:button {:disabled busy? :on-click #(rf/dispatch [::collab/create])} "new"] [:span.sep]
[openmenu/view] [:div.icons
[undo/view] [btn :new "new project" {:disabled busy? :on-click #(rf/dispatch [::collab/create])}]
[menu/view [dropper panel :open :open "open a project" #(rf/dispatch [::project/list]) openmenu/panel]]
{:label "edit" :title "selection and clipboard" [:span.sep]
:items [{:label "copy" :sub "Copy selection · ⌘/Ctrl C" [:div.icons
:disabled? (not selected?) :on-click #(rf/dispatch [::ui/copy])} [btn :undo (if (seq done) (str "undo " (first done) " (⌘Z)") "nothing to undo")
{:label "cut" :sub "Cut selection · ⌘/Ctrl X" {:disabled (empty? done) :on-click #(rf/dispatch [::history/undo])}]
:disabled? (not selected?) :on-click #(rf/dispatch [::ui/cut])} [dropper panel :history :history "undo history" identity undo/panel (empty? done)]
{:label "paste" :sub "Paste at playhead into the active row · ⌘/Ctrl V" [btn :redo (if (seq undone) (str "redo " (first undone) " (⇧⌘Z)") "nothing to redo")
:disabled? (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])} {:disabled (empty? undone) :on-click #(rf/dispatch [::history/redo])}]]
{:label "duplicate" :sub "Duplicate beside selection; lanes repeat forward · ⌘/Ctrl D" [:span.sep]
:disabled? (not selected?) :on-click #(rf/dispatch [::ui/duplicate])} [:div.icons
{:label "duplicate unique" [btn :cut "cut selection (⌘X)" {:disabled (not selected?) :on-click #(rf/dispatch [::ui/cut])}]
:sub "Duplicate with a private nested symbol graph · ⇧⌘/Ctrl D" [btn :copy "copy selection (⌘C)" {:disabled (not selected?) :on-click #(rf/dispatch [::ui/copy])}]
:disabled? (not selected?) [btn :paste "paste at the playhead into the active row (⌘V)"
:on-click #(rf/dispatch [::ui/duplicate-unique])}]}] {:disabled (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])}]
[snapshots/view] [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? (if @renaming?
[:input.project-name {:auto-focus true :value @draft [:input.project-name {:auto-focus true :value @draft
:on-change #(reset! draft (.. % -target -value)) :on-change #(reset! draft (.. % -target -value))
@ -99,7 +144,9 @@
[:button.project-title {:disabled busy? :title "click to rename project" [:button.project-title {:disabled busy? :title "click to rename project"
:on-click (fn [] (reset! draft title) :on-click (fn [] (reset! draft title)
(reset! renaming? true))} (reset! renaming? true))}
title (when project-seq (str " r" project-seq))]) title (when project-seq [:span.dim (str " r" project-seq)])])
[:span.status (or export-status status footage-status)] [: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?] [exporter export-open?]
[share/view]]))) [share/view]])))

View file

@ -1,32 +1,22 @@
(ns arthur.ui.undo (ns arthur.ui.undo
"Undo, redo, and the list of what undo would take off — newest first, so "The list of what undo would take off — newest first, so choosing the third
choosing the third row undoes three steps." row undoes three steps. Undo and redo themselves are the top bar's."
(:require [arthur.events.history :as history] (:require [arthur.events.history :as history]
[re-frame.core :as rf] [re-frame.core :as rf]))
[reagent.core :as r]))
(defn view [] (defn panel
(r/with-let [open? (r/atom false)] "What undo would take off, newest first, under the edit menu. `close!` shuts it."
(let [{:keys [done undone]} @(rf/subscribe [::history/steps])] [close!]
[:div.menu-wrap.undo (let [{:keys [done]} @(rf/subscribe [::history/steps])]
[: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 [:div.menu.menu-left
[:h2 "undo"] [:h2 "undo history"]
(if (empty? done)
[:div.dim "nothing to undo"]
(doall (doall
(map-indexed (map-indexed
(fn [i label] (fn [i label]
^{:key i} ^{:key i}
[:button.menu-item {:on-click (fn [] (reset! open? false) [:button.menu-item {:on-click (fn [] (close!)
(rf/dispatch [::history/undo (inc i)]))} (rf/dispatch [::history/undo (inc i)]))}
label (when (pos? i) [:span.sub (str (inc i) " steps")])]) label (when (pos? i) [:span.sub (str (inc i) " steps")])])
(take 30 done)))]])]))) (take 30 done))))]))

View file

@ -441,6 +441,9 @@
extended (nest/resize-out document :palettes [:change] 1 false)] extended (nest/resize-out document :palettes [:change] 1 false)]
(resolve 2) (resolve 2)
(is (= {:from :day :to :night :t 0.5} (clip/active-palette resolve))) (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] (is (= [128 128 128]
(nth (pal/effective-ramp context (clip/active-palette resolve)) (nth (pal/effective-ramp context (clip/active-palette resolve))
(pal/render-index context :day 1)))) (pal/render-index context :day 1))))

View file

@ -72,14 +72,16 @@ try {
})()`); })()`);
await sleep(250); await sleep(250);
assert.equal(await evaluate(`getComputedStyle(document.querySelector('.onion-controls summary')).listStyleType`), 'none'); assert.equal(await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').textContent`), '▾');
assert.equal(await evaluate(`document.querySelector('.onion-controls summary').textContent`), '▾');
await evaluate(`document.querySelector('.onion-controls > button').click()`); await evaluate(`document.querySelector('.onion-controls > button').click()`);
await sleep(150); await sleep(150);
assert.equal(await evaluate(`document.querySelector('.onion-controls > button').getAttribute('aria-pressed')`), 'true'); 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); 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(`(() => { await evaluate(`(() => {
const c = cljs.core, k = c.keyword, m = (...xs) => c.hash_map(...xs), v = (...xs) => c.vector(...xs); 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); const db = c.deref(re_frame.db.app_db);

View file

@ -251,8 +251,7 @@ const CLICK = (label) => `(() => {
// turn of the event loop between them, as it is for anybody using the app. // turn of the event loop between them, as it is for anybody using the app.
const MENU_OPEN = `(() => { const MENU_OPEN = `(() => {
if (document.querySelector('.menu')) return true; if (document.querySelector('.menu')) return true;
const b = [...document.querySelectorAll('button')] const b = document.querySelector('.top button[aria-label="open a project"]');
.find((b) => b.textContent.trim() === 'open \u25be');
if (!b || b.disabled) return false; if (!b || b.disabled) return false;
b.click(); b.click();
return true; return true;

View file

@ -155,8 +155,6 @@ audio { display: none; }
/* Which panes are open, in the top bar. Lower-case words rather than icons: the /* 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 three panes have names and the names are what the rest of the chrome calls
them. */ 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 /* 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 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 .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; flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
}
.top .status {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -322,7 +341,13 @@ button.retry { border-color: var(--sel); color: var(--sel); }
color: var(--dim); 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; } .project-name { width: 180px; }
.export-scrim { position: fixed; inset: 0; z-index: 50; background: rgba(0, 0, 0, .45); } .export-scrim { position: fixed; inset: 0; z-index: 50; background: rgba(0, 0, 0, .45); }
.export-dialog { .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); } .index-list a:hover { color: var(--sel); }
a.brand { text-decoration: none; } 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; } .menu.menu-left { left: 0; right: auto; }
/* A menu dropping out of a PANE HEAD. Fixed and placed by `ui/menu` against the /* 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-size: 8px 8px;
background-position: 0 0, 4px 4px; 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 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%); 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); 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 { position: relative; }
.palette .cell .badge { .palette .cell .badge {
position: absolute; right: -2px; bottom: -2px; 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; border-radius: 2px;
} }
/* Palette assets stay in the existing narrow tool strip. */ /* The palette's own settings, at the foot of the strip: drawn as a tool button,
.toolbox .palette-assets { display: block; width: 44px; flex: 0 0 auto; } because it is one more thing in the strip you press. */
.palette-assets summary { cursor: pointer; font-size: 10px; line-height: 1.25; overflow-wrap: anywhere; padding: 3px; } .palette-assets > button {
.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; } width: 34px; height: 30px; padding: 0;
.palette-popout { left: 52px; width: 230px; display: flex; flex-wrap: wrap; gap: 7px; } display: grid; place-items: center;
.palette-popout strong { width: 100%; overflow-wrap: anywhere; } border: 1px solid transparent; border-radius: 4px; background: none;
.palette-popout select { width: 100%; } }
.view-popout { right: 16px; width: 240px; } .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 label { display: block; margin-bottom: 8px; }
.view-popout input { width: 100%; } .view-popout label:last-child { margin-bottom: 0; }
.passepartout-controls summary { cursor: pointer; } .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; } .stage-wrap { flex: 0 0 auto; }
.onion-skin { position: absolute; inset: 0; pointer-events: none; image-rendering: pixelated; } .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. */ /* 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%; } 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%; }