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%; }