Make the media pool a list you can actually read

Every row is one line now — a thumbnail at the stage's own 16:10, the name,
and the one number you need before dragging it somewhere — at the timeline's
own row height. Two always-open folders cost eight headings before the first
row in a pane 210px wide; they are two tabs, and a search counts its hits in
the scope you are not looking at so nothing can hide behind the one you did
not pick.

Symbols draw their own first frame, through the resolver and rasteriser the
stage uses. A PLACED symbol is drawn where it is placed, with the rest of its
host isolated away: rooting at a symbol renders its DRAWING, and a rotoscoped
face is head-local in units of one image height, so the source-to-stage scale
that makes it pixels lives on the :face group of whatever places it. Rendered
rooted at itself a face is correct and under a pixel across. Thumbnails are
smoothly downscaled for the same kind of reason the preview is not: at a tenth
of the stage's size nearest neighbour samples one pixel in a hundred, and the
silhouette is the whole of what makes a thumbnail recognisable.

Names are editable, and that is two operations behind one pencil. A symbol's
name is a field of this document, so it is an undoable edit and blank gives it
back its id. Footage and sounds live beside projects rather than inside one,
so theirs is a server write shared by every project using the row — PATCH on
the existing Footage.label, and a new Sound.label kept separate from the
filename, which is a fact about the upload and not somebody's name for it.

No TIMELINES section beside a SYMBOLS one: every symbol here IS a timeline, so
that pair named one thing twice. What is true is that exactly one of them is
where the work happens, and clip/opens-on already answers which — it leads the
pane as PROJECT, drawn at a size you can read a pose off, with the library
under it. Still not :main being special; rename it or place it inside
something else and the pool follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-01 00:56:39 -04:00
parent 815ce449ea
commit 7e34d0c704
11 changed files with 924 additions and 160 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-01 04:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0011_occurrence_schema'),
]
operations = [
migrations.AddField(
model_name='sound',
name='label',
field=models.CharField(blank=True, help_text='what a person called it; the filename when empty. Separate from `filename` because the name on disk is a fact about the upload and renaming must not rewrite it', max_length=200),
),
]

View file

@ -60,6 +60,12 @@ class Sound(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="sound_for")
filename = models.CharField(max_length=255)
label = models.CharField(
max_length=200, blank=True,
help_text="what a person called it; the filename when empty. Separate "
"from `filename` because the name on disk is a fact about the "
"upload and renaming must not rewrite it",
)
duration = models.FloatField(help_text="seconds, as ffprobe reports it")
created = models.DateTimeField(auto_now_add=True)

View file

@ -657,6 +657,64 @@ class FootageTests(TestCase):
with self.assertRaisesMessage(CommandError, "refusing an inaccurate footage"):
call_command("ingest_bundle", str(root), stdout=StringIO())
def test_footage_can_be_renamed_and_falls_back_when_cleared(self):
"""A LABEL IS THE ONE FIELD A CLIENT MAY WRITE ON FOOTAGE. The rest is a
description of bytes that are content-addressed and immutable, so a
rename that could reach `frames` or `digest` would let the pool's name
for a clip contradict the clip."""
footage = self.ingest(self.bundle())
self.assertEqual("IMG_8608.MOV", self.client.get(
f"/api/footage/{footage.id}").json()["label"])
renamed = self.client.patch(f"/api/footage/{footage.id}",
json.dumps({"label": " the long take "}),
content_type="application/json")
self.assertEqual(200, renamed.status_code, renamed.content)
self.assertEqual("the long take", renamed.json()["label"])
# On the row, so every project listing this footage sees the new name.
footage.refresh_from_db()
self.assertEqual("the long take", footage.label)
self.assertEqual("the long take",
self.client.get("/api/footage").json()["footage"][0]["label"])
# Cleared gives back the name it was ingested under rather than nothing.
cleared = self.client.patch(f"/api/footage/{footage.id}",
json.dumps({"label": ""}),
content_type="application/json")
self.assertEqual("IMG_8608.MOV", cleared.json()["label"])
self.assertEqual(3, Footage.objects.get().frames)
def test_a_rename_that_names_no_label_is_refused(self):
footage = self.ingest(self.bundle())
refused = self.client.patch(f"/api/footage/{footage.id}",
json.dumps({"frames": 900}),
content_type="application/json")
self.assertEqual(400, refused.status_code)
self.assertEqual("a rename needs a label", refused.json()["error"])
self.assertEqual(3, Footage.objects.get().frames)
def test_a_sound_is_renamed_without_losing_the_name_it_arrived_as(self):
"""`filename` is a fact about the upload and `label` is what a person
called it, which is why renaming does not write over the first one."""
digest, size = blobs.write_stream([b"RIFF....WAVEfmt "])
blob = Blob.objects.create(digest=digest, size=size, media_type="audio/wav")
sound = Sound.objects.create(blob=blob, filename="rec0012.wav", duration=2.5)
renamed = self.client.patch(f"/api/sounds/{sound.id}",
json.dumps({"label": "arthur, line 4"}),
content_type="application/json")
self.assertEqual(200, renamed.status_code, renamed.content)
self.assertEqual("arthur, line 4", renamed.json()["label"])
self.assertEqual("rec0012.wav", renamed.json()["filename"])
sound.refresh_from_db()
self.assertEqual("rec0012.wav", sound.filename)
self.assertEqual("arthur, line 4", sound.label)
self.client.patch(f"/api/sounds/{sound.id}", json.dumps({"label": " "}),
content_type="application/json")
self.assertEqual("rec0012.wav",
self.client.get(f"/api/sounds/{sound.id}").json()["label"])
def test_the_footage_list_does_not_carry_every_url(self):
# A list of takes should not be a list of six hundred URLs each.
self.ingest(self.bundle())

View file

@ -226,10 +226,31 @@ def sources(request):
def _sound_json(row):
return {"id": str(row.id), "label": row.filename, "duration": row.duration,
return {"id": str(row.id), "label": row.label or row.filename,
"filename": row.filename, "duration": row.duration,
"audio": f"/blob/{row.blob_id}"}
def _relabel(request, row):
"""PATCH one asset's display name.
A LABEL IS THE ONLY FIELD EITHER ROW LETS A CLIENT WRITE, and the body is
read for that key alone. Footage is content-addressed and its frame count,
rate and digest are facts about the bytes; an endpoint that merged whatever
it was sent would let a rename quietly contradict them. Blank clears it,
which puts the row back to the name it was uploaded under rather than
leaving it nameless.
"""
data = _body(request)
if "label" not in data:
raise Bad("a rename needs a label")
label = str(data["label"] or "").strip()[:200]
if label != row.label:
row.label = label
row.save(update_fields=["label"])
return row
@require_http_methods(["GET", "POST"])
def sounds(request):
if request.method == "GET":
@ -252,12 +273,18 @@ def sounds(request):
return JsonResponse({"error": str(exc)}, status=400)
@require_http_methods(["GET"])
@require_http_methods(["GET", "PATCH"])
def sound_detail(request, sound_id):
try:
return JsonResponse(_sound_json(Sound.objects.get(id=sound_id)))
row = Sound.objects.get(id=sound_id)
except Sound.DoesNotExist:
return JsonResponse({"error": "no such sound"}, status=404)
try:
if request.method == "PATCH":
row = _relabel(request, row)
except Bad as exc:
return _error(exc)
return JsonResponse(_sound_json(row))
def _extraction_json(row):
@ -367,12 +394,17 @@ def symbols(request):
return JsonResponse({"symbols": rows})
@require_http_methods(["GET"])
@require_http_methods(["GET", "PATCH"])
def footage_detail(request, footage_id):
try:
footage = Footage.objects.select_related("audio", "video", "stream").get(id=footage_id)
except Footage.DoesNotExist:
return JsonResponse({"error": "no such footage"}, status=404)
try:
if request.method == "PATCH":
footage = _relabel(request, footage)
except Bad as exc:
return _error(exc)
return JsonResponse(_footage_json(footage))

View file

@ -394,6 +394,57 @@
::choose
(fn [db [_ id]] (assoc-in db [:footage :chosen] id)))
;; ---------------------------------------------------------------------------
;; renaming an asset
;;
;; A SERVER WRITE, NOT A DOCUMENT EDIT, and so not on the undo list. Footage and
;; sounds live beside projects rather than inside one — the pool's ALL ASSETS
;; folder is exactly that — so a label is shared by every project that uses the
;; row, and undoing an edit to this document must not reach out and rename
;; something another one is showing.
;;
;; Written through optimistically. The lists in app-db are what the pool draws
;; from; waiting for the round trip would leave the old name under the cursor for
;; as long as the request takes, and the failure is visible and recoverable —
;; `::failed` says so, and `::refresh` puts back whatever the server actually
;; holds.
(defn- relabelled
"Replace one row's `:label` in a list held by id."
[rows id label]
(mapv #(cond-> % (= id (:id %)) (assoc :label label)) rows))
(rf/reg-fx
::relabel!
(fn [{:keys [url label]}]
(-> (http/PATCH url #js {:label label})
(.then (fn [_]
;; Only a CLEARED label needs the answer. The server's fallback
;; is the name the file was uploaded under, which this client
;; cannot reconstruct — footage falls back to its source and a
;; sound to its filename — so the one case the optimistic write
;; cannot guess is the one case that re-lists.
(when (empty? label) (rf/dispatch [::refresh]))))
(.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))])
(rf/dispatch [::refresh]))))))
(rf/reg-event-fx
::relabel
(fn [{:keys [db]} [_ kind id value]]
;; `kind` is `:footage` or `:sound`: two resources with one field between
;; them, and one event rather than two that differ by a path and a URL.
(let [label (string/trim (str value))
[key url] (case kind
:footage [:available (str "/api/footage/" id)]
:sound [:sounds (str "/api/sounds/" id)]
[nil nil])]
(if (or (nil? id) (nil? key))
{}
{:db (cond-> db
(seq label) (update-in [:footage key] relabelled id label))
::relabel! {:url url :label label}}))))
(rf/reg-event-db
::progress
(fn [db [_ message]] (assoc-in db [:footage :status] message)))

View file

@ -601,6 +601,23 @@
(cond-> {:db db'}
(= key :fps) (assoc ::pb/seek! [value (pb/frames db') frame]))))))
(rf/reg-event-db
::rename-symbol
(fn [db [_ sid value]]
;; A transaction, so one rename is one undo step: `edit/edit` alone would let
;; a rename coalesce with whatever edit happened next.
;;
;; BLANK REMOVES THE NAME rather than storing an empty one. `clip/symbol-name`
;; falls back to the id, so a symbol cleared of its name reads as `main`
;; again instead of as a row with nothing on it — and the document carries no
;; field it did not need.
(let [value (not-empty (str/trim (str value)))]
(if-not (clip/symbol (:clip (store/entry (:clip/current db))) sid)
db
(edit/transaction db #(if value
(assoc-in % [:symbols sid :name] value)
(update-in % [:symbols sid] dissoc :name)))))))
(rf/reg-event-db
::symbol-setting
(fn [db [_ sid key value]]

View file

@ -57,3 +57,4 @@
(defn POST [url body] (request! "POST" url body))
(defn POST-form [url body] (request! "POST" url body))
(defn PUT [url body] (request! "PUT" url body))
(defn PATCH [url body] (request! "PATCH" url body))

View file

@ -70,6 +70,22 @@
(when-let [pl (nest/placement clip st open (or path [id]) f)]
(assoc pl :node n :bounds ((pick/bounds-of clip st n) (:frame pl))))))))
(rf/reg-sub
::settled-clip
:<- [::render/clip-id]
:<- [::render/paint-revision]
(fn [[id _] _]
;; The document AS WRITTEN, not as a drag currently has it — the same choice
;; `::render/sounds` makes, for a sharper version of the same reason.
;;
;; `::render/clip` yields a fresh document on every pointer move of a timeline
;; slide so the stage can follow it. The media pool draws its symbols as
;; RASTERISED PICTURES, so subscribing to that would re-resolve and re-encode
;; every thumbnail in the pool thirty times a second, to show a change no
;; thumbnail of a symbol has any way to show. Nothing the pool lists —
;; a symbol's name, its length, what places it — moves during a drag.
(:clip (store/entry id))))
(rf/reg-sub
::project-footage
:<- [::render/clip-id]

View file

@ -43,3 +43,34 @@
img (image-data-for ctx el w h)]
(raster/->rgba r palette-rgb 1 (.-data img))
(.putImageData ctx img 0 0)))))
(defn ->png
"An indexed raster as a PNG data URL, expanded through `palette-rgb`.
For a THUMBNAIL, which is the one picture in this app that is not the preview.
Two consequences, and both are departures from the rule at the top of this
namespace:
An `<img>` rather than the canvas itself, because a pool of them is a list that
rebuilds whenever anything in the document changes, and a data URL is a value
the caller can cache against the document it was drawn from — a canvas is an
element that has to be found and repainted.
And therefore SMOOTHLY downscaled, because `image-rendering: pixelated` is a
rule about canvases. At a tenth of the stage's size nearest neighbour samples
one pixel in a hundred, and a drawing made of flat shapes a few pixels across
reduces to speckle; the silhouette is what makes a thumbnail recognisable, and
smoothing is what keeps it. The pixelated rule is load-bearing for the preview
that JUDGES the output. This is a picture to tell one name from another by."
[{:keys [w h] :as r} palette-rgb]
(let [el (js/document.createElement "canvas")]
(set! (.-width el) w)
(set! (.-height el) h)
;; Deliberately not through `image-data-for`: that cache is keyed by element
;; and this element is thrown away, so every thumbnail would leave a quarter
;; of a megabyte in it that nothing can ever find again.
(let [ctx (.getContext el "2d")
img (.createImageData ctx w h)]
(raster/->rgba r palette-rgb 1 (.-data img))
(.putImageData ctx img 0 0))
(.toDataURL el "image/png")))

View file

@ -1,5 +1,5 @@
(ns arthur.ui.pool
"The media pool: what can be put into the open symbol, in two folders.
"The media pool: what can be put into the open symbol, in two scopes.
THIS PROJECT is the open document's own: every symbol in it — the open one
included, because none is special — and the video it uses or was given this
@ -8,6 +8,21 @@
the split is the point: opening a project REPLACES what is on screen, where
everything in here is a thing to put INTO it.
TABS RATHER THAN TWO OPEN FOLDERS. Both scopes expanded cost eight lines of
heading before the first row, in a pane 210px wide; you are either looking at
what the document has or shopping what the server has, and the one case where
that costs you is covered — a search counts its hits in the scope you are not
looking at and puts the number on its tab.
THE MAIN TIMELINE LEADS, and it is `clip/opens-on`'s answer rather than a
name: the longest symbol nothing else places is the one the project plays and
the one the work happens in, so it gets the top of the pane and a row drawn at
a size you can read a pose off. Every other symbol is the list under it. That
is one list and not two — every symbol here IS a timeline, and heading a
section TIMELINES and the next one SYMBOLS would name the same thing twice.
None of it is `:main` being special either: rename it, place it inside
something else, and the pool follows the document.
Every row is a drag source, and what it carries says what it is:
`symbol:<id>` for a symbol of this document, `import:<project>|<cid>|<id>` for
one of another's, and `footage:<id>` for video. The stage and the timeline are
@ -16,6 +31,13 @@
A SOUND — mp3, wav — is `sound:<id>`, and is dropped on the timeline, where it
becomes an audio node in the open symbol from the frame it lands on.
RENAMING IS TWO DIFFERENT OPERATIONS behind one affordance. A symbol's name is
a field of this document: it is an undoable edit, and blank gives it back its
id. Footage and sounds live beside projects rather than inside one, so their
label is a server write shared by every project that uses the row — see
`events.footage/relabel`. A symbol of ANOTHER project is renameable where it
lives and not from here.
THE WHOLE PANE IS THE DROP TARGET for a file. A video dropped anywhere in it
uploads and lands in THIS PROJECT's media, and goes no further: which frames of
it become a symbol, and what that symbol is called, is asked when it is dropped
@ -23,6 +45,9 @@
footage stay separate records on the server, so dropping the same file twice
does not decode it twice."
(:require [arthur.domain.clip :as clip]
[arthur.domain.node :as node]
[arthur.domain.raster :as raster]
[arthur.export :as export]
[arthur.events.footage :as footage]
[arthur.events.playback :as pb]
[arthur.events.project :as project]
@ -30,21 +55,117 @@
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
[arthur.ui.canvas :as canvas]
[arthur.ui.drag :as drag]
[clojure.string :as str]
[re-frame.core :as rf]
[reagent.core :as r]))
(defn- item
"One row. `opts` is merged last so a caller can add its handlers without this
function growing a parameter per affordance."
[{:keys [label sub on? disabled? thumb] :as opts}]
[:button (merge {:class (str "pool-item" (when on? " on") (when thumb " media"))
:disabled (boolean disabled?)
:title label}
(dissoc opts :label :sub :on? :disabled? :thumb))
thumb
[:span.text label (when sub [:span.sub sub])]])
;; ---------------------------------------------------------------------------
;; pictures
(defonce ^:private thumbs
;; `{:clip <the document these were drawn from> :urls {sid url-or-nil}}`,
;; compared by IDENTITY. A document is an immutable value, so the same object
;; means the same pictures and a different one means an edit landed — at which
;; point the whole map goes rather than being diffed, because the cheap test is
;; the wrong one: a symbol unchanged in itself changes picture when any symbol
;; it places changes, and following that is `clip/resolver`'s job, not a
;; cache's.
(atom {:clip nil :urls {}}))
(defn- placed-in
"Where `sid` is placed, as `[host-symbol node-id]`, or nil for one nothing
places. Lowest ids first, so a symbol placed several times gets one picture and
the same one every time."
[document sid]
(first (for [h (sort-by str (keys (:symbols document)))
n (sort-by str (keys (get-in document [:symbols h :nodes])))
:when (contains? (node/sources (get-in document [:symbols h :nodes n])) sid)]
[h n])))
(defn- draw-symbol
"`sid`'s first frame as a PNG data URL, through the resolver and the rasteriser
the stage uses — so the picture in the pool is the picture, not a sketch of it.
THROUGH THE PLACEMENT, WHERE THERE IS ONE, and this is the whole subtlety.
Rooting at a symbol renders its DRAWING, in its own local space; a rotoscoped
face is stored head-local in units of one image height, so its numbers are
around 0..1 and the source-to-stage scale that turns them into stage pixels —
several hundred — lives on the `:face` group of the symbol that PLACES it. See
`freeze/face-placement` and \"What space geometry is in\" in
docs/animation-model.md. Rendered rooted at itself, a face is therefore correct
and under a pixel across, which is a true picture of nothing anybody wants to
look at.
So a placed symbol is drawn where it is placed, with everything else in that
host isolated away — `export/isolate`, whose docstring draws the same
distinction for the same reason. A symbol nothing places has no placement to
borrow and is rendered rooted at itself, which for the main timeline is exactly
right because the stage is its own.
nil rather than a throw when the frame will not resolve — a cycle, a missing
block, an instance naming a symbol that has gone. A pool is a list of names,
and a symbol that cannot be drawn today is still one you want listed, named
and draggable.
THE FIRST FRAME, unlike `video-thumb` below, which seeks past the black leader
most phone footage opens on. A symbol's frame 0 is authored: what is on it is
there because somebody put it there, and the frame a person names a symbol by
is the one it starts on."
[document sid store palette ramp]
(try
(when (clip/symbol document sid)
(let [[host id] (placed-in document sid)
document (cond-> document
host (update-in [:symbols host] export/isolate id))
root (or host sid)
resolve (clip/resolver document root store palette {})
[w h] (clip/stage document root)]
(-> (raster/make w h)
(raster/clear! (get palette :bg 0))
(raster/draw-ops! (resolve 0))
(canvas/->png ramp))))
(catch :default _ nil)))
(defn- symbol-thumb
"`draw-symbol`, once per symbol per version of the document. Rasterising and
PNG-encoding the stage is milliseconds, and the pool redraws on every selection
click."
[document sid store palette ramp]
(when-not (identical? document (:clip @thumbs))
(reset! thumbs {:clip document :urls {}}))
(let [urls (:urls @thumbs)]
(if (contains? urls sid)
(get urls sid)
(let [url (draw-symbol document sid store palette ramp)]
(swap! thumbs assoc-in [:urls sid] url)
url))))
(defn- picture
"A row's leading picture. One box at the stage's own proportions for all three
kinds of row, so the names line up down the list whatever is beside them, and
a row with no picture to show still holds the column open."
[url]
(if url
[:img.thumb {:src url :alt "" :draggable false}]
[:span.thumb]))
(defn- video-thumb
"One frame of a video, from its proxy. `#t=` seeks a paused, muted element to a
frame that is not the black leader most phone footage opens on; nothing is
played and nothing is decoded past it."
[{:keys [video]}]
(if video
;; Sized here as well as in the stylesheet: a video element with no size
;; is as big as its footage, and a portrait phone clip is 1440×1920.
[:video.thumb {:src (str video "#t=0.2") :muted true :preload "metadata"
:plays-inline true :tab-index -1
:style {:width 32 :height 20 :max-width 32 :max-height 20}}]
[:span.thumb]))
;; ---------------------------------------------------------------------------
;; the row
(defn- carrying
"The drag handlers for a row. `text` is what makes it a drag at all and what
@ -58,24 +179,135 @@
(start!))
:on-drag-end (fn [_] (drag/done!))})
(defn- thumbnail
"One frame of a video, from its proxy. `#t=` seeks a paused, muted element to a
frame that is not the black leader most phone footage opens on; nothing is
played and nothing is decoded past it."
[{:keys [video]}]
(if video
;; Sized here as well as in the stylesheet: a video element with no size
;; is as big as its footage, and a portrait phone clip is 1440×1920.
[:video.thumb {:src (str video "#t=0.2") :muted true :preload "metadata"
:plays-inline true :tab-index -1
:style {:width 40 :height 30 :max-width 40 :max-height 30}}]
[:span.thumb]))
(defn- row
"One row of the pool: a picture, a name, and one number.
(defn- footage-row [{:keys [id label frames fps video] :as f} chosen]
[item (merge {:label label
:sub (str frames "f @ " fps)
:thumb [thumbnail f]
ONE LINE, because the pane is 210px wide and the pane under it is the timeline
— vertical space spent here is spent on something. The picture carries what the
row is, the name carries which one, and the single number is the one a person
needs before dragging it somewhere: how long it is. Everything else the row
knows goes in `:title`, where a pointer can ask for it.
THE WHOLE ROW IS THE BUTTON. Clicking selects and double-clicking opens, and
the target for both is the row — not the name, which is a word a few characters
wide that you would have to aim at.
WHICH IS WHY THE PENCIL IS A SPAN. It belongs beside the name, where it reads
as acting on that word rather than on the row; inside the button is the only
place that can be, and a button inside a button is not a thing HTML has. A span
with a click handler is, and it costs one thing: the pencil is not a tab stop.
F2 on the row is the keyboard path, which is the conventional one anyway, and
`aria-keyshortcuts` is what announces it. Stopping propagation is what keeps a
click on the pencil from also selecting the row underneath it.
`opts` beyond the keys destructured here is merged onto that drag surface, so a
caller adds its handlers without this growing a parameter per affordance.
THE MAIN TIMELINE is the one exception, and the only one. Border, fill and size
each say \"separate thing\", so spending them on every row flattens the list
into wallpaper; spent on the one row where the work happens, they say so. It
gets a picture big enough to read a pose off, and both its facts on a line of
its own under the name — `sub2` — rather than one of them out at the edge.
`rename` is `{:key :value :begin! :commit!}` and is what makes a row
renameable at all: a row without one has no pencil and does not answer F2."
[{:keys [label sub sub2 title thumb on? open? main? disabled? rename] :as opts}]
(let [{:keys [key value begin! commit!]} rename
editing? (boolean (and key (= key (:editing rename))))]
[:div.pool-row {:class (str (when main? "main ") (when on? "on ")
(when open? "open ") (when disabled? "disabled ")
(when editing? "editing"))}
(if editing?
;; The input REPLACES the row rather than floating over it, so the list
;; does not change height while you type in it.
[:input.pool-name
{:auto-focus true
:default-value value
:aria-label (str "rename " label)
:on-focus (fn [^js e] (.select (.-target e)))
;; Blur commits, as the project title in `ui/topbar` does: clicking away
;; from a half-finished rename means the name you typed, not nothing.
:on-blur (fn [^js e] (commit! (.. e -target -value)))
:on-key-down (fn [^js e]
(case (.-key e)
"Enter" (do (.preventDefault e) (commit! (.. e -target -value)))
;; Escape has to stop editing WITHOUT the blur that
;; follows it committing the draft.
"Escape" (do (.preventDefault e) (begin! nil) (.blur (.-target e)))
nil))}]
[:<>
[:button.pool-item
(merge {:disabled (boolean disabled?)
:title (or title label)
:aria-keyshortcuts (when begin! "F2")
:on-key-down (when begin!
(fn [^js e]
(when (= "F2" (.-key e))
(.preventDefault e)
(begin! key))))}
(dissoc opts :label :sub :sub2 :title :on? :open? :main?
:disabled? :thumb :rename))
thumb
[:span.text
;; The label is `.text`'s first child and stays that way: it is how
;; the browser test finds a row by the name a person reads.
[:span.name label]
(when (and main? sub2) [:span.detail sub2])]
(when begin!
[:span.pool-rename
{:title (str "rename " label " (F2)")
;; Mouse-only by design — see the docstring. A `tabindex` here would
;; make it interactive content inside a button, which is the thing
;; being avoided, and F2 already reaches it from the keyboard.
:on-click (fn [^js e] (.stopPropagation e) (begin! key))}
"\u270E"])
;; The main row says both its facts under the name instead.
(when (and sub (not main?)) [:span.sub sub])]])]))
;; ---------------------------------------------------------------------------
;; the rows of each kind
(defn- symbol-row [document sid {:keys [clip-id store palette ramp selection open main rename]}]
(let [sym (clip/symbol document sid)
label (clip/symbol-name document sid)
nodes (count (:nodes sym))]
^{:key (str sid)}
[row (merge {:label label
:sub (str (:frames sym) "f")
:title (str label " · " (:frames sym) " frames · " nodes
(if (= 1 nodes) " node" " nodes")
(when (= sid open) " · open")
" — double-click to open, drag to place")
:thumb [picture (symbol-thumb document sid store palette ramp)]
:sub2 (str (:frames sym) "f · " nodes (if (= 1 nodes) " node" " nodes"))
:on? (= selection [:symbol sid])
:open? (= sid open)
:main? (= sid main)
:rename (assoc rename
:key [:symbol sid]
:value label
:commit! (fn [value]
((:begin! rename) nil)
(rf/dispatch [::project/rename-symbol sid value])))
:on-click #(rf/dispatch [::ui/select [:symbol sid]])
:on-double-click #(rf/dispatch [::pb/open-symbol sid])}
(carrying (str "symbol:" (subs (str sid) 1))
#(drag/symbol! clip-id sid open)))]))
(defn- footage-row [{:keys [id label frames fps video] :as f} chosen rename]
^{:key id}
[row (merge {:label label
:sub (str frames "f")
:title (str label " · " frames " frames @ " fps "fps"
" — drag onto the stage to make a symbol of it")
:thumb [video-thumb f]
:on? (= id chosen)
:rename (assoc rename
:key [:footage id]
:value label
:commit! (fn [value]
((:begin! rename) nil)
(rf/dispatch [::footage/relabel :footage id value])))
:on-click #(rf/dispatch [::footage/choose id])}
(carrying (str "footage:" id)
#(drag/other! {:kind :footage :id id :label label
@ -84,104 +316,204 @@
(defn- sound-row
"An uploaded sound, or with `:footage?` a video's own — which is how a take's
sound goes back into its symbol after it was deleted there."
[{:keys [id label duration footage? fps]} project-fps]
[{:keys [id label duration footage? fps]} project-fps rename]
(let [frames (js/Math.ceil (* duration project-fps))
[source length rate] (if footage?
[{:footage id} (js/Math.round (* duration fps)) (/ fps project-fps)]
[{:sound id} frames 1])]
[item (merge {:label label
:sub (str (.toFixed duration 1) "s · " frames "f")
:thumb [:span.thumb.sound "♪"]}
^{:key (str (when footage? "f") id)}
[row (merge {:label label
:sub (str (.toFixed duration 1) "s")
:title (str label " · " (.toFixed duration 1) "s · " frames " frames"
(when footage? " · this video's own sound")
" — drag onto the timeline")
:thumb [:span.thumb.sound "♪"]
;; A video's own sound is named by the video. Renaming it here
;; would rename the footage row two sections up, which is not
;; what the pencil on a sound looks like it does.
:rename (when-not footage?
(assoc rename
:key [:sound id]
:value label
:commit! (fn [value]
((:begin! rename) nil)
(rf/dispatch [::footage/relabel :sound id value]))))}
(carrying (str "sound:" id)
#(drag/other! {:kind :sound :source source :label label
:length length :rate rate :frames frames})))]))
(defn- folder [title & children]
(into [:details.pool-folder {:open true} [:summary title]] children))
(defn- import-row [{:keys [cid symbol name frames]} pid]
^{:key (str cid symbol)}
[row (merge {:label name
:sub (str frames "f")
:title (str name " · " frames " frames — drag to copy it into this project")
:thumb [picture nil]}
(carrying (str "import:" (str/join "|" [pid cid symbol]))
#(drag/other! {:kind :import :label name :frames frames
:project pid :cid cid :symbol symbol})))])
(defn- group [title & children]
(into [:div.pool-group {:class title} [:h2 title]] children))
;; ---------------------------------------------------------------------------
;; sections
(defn- this-project []
(let [document @(rf/subscribe [::render/clip])
clip-id @(rf/subscribe [::render/clip-id])
selection @(rf/subscribe [::sub/selection])
open @(rf/subscribe [::render/open])
media @(rf/subscribe [::sub/project-footage])
;; The project's videos' sounds first, then its uploaded ones.
sounds (into (mapv (fn [{:keys [id label frames fps]}]
{:id id :label label :footage? true :fps fps
:duration (/ frames fps)})
media)
@(rf/subscribe [::sub/project-sounds]))
{:keys [chosen]} @(rf/subscribe [::playback/footage])]
(folder "this project"
(group "symbols"
(doall
(for [sid (sort-by str (keys (:symbols document)))
:let [sym (clip/symbol document sid)]]
^{:key (str sid)}
[item (merge {:label (clip/symbol-name document sid)
:sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes"
(when (= sid open) " · open"))
:on? (= selection [:symbol sid])
:on-click #(rf/dispatch [::ui/select [:symbol sid]])
:on-double-click #(rf/dispatch [::pb/open-symbol sid])}
(carrying (str "symbol:" (subs (str sid) 1))
#(drag/symbol! clip-id sid open)))]))
[:div.dim "double-click to open · drag to place"])
(group "media"
(if (empty? media)
[:div.dim "drop a video here"]
(doall (for [f media] ^{:key (:id f)} [footage-row f chosen]))))
(group "sounds"
(if (empty? sounds)
[:div.dim "drop an mp3 or wav here"]
(doall (for [s sounds] ^{:key (:id s)} [sound-row s (:fps document)])))))))
(defn- hit?
"Is `label` a hit for `query`? A case-folded substring, which is the whole of
what a list of a few dozen names needs."
[query label]
(or (str/blank? query)
(str/includes? (str/lower-case (str label)) query)))
(defn- all-assets []
(let [{:keys [available chosen sounds]} @(rf/subscribe [::playback/footage])
fps (:fps @(rf/subscribe [::render/clip]))
{:keys [symbols]} @(rf/subscribe [::project/assets])
{:keys [id]} @(rf/subscribe [::playback/project])]
(folder "all assets"
(group "media"
(if (empty? available)
[:div.dim "nothing uploaded yet"]
(doall (for [f available] ^{:key (:id f)} [footage-row f chosen]))))
(group "sounds"
(if (empty? sounds)
[:div.dim "no sounds uploaded yet"]
(doall (for [s sounds] ^{:key (:id s)} [sound-row s fps]))))
(group "symbols"
(let [others (remove #(= id (:project %)) symbols)]
(if (empty? others)
[:div.dim "no other saved projects"]
(doall
(for [[[pid pname] rows] (group-by (juxt :project :project-name) others)]
^{:key pid}
;; Closed: a server holds many projects, and a wall of
;; every symbol in every one buries the one you want.
[:details.pool-project
(defn- section
"A heading and its rows.
NOTHING AT ALL while a search is running and this section has no hit. A column
of headings over emptiness is the worst thing a filter can show you: the answer
to \"where is it\" should be the one section still holding rows."
[{:keys [title rows blank searching?]}]
(cond
(seq rows) [:div.pool-section [:h2 title] (into [:div.pool-rows] rows)]
searching? nil
:else [:div.pool-section [:h2 title] [:div.dim blank]]))
(defn- sections
"Draw a scope's sections, or the one line that says a search found nothing in
it. `rows` is the scope's total so the empty answer can be given once rather
than per section."
[searching? rows children]
(if (and searching? (zero? rows))
;; Its own class rather than `.dim`: a `.dim` directly inside `.pane-body`
;; is how the page says what loading, saving and opening are doing — see
;; the status line at the foot of `view` — and a search result is not that.
[:div.pool-empty "no match in this scope"]
(into [:<>] (map section) children)))
(defn- this-project
"The open document's own, with the main timeline at the top and every other
symbol under it.
NOT \"TIMELINES AND SYMBOLS\". Every symbol in this model IS a timeline — it
has frames and nodes and you open it and work in it — so a pair of headings
naming those two things names one thing twice, and invites a reader to go
looking for the difference. There is one list of symbols. What is true is that
exactly one of them is where the work happens: `clip/opens-on`'s answer, the
longest symbol nothing else places. That gets the top of the pane and a row
drawn like the thing it is, and the rest of the library is the list below it.
Still not `:main` being special. The document says which symbol that is by its
structure; rename it, place it inside something else, and the pool follows."
[{:keys [document query searching? media sounds chosen rename main] :as ctx}]
(let [named? #(hit? query (clip/symbol-name document %))
top (when (and main (named? main)) main)
rest (filterv #(and (named? %) (not= main %))
(sort-by str (keys (:symbols document))))
media (filterv #(hit? query (:label %)) media)
sounds (filterv #(hit? query (:label %)) sounds)]
[sections searching?
(+ (if top 1 0) (count rest) (count media) (count sounds))
[{:title "project" :searching? searching?
:blank "nothing to open yet"
:rows (when top [(symbol-row document top ctx)])}
{:title "symbols" :searching? searching?
:blank "nothing else in the library"
:rows (mapv #(symbol-row document % ctx) rest)}
{:title "media" :searching? searching?
:blank "drop a video here"
:rows (mapv #(footage-row % chosen rename) media)}
{:title "sounds" :searching? searching?
:blank "drop an mp3 or wav here"
:rows (mapv #(sound-row % (:fps document) rename) sounds)}]]))
(defn- all-assets
"Everything the server holds. Other projects' symbols stay grouped by project
and closed: a server holds many, and a wall of every symbol in every one buries
the one you want."
[{:keys [document query searching? rename chosen available all-sounds symbols
project-id]}]
(let [media (filterv #(hit? query (:label %)) available)
sounds (filterv #(hit? query (:label %)) all-sounds)
others (filterv #(and (not= project-id (:project %)) (hit? query (:name %)))
symbols)
grouped (sort-by (comp str second key) (group-by (juxt :project :project-name) others))]
[sections searching?
(+ (count media) (count sounds) (count others))
[{:title "media" :searching? searching?
:blank "nothing uploaded yet"
:rows (mapv #(footage-row % chosen rename) media)}
{:title "sounds" :searching? searching?
:blank "no sounds uploaded yet"
:rows (mapv #(sound-row % (:fps document) rename) sounds)}
{:title "symbols" :searching? searching?
:blank "no other saved projects"
:rows (for [[[pid pname] rows] grouped]
^{:key (str pid)}
;; Open while a search is running: you asked for these by name,
;; and a hit behind a closed twisty is a hit you cannot see.
[:details.pool-project {:open (boolean searching?)}
[:summary (str pname " · " (count rows)
(if (= 1 (count rows)) " symbol" " symbols"))]
(doall
(for [{:keys [cid symbol name frames]} rows]
^{:key (str cid symbol)}
[item (merge {:label name :sub (str frames "f")}
(carrying (str "import:" (str/join "|" [pid cid symbol]))
#(drag/other! {:kind :import :label name
:frames frames
:project pid :cid cid
:symbol symbol})))]))]))))))))
(into [:div.pool-rows] (map #(import-row % pid)) rows)])}]]))
;; ---------------------------------------------------------------------------
(defn- counts
"How many rows each scope holds for `query`.
This is what keeps the tabs from hiding anything. A tab shows its tally only
while a search is running and only on the scope you are NOT looking at, so the
one cost of collapsing two folders into two tabs — that a hit could be behind
the tab you did not pick — is paid off by a number, counted over the same
labels the rows are filtered by."
[{:keys [document query media sounds available all-sounds symbols project-id]}]
(let [n (fn [labels] (count (filter #(hit? query %) labels)))]
{:project (+ (n (map #(clip/symbol-name document %) (keys (:symbols document))))
(n (map :label media))
(n (map :label sounds)))
:assets (+ (n (map :label available))
(n (map :label all-sounds))
(n (map :name (remove #(= project-id (:project %)) symbols))))}))
(defn view []
(r/with-let [;; Counted, not a boolean. `dragenter`/`dragleave` fire for every
;; child element the pointer crosses, so a flag set on enter and
;; cleared on leave flickers off the moment the drag passes over a
;; row — the depth counter is what makes the outline steady.
depth (r/atom 0)]
(let [{:keys [loading? status]} @(rf/subscribe [::playback/footage])
depth (r/atom 0)
query (r/atom "")
scope (r/atom :project)
;; Which row is being renamed, as `[kind id]`. One atom rather
;; than a flag per row: exactly one name is ever being edited, and
;; opening a second input has to close the first.
editing (r/atom nil)]
(let [{:keys [loading? status available sounds chosen]} @(rf/subscribe [::playback/footage])
document @(rf/subscribe [::sub/settled-clip])
clip-id @(rf/subscribe [::render/clip-id])
store @(rf/subscribe [::render/store])
palette @(rf/subscribe [::render/palette])
ramp @(rf/subscribe [::render/ramp])
selection @(rf/subscribe [::sub/selection])
open @(rf/subscribe [::render/open])
media @(rf/subscribe [::sub/project-footage])
{:keys [symbols]} @(rf/subscribe [::project/assets])
{project-id :id} @(rf/subscribe [::playback/project])
;; The project's videos' sounds first, then its uploaded ones.
own-sounds (into (mapv (fn [{:keys [id label frames fps]}]
{:id id :label label :footage? true :fps fps
:duration (/ frames fps)})
media)
@(rf/subscribe [::sub/project-sounds]))
needle (str/lower-case (str/trim @query))
searching? (boolean (seq needle))
ctx {:document document :clip-id clip-id :store store :palette palette
:ramp ramp :selection selection :open open
:media media :sounds own-sounds :chosen chosen
:available (vec available) :all-sounds (vec sounds)
:symbols symbols :project-id project-id
:query needle :searching? searching?
;; `clip/opens-on` and not `:main`: the longest symbol nothing
;; else places is the timeline the work happens in, and it is the
;; one row in this pane drawn big enough to read a pose off.
:main (clip/opens-on document)
:rename {:editing @editing :begin! #(reset! editing %)}}
tallies (counts ctx)
files? (fn [^js e] (some #{"Files"} (array-seq (.. e -dataTransfer -types))))]
[:section.pane.pool
{:class (when (pos? @depth) "dropping")
@ -208,7 +540,27 @@
(when-let [file (aget (.. event -target -files) 0)]
(rf/dispatch [::footage/upload file])
(set! (.. event -target -value) "")))}]
[:div.pool-find
[:input.pool-search
{:id "pool-search" :type "search" :value @query
:placeholder "search" :aria-label "search the media pool"
:on-change #(reset! query (.. % -target -value))
:on-key-down (fn [^js e] (when (= "Escape" (.-key e)) (reset! query "")))}]
(when searching?
[:button.pool-clear {:title "clear the search" :aria-label "clear the search"
:on-click #(reset! query "")}
"×"])]
[:div.tabs
(doall
(for [[k label] [[:project "this project"] [:assets "all assets"]]]
^{:key (str k)}
[:button.tab {:class (when (= k @scope) "on")
:on-click #(reset! scope k)}
label
;; Only while searching, and only on the tab you are not looking at:
;; it exists to say "the thing you asked for is over here".
(when (and searching? (not= k @scope))
[:span.count (get tallies k)])]))]
[:div.pane-body
[this-project]
[all-assets]
(if (= :project @scope) [this-project ctx] [all-assets ctx])
(when status [:div.dim status])]])))

View file

@ -416,67 +416,249 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.peer.guest { background: var(--dim); }
/* --------------------------------------------------------------------------
media pool */
media pool
/* Two folders, THIS PROJECT and ALL ASSETS, each holding groups. */
.pool-folder { margin-bottom: 8px; }
.pool-folder > summary {
cursor: pointer;
padding: 2px 0 4px;
font-weight: 600;
color: var(--fg);
A FILE LIST, AND IT IS 210px WIDE. Every row is one line — a picture at the
stage's own proportions, a name, one number — because the pane under this one
is the timeline and a row that costs two lines costs timeline rows. The row is
--row tall, which is a timeline row, so a pool of symbols and the rows they
end up on are the same size object.
One exception, deliberately: the main timeline. See `.pool-row.main`. */
/* The search strip, between the pane head and the tabs. Its own band rather
than crowded into the head, which is one line and already holds the + . */
.pool-find {
position: sticky;
top: 21px;
z-index: 2;
display: flex;
align-items: center;
gap: 4px;
padding: 3px 5px;
background: var(--chrome);
border-bottom: 1px solid var(--hair);
}
.pool-folder > .pool-group { padding-left: 8px; }
.pool-project { margin-bottom: 2px; }
.pool-project > summary { cursor: pointer; color: var(--dim); padding: 1px 0; }
.pool-project > .pool-item { margin-left: 10px; width: calc(100% - 10px); }
.pool-group { margin-bottom: 9px; }
.pool-search {
flex: 1;
min-width: 0;
height: 17px;
padding: 0 5px;
font: inherit;
color: var(--fg);
background: var(--pane);
border: 1px solid var(--line);
border-radius: 2px;
}
/* The one-line gloss under a group heading. Small and quiet: it answers "what
is this list" once, for someone who has not read the model. */
.pool-group > h2 + .dim { margin-bottom: 4px; }
/* Safari draws its own clear button inside a search input, in its own idiom and
at its own size. The one next to it is this file's. */
.pool-search::-webkit-search-decoration,
.pool-search::-webkit-search-cancel-button { -webkit-appearance: none; }
.pool-group > h2 {
margin: 0 0 3px;
.pool-clear {
flex: 0 0 auto;
width: 17px;
height: 17px;
padding: 0;
border: 0;
border-radius: 2px;
background: none;
color: var(--dim);
cursor: pointer;
}
.pool-clear:hover { background: var(--hair); color: var(--fg); }
/* The scope tabs, in the vocabulary `ui/tabs` already established for symbols —
the same object, so two rows of tabs on one screen read as one idea. */
.pool > .tabs { top: 45px; position: sticky; z-index: 2; }
/* A tally, shown only on the tab you are NOT looking at and only while a search
is running: it says "what you asked for is over here". */
.tab .count {
padding: 0 4px;
border-radius: 7px;
background: var(--sel-bg);
color: var(--sel);
font-variant-numeric: tabular-nums;
}
/* MEDIA, SOUNDS, TIMELINES. A heading is one line and it sticks, so scrolling a
long list never leaves you looking at rows with no idea what kind they are. */
.pool-section { margin-bottom: 7px; }
.pool-section > h2 {
position: sticky;
top: 69px;
z-index: 1;
margin: 0 0 2px;
padding: 1px 5px;
font: inherit;
font-weight: 600;
color: var(--dim);
text-transform: uppercase;
letter-spacing: .07em;
background: var(--pane);
}
.pool-item {
display: block;
width: 100%;
text-align: left;
padding: 2px 6px;
.pool-section > .dim { padding: 0 5px 2px; }
.pool-rows { display: flex; flex-direction: column; }
/* What a search found nothing to say. */
.pool-empty { padding: 6px 5px; color: var(--dim); }
.pool-project { margin: 0 0 1px 5px; }
.pool-project > summary { cursor: pointer; color: var(--dim); padding: 1px 0; }
.pool-project > .pool-rows { margin-left: 8px; }
/* THE ROW IS THE BUTTON. Click selects, double-click opens, and drag starts
anywhere across it — the whole strip, not the few characters of the name. The
wrapper carries the state classes and the frame; the button inside it fills
that frame edge to edge, so there is no dead strip anywhere along the row. */
.pool-row {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
height: var(--row);
padding: 0 4px;
border: 1px solid transparent;
border-radius: 2px;
}
.pool-row:hover:not(.disabled) { background: #fff; }
.pool-row.on { background: var(--sel-bg); border-color: var(--sel); }
/* WHICH ONE IS OPEN, as a mark and not as the word "open" — a list this narrow
cannot spend twenty pixels of every name on a word that is true of one row.
The accent, because selection is the only thing it is ever used for and the
open symbol is what the rest of the screen is showing. */
.pool-row.open { box-shadow: inset 2px 0 0 var(--sel); }
.pool-item {
display: flex;
align-items: center;
gap: 5px;
flex: 1;
min-width: 0;
padding: 0;
text-align: left;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: grab;
}
/* Shrinks to the name, so the pencil sits against the end of the word rather
than out at the edge of the pane — and ellipses rather than pushing the
number off the row. */
.pool-item .text { display: flex; flex-direction: column; flex: 0 1 auto; min-width: 0; }
.pool-item .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The number, at the far edge and never moving: `auto` on the left is what
pushes it there, and tabular figures are what stop it shuffling as the rows
scroll past. */
.pool-item > .sub {
flex: 0 0 auto;
margin-left: auto;
padding-left: 4px;
color: var(--dim);
font-variant-numeric: tabular-nums;
}
/* Every row leads with a picture, and they are all this box, so the names line
up down the list whatever is beside them. 16:10 is the stage's own ratio at
320x200 — a thumbnail that is not the picture's shape is a thumbnail you have
to think about. */
.pool-item .thumb {
flex: 0 0 32px;
width: 32px;
height: 20px;
object-fit: cover;
background: var(--stage);
border-radius: 1px;
pointer-events: none;
}
.pool-item .thumb.sound {
display: flex;
align-items: center;
justify-content: center;
color: #d9d9d9;
}
/* THE MAIN TIMELINE, under the PROJECT heading and at the top of the pane.
`clip/opens-on`'s answer: the longest symbol nothing else places, which is
where the work happens. The one row in the pane given size, a second line and
a surface of its own — spent here rather than spread over every row, which is
what keeps the list below it a list. */
.pool-row.main {
height: auto;
margin-bottom: 5px;
padding: 4px;
background: var(--sunk);
border-color: var(--hair);
}
.pool-row.main .thumb {
flex: 0 0 64px;
width: 64px;
height: 40px;
}
.pool-row.main .name { font-weight: 600; }
/* Both facts on one line under the name. Beside a 64px picture there is room,
and this is the one row where a person wants more than the length before
they click. */
.pool-row.main .detail {
color: var(--dim);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-item:hover:not(:disabled) { background: #fff; }
.pool-item.on { background: var(--sel-bg); border-color: var(--sel); }
.pool-item .sub { display: block; color: var(--dim); }
.pool-item .text { display: block; overflow: hidden; text-overflow: ellipsis; }
/* The pencil. A span and not a button — see `ui/pool`'s `row` — so it needs its
own centring and cursor rather than inheriting a control's.
/* A media row leads with one frame of the video. */
.pool-item.media { display: flex; align-items: center; gap: 6px; }
.pool-item .thumb {
flex: 0 0 40px;
width: 40px;
height: 30px;
max-width: 40px;
max-height: 30px;
object-fit: cover;
background: var(--stage);
Hidden until the row is under the pointer or holds focus, because a column of
twenty pencils is a column of twenty pencils; `visibility` rather than
`display`, so it keeps its width and the name beside it does not reflow as the
pointer crosses the list. */
.pool-rename {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 15px;
width: 15px;
height: 15px;
border-radius: 2px;
color: var(--dim);
line-height: 1;
cursor: pointer;
visibility: hidden;
}
.pool-row:hover .pool-rename,
.pool-row:focus-within .pool-rename { visibility: visible; }
.pool-rename:hover { background: var(--hair); color: var(--fg); }
/* Renaming. The input REPLACES the row at the same height, so a list does not
jump while you type in it. */
.pool-name {
flex: 1;
min-width: 0;
height: 17px;
padding: 0 4px;
font: inherit;
color: var(--fg);
background: #fff;
border: 1px solid var(--sel);
border-radius: 2px;
pointer-events: none;
}
/* The whole pane is the drop target, so the cue has to be the pane and not a