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>
This commit is contained in:
Olive Vaughn 2026-10-04 03:16:09 -04:00
parent f238ff4f62
commit 208dddee07
7 changed files with 168 additions and 207 deletions

View file

@ -121,26 +121,21 @@
:on-pointer-cancel (fn [_] (reset! sizing nil))}]))
(defn panes
"Which panes are open, as three switches at the left of the top bar, each a
picture of the window with its pane filled in. In the top bar because a shut
pane has no head left to carry its own handle, and because on a narrow screen
this is the whole of the navigation."
"Which panes are open, as a segmented control in the top bar. In the top bar
because a shut pane has no head left to carry its own handle, and because on a
narrow screen this is the whole of the navigation."
[]
(let [{:keys [shut]} @(rf/subscribe [::state])]
[:div.icons.panes {:aria-label "panes"}
[:div.seg.panes {:aria-label "panes"}
(doall
(for [[k label strip] [[:pool "media pool" {:x 1.5 :y 2.5 :width 4 :height 11}]
[:time "timeline" {:x 1.5 :y 9.5 :width 13 :height 4}]
[:params "inspector" {:x 10.5 :y 2.5 :width 4 :height 11}]]]
(for [[k label] [[:pool "pool"] [:params "inspector"] [:time "timeline"]]]
(let [open? (not (contains? shut k))]
^{:key k}
[:button.ico {:class (when open? "on")
:aria-pressed open? :aria-label label
:title (str (if open? "hide the " "show the ") label)
:on-click #(rf/dispatch [::toggle k])}
[:svg {:view-box "0 0 16 16"}
[:rect.frame {:x 1.5 :y 2.5 :width 13 :height 11 :rx 1}]
[:rect (assoc strip :rx 0.5)]]])))]))
[:button {:class (when open? "on")
:aria-pressed open?
:title (str (if open? "hide the " "show the ") label)
:on-click #(rf/dispatch [::toggle k])}
label])))]))
(defn- stepped [k z out?]
;; Geometric steps work below 100% as well as above it.

View file

@ -14,7 +14,9 @@
(:require [arthur.db :as db]
[arthur.events.playback :as pb]
[arthur.events.project :as project]
[re-frame.core :as rf]))
[arthur.subs.playback :as playback]
[re-frame.core :as rf]
[reagent.core :as r]))
(defn- when-said
"The ISO stamp the server sends, as a date. Truncated rather than formatted:
@ -33,30 +35,44 @@
label
(when sub [:span.sub sub])])
(defn panel
"The list, under the top bar's file menu. `close!` shuts it."
[close!]
(let [{:keys [items loading?]} @(rf/subscribe [::project/listing])
choose! (fn [event] (close!) (rf/dispatch event))]
[:div.menu.menu-left
[:h2 "projects"]
(cond
loading? [:div.dim "…"]
(empty? items) [:div.dim "none saved yet"]
:else
(doall
(for [{:keys [id name seq updated]} items]
^{:key id}
[item {:label (or name "untitled")
:sub (str "r" seq " · " (when-said updated))
:on-click #(choose! [::project/open id])}])))
[:h2 "built-in examples"]
[:div.dim "compiled in, not saved documents"]
(doall
(for [[id {:keys [label]}] db/clips]
^{:key id}
[item {:label label :example? true
:on-click #(choose! [::pb/select-clip id])}]))
[item {:label "8625 stage study" :example? true
:sub "composed from a locally saved project"
:on-click #(choose! [::project/load-stage])}]]))
(defn view []
(r/with-let [open? (r/atom false)]
(let [{:keys [items loading?]} @(rf/subscribe [::project/listing])
busy? (:busy? @(rf/subscribe [::playback/project]))
choose! (fn [event] (reset! open? false) (rf/dispatch event))]
[:div.menu-wrap
[:button {:disabled busy?
:class (when @open? "on")
:on-click (fn []
(when-not @open? (rf/dispatch [::project/list]))
(swap! open? not))}
"open ▾"]
(when @open?
[:<>
;; A full-page catcher behind the panel, so clicking anywhere else
;; dismisses it. Cheaper and more predictable than a document-level
;; listener that has to be added, removed and told to ignore the click
;; that opened the menu.
[:div.menu-scrim {:on-click #(reset! open? false)}]
[:div.menu
[:h2 "projects"]
(cond
loading? [:div.dim "…"]
(empty? items) [:div.dim "none saved yet"]
:else
(doall
(for [{:keys [id name seq updated]} items]
^{:key id}
[item {:label (or name "untitled")
:sub (str "r" seq " · " (when-said updated))
:on-click #(choose! [::project/open id])}])))
[:h2 "built-in examples"]
[:div.dim "compiled in, not saved documents"]
(doall
(for [[id {:keys [label]}] db/clips]
^{:key id}
[item {:label label :example? true
:on-click #(choose! [::pb/select-clip id])}]))
[item {:label "8625 stage study" :example? true
:sub "composed from a locally saved project"
:on-click #(choose! [::project/load-stage])}]]])])))

View file

@ -7,32 +7,38 @@
[re-frame.core :as rf]
[reagent.core :as r]))
(defn panel
"Named versions, under the top bar's file menu. `close!` shuts it."
[close!]
(r/with-let [draft (r/atom "")]
(defn view []
(r/with-let [open? (r/atom false)
draft (r/atom "")]
(let [{:keys [can-edit?]} @(rf/subscribe [::playback/project])
rows @(rf/subscribe [::collab/snapshot-list])
editable? (not (false? can-edit?))]
[:div.menu.menu-left
(when editable?
[:form.row {:on-submit (fn [e]
(.preventDefault e)
(rf/dispatch [::collab/snapshot
(or (not-empty (str/trim @draft)) "snapshot")])
(reset! draft ""))}
[:input {:placeholder "name this version" :value @draft :auto-focus true
:on-change #(reset! draft (.. % -target -value))}]
[:button {:type "submit"} "take snapshot"]])
[:h2 "snapshots"]
(if (empty? rows)
[:div.dim "none yet"]
(doall
(for [{:keys [id name author created] :as row} rows]
^{:key id}
[:div.menu-item.static
[:span name [:span.sub (str author " · " (subs (str created) 0 16))]]
(when editable?
[:button.link {:on-click (fn [] (close!)
(rf/dispatch [::collab/restore row]))}
"restore"])])))])))
[:div.menu-wrap
[:button {:class (when @open? "on")
:on-click (fn [] (when-not @open? (rf/dispatch [::collab/snapshots]))
(swap! open? not))}
"snapshots ▾"]
(when @open?
[:<> [:div.menu-scrim {:on-click #(reset! open? false)}]
[:div.menu.menu-left
(when editable?
[:form.row {:on-submit (fn [e]
(.preventDefault e)
(rf/dispatch [::collab/snapshot
(or (not-empty (str/trim @draft)) "snapshot")])
(reset! draft ""))}
[:input {:placeholder "name this version" :value @draft :auto-focus true
:on-change #(reset! draft (.. % -target -value))}]
[:button {:type "submit"} "take snapshot"]])
[:h2 "snapshots"]
(if (empty? rows)
[:div.dim "none yet"]
(doall
(for [{:keys [id name author created] :as row} rows]
^{:key id}
[:div.menu-item.static
[:span name [:span.sub (str author " · " (subs (str created) 0 16))]]
(when editable?
[:button.link {:on-click (fn [] (reset! open? false)
(rf/dispatch [::collab/restore row]))}
"restore"])])))]])])))

View file

@ -1,20 +1,18 @@
(ns arthur.ui.topbar
"The strip across the top: everything you do to the whole document, on the
surface rather than in menus, and as pictures rather than words so there is
room for all of it. Each group is a run of the same plain icon button — panes,
file, history, clipboard, versions — then the document's name in the middle,
then export and the people on the right. Every icon says what it is on hover.
"The strip across the top: what document this is, and the three things you can
do to the whole of it.
Open, snapshots and undo history are lists, so they drop from their button;
only one of them is open at a time."
Save, open and export are here rather than in a pane because none of them is a
property of a selection — they act on the document, and the document is the
window."
(:require [arthur.events.collab :as collab]
[arthur.events.export :as export]
[arthur.events.history :as history]
[arthur.events.project :as project]
[arthur.events.ui :as ui]
[arthur.subs.playback :as playback]
[arthur.subs.ui :as sub]
[arthur.ui.layout :as layout]
[arthur.ui.menu :as menu]
[arthur.ui.openmenu :as openmenu]
[arthur.ui.share :as share]
[arthur.ui.snapshots :as snapshots]
@ -26,6 +24,7 @@
(let [{:keys [target zoom busy? done total]} @(rf/subscribe [::export/state])
targets @(rf/subscribe [::export/targets])]
[:<>
[:button {:disabled busy? :on-click #(reset! open? true)} "export…"]
(when @open?
[:<> [:div.export-scrim {:on-click #(when-not busy? (reset! open? false))}]
[:section.export-dialog {:role "dialog" :aria-modal true :aria-label "Export"}
@ -49,53 +48,16 @@
(if busy? (str "rendering " done "/" total) "start export")]
[:button {:disabled busy? :on-click #(reset! open? false)} "close"]]]])]))
(defn- icon
"A 16-unit glyph, stroked in the button's own colour."
[& paths]
(into [:svg {:view-box "0 0 16 16"}] paths))
(def ^:private glyphs
{:new (icon [:path {:d "M4 1.5h5.5L12.5 4.5V14.5H4Z"}] [:path {:d "M8.25 7v5M5.75 9.5h5"}])
:open (icon [:path {:d "M1.5 3.5h4.5l1.5 1.5h7v8.5h-13Z"}])
:undo (icon [:path {:d "M5.5 3 2.5 6l3 3"}] [:path {:d "M2.5 6h7a4 4 0 0 1 0 8H6"}])
:redo (icon [:path {:d "M10.5 3l3 3-3 3"}] [:path {:d "M13.5 6h-7a4 4 0 0 0 0 8H10"}])
:history (icon [:path {:d "M4.5 6.5 8 10l3.5-3.5"}])
:cut (icon [:circle {:cx 4.5 :cy 12 :r 2}] [:circle {:cx 11.5 :cy 12 :r 2}]
[:path {:d "M5.8 10.5 11.5 2M10.2 10.5 4.5 2"}])
:copy (icon [:rect {:x 2 :y 2 :width 8 :height 8 :rx 1}] [:rect {:x 6 :y 6 :width 8 :height 8 :rx 1}])
:paste (icon [:path {:d "M5.5 2.5h-2v12h9v-12h-2"}] [:rect {:x 5.5 :y 1.5 :width 5 :height 2.5 :rx 0.5}])
:duplicate (icon [:rect.fill {:x 2 :y 2 :width 8 :height 8 :rx 1}] [:rect {:x 6 :y 6 :width 8 :height 8 :rx 1}])
:unique (icon [:rect.fill {:x 2 :y 2 :width 8 :height 8 :rx 1}]
[:rect {:x 6 :y 6 :width 8 :height 8 :rx 1 :stroke-dasharray "2 1.5"}])
:snapshots (icon [:path {:d "M4 1.5h8v13l-4-3-4 3Z"}])
:export (icon [:path {:d "M8 10V1.5M4.5 5 8 1.5 11.5 5"}] [:path {:d "M2 9.5v5h12v-5"}])})
(defn- btn [glyph title attrs]
[:button.ico (merge {:title title :aria-label title} attrs) (glyphs glyph)])
(defn- dropper
"An icon button with a list hanging from it: projects, snapshots, undo steps."
[panel k glyph title on-open body & [disabled?]]
[:div.menu-wrap
[btn glyph title {:class (when (= k @panel) "on") :disabled disabled?
:on-click (fn [] (if (= k @panel)
(reset! panel nil)
(do (on-open) (reset! panel k))))}]
(when (= k @panel)
[:<> [:div.menu-scrim {:on-click #(reset! panel nil)}]
[body #(reset! panel nil)]])])
(defn view []
(r/with-let [renaming? (r/atom false)
draft (r/atom "")
panel (r/atom nil)
export-open? (r/atom false)]
(let [{project-name :name project-seq :seq :keys [busy? status]} @(rf/subscribe [::playback/project])
{footage-status :status} @(rf/subscribe [::playback/footage])
{export-status :status export-busy? :busy?} @(rf/subscribe [::export/state])
{:keys [done undone]} @(rf/subscribe [::history/steps])
selected? (boolean (seq @(rf/subscribe [::sub/selections])))
{export-status :status} @(rf/subscribe [::export/state])
selections @(rf/subscribe [::sub/selections])
clipboard @(rf/subscribe [::sub/clipboard])
selected? (boolean (seq selections))
title (or project-name "untitled")
commit! (fn []
(rf/dispatch [::project/rename @draft])
@ -107,46 +69,37 @@
;; this strip scrolls, and which panes are open is the one control that
;; must not be the thing off the end of it.
[layout/panes]
[:span.sep]
[:div.icons
[btn :new "new project" {:disabled busy? :on-click #(rf/dispatch [::collab/create])}]
[dropper panel :open :open "open a project" #(rf/dispatch [::project/list]) openmenu/panel]]
[:span.sep]
[:div.icons
[btn :undo (if (seq done) (str "undo " (first done) " (⌘Z)") "nothing to undo")
{:disabled (empty? done) :on-click #(rf/dispatch [::history/undo])}]
[dropper panel :history :history "undo history" identity undo/panel (empty? done)]
[btn :redo (if (seq undone) (str "redo " (first undone) " (⇧⌘Z)") "nothing to redo")
{:disabled (empty? undone) :on-click #(rf/dispatch [::history/redo])}]]
[:span.sep]
[:div.icons
[btn :cut "cut selection (⌘X)" {:disabled (not selected?) :on-click #(rf/dispatch [::ui/cut])}]
[btn :copy "copy selection (⌘C)" {:disabled (not selected?) :on-click #(rf/dispatch [::ui/copy])}]
[btn :paste "paste at the playhead into the active row (⌘V)"
{:disabled (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])}]
[btn :duplicate "duplicate beside the selection; lanes repeat forward (⌘D)"
{:disabled (not selected?) :on-click #(rf/dispatch [::ui/duplicate])}]
[btn :unique "duplicate unique — with a private copy of its nested symbols (⇧⌘D)"
{:disabled (not selected?) :on-click #(rf/dispatch [::ui/duplicate-unique])}]]
[:span.sep]
[dropper panel :snapshots :snapshots "snapshots — name this version, or go back to one"
#(rf/dispatch [::collab/snapshots]) snapshots/panel]
[:div.doc
(if @renaming?
[:input.project-name {:auto-focus true :value @draft
:on-change #(reset! draft (.. % -target -value))
:on-blur (fn [_] (commit!))
:on-key-down (fn [e]
(case (.-key e)
"Enter" (do (.preventDefault e) (commit!))
"Escape" (reset! renaming? false)
nil))}]
[:button.project-title {:disabled busy? :title "click to rename project"
:on-click (fn [] (reset! draft title)
(reset! renaming? true))}
title (when project-seq [:span.dim (str " r" project-seq)])])
[:span.status (or export-status status footage-status)]]
[btn :export "export a PNG sequence and audio"
{:disabled export-busy? :on-click #(reset! export-open? true)}]
[:button {:disabled busy? :on-click #(rf/dispatch [::collab/create])} "new"]
[openmenu/view]
[undo/view]
[menu/view
{:label "edit" :title "selection and clipboard"
:items [{:label "copy" :sub "Copy selection · ⌘/Ctrl C"
:disabled? (not selected?) :on-click #(rf/dispatch [::ui/copy])}
{:label "cut" :sub "Cut selection · ⌘/Ctrl X"
:disabled? (not selected?) :on-click #(rf/dispatch [::ui/cut])}
{:label "paste" :sub "Paste at playhead into the active row · ⌘/Ctrl V"
:disabled? (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])}
{:label "duplicate" :sub "Duplicate beside selection; lanes repeat forward · ⌘/Ctrl D"
:disabled? (not selected?) :on-click #(rf/dispatch [::ui/duplicate])}
{:label "duplicate unique"
:sub "Duplicate with a private nested symbol graph · ⇧⌘/Ctrl D"
:disabled? (not selected?)
:on-click #(rf/dispatch [::ui/duplicate-unique])}]}]
[snapshots/view]
(if @renaming?
[:input.project-name {:auto-focus true :value @draft
:on-change #(reset! draft (.. % -target -value))
:on-blur (fn [_] (commit!))
:on-key-down (fn [e]
(case (.-key e)
"Enter" (do (.preventDefault e) (commit!))
"Escape" (reset! renaming? false)
nil))}]
[:button.project-title {:disabled busy? :title "click to rename project"
:on-click (fn [] (reset! draft title)
(reset! renaming? true))}
title (when project-seq (str " r" project-seq))])
[:span.status (or export-status status footage-status)]
[exporter export-open?]
[share/view]])))

View file

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

View file

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