Compare commits

...

3 commits

Author SHA1 Message Date
Olive Vaughn
78fb120edd ikd mn 2026-10-04 18:14:05 -04:00
Olive Vaughn
208dddee07 Put the top bar back to words
The icon run in the top bar read worse than what it replaced. Restore the
earlier top bar — pane words, new, open ▾, undo ▾ redo, the edit menu,
snapshots ▾, title, status, export… — and its CSS. The stage bar's overlay
switches, the palette chooser and `menu/popover` stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:16:09 -04:00
Olive Vaughn
f238ff4f62 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>
2026-10-04 03:08:21 -04:00
17 changed files with 459 additions and 193 deletions

@ -0,0 +1 @@
Subproject commit f4dd04764204506fc275180364d0366d693036f4

View file

@ -57,8 +57,9 @@
THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at
the stage, like solo and zoom: not an undo step, not sent to collaborators, the stage, like solo and zoom: not an undo step, not sent to collaborators,
and it cannot reach an export. A layer is shown unless it is in `:hidden`, so and it cannot reach an export. A layer is shown unless it is in `:hidden`, so
footage brought in shows without being found and switched on first." footage brought in shows without being found and switched on first — once
{:on? true :opacity 0.5 :hidden #{}}) tracing itself is on, which it is not until asked for."
{:on? false :opacity 0.5 :hidden #{}})
(def default (def default
{;; --- the document --- {;; --- the document ---

View file

@ -25,13 +25,14 @@
another clip is `arthur.domain.bring`. This namespace is the document and the another clip is `arthur.domain.bring`. This namespace is the document and the
operations that only need the document. operations that only need the document.
NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which NO SYMBOL IS SPECIAL. There is no reserved root id: which symbol is on screen
symbol is on screen is the editor's state, not the document's, and every is the editor's state, not the document's, and every function here that needs
function here that needs a symbol is told which. A new document has one symbol a symbol is told which. A new document has one symbol called `:main` because
called `:main` because it has to be called something, and that is all the name it has to be called something, and that is all the name means — it can be
means — it can be renamed, placed inside another symbol or deleted like any of renamed or placed inside another symbol like any of them. What the document
them. `unplaced` answers the question a reserved root used to: which symbols does say is `:root`, which symbol it opens on: a pointer, not a kind of
nothing else places, and so which ones a person opening the document wants. symbol, the way a Flash file names its scene. See `opens-on` for why that
cannot be worked out instead.
:fps is the output grid. A symbol's optional :fps names the native grid its :fps is the output grid. A symbol's optional :fps names the native grid its
frames were authored or measured on; absent means the document's grid." frames were authored or measured on; absent means the document's grid."
@ -52,7 +53,7 @@
layer must not be able to have. Add the field here and to `leaf/leaves` and layer must not be able to have. Add the field here and to `leaf/leaves` and
`leaf/clip` in the same commit." `leaf/clip` in the same commit."
#{:name :fps :analyses :subjects :features :groups :width :height :symbols #{:name :fps :analyses :subjects :features :groups :width :height :symbols
:palettes :default-palette}) :palettes :default-palette :root})
(defn symbol (defn symbol
"One of the clip's symbols, by id." "One of the clip's symbols, by id."
@ -201,18 +202,39 @@
(let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))] (let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))]
(vec (sort-by str (remove placed (keys (:symbols clip))))))) (vec (sort-by str (remove placed (keys (:symbols clip)))))))
(defn opens-on (defn- longest-unplaced
"The symbol a document opens on: the longest one nothing else places, ties "The longest symbol nothing else places, ties broken by id, and never a
broken by id. The symbol that contains everything else is the longest of the tracing symbol: that is footage nobody has placed yet, as long as its take."
unplaced ones in every document made so far, and a reserved name is what this
replaces.
Never a tracing symbol: it is footage nobody has placed yet, and as long as
the take it came from, so it would otherwise win."
[clip] [clip]
(first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)]) (first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)])
(remove #(trace? (symbol clip %)) (unplaced clip))))) (remove #(trace? (symbol clip %)) (unplaced clip)))))
(defn opens-on
"The symbol a document opens on: its `:root`.
IT IS STORED, NOT WORKED OUT. It used to be the longest unplaced symbol, on
the theory that the one containing everything else is always that. A take
disproves it: imported footage is a symbol a thousand frames long, and the
moment its instance is deleted, or the drop lands somewhere other than the
root, nothing places it and it outranks a 120-frame `:main`. The document then
opened on the take, and `set-root-fps` rewrote the take's rate to the
project's on the way in.
A document with no `:root` — a demo, a fixture — still gets the old answer."
[clip]
(let [root (:root clip)]
(if (contains? (:symbols clip) root) root (longest-unplaced clip))))
(defn pin-root
"Give a document saved before `:root` existed the root it was made with.
Every such document started as `blank`, whose root is `:main`, and ids never
change, so `:main` is the answer wherever it survives; the old rule is only
for documents that never had one."
[clip]
(cond-> clip
(not (:root clip))
(assoc :root (if (contains? (:symbols clip) :main) :main (longest-unplaced clip)))))
(defn set-root-fps (defn set-root-fps
"Set the document/output rate and the root symbol's editing rate together. "Set the document/output rate and the root symbol's editing rate together.
@ -250,6 +272,7 @@
:width 320 :height 200 :width 320 :height 200
:palettes {pal/default-id pal/default-palette} :palettes {pal/default-id pal/default-palette}
:default-palette pal/default-id :default-palette pal/default-id
:root :main
;; No native fps yet: an untouched canvas follows the project grid. Imported ;; No native fps yet: an untouched canvas follows the project grid. Imported
;; and generated symbols carry their own rate explicitly. ;; and generated symbols carry their own rate explicitly.
:symbols {:main {:id :main :frames blank-frames :nodes {}}}}) :symbols {:main {:id :main :frames blank-frames :nodes {}}}})
@ -351,6 +374,58 @@
(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- 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 (defn resolver
"Resolve an output frame, selecting native content at each symbol boundary. "Resolve an output frame, selecting native content at each symbol boundary.
Every instance owns its cursors and buffers. The IResolver queries return Every instance owns its cursors and buffers. The IResolver queries return
@ -358,51 +433,8 @@
[clip sid store palette opts] [clip sid store palette opts]
(let [context? (and (map? palette) (:palettes palette) (:offsets palette)) (let [context? (and (map? palette) (:palettes palette) (:offsets palette))
active-palette-state (atom (:default palette))] active-palette-state (atom (:default palette))]
(letfn [(channel-value [selection frame] (letfn [(root-selection-at [owner frame inherited]
(cond (palette-at clip store (:default palette) owner frame inherited))
(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)))
(selection-at [owner frame inherited] (selection-at [owner frame inherited]
(let [selection (:palette owner) (let [selection (:palette owner)
chosen (cond chosen (cond
@ -714,6 +746,9 @@
(when (and (contains? clip :default-palette) (map? (:palettes clip)) (when (and (contains? clip :default-palette) (map? (:palettes clip))
(not (contains? (:palettes clip) (:default-palette clip)))) (not (contains? (:palettes clip) (:default-palette clip))))
[":default-palette must name a project palette"]) [":default-palette must name a project palette"])
(when (and (contains? clip :root) (map? (:symbols clip))
(not (contains? (:symbols clip) (:root clip))))
[(str ":root names missing symbol " (pr-str (:root clip)))])
(for [[id p] (:palettes clip) (for [[id p] (:palettes clip)
:when (or (not= id (:id p)) (not (pal/valid-palette? p)))] :when (or (not= id (:id p)) (not (pal/valid-palette? p)))]
(str "palette " (pr-str id) " is invalid or has a different :id")) (str "palette " (pr-str id) " is invalid or has a different :id"))

View file

@ -12,6 +12,7 @@
clip/<cid>/stage width, height clip/<cid>/stage width, height
clip/<cid>/palette/<pid> a named indexed palette asset clip/<cid>/palette/<pid> a named indexed palette asset
clip/<cid>/palette-default the project fallback palette id clip/<cid>/palette-default the project fallback palette id
clip/<cid>/root the symbol the document opens on
clip/<cid>/source the analysis record this came out of clip/<cid>/source the analysis record this came out of
clip/<cid>/subject/<subj> a tracked subject and its params clip/<cid>/subject/<subj> a tracked subject and its params
clip/<cid>/feature/<fid> one feature: area, nodes, params clip/<cid>/feature/<fid> one feature: area, nodes, params
@ -138,6 +139,7 @@
(some-leaf (at "timing") (select-keys clip [:fps])) (some-leaf (at "timing") (select-keys clip [:fps]))
(some-leaf (at "stage") (select-keys clip [:width :height])) (some-leaf (at "stage") (select-keys clip [:width :height]))
(some-leaf (at "palette-default") (select-keys clip [:default-palette])) (some-leaf (at "palette-default") (select-keys clip [:default-palette]))
(some-leaf (at "root") (select-keys clip [:root]))
(some-leaf (at "analyses") (:analyses clip)) (some-leaf (at "analyses") (:analyses clip))
(concat (concat
(for [[id v] (:subjects clip)] {(at "subject" (segment id)) v}) (for [[id v] (:subjects clip)] {(at "subject" (segment id)) v})
@ -198,6 +200,7 @@
"stage" (merge acc v) "stage" (merge acc v)
"analyses" (assoc acc :analyses v) "analyses" (assoc acc :analyses v)
"palette-default" (merge acc v) "palette-default" (merge acc v)
"root" (merge acc v)
"palette" (assoc-in acc [:palettes (unsegment a)] v) "palette" (assoc-in acc [:palettes (unsegment a)] v)
"subject" (assoc-in acc [:subjects (unsegment a)] v) "subject" (assoc-in acc [:subjects (unsegment a)] v)
"feature" (assoc-in acc [:features (unsegment a)] v) "feature" (assoc-in acc [:features (unsegment a)] v)
@ -239,7 +242,7 @@
false) false)
(case (count p) (case (count p)
;; The clip's own facts carry no id. ;; The clip's own facts carry no id.
3 (#{"name" "timing" "stage" "analyses" "palette-default"} (nth p 2)) 3 (#{"name" "timing" "stage" "analyses" "palette-default" "root"} (nth p 2))
4 (#{"subject" "feature" "group" "palette"} (nth p 2)) 4 (#{"subject" "feature" "group" "palette"} (nth p 2))
false))))] false))))]
(vec (vec

View file

@ -113,7 +113,9 @@
;; also normalizes documents saved by the earlier model, ;; also normalizes documents saved by the earlier model,
;; where changing project FPS left the root on its old ;; where changing project FPS left the root on its old
;; editing grid. ;; editing grid.
built (clip/set-root-fps (:clip loaded) (:fps (:clip loaded)))] built (-> (:clip loaded)
clip/pin-root
(clip/set-root-fps (:fps (:clip loaded))))]
(let [entry (merge (select-keys built [:fps :width :height]) (let [entry (merge (select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)") {:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid :cid cid

View file

@ -841,6 +841,7 @@
:width (first stage) :height (second stage) :width (first stage) :height (second stage)
:palettes {pal/default-id pal/default-palette} :palettes {pal/default-id pal/default-palette}
:default-palette pal/default-id :default-palette pal/default-id
:root :main
:subjects (into {} (map (fn [[id _]] :subjects (into {} (map (fn [[id _]]
[id {:id id :params {} [id {:id id :params {}
:analysis (:id analysis) :analysis (:id analysis)

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?
@ -146,8 +147,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 +164,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,30 +178,42 @@
(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 [:div.view-popout
[:summary {:aria-label "Onion skin settings" :title "Onion skin settings"} "▾"] [:label "Scope"
[:div.view-popout [:select {:value (name scope)
[:label "Scope" :on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])}
[:select {:value (name scope) [:option {:value "selected"} "Selected lane"]
:on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])} [:option {:value "symbol"} "Whole symbol"]]]
[:option {:value "selected"} "Selected lane"] (for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]]
[:option {:value "symbol"} "Whole symbol"]]] ^{:key k} [:label label
(for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]] [:input {:type "number" :min 0 :max 5 :value v
^{:key k} [:label label :on-change #(let [n (js/parseInt (.. % -target -value) 10)]
[:input {:type "number" :min 0 :max 5 :value v (when-not (js/isNaN n)
:on-change #(let [n (js/parseInt (.. % -target -value) 10)] (rf/dispatch [::onion k (max 0 (min 5 n))])))}]])
(when-not (js/isNaN n) [slider "Opacity" 0.05 0.6 opacity #(rf/dispatch [::onion :opacity %])]
(rf/dispatch [::onion k (max 0 (min 5 n))])))}]]) [:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]])]))
[: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."]]]]))
(defn fit-stage! [] (defn fit-stage! []
(when-let [area (.querySelector js/document ".stage-area")] (when-let [area (.querySelector js/document ".stage-area")]
@ -209,14 +224,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?)])
[:div.view-popout (fn [_]
[:label "Passepartout" [:div.view-popout
[:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity [slider "Opacity" 0 1 opacity #(rf/dispatch [::mat :opacity %])]])]
:on-change #(rf/dispatch [::mat :opacity (js/parseFloat (.. % -target -value))])}]]]]])) [:span.sep]
[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

@ -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- 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 (defn assets
"Which palette asset the grid shows, and a new or duplicated one, for the "The palettes behind a button at the foot of the strip: switch, rename, add.
tool strip, with a popout for the full asset controls." 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)] (let [[clip pid palette] (shown)]
[:details.palette-assets [:div.palette-assets-wrap
{:on-toggle (fn [e] [menu/popover {:class "palette-assets" :title (str "palette: " (:name palette))
(let [el (.-currentTarget e) :label [:svg {:view-box "0 0 18 18" :width 18 :height 18}
box (.getBoundingClientRect el) [: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"}]
pop (.querySelector el ".palette-popout")] [:circle {:cx 5.5 :cy 8 :r 1.3}]
(set! (.. pop -style -left) (str (+ 4 (.-right box)) "px")) [:circle {:cx 8.5 :cy 5 :r 1.3}]
(set! (.. pop -style -top) (str (max 8 (min (.-top box) (- (.-innerHeight js/window) 160))) "px"))))} [:circle {:cx 12.5 :cy 6.5 :r 1.3}]]}
[:summary {:title (:name palette)} [:span (:name palette)]] (fn [_]
[:div.palette-popout [:div.palette-popout
[:strong (:name palette)] [chooser clip pid]
[:select {:value (str pid) [:div.row
:title "palette asset" [:button {:title "a new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "new"]
:on-change (fn [e] [:button {:title (str "a copy of " (:name palette) " you can retone")
(let [v (.. e -target -value) :on-click #(rf/dispatch [::project/duplicate-palette pid])} "duplicate"]]])]
id (first (filter #(= v (str %)) (keys (pal/palettes clip))))] [elsewhere pid palette]]))
(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"]]]))

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

@ -1241,10 +1241,9 @@
(let [by-frame (group-by :at (concat key-items (mapcat :key-items cels)))] (let [by-frame (group-by :at (concat key-items (mapcat :key-items cels)))]
(for [[index f] (map-indexed vector (sort (distinct (concat keys (mapcat :keys cels))))) (for [[index f] (map-indexed vector (sort (distinct (concat keys (mapcat :keys cels)))))
:when (and (<= 0 f) (< f frames))] :when (and (<= 0 f) (< f frames))]
^{:key index}
(if-let [items (and (= :channel kind) (seq (get by-frame f)))] (if-let [items (and (= :channel kind) (seq (get by-frame f)))]
[key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements] ^{:key index} [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements]
[:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))])) ^{:key index} [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))]))
(defn- cursor-hint (defn- cursor-hint
"What the drag in flight would do, beside the pointer. "What the drag in flight would do, beside the pointer.

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

@ -216,10 +216,19 @@
(deftest no-symbol-is-special (deftest no-symbol-is-special
(let [c (nested)] (let [c (nested)]
(testing "a document opens on the longest symbol nothing places" (testing "a document opens on its root, however long its unplaced symbols are"
(is (= [:loose :main :outer] (clip/unplaced c))) (is (= [:loose :main :outer] (clip/unplaced c)))
(is (= :outer (clip/opens-on c))) (is (= :main (clip/opens-on c)))
(is (= :main (clip/opens-on (clip/blank))))) (is (= :main (clip/opens-on (clip/blank))))
(is (= :loose (clip/opens-on (assoc c :root :loose))))
(is (= c (leaf/clip "u" (leaf/leaves "u" c))))
(is (seq (clip/problems (assoc c :root :gone)))))
(testing "without a root, the longest symbol nothing places"
(is (= :outer (clip/opens-on (dissoc c :root)))))
(testing "a document saved before roots opens on :main, not a longer take"
(is (= :main (:root (clip/pin-root (dissoc c :root)))))
(is (= :outer (:root (clip/pin-root (-> c (dissoc :root) (update :symbols dissoc :main))))))
(is (= :loose (:root (clip/pin-root (assoc c :root :loose))))))
(testing "an instance can go into any symbol, and spans that symbol's frames" (testing "an instance can go into any symbol, and spans that symbol's frames"
(let [[n] (vals (get-in c [:symbols :outer :nodes]))] (let [[n] (vals (get-in c [:symbols :outer :nodes]))]
(is (= #{:inner} (node/sources n))) (is (= #{:inner} (node/sources n)))
@ -441,6 +450,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

@ -523,32 +523,32 @@ async function main() {
// same synchronous block arrives while the tool is still unset and is dropped. // same synchronous block arrives while the tool is still unset and is dropped.
// A person cannot click twice inside one microtask; a test should not either. // A person cannot click twice inside one microtask; a test should not either.
const armed = await page.eval(`(() => { const armed = await page.eval(`(() => {
const button = [...document.querySelectorAll('.palette-bar button')] const button = document.querySelector('.toolbox button.tool[title^="Pen"]');
.find(b => b.textContent === 'pen');
if (!button) return false; if (!button) return false;
button.click(); button.click();
return true; return true;
})()`); })()`);
await sleep(100); await sleep(100);
const painted = armed && await page.eval(`(() => { // The overlay reaches past the stage on every side, so a stage pixel is
const svg = document.querySelector('.paint-overlay'); // found through the SVG's own transform, as the stage finds it coming back.
const box = svg.getBoundingClientRect(); // Each point is its own eval: a click is a re-frame event, and the next one
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) { // reads the draft it added to.
svg.dispatchEvent(new PointerEvent('pointerdown', { const PEN_AT = (x, y) => `(() => {
bubbles: true, clientX: box.left + x * box.width / 320, const svg = document.querySelector('.paint-overlay.tool-pen');
clientY: box.top + y * box.height / 200, if (!svg) return false;
})); const p = new DOMPoint(${x}, ${y}).matrixTransform(svg.getScreenCTM());
} svg.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, clientX: p.x, clientY: p.y,
}));
return true; return true;
})()`); })()`;
await sleep(100); let painted = armed;
const finished = await page.eval(`(() => { for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
const button = [...document.querySelectorAll('.palette-bar button')] painted = painted && await page.eval(PEN_AT(x, y));
.find(b => b.textContent === 'finish'); await sleep(60);
if (!button || button.disabled) return false; }
button.click(); // Closed as a person closes it: on the first point.
return true; const finished = painted && await page.eval(PEN_AT(10, 10));
})()`);
await sleep(250); await sleep(250);
const afterPaint = await page.eval(`(() => { const afterPaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage'); const c = document.querySelector('canvas.stage');
@ -556,7 +556,8 @@ async function main() {
return [...d]; return [...d];
})()`); })()`);
check(painted && finished && beforePaint.join(',') !== afterPaint.join(','), check(painted && finished && beforePaint.join(',') !== afterPaint.join(','),
'a polygon drawn with the paint controls appears on the canvas'); 'a polygon drawn with the paint controls appears on the canvas',
`${beforePaint} -> ${afterPaint}`);
for (const f of [8, 16]) { for (const f of [8, 16]) {
await page.eval(SEEK(f)); await page.eval(SEEK(f));
await sleep(100); await sleep(100);
@ -597,7 +598,18 @@ async function main() {
return [...c.getContext('2d').getImageData(25, 20, 1, 1).data]; return [...c.getContext('2d').getImageData(25, 20, 1, 1).data];
})()`); })()`);
check(afterPaint.join(',') === reopenedPaint.join(','), check(afterPaint.join(',') === reopenedPaint.join(','),
'the painted polygon survives the project round trip'); 'the painted polygon survives the project round trip', `${afterPaint} vs ${reopenedPaint}`);
// A reopened document has nothing selected, so the shape's keys are not in
// the inspector until it is selected again, as anybody would.
await page.eval(`(() => {
const c = cljs.core, k = c.keyword;
const db = c.deref(re_frame.db.app_db);
const nodes = c.get_in(c.get(arthur.footage.store.entry(c.get(db, k('clip/current'))), k('clip')),
c.vector(k('symbols'), k('main'), k('nodes')));
const id = c.first(c.filter((id) => c.name(id).startsWith('paint-'), c.keys(nodes)));
re_frame.core.dispatch_sync(c.vector(k('arthur.events.ui/select'),
c.vector(k('node'), k('main'), id, c.vector(id))));
})()`);
await page.eval(SEEK(8)); await page.eval(SEEK(8));
await sleep(100); await sleep(100);
check(await page.eval(`(() => { check(await page.eval(`(() => {

View file

@ -1650,7 +1650,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 +1696,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 +1724,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%; }