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>
This commit is contained in:
Your Name 2026-10-01 16:22:33 -04:00
parent 10b96761fa
commit 6e7827e03f

View file

@ -1,7 +1,8 @@
# A lane is a view # A lane is a view
Plan, 2026-10-01, written at `2dc5735`. It undoes the lane model as a thing in Plan, 2026-10-01, written at `2dc5735`. It undoes the lane model as a thing in
the document and keeps what it was for. the document and keeps what it was for. Build on what is there and tear out
half of it.
## The decision ## The decision
@ -25,7 +26,7 @@ places.
| a group node with `:layout :sequence` | nothing — the symbol is the container | | a group node with `:layout :sequence` | nothing — the symbol is the container |
| `node/lane?` | a view question: is this symbol drawn in lane mode | | `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-clips nodes lane-id` | the children of a symbol, sorted by `node/placed-span` |
| `symbol/lane-problems` | gone. Nothing enforces non-overlap; the drag that claims time produces it | | `symbol/lane-problems` | `symbol/overlaps`, a diagnostic the write path calls |
| `lane/lane-frame` | `clip/source-time` — one clock instead of two | | `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 | | `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 | | `clip/lane-node`, the born-with lane | gone; a symbol is born empty again |
@ -44,13 +45,66 @@ 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 `finish`. The sequence operations are the same subject — re-spanning children
— and keeping them apart was a consequence of lanes existing. — 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 ## Where lane mode lives
A symbol is drawn as a lane because somebody said so, not because of what its 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 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 a drag not currently overlap" means a symbol stops being a lane the moment anything
makes two children overlap, and the rules that were maintaining non-overlap overlaps, and the rules that maintain non-overlap switch off exactly when they
switch off exactly when they are needed. are needed.
Two options: Two options:
@ -66,8 +120,8 @@ Two options:
Recommended: 2. It is one field, it keeps editing rules reproducible between 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 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 its drag handling to hold the line on is that nothing outside the timeline, and the commit
is allowed to read it. path's overlap check, is allowed to read it.
## Order of work ## Order of work
@ -79,27 +133,29 @@ Each step compiles, passes `npm test`, and leaves the editor usable.
`:cels` blocks for the collapsed row, and keep `inside-rows` — including `:cels` blocks for the collapsed row, and keep `inside-rows` — including
its `:unmapped?` branch, which is what makes a held drawing's contents its `:unmapped?` branch, which is what makes a held drawing's contents
reachable at all. reachable at all.
2. **Lane mode as a hint.** Add the field, draw a symbol's children as blocks 2. **Lane mode as a hint.** Add the field and the toggle, draw a symbol's
when it is set and as rows when it is not, and move the lane-row drag children as blocks when it is set and as rows when it is not, and move the
handling onto it. Now both display paths exist and nothing in the domain lane-row drag handling onto it. Both display paths now exist and nothing in
has changed yet. the domain has changed.
3. **Re-base the commands.** Move `lane.cljs` into `span.cljs`, replacing 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-clips nodes lane-id)` with the symbol's children and dropping the
`lane-id` argument. The tests in `frontend/test/arthur/domain/lane_test.cljs` `lane-id` argument. `frontend/test/arthur/domain/lane_test.cljs` is the
are the proof of this step: they should need their fixtures changed and proof: its fixtures should change and its assertions should not, and any
their assertions kept, and any assertion that has to change is a behaviour assertion that has to change is a behaviour change worth noticing.
change worth noticing. 4. **Move the invariant.** `symbol/lane-problems` becomes `symbol/overlaps`,
4. **Delete the rest.** `node/lane?`, `symbol/lane-clips`, called by `span/finish` for a symbol in lane mode, plus the property test
`symbol/lane-problems`, `clip/lane-node`, `::ui/new-lane`, that no command can produce an overlap.
`::ui/adopt-in-lane`, the lane branch of `::ui/new-symbol`, lane renaming, 5. **Delete the rest.** `node/lane?`, `symbol/lane-clips`, `clip/lane-node`,
`aimed-lane`, and the `:lane?`/`sound-lane?` row flags. Rename what is left `::ui/new-lane`, `::ui/adopt-in-lane`, the lane branch of
so the word does not appear outside the timeline. `::ui/new-symbol`, lane renaming, `aimed-lane`, and the
5. **Audio falls out.** An audio node is already a parent-less child of a `: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 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 `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 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. children are sounds is an audio lane, and that is the whole of it.
6. **Shift-to-reparent stays** as it is: `nest/move-node` and 7. **Shift-to-reparent stays** as it is: `nest/move-node` and
`nest/move-refusal` never knew about lanes. `nest/move-refusal` never knew about lanes.
## What must not be lost ## What must not be lost
@ -121,15 +177,13 @@ to keep.
## Open questions ## Open questions
1. **What happens to a symbol in lane mode whose children overlap anyway** — 1. **Is lane mode a property of the symbol or of the instance placing it?** A
through a reparent, a paste, or a document written before the mode existed?
Drawing it as rows is honest and loses the mode silently; drawing
overlapping blocks is a lie. Suggest: draw the blocks, and report it the
way `clip/conflicts` reports a correction nobody has resolved — a decision
waiting for a person, not a `problem`.
2. **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 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. first reading. That is probably right, and worth saying out loud.
3. **Does a lane row's edge drag trim the placing instance's span, or ripple 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 the children?** Same handle, two commands; the row is now an instance, so
it has a span of its own for the first time. 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.