`[:xform :anchor]` is deleted. `T(a)·M·T(-a)` is a transform conjugated by a
translation — "do M in a frame shifted by a" — and a parent already IS a shifted
frame, so an anchor was a peg written inline: one that could not be selected,
keyed, shared between nodes, or placed above a measured channel. Same expressive
content, strictly less reach. `node-test` asserts the two produce the same matrix.
Its two jobs split, and neither is a field on a node any more.
A pivot nobody chose is DERIVED PER DRAG and stored nowhere. `gesture/pivot` is
the middle of what the node draws — `pick/bounds-of`, the same call the stage
draws the selection box from, on the same frame — or the node's own origin when it
draws nothing. `gesture/about` solves for the position that holds that point
still, so a turn now writes `pos` as well as `rot`. Nothing is cached, so nothing
goes stale: the stored anchor was that same middle captured once at creation while
the box beside it was recomputed every render, so on anything edited since it was
made the cross and the box disagreed and the pivot was wrong. A symbol with more
than one node diverged on its first edit.
A pivot somebody chose is a PEG — `nest/peg`, an ordinary `:group` parent sitting
on the derived pivot with `:pinv` captured so nothing moves. It is the answer to
the three things a derived pivot cannot do: a pivot that persists (an arm about
its shoulder), a pivot that travels (a keyed `pos`), and a hand transform over a
measured one. The last was impossible before — `local`'s translation is
`pos − M·a`, so under a measured `M` writing an anchor moves the thing it was
meant to leave alone. `flow/freeze`'s `pivoted` pass knew this and skipped every
`node/measured?` node, which is exactly why the traced mouth pivoted about
(-234, -395) on a 320x200 stage: the top-left corner of the footage. That pass is
gone; there is no node a derived pivot can be missing from.
`demo/stage` is the one place the anchor did work a static `pos` cannot: `:scale`
is keyed, and the source's middle has to stay on its authored centre throughout.
It is now seven pegs, identical to the pixel.
Also: `events/ui`'s `fitted` rescales a dropped tracing right after placement, and
the anchor had been silently keeping the picture centred through that; it solves
for the middle explicitly now.
Schema 7. Nothing is converted, as in 6: every project is marked 7 and one still
carrying an anchor is refused by name, with what to do about it. Dropping an
anchor is pixel-exact wherever rotation and scale are the identity — everywhere a
freeze or a drop wrote one — but not on anything since turned by hand, and not at
all where `pos` is dense, so a conversion would be silent and wrong for exactly
the nodes somebody had placed themselves.
601 CLJS tests, 68 Django tests, and the onion and take browser suites pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
The transfer is the longest part of an import on anything but a local
server, and it was the only part with no number on it: `uploading video…`
sat unchanged from the first byte to the last, however many there were, and
only the extraction that followed it ever counted. Most of what "slow"
means to whoever is waiting was a spinner of unknown length.
`fetch` cannot report this. A Request built from a FormData gives no way to
observe its own upload — the promise settles when the response arrives — so
`POST-form` gained an XMLHttpRequest arity, which is the one thing XHR can
still do that fetch cannot. `fail` became `failure`, building the ex-info
rather than throwing it, because the two transports raise it differently:
fetch throws inside a `.then` and XHR has to reject by hand.
`sending` dispatches only when the whole percentage moves, since the
browser fires progress as often as it pleases and each dispatch re-renders
the pane. Video, sound and image uploads all report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`probe` took `r_frame_rate` whenever it was at or under the cap, on the
grounds that it is the rate that keeps every distinct source frame. It is
not a claim about frames at all: ordinary iPhone footage declares 120 over
a stream whose timestamps are 1/30s apart, and resampling it up turned an
11-second clip into 1293 proxy frames instead of 323 — four times the
encode, four times the tracing stills (91MB against 23MB), four times the
blobs and the rows, for 970 frames that are copies of their neighbours.
So `_measured_rate` reads the timestamps and `_choose_rate` keeps whichever
declared rate they bear out. Two details carry it: the times are sorted
before differencing, because an HEVC stream arrives in decode order and
differencing that measures the reordering delay instead of the rate; and
the statistic is the MEDIAN interval, which is what keeps the property the
nominal rate was being taken for — a take held on one frame still reports
the rate of the parts that move, so no distinct frame is dropped. A genuine
120fps capture still extracts at 120, and there is a test on that
specifically.
`Source.probe` also stopped being the place a reading goes to be preserved.
The facts are a pure function of bytes that are the row's own identity, so
a re-upload re-reads them: otherwise every already-uploaded source would
have gone on resampling to four times the frames with no way to correct it
short of deleting the row.
Already-extracted footage is untouched — `extraction_key` still says
scheme 3, so those jobs stay done and reachable. Bumping it re-extracts
everything at the corrected rate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The player's tracker derefed its inputs one by one inside `ratom/run!`. An
edit that also changed the selection ran it from the selection's change: it
read `::render/shown` while still stale, then pulled the dirty
`::render/clip`, which re-ran it nested with the new resolver — and the outer
run, finishing last, wrote the old one back. A deleted layer stayed on the
stage until something else ran the tracker. The snapshot is now one sub, so
the tracker derefs a single clean input and is never re-entered.
Onion skinning only ghosted cels in `:display :lane` symbols, so a drawing
made of keyed shapes showed nothing. A ghost is now just the picture the
resolver draws n frames back and ahead, limited to the selection when there
is one. The scope setting is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
One tool at a time, picked from a strip down the left of the stage or by its
letter, as in Photoshop, Illustrator and Flash: V selects and transforms, P is
the pen, B the brush, E the eraser. The options bar above the stage holds the
tool's settings, and the palette is a grid under the tools with the colour a
new shape gets above it, as Deluxe Paint's was.
The pen's draft is filled into the picture as it is drawn, so the edge on
screen is the pixels the shape will be. Click the first point, Enter, Esc or
leaving the pen closes it; the pen stays the tool. Editing a shape's points is
the pen ON that shape — Figma's vector edit mode — so the separate points mode
is gone: click an edge to add a point, ⌥-click one to delete it, on every key
at once so tweens keep meaning something.
The brush paints a mask, and what it will be is shown while painting: the
stroke is traced round its pixel edges, holes and all, and simplified to a
point every so many pixels of outline by the same function the saved shapes
come from. A hole is bridged into the one ring along a whole-pixel row, which
the fill never samples. Blender's Adjust Last Operation re-traces the last
stroke from what it was made of.
A shape coloured CLEAR is a knockout: its symbol is drawn into a layer of its
own and the knockout clears it, every colour or one. The eraser makes one in
the symbol it starts on, of the colour it starts on, or of every colour with
⌥. A click goes through a knockout to what shows. Previews are drawn in the
stacking context of what they will land in.
The polygon's points no longer stay behind when the shape is moved: they were
read off the saved document while the box handles read the one mid-drag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening the 8625 study froze the main thread for 4.7 seconds and settled at
615MB of heap. A profile put three quarters of a project open inside
mix/wav-bytes, which playback had no business calling at all.
Two separate causes. The peak scan built a lazy sequence of one boxed double
per SAMPLE -- ten million of them for a seven-minute mix -- to compute a
single maximum over data already sitting in Float32Arrays; the hand-written
loop is 73x faster and agrees to the bit. The rest was structural: the WAV
existed only because an <audio> element can hold a URL and nothing else, and
the element existed only to be the clock. So a mixdown that was already
rendered got encoded to 73MB of 16-bit PCM, on the main thread, on open, on
every tab switch and on every edit to a track -- and a symbol with no sound
got silence synthesized and encoded full length so the element had a duration
to report.
arthur.clock keeps its interface and all of its arithmetic; the position now
comes from a backend behind a protocol. clock.graph plays the AudioBuffer
through an AudioBufferSourceNode and derives the frame from the context's own
clock, which is the audio device's position in double precision rather than
whatever the media pipeline last published. clock.element is the old path,
kept switchable while the new one earns trust -- BACKEND, or use-backend! --
which is also why every one of the original clock tests passes unchanged: the
derivation they assert is shared, and the backends can only disagree about the
position under it. 6.5s to 1.8s, 4.7s of blocking to 370ms, 615MB to 68MB.
THE POSITION IS COMPENSATED FOR OUTPUT LATENCY, and piecewise because of it.
currentTime is the quantum being rendered, which the speaker is tens of
milliseconds behind; report the renderer and the picture leads the sound,
which in a lip-sync tool is the only artefact that matters. Audio already
rendered cannot be re-rated, though, so reading it back at a new rate jumped
the playhead backwards by three latencies on every press of the rate button.
Each play, pause, seek and rate change now records a segment and a position is
read against whichever segment was in force when that audio was rendered.
One duplicate fell out of this. Opening a project asked for its clock twice --
once from ::opened and once from a ::refresh-clock the shell raised because it
compared symbol ids, and two different documents both open on :main. The
sounds subscription carries the clip id now, so "an edit under the same
symbol" means what it says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gesture was handed the pointer's frame and threw it away, creating at
the playhead instead; and the palette row carried :lane? only once its
track existed, so the first double-click on a palette lane -- the one that
has to make the track -- dispatched nothing at all.
Both are one rule now. ::new-symbol-at uses the frame it is given and
resolves every row through drop-destination, with the destination deciding
what is created: a clip of a palette track is a palette symbol, a clip of
any other lane is blank. A row with no path of its own resolves to the
symbol it names, which is what lets a palette track -- hanging off its
owner by :palette-track rather than placed in it -- be reached without a
special case; it also stops a palette cel's slide resolving against the
open symbol and looking like a transfer out of the track. The one thing
left that knows about palettes is materializing the lane a palette row
names before anything asks where the row leads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making a variant meant 16 colour pickers from black: the only button was
"+", which seeds an untitled palette from the built-in defaults. The copy
is selected on creation, so the next colour edit lands on it rather than
on the palette it came from.
`pal/palettes` is the source, so the implicit default — a project that
has never had a palette asset of its own — duplicates like any other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A paste put an instance of "bg" inside "bg" itself. It saved, loaded, and then
threw "symbol cycle in audio" out of nest/audio-tracks, because a container that
contains itself has no finite expansion.
place-symbol and ui/drag already refused that, each by asking
clip/contains-symbol?. Paste asks clip/problems instead, and problems did not
encode the invariant at all -- it checked missing symbols, pose tracks and audio
links, but never the placement graph. So the rule goes where every command is
already checked: paste, cut, duplicate, correction and the span ops all gate on
problems, so one rule covers them all.
Why it looked like a reasonable thing to do is the other half. place-symbol
copied the symbol's name onto the instance it made, and that copy went stale on
the next rename: the symbol read "bg" in its tab while an instance of it still
read "symbol-18", which is its id from before it was named. One object under two
names, with nothing on screen to connect them.
So instances are no longer given a name at creation, and clip/node-label reads
the symbol's name through on every render. :name on an instance now means only
what a person typed, which is what tells two instances of one symbol apart --
"8625 left" and "8625 right" of one "face" -- so an authored name still wins and
read-through is the fallback. The label logic was duplicated across four call
sites with three different fallback orders; location.cljs already read through
and the others did not. They now share one function.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grid was five panes at three fixed widths and one fixed height, written as
lengths in the stylesheet. That is a claim about every screen the tool is ever
opened on, and it was wrong on two of them: a phone, where 210 + 250 pixels of
side pane leave nothing for a 320x200 stage, and a large display, where the
inspector is the width somebody once typed rather than the width their work
wants.
So the tracks are custom properties and `ui/layout` owns the numbers. A drag of
an edge is one `assoc-in` and one property on `.app`; no pane's contents
re-render, which is the same reason the picture is painted by `ui/player`'s loop
rather than by anything reactive. Sizes stay in PIXELS and not fractions —
three of these panes hold fixed-width rows, so what you drag an edge to is the
width you meant at any window size.
State, events and widgets are one namespace, which is not this repo's shape
anywhere else. They are the same six integers: `:pool` is a width, a grid track,
a drag's clamp and a button's label at once, and splitting six integers across
`db`, `events/` and `ui/` is more wiring than state.
Three things follow from making it state at all:
SHUTTING A PANE. Pool and inspector unmount; the timeline collapses to its
transport strip instead, because play, pause and the frame readout live in that
strip and a window with nowhere to press play is broken rather than small. The
toggles are in the top bar because a shut pane has no head left to carry its
own handle.
A NARROW WINDOW. Below 760px the grid is one column and the side panes are
drawers over the stage rather than columns beside it. The breakpoint is read
once, at load, and only decides what the layout OPENS at: throwing away the
sizes somebody dragged because they turned a tablet sideways is worse than a
layout that is briefly the wrong shape.
ZOOM. The stage's was already a constant 2 in `ui/stage`; it is now an integer
in [1 8], still CSS over a canvas that is the raster's own size, so the browser
suite still reads 320x200 of real pixels off `canvas.stage`. Whole pixels
only — a fractional scale under `image-rendering: pixelated` draws some rows of
the raster thicker than others, which misrepresents the one thing the preview
exists to judge. The timeline's is the stylesheet's entirely: every mark in the
tracks is positioned as a percentage of their width, so one multiplier on that
width spreads the grid, the spans, the keys, the ruler and the playhead apart
together. Both readouts are the button back to normal, accented while there is
something to return from, because the quick way back belongs in the one place
you already look to find out where you are.
One trap, paid for and then found: the top strip has to scroll sideways on a
narrow screen, and `overflow-x: auto` makes an element a clipping container on
BOTH axes. Every top-bar menu — open, snapshots, share, sign in — was then cut
off by a 30px-tall bar and drawn at whatever x the strip happened to be
scrolled to, which reads as the button doing nothing at all. They are fixed to
the viewport there, as `.menu-drop` already was one pane down for the same
reason. The same shape of bug ate the polygon tool and both zoom readouts: a
flex row that does not fit shrinks every item and pushes the last ones off the
end, so nothing in a strip shrinks now and the strip scrolls as a strip.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>