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 stage, like solo and zoom: not an undo step, not sent to collaborators,
and it cannot reach an export. A layer is shown unless it is in `:hidden`, so
footage brought in shows without being found and switched on first."
{:on? true :opacity 0.5 :hidden #{}})
footage brought in shows without being found and switched on first — once
tracing itself is on, which it is not until asked for."
{:on? false :opacity 0.5 :hidden #{}})
(def default
{;; --- the document ---

View file

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

View file

@ -12,6 +12,7 @@
clip/<cid>/stage width, height
clip/<cid>/palette/<pid> a named indexed palette asset
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>/subject/<subj> a tracked subject and its 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 "stage") (select-keys clip [:width :height]))
(some-leaf (at "palette-default") (select-keys clip [:default-palette]))
(some-leaf (at "root") (select-keys clip [:root]))
(some-leaf (at "analyses") (:analyses clip))
(concat
(for [[id v] (:subjects clip)] {(at "subject" (segment id)) v})
@ -198,6 +200,7 @@
"stage" (merge acc v)
"analyses" (assoc acc :analyses v)
"palette-default" (merge acc v)
"root" (merge acc v)
"palette" (assoc-in acc [:palettes (unsegment a)] v)
"subject" (assoc-in acc [:subjects (unsegment a)] v)
"feature" (assoc-in acc [:features (unsegment a)] v)
@ -239,7 +242,7 @@
false)
(case (count p)
;; The clip's own facts carry no id.
3 (#{"name" "timing" "stage" "analyses" "palette-default"} (nth p 2))
3 (#{"name" "timing" "stage" "analyses" "palette-default" "root"} (nth p 2))
4 (#{"subject" "feature" "group" "palette"} (nth p 2))
false))))]
(vec

View file

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

View file

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

View file

@ -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]

View file

@ -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?
@ -146,8 +147,9 @@
"− <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
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 +164,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,14 +178,28 @@
(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"} "▾"]
[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)
@ -195,10 +212,8 @@
: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."]]]]))
[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 +224,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"]
[overlay "passepartout-controls" "passepartout" on?
"Darken the desk around the stage"
#(rf/dispatch [::mat :on? (not on?)])
(fn [_]
[: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))])}]]]]]))
[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"]]]))

View file

@ -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!)]])]))))

View file

@ -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 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."
[]
(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))))]
(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])))}
(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"]]]))
[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
"The palettes behind a button at the foot of the strip: switch, rename, add.
The name is NOT printed in the strip: 44px holds no palette's name, and a name
broken over three lines is worse than one shown when asked for."
[]
(let [[clip pid palette] (shown)]
[:div.palette-assets-wrap
[menu/popover {:class "palette-assets" :title (str "palette: " (:name palette))
:label [:svg {:view-box "0 0 18 18" :width 18 :height 18}
[:path {:d "M9 2C4.6 2 1.5 5.2 1.5 9c0 4 3.4 7 7.3 7 1.4 0 1.9-.9 1.4-1.9-.5-1 0-2.1 1.4-2.1h1.9c1.9 0 3-1.2 3-3.2C16.5 5 13.2 2 9 2Z"}]
[:circle {:cx 5.5 :cy 8 :r 1.3}]
[:circle {:cx 8.5 :cy 5 :r 1.3}]
[:circle {:cx 12.5 :cy 6.5 :r 1.3}]]}
(fn [_]
[:div.palette-popout
[chooser clip pid]
[:div.row
[:button {:title "a new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "new"]
[:button {:title (str "a copy of " (:name palette) " you can retone")
:on-click #(rf/dispatch [::project/duplicate-palette pid])} "duplicate"]]])]
[elsewhere pid palette]]))

View file

@ -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

View file

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

View file

@ -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

View file

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

View file

@ -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);

View file

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

View file

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