An occurrence is a node, with a clock of its own
A lane's drawings were going to be one instance whose source was a KEYED
channel: frame 0 says `:drawing-a`, frame 4 says `:drawing-b`, and the cels of
a row are that channel's keys. Two things followed from it, and both were
wrong.
The first is that playback meant whichever shape the channel happened to have.
A framed source played its symbol; a keyed source froze the selected frame.
So `node/placed-at` read animation out of storage, and adding an ordinary key
to a still turned it into an animation — the last-key bug, which was not a bug
in the code so much as the rule working as written. But WHICH drawing is used
and HOW time runs inside it are independent questions, and all four combinations
are ordinary: hold one drawing, play one animation, cut between held drawings,
cut between playing ones.
So an occurrence names one symbol in `:source {:symbol ...}` and says how its
source time advances in `:playback {:in :speed :end}` — `source = in + speed *
f`, a hold being speed 0, with `:stop`, `:hold` or `:loop` at the end named
rather than guessed. `node/placed-frame` samples it forwards, which works for
holds too, and `node/source-time` is the separate, invertible edit map, nil
where inversion is meaningless. The two were one function before, and a hold
had to lie about one of them.
The second is that a keyed source only looked necessary because an occurrence
was assumed to need a ROW. It does not. A lane is a group with `:layout
:sequence`, its occurrences are ordinary instances in the same flat node map,
and `timeline/rows` draws them as cel blocks on the lane's own row: twelve
exposures, one row, each cel still separately selectable and addressable. The
vertical growth that justified the keyed source is a presentation question, and
it is answered in the view.
`arthur.domain.sequence` holds the first commands over that shape — add lane,
append drawing, extend hold — each one history step, each refusing rather than
half-applying. Extending a hold leaves the lane's keys at their authored times,
because you are adjusting drawings underneath timed motion; a correction owned
by an occurrence travels with it. Ownership does that work, so no key needs a
flag saying what it follows. Ripple past the symbol's end is refused with the
frame count it would need, and `:extent :grow-symbol` is the caller saying yes.
`clip/blank` no longer carries `:subjects {} :features {} :groups {}`. Empty
maps write no leaf, so a blank document could not survive its own round trip —
`leaf/leaves` promises exactness and was the only honest side of that.
Documents are schema 3. A version 2 document is not read; nothing here converts
one. `docs/lane-model.md` is the design, and says which of its parts are built.
392 tests, 5,525 assertions, and `test/browser/sequence.mjs` drives the editor
through create, hold, explicit overflow and undo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 15:20:23 -04:00
// Local editor smoke test. Uses the in-memory blank document and disables the
// project route, so it never creates an account, project, or server-side write.
import { spawn } from 'node:child_process' ;
import { mkdtempSync , rmSync } from 'node:fs' ;
import { tmpdir } from 'node:os' ;
import { join } from 'node:path' ;
import assert from 'node:assert/strict' ;
const url = process . env . ARTHUR _URL ? ? 'http://localhost:8778/' ;
const profile = mkdtempSync ( join ( tmpdir ( ) , 'arthur-sequence-' ) ) ;
const port = 9335 ;
const chrome = spawn ( process . env . CHROME ? ? '/usr/bin/chromium' , [
'--headless=new' , '--no-sandbox' , '--disable-gpu' , '--no-first-run' ,
'--no-default-browser-check' , '--mute-audio' , '--window-size=1440,1000' ,
` --user-data-dir= ${ profile } ` , ` --remote-debugging-port= ${ port } ` , url ,
] , { stdio : 'ignore' } ) ;
const sleep = ms => new Promise ( resolve => setTimeout ( resolve , ms ) ) ;
let ws ;
try {
let target ;
for ( let i = 0 ; i < 100 && ! target ; i ++ ) {
await sleep ( 100 ) ;
try {
target = ( await fetch ( ` http://127.0.0.1: ${ port } /json/list ` ) . then ( r => r . json ( ) ) )
. find ( t => t . type === 'page' && t . url . startsWith ( url ) ) ;
} catch { /* browser starting */ }
}
assert ( target , 'browser exposes the editor page' ) ;
ws = new WebSocket ( target . webSocketDebuggerUrl ) ;
await new Promise ( ( resolve , reject ) => { ws . onopen = resolve ; ws . onerror = reject ; } ) ;
let serial = 0 ;
const pending = new Map ( ) ;
const errors = [ ] ;
ws . onmessage = ( { data } ) => {
const msg = JSON . parse ( data ) ;
if ( msg . method === 'Runtime.exceptionThrown' ) errors . push ( msg . params . exceptionDetails ) ;
if ( msg . id && pending . has ( msg . id ) ) {
const { resolve , reject } = pending . get ( msg . id ) ;
pending . delete ( msg . id ) ;
if ( msg . error ) reject ( new Error ( JSON . stringify ( msg . error ) ) ) ;
else resolve ( msg . result ) ;
}
} ;
const send = ( method , params = { } ) => new Promise ( ( resolve , reject ) => {
const id = ++ serial ;
pending . set ( id , { resolve , reject } ) ;
ws . send ( JSON . stringify ( { id , method , params } ) ) ;
} ) ;
const evaluate = async expression => {
const r = await send ( 'Runtime.evaluate' , { expression , returnByValue : true , awaitPromise : true } ) ;
if ( r . exceptionDetails ) throw new Error ( JSON . stringify ( r . exceptionDetails ) ) ;
return r . result . value ;
} ;
await send ( 'Runtime.enable' ) ;
for ( let i = 0 ; i < 100 ; i ++ ) {
if ( await evaluate ( 'typeof arthur !== "undefined" && !!arthur.events?.ui && !!document.querySelector("canvas.stage")' ) ) break ;
await sleep ( 100 ) ;
}
await evaluate ( ` (() => {
const k = cljs . core . keyword ;
cljs . core . swap _BANG _ ( re _frame . db . app _db , db => cljs . core . assoc ( db , k ( 'route' ) , k ( 'local-test' ) ) ) ;
window . sequenceSnapshot = ( ) => {
const db = cljs . core . deref ( re _frame . db . app _db ) ;
const entry = arthur . footage . store . entry ( cljs . core . get ( db , k ( 'clip/current' ) ) ) ;
return cljs . core . clj _ _GT _js ( entry ) ;
} ;
return true ;
} ) ( ) ` );
await sleep ( 250 ) ;
const click = async label => {
assert ( await evaluate ( ` (() => {
const b = [ ... document . querySelectorAll ( 'button' ) ] . find ( b => b . textContent . trim ( ) === $ { JSON . stringify ( label ) } ) ;
if ( ! b || b . disabled ) return false ;
b . click ( ) ; return true ;
} ) ( ) ` ), ` enabled button : $ { label } ` );
await sleep ( 180 ) ;
} ;
const shot = async ( ) => ( await evaluate ( 'sequenceSnapshot()' ) ) ;
const instances = s => Object . values ( s . clip . symbols . main . nodes ) . filter ( n => n . kind === 'instance' )
. sort ( ( a , b ) => a . time . at - b . time . at ) ;
await click ( '+ lane' ) ;
await click ( 'new drawing' ) ;
await click ( 'hold +' ) ;
await click ( 'hold +' ) ;
await click ( 'hold +' ) ;
await click ( 'new drawing' ) ;
let s = await shot ( ) ;
assert . deepEqual ( instances ( s ) . map ( n => [ n . time . at , n . span [ 1 ] ] ) , [ [ 0 , 4 ] , [ 4 , 1 ] ] ) ;
assert . equal ( await evaluate ( 'document.querySelectorAll(".tl-cel").length' ) , 2 ) ;
assert . equal ( await evaluate ( 'document.querySelectorAll(".tl-label:not(.tl-corner)").length' ) , 1 ) ;
assert . equal ( await evaluate ( ` cljs.core.get_in(cljs.core.deref(re_frame.db.app_db),
cljs . core . vector ( cljs . core . keyword ( 'playback' ) , cljs . core . keyword ( 'frame' ) ) ) ` ), 4,
'new drawing seeks to its occurrence' ) ;
// Shorten this test shot to the occupied extent, purely in memory.
await evaluate ( ` (() => {
const k = cljs . core . keyword , db = cljs . core . deref ( re _frame . db . app _db ) ;
arthur . footage . store . edit _clip _BANG _ ( cljs . core . get ( db , k ( 'clip/current' ) ) ,
clip => cljs . core . assoc _in ( clip , cljs . core . vector ( k ( 'symbols' ) , k ( 'main' ) , k ( 'frames' ) ) , 5 ) ) ;
document . querySelector ( '.tl-cel' ) . click ( ) ;
} ) ( ) ` );
await sleep ( 200 ) ;
const before = await shot ( ) ;
await click ( 'hold +' ) ;
s = await shot ( ) ;
assert . deepEqual ( s . clip , before . clip , 'refused overflow makes no document change' ) ;
assert . equal ( s . history . done . length , before . history . done . length ) ;
await click ( 'extend shot and apply' ) ;
s = await shot ( ) ;
assert . equal ( s . clip . symbols . main . frames , 6 ) ;
assert . deepEqual ( instances ( s ) . map ( n => [ n . time . at , n . span [ 1 ] ] ) , [ [ 0 , 5 ] , [ 5 , 1 ] ] ) ;
assert . equal ( s . history . done . length , before . history . done . length + 1 ) ;
await evaluate ( ` document.dispatchEvent(new KeyboardEvent('keydown', {key:'z', ctrlKey:true, bubbles:true})) ` ) ;
await sleep ( 250 ) ;
assert . deepEqual ( ( await shot ( ) ) . clip , before . clip , 'one undo restores exposure, ripple, and shot length' ) ;
Reuse, duplicate and make unique: deciding what is shared
The model's whole claim is that content and its occurrences are different
things, and until now nothing in the editor could tell them apart: you could
make a drawing and time it, but not expose one drawing twice, and so never find
out whether an edit arrives in two places. That is the first proof obligation in
the lane model and it was the one the commands could not reach.
Three commands, and the distinctions between them are the point:
reuse another occurrence of the same drawing. A decision to share,
made on purpose, because sharing discovered later — when an edit
turns up somewhere you did not expect — is the bad version.
duplicate a copy of the drawing, appended, for when what is on screen is
the starting point for the next one.
make unique this occurrence gets a private copy; the others keep sharing.
The undo of reuse, and refused when nothing else uses the
drawing: a copy nobody asked for is a second identical symbol in
the library for no reason a person could see.
Duplicate copies the CONTENT and not the exposure. Its new occurrence is a plain
one-frame hold, not a copy of the source occurrence's transform or corrections,
because those belong to that use of the drawing — carrying them over would make
duplicating a drawing quietly duplicate the treatment of one exposure of it.
A copy is SHALLOW by default and keeps its references to other symbols, so a
head built out of reusable eyes still uses those eyes. `:deep? true` copies
everything it places with new ids throughout. The lane model asks for both and
says why: never promise decoupling while leaving the edited object shared, and
only the deep copy can keep that promise. `bring/symbols` already did the
reachability walk and the id remapping, so the deep copy is that function
pointed at its own clip.
`node/sources` was still being read as a SET at five call sites, each with a
comment about a lane that cuts between several drawings — the keyed source that
no longer exists. An occurrence names one symbol, so they now ask `node/source`,
and `placed-frame` answers with `:symbol` rather than `:of`, which was the last
echo of the retired field name.
To let the commands use `clip/free-id` and the copy machinery, the lane's own
validation moved from `domain/sequence` to `domain/symbol`, which is where it
belonged anyway: a sequence is the one composition rule a node map carries, and
it now sits beside the parent and stencil checks rather than in the namespace
that happens to build lanes. That also breaks the cycle — sequence can require
clip and bring, and nothing below it requires sequence. Preconditions still
check only the LANE's shape: refusing an exposure edit over an unrelated defect
elsewhere in the symbol would be this command answering for a part of the
document it never touches.
The cel strip gains reuse, duplicate and make unique, the last shown only where
the selected exposure actually shares its drawing. Drawing on twos is also now
under test: exposure length is the cadence, the lane's transform has its own
clock, and it still moves on every frame — stepping it would be the cel cadence
leaking into continuous motion.
397 tests, 5,561 assertions. `test/browser/sequence.mjs` drives the three new
commands through the real editor and checks that three exposures are still one
row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 15:30:59 -04:00
// Sharing: one drawing exposed twice, then one exposure decoupled. Room is
// made first so these assertions are about content and not about overflow.
await evaluate ( ` (() => {
const k = cljs . core . keyword , db = cljs . core . deref ( re _frame . db . app _db ) ;
arthur . footage . store . edit _clip _BANG _ ( cljs . core . get ( db , k ( 'clip/current' ) ) ,
clip => cljs . core . assoc _in ( clip , cljs . core . vector ( k ( 'symbols' ) , k ( 'main' ) , k ( 'frames' ) ) , 20 ) ) ;
document . querySelector ( '.tl-cel' ) . click ( ) ;
} ) ( ) ` );
await sleep ( 200 ) ;
const enabled = async label => await evaluate ( ` (() => {
const b = [ ... document . querySelectorAll ( 'button' ) ] . find ( b => b . textContent . trim ( ) === $ { JSON . stringify ( label ) } ) ;
return ! ! b && ! b . disabled ;
} ) ( ) ` );
assert . equal ( await enabled ( 'make unique' ) , false , 'nothing to decouple from yet' ) ;
await click ( 'reuse' ) ;
s = await shot ( ) ;
let cels = instances ( s ) ;
assert . equal ( cels . length , 3 ) ;
assert . equal ( cels [ 2 ] . source . symbol , cels [ 0 ] . source . symbol , 'reuse exposes the same drawing' ) ;
assert . equal ( await evaluate ( 'document.querySelectorAll(".tl-cel").length' ) , 3 ) ;
assert . equal ( await evaluate ( 'document.querySelectorAll(".tl-label:not(.tl-corner)").length' ) , 1 ,
'three exposures, still one row' ) ;
assert . equal ( await enabled ( 'make unique' ) , true ) ;
await click ( 'make unique' ) ;
s = await shot ( ) ;
cels = instances ( s ) ;
assert . notEqual ( cels [ 2 ] . source . symbol , cels [ 0 ] . source . symbol , 'that exposure has its own drawing' ) ;
assert . equal ( await enabled ( 'make unique' ) , false , 'and is not shared any more' ) ;
await click ( 'duplicate' ) ;
s = await shot ( ) ;
cels = instances ( s ) ;
assert . equal ( cels . length , 4 ) ;
assert . equal ( new Set ( cels . map ( n => n . source . symbol ) ) . size , 4 ,
'four exposures of four drawings: nothing is shared once every copy is made' ) ;
assert . equal ( s . history . done . length , before . history . done . length + 3 , 'three more commands, three more steps' ) ;
A position is an argument, not another command
Everything could only be added to the end, because `append` computed its own
position — the max end of the lane — and so had no opinion to state. Insert is
not a new command; it is the argument that function was missing. `:at` takes a
lane frame or `:end`, `:end` is the position where nothing has to move, and
appending stops being a separate operation from inserting. New, reused and
duplicated drawings all take it, because there was only ever one placement rule.
Placing ripples: occurrences at or after the position move later by the new
exposure's duration, and `:keep` against `:grow-symbol` still decides what
happens at the shot's end. OVERWRITE is deliberately not a policy argument yet.
Taking frames away from the occurrence already there is TRIMMING, and an
argument whose second value is unimplemented is worse than an argument that is
not there. A position strictly inside an existing exposure refuses and names
`split`, rather than splitting on the quiet: one command performing two is how
a command stops being predictable.
Then split, which turned out to cost almost nothing, and that is the
interesting part. The two pieces keep ONE `:time` and differ only in `:span`.
The right piece's own frames therefore carry on exactly where the left's
stopped, so its source clock, its keys and its corrections go on meaning what
they meant: a held drawing holds the same frame either side of the cut, and a
playing insert plays through it without a seam. There is no arithmetic on
in-points to get wrong, and no shot-length question, since the pieces occupy
the frames the one exposure occupied. The test samples every frame before and
after and asserts the picture is identical — for a hold, for an exposure with a
correction of its own, and for a playing insert.
That is not a clever split. It is `:span` being in the node's OWN coordinates,
which was decided long before there were lanes, paying for something it was not
designed for. The same property is why extending a hold leaves lane keys alone.
Both new commands act at the playhead, which needed `lane-frame` — the symbol's
frame as a frame of the lane's own time, nil through a stepped or looping lane
where one is not the other. Nil refuses; it does not snap to a nearby frame.
Two smaller things found while doing it. `placeable` promised "a whole lane
frame" in its refusal and then accepted 2.5, so both it and `split` now require
an integer, as `extend-hold` already did for its delta. And `lane-end` is
private: `:end` is the only way to ask for it.
401 tests, 5,612 assertions. The browser flow now splits an exposure at the
playhead and puts a drawing in the gap, and checks that six exposures are still
one row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 15:56:26 -04:00
// A drawing into the middle of a hold: split, then insert. Both act at the
// playhead, and neither guesses what the other one is for.
const placed = s => instances ( s )
. map ( n => [ n . time . at + n . span [ 0 ] / ( n . time . rate ? ? 1 ) , n . time . at + n . span [ 1 ] / ( n . time . rate ? ? 1 ) ] )
. sort ( ( a , b ) => a [ 0 ] - b [ 0 ] ) ;
assert . deepEqual ( placed ( s ) , [ [ 0 , 4 ] , [ 4 , 5 ] , [ 5 , 6 ] , [ 6 , 7 ] ] ) ;
await evaluate ( ` document.querySelector('.tl-cel').click() ` ) ;
await sleep ( 200 ) ;
assert . equal ( await enabled ( 'split' ) , false , 'the start of an exposure is not inside it' ) ;
await click ( '+1' ) ;
await click ( '+1' ) ;
assert . equal ( await enabled ( 'split' ) , true ) ;
await click ( 'split' ) ;
s = await shot ( ) ;
assert . deepEqual ( placed ( s ) , [ [ 0 , 2 ] , [ 2 , 4 ] , [ 4 , 5 ] , [ 5 , 6 ] , [ 6 , 7 ] ] ,
'one exposure became two, over the frames it had' ) ;
await click ( 'insert' ) ;
s = await shot ( ) ;
assert . deepEqual ( placed ( s ) , [ [ 0 , 2 ] , [ 2 , 3 ] , [ 3 , 5 ] , [ 5 , 6 ] , [ 6 , 7 ] , [ 7 , 8 ] ] ,
'the new drawing took frame 2 and everything from there rippled later' ) ;
assert . equal ( await evaluate ( 'document.querySelectorAll(".tl-cel").length' ) , 6 ) ;
assert . equal ( await evaluate ( 'document.querySelectorAll(".tl-label:not(.tl-corner)").length' ) , 1 ,
'six exposures, still one row' ) ;
assert . equal ( s . history . done . length , before . history . done . length + 5 ) ;
An occurrence is a node, with a clock of its own
A lane's drawings were going to be one instance whose source was a KEYED
channel: frame 0 says `:drawing-a`, frame 4 says `:drawing-b`, and the cels of
a row are that channel's keys. Two things followed from it, and both were
wrong.
The first is that playback meant whichever shape the channel happened to have.
A framed source played its symbol; a keyed source froze the selected frame.
So `node/placed-at` read animation out of storage, and adding an ordinary key
to a still turned it into an animation — the last-key bug, which was not a bug
in the code so much as the rule working as written. But WHICH drawing is used
and HOW time runs inside it are independent questions, and all four combinations
are ordinary: hold one drawing, play one animation, cut between held drawings,
cut between playing ones.
So an occurrence names one symbol in `:source {:symbol ...}` and says how its
source time advances in `:playback {:in :speed :end}` — `source = in + speed *
f`, a hold being speed 0, with `:stop`, `:hold` or `:loop` at the end named
rather than guessed. `node/placed-frame` samples it forwards, which works for
holds too, and `node/source-time` is the separate, invertible edit map, nil
where inversion is meaningless. The two were one function before, and a hold
had to lie about one of them.
The second is that a keyed source only looked necessary because an occurrence
was assumed to need a ROW. It does not. A lane is a group with `:layout
:sequence`, its occurrences are ordinary instances in the same flat node map,
and `timeline/rows` draws them as cel blocks on the lane's own row: twelve
exposures, one row, each cel still separately selectable and addressable. The
vertical growth that justified the keyed source is a presentation question, and
it is answered in the view.
`arthur.domain.sequence` holds the first commands over that shape — add lane,
append drawing, extend hold — each one history step, each refusing rather than
half-applying. Extending a hold leaves the lane's keys at their authored times,
because you are adjusting drawings underneath timed motion; a correction owned
by an occurrence travels with it. Ownership does that work, so no key needs a
flag saying what it follows. Ripple past the symbol's end is refused with the
frame count it would need, and `:extent :grow-symbol` is the caller saying yes.
`clip/blank` no longer carries `:subjects {} :features {} :groups {}`. Empty
maps write no leaf, so a blank document could not survive its own round trip —
`leaf/leaves` promises exactness and was the only honest side of that.
Documents are schema 3. A version 2 document is not read; nothing here converts
one. `docs/lane-model.md` is the design, and says which of its parts are built.
392 tests, 5,525 assertions, and `test/browser/sequence.mjs` drives the editor
through create, hold, explicit overflow and undo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 15:20:23 -04:00
assert . equal ( errors . length , 0 , JSON . stringify ( errors ) ) ;
A position is an argument, not another command
Everything could only be added to the end, because `append` computed its own
position — the max end of the lane — and so had no opinion to state. Insert is
not a new command; it is the argument that function was missing. `:at` takes a
lane frame or `:end`, `:end` is the position where nothing has to move, and
appending stops being a separate operation from inserting. New, reused and
duplicated drawings all take it, because there was only ever one placement rule.
Placing ripples: occurrences at or after the position move later by the new
exposure's duration, and `:keep` against `:grow-symbol` still decides what
happens at the shot's end. OVERWRITE is deliberately not a policy argument yet.
Taking frames away from the occurrence already there is TRIMMING, and an
argument whose second value is unimplemented is worse than an argument that is
not there. A position strictly inside an existing exposure refuses and names
`split`, rather than splitting on the quiet: one command performing two is how
a command stops being predictable.
Then split, which turned out to cost almost nothing, and that is the
interesting part. The two pieces keep ONE `:time` and differ only in `:span`.
The right piece's own frames therefore carry on exactly where the left's
stopped, so its source clock, its keys and its corrections go on meaning what
they meant: a held drawing holds the same frame either side of the cut, and a
playing insert plays through it without a seam. There is no arithmetic on
in-points to get wrong, and no shot-length question, since the pieces occupy
the frames the one exposure occupied. The test samples every frame before and
after and asserts the picture is identical — for a hold, for an exposure with a
correction of its own, and for a playing insert.
That is not a clever split. It is `:span` being in the node's OWN coordinates,
which was decided long before there were lanes, paying for something it was not
designed for. The same property is why extending a hold leaves lane keys alone.
Both new commands act at the playhead, which needed `lane-frame` — the symbol's
frame as a frame of the lane's own time, nil through a stepped or looping lane
where one is not the other. Nil refuses; it does not snap to a nearby frame.
Two smaller things found while doing it. `placeable` promised "a whole lane
frame" in its refusal and then accepted 2.5, so both it and `split` now require
an integer, as `extend-hold` already did for its delta. And `lane-end` is
private: `:end` is the only way to ask for it.
401 tests, 5,612 assertions. The browser flow now splits an exposure at the
playhead and puts a drawing in the gap, and checks that six exposures are still
one row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 15:56:26 -04:00
console . log ( 'PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo, reuse/make unique/duplicate, split and insert; no server writes' ) ;
An occurrence is a node, with a clock of its own
A lane's drawings were going to be one instance whose source was a KEYED
channel: frame 0 says `:drawing-a`, frame 4 says `:drawing-b`, and the cels of
a row are that channel's keys. Two things followed from it, and both were
wrong.
The first is that playback meant whichever shape the channel happened to have.
A framed source played its symbol; a keyed source froze the selected frame.
So `node/placed-at` read animation out of storage, and adding an ordinary key
to a still turned it into an animation — the last-key bug, which was not a bug
in the code so much as the rule working as written. But WHICH drawing is used
and HOW time runs inside it are independent questions, and all four combinations
are ordinary: hold one drawing, play one animation, cut between held drawings,
cut between playing ones.
So an occurrence names one symbol in `:source {:symbol ...}` and says how its
source time advances in `:playback {:in :speed :end}` — `source = in + speed *
f`, a hold being speed 0, with `:stop`, `:hold` or `:loop` at the end named
rather than guessed. `node/placed-frame` samples it forwards, which works for
holds too, and `node/source-time` is the separate, invertible edit map, nil
where inversion is meaningless. The two were one function before, and a hold
had to lie about one of them.
The second is that a keyed source only looked necessary because an occurrence
was assumed to need a ROW. It does not. A lane is a group with `:layout
:sequence`, its occurrences are ordinary instances in the same flat node map,
and `timeline/rows` draws them as cel blocks on the lane's own row: twelve
exposures, one row, each cel still separately selectable and addressable. The
vertical growth that justified the keyed source is a presentation question, and
it is answered in the view.
`arthur.domain.sequence` holds the first commands over that shape — add lane,
append drawing, extend hold — each one history step, each refusing rather than
half-applying. Extending a hold leaves the lane's keys at their authored times,
because you are adjusting drawings underneath timed motion; a correction owned
by an occurrence travels with it. Ownership does that work, so no key needs a
flag saying what it follows. Ripple past the symbol's end is refused with the
frame count it would need, and `:extent :grow-symbol` is the caller saying yes.
`clip/blank` no longer carries `:subjects {} :features {} :groups {}`. Empty
maps write no leaf, so a blank document could not survive its own round trip —
`leaf/leaves` promises exactness and was the only honest side of that.
Documents are schema 3. A version 2 document is not read; nothing here converts
one. `docs/lane-model.md` is the design, and says which of its parts are built.
392 tests, 5,525 assertions, and `test/browser/sequence.mjs` drives the editor
through create, hold, explicit overflow and undo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 15:20:23 -04:00
} finally {
if ( ws ? . readyState === WebSocket . OPEN ) {
ws . send ( JSON . stringify ( { id : 999999 , method : 'Browser.close' } ) ) ;
await sleep ( 350 ) ;
}
ws ? . close ( ) ;
chrome . kill ( ) ;
await new Promise ( resolve => { if ( chrome . exitCode !== null || chrome . signalCode !== null ) resolve ( ) ; else chrome . once ( 'exit' , resolve ) ; } ) ;
try {
rmSync ( profile , { recursive : true , force : true , maxRetries : 5 , retryDelay : 100 } ) ;
} catch ( error ) {
console . warn ( ` Temporary browser profile retained at ${ profile } : ${ error . code } ` ) ;
}
}