arthur/docs/lane-is-a-view-plan.md
Your Name 6e7827e03f Non-overlap is an invariant, not a report
The plan asked what to draw when a symbol in lane mode holds overlapping
children, and offered to report it as a decision waiting for a person. Wrong
question: they never overlap. Placement claims time — anything placed, moved
or grown over occupied time trims, removes or splits what it lands on — so the
operation that could have made an overlap did not, and `span/finish`, which is
already the single commit path and already refuses rather than half-applying,
is where that is enforced. An overlap is then a bug in a command and not a
state to design around.

The check stays, named `symbol/overlaps` and used three ways: the commit path
refuses one, a property test asserts no command can produce one, and a
document that somehow holds one still LOADS and is drawn visibly wrong with
the status saying so. Not `problems`, which stops a document loading, and not
`conflicts`, which means somebody has a decision to make — a display hint must
never be able to keep a document from opening.

The one place a person can ask for the impossible is toggling lane mode on
over children that already overlap. That refuses and offers to trim them into
a sequence, through the `:required-frames` retry the model already uses.

Also written down, because it is the pair the modifier exists to separate:
a plain body drag is temporal and replaces, trimming extents as needed; shift
is structural and goes through `nest/move-node` into the symbol under the
pointer, which has to keep working for symbols held in a lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 16:22:33 -04:00

10 KiB

A lane is a view

Plan, 2026-10-01, written at 2dc5735. It undoes the lane model as a thing in the document and keeps what it was for. Build on what is there and tear out half of it.

The decision

A lane is a view over a symbol with sequential, non-overlapping children.

Nothing in the document is a lane. There is no lane type, no lane group, no :layout :sequence, no lane commands and no lane validation. The word survives in exactly two places: the UI, where a symbol can be DRAWN as a lane, and the drag handling that re-spans a symbol's children while it is being drawn that way.

The display model goes back to a row per symbol. A symbol in lane mode draws its children as blocks on its own single row; expanded, they are rows like anything else. Everything else is an ordinary row that expands into what it places.

What a lane was, and what each part becomes

was becomes
a group node with :layout :sequence nothing — the symbol is the container
node/lane? a view question: is this symbol drawn in lane mode
symbol/lane-clips nodes lane-id the children of a symbol, sorted by node/placed-span
symbol/lane-problems symbol/overlaps, a diagnostic the write path calls
lane/lane-frame clip/source-time — one clock instead of two
domain/lane.cljs re-based onto domain/span.cljs: re-spanning a symbol's children
clip/lane-node, the born-with lane gone; a symbol is born empty again
::ui/new-lane, ::ui/adopt-in-lane, lane renaming gone, gone, and ordinary node renaming

lane.cljs's fourteen commands are not deleted — they are what "endpoint drag overlap handling" means, and they already do the right arithmetic. What changes is their subject: every one of them currently takes a host symbol AND a lane id and asks lane-clips nodes lane-id; each takes a symbol and asks for its children. extend-hold, resize-out, resize-in, roll, blank, place-symbol, adopt, append-drawing, reuse-drawing, duplicate-drawing, overwrite-drawing, make-unique. span/finish is already the one commit path and stays exactly as it is.

Put them in span.cljs, which already owns "one write to one node's span" and finish. The sequence operations are the same subject — re-spanning children — and keeping them apart was a consequence of lanes existing.

Children in lane mode never overlap

This is an invariant, not a condition to check for and report. Placement claims time: anything placed, moved or grown over occupied time TRIMS the extents it lands on — trimming the incumbent, removing one wholly covered, or splitting one it lands inside — so the result has no overlap because the operation that could have made one did not. That is blank followed by a non-rippling placement, which is what overwrite-drawing already composes.

Enforced at the boundary, which already exists: span/finish is the single commit path for every one of these commands, it validates before it returns, and it refuses rather than half-applying. So finish gains the overlap check for a symbol in lane mode, and no command can commit one. An overlap that appears anyway is a bug in a command, not a state to design around.

Keep the check as a named diagnostic — symbol/overlaps, taking a symbol and returning the pairs — used three ways:

  1. span/finish refuses when it would commit one.
  2. The test suite asserts no command can produce one: a property over the commands in the style of drawn in lane_test, which samples rather than computing expected numbers by hand.
  3. A document that somehow arrives holding one still LOADS — a display hint must never be able to stop a document loading — and the timeline draws it visibly wrong with the status line saying so. Not clip/problems, which means the document will not load, and not clip/conflicts, which means a person has a decision to make. This is neither: it is a bug report.

Toggling lane mode ON for a symbol whose children already overlap is the one place a person can ask for the impossible. Refuse it and say why, with the :required-frames retry pattern offering to trim them into a sequence — the domain reports what it would need, the UI offers one button.

Outside lane mode nothing is enforced, because overlapping children are what compositing IS. An endpoint drag there is an ordinary span edit that may overlap; the claim-time rule follows the mode.

The two drag intentions

Unchanged from docs/lane-nesting-notes.md, and both kept:

  • Plain drag of a clip body is temporal: it moves in time, within its symbol or into another symbol drawn as a lane, and it REPLACES — trimming, removing and splitting extents as needed so nothing overlaps.
  • Shift-drag is structural: the dragged node goes INSIDE the symbol the clip under the pointer places, through nest/move-node, which preserves the world transform and the root timing. This must keep working for symbols contained in a lane, which is the case it exists for.

Overlap cannot distinguish them — dropping on occupied time already means claiming it — so the modifier says which, and the label by the pointer says it back. nest/move-refusal already answers before the drop.

Where lane mode lives

A symbol is drawn as a lane because somebody said so, not because of what its children happen to look like at this moment. Deriving it from "the children do not currently overlap" means a symbol stops being a lane the moment anything overlaps, and the rules that maintain non-overlap switch off exactly when they are needed.

Two options:

  1. Editor state, [:ui :lane-mode #{sid}]. Purest reading of "a lane is a view". But the drag rules follow the mode, so an unsaved, per-person toggle would decide whether dropping a symbol trims its neighbour or composites over it — the same gesture doing two different things to the document depending on something the document does not record.
  2. A display hint on the symbol, e.g. :display :lane, saved like any other field (clip-keys, leaf/leaves, leaf/clip in the same commit). Still not a type: nothing in evaluation reads it, symbol/problems does not check it, and a symbol with it set behaves identically on the stage.

Recommended: 2. It is one field, it keeps editing rules reproducible between people, and it does not make the symbol a different kind of thing. The thing to hold the line on is that nothing outside the timeline, and the commit path's overlap check, is allowed to read it.

Order of work

Each step compiles, passes npm test, and leaves the editor usable.

  1. Row per symbol. In ui/timeline.cljs, delete portal, the ::portal hint row, the under? lineage predicate and the chosen argument; emit a row for a clip whose parent is a lane instead of skipping it. Keep the :cels blocks for the collapsed row, and keep inside-rows — including its :unmapped? branch, which is what makes a held drawing's contents reachable at all.
  2. Lane mode as a hint. Add the field and the toggle, draw a symbol's children as blocks when it is set and as rows when it is not, and move the lane-row drag handling onto it. Both display paths now exist and nothing in the domain has changed.
  3. Re-base the commands. Move lane.cljs into span.cljs, replacing (lane-clips nodes lane-id) with the symbol's children and dropping the lane-id argument. frontend/test/arthur/domain/lane_test.cljs is the proof: its fixtures should change and its assertions should not, and any assertion that has to change is a behaviour change worth noticing.
  4. Move the invariant. symbol/lane-problems becomes symbol/overlaps, called by span/finish for a symbol in lane mode, plus the property test that no command can produce an overlap.
  5. Delete the rest. node/lane?, symbol/lane-clips, clip/lane-node, ::ui/new-lane, ::ui/adopt-in-lane, the lane branch of ::ui/new-symbol, lane renaming, aimed-lane, and the :lane?/sound-lane? row flags. Rename what is left so the word does not appear outside the timeline.
  6. Audio falls out. An audio node is already a parent-less child of a symbol, which is exactly the new shape — so the audio-in-lane rules added in 2dc5735 (holds-other?, the mixed-lane refusal, the in-lane filter in sound-rows) delete rather than migrate. A symbol drawn as a lane whose children are sounds is an audio lane, and that is the whole of it.
  7. Shift-to-reparent stays as it is: nest/move-node and nest/move-refusal never knew about lanes.

What must not be lost

All of this was broken at some point today and is now proved; each has a test to keep.

  • A held clip's contents are reachable from the root timeline, with no keys and no draggable edges — source-time is nil for a hold, and the walk used to stop there.
  • Double-clicking a clip opens its symbol as a tab, and the editor survives it: symbol/lineage must not report a cycle for an id the symbol does not hold, and opening a symbol must drop a selection pointing into the one being left.
  • Selection waits for pointer-up, so a press does not re-draw the timeline out from under the gesture it is starting.
  • A drop never silently deletes what it lands on.
  • A sound is drawn once, not twice.

Open questions

  1. Is lane mode a property of the symbol or of the instance placing it? A symbol placed twice would be drawn the same way in both places under the first reading. That is probably right, and worth saying out loud.
  2. Does a lane row's edge drag trim the placing instance's span, or ripple the children? Same handle, two commands; the row is now an instance, so it has a span of its own for the first time.
  3. The held destination for shift-to-reparent is still refused — nest/inside has no invertible clock for a hold, which is most of what anybody would try to nest into. docs/lane-nesting-notes.md argues the refusal is stronger than the facts require and says what would settle it.