arthur/frontend/test/browser/lane.mjs

403 lines
19 KiB
JavaScript
Raw Normal View History

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')));
One word for one thing: it is a cel Four words had accumulated for a node that puts a symbol inside another symbol. `instance` was the document's, from the model. `placement` was the stage and export work's. `occurrence` came in with the lane model. `exposure` came in with me, because it is what an animator would say. Three bodies of work each brought a word and none of them retired anybody else's, which is how you get a codebase that reads like three people describing the same object over each other. It is a CEL. One drawing, held for some duration. `cel` was already the view's word — `.tl-cel`, the cel strip — so choosing it was also the smallest change, and the app already says "drawing" for the content, which is what frees the word up: historically a cel IS the celluloid with the drawing on it, and that sense has somewhere else to live here. instance the `:kind`. The general thing, anywhere in a document. cel an instance in a lane. UI labels, command names, prose. lane the group with `:layout :sequence`. drawing the content a cel names. placement kept ONLY for where a node sits — `nest/placement` and the transform that puts a face on the stage. Retired as a noun for the node itself. occurrence gone. AND IT SETTLES A COLLISION I SHOULD HAVE SEEN EARLIER. `:time :expose` already existed and means something else entirely: how many frames each step of a subtree lasts, which is what shooting on twos is. Had the block been called an exposure too, `node/expose`, `clock/exposed-frame` and `subs/render ::exposure` would have been permanently confusable with it. Choosing `cel` lets the word `exposure` keep the thing it actually names, and every remaining use of it in `src` is now that one. `:layout :sequence` stays as the field, and it is the one place two words are kept deliberately: the layout names the RULE — children follow one another and may not overlap — and a group carrying it is called a lane. `node/lane?` says so where the two meet. The second view is traditionally the exposure sheet. It will be the CEL SHEET, for one vocabulary. Renamed with a script and then read, because a blind pass does real damage: it produced "an cel" thirty times, renamed the `::exposure` sub that is about the `:expose` grid, and turned an "exposure grid" into a "cel grid" in two docstrings. All three classes are fixed. `arthur.domain.sequence` is now `arthur.domain.lane`, which is what its test file was already called. 424 tests, 5,749 assertions, and both browser flows — `test/browser/lane.mjs`, renamed too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 19:43:18 -04:00
window.laneSnapshot = () => {
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
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);
2026-10-01 00:11:16 -04:00
// A command is named the same wherever it is drawn, and since the transport
// strip was consolidated it is drawn in one of two places: as a button in the
// strip, or as a row in one of the strip's menus. So the test asks for it by
// name and this finds it — opening each menu in turn to look — rather than the
// test knowing which menu anything ended up in. An icon button is matched on
// its `aria-label`, which is also what a screen reader is told it is.
2026-10-01 00:27:01 -04:00
// Two bars carry commands: the location bar says where an edit lands and holds
// what creates things there, the transport strip holds what acts on a cel.
2026-10-01 01:21:55 -04:00
const bars = ['.loc', '.pane.time .pane-head', '.section'];
2026-10-01 00:27:01 -04:00
const within = (suffix) => bars.map((b) => `${b} ${suffix}`).join(', ');
2026-10-01 00:11:16 -04:00
const named = label =>
`(b => b.textContent.trim() === ${JSON.stringify(label)}` +
` || b.getAttribute('aria-label') === ${JSON.stringify(label)})`;
const shut = async () => {
await evaluate(`(() => { document.querySelectorAll('.menu-scrim').forEach(s => s.click()); return true })()`);
await sleep(120);
};
// Leaves the control on screen and returns what to select it with.
const reveal = async label => {
await shut();
2026-10-01 00:27:01 -04:00
if (await evaluate(`![...document.querySelectorAll('${within('button')}')].find(${named(label)})`)) {
2026-10-01 00:11:16 -04:00
const menus = await evaluate(
2026-10-01 00:27:01 -04:00
`[...document.querySelectorAll('${within('.menu-wrap > button')}')].map(b => b.textContent.trim())`);
2026-10-01 00:11:16 -04:00
let found = false;
for (const menu of menus) {
2026-10-01 00:27:01 -04:00
await evaluate(`(() => { [...document.querySelectorAll('${within('.menu-wrap > button')}')]
2026-10-01 00:11:16 -04:00
.find(b => b.textContent.trim() === ${JSON.stringify(menu)}).click(); return true })()`);
await sleep(180);
if (await evaluate(`!![...document.querySelectorAll('.menu-item')].find(${named(label)})`)) { found = true; break; }
await shut();
}
assert(found, `a control named: ${label}`);
return '.menu-item';
}
2026-10-01 00:27:01 -04:00
return within('button');
2026-10-01 00:11:16 -04:00
};
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
const click = async label => {
2026-10-01 00:11:16 -04:00
const where = await reveal(label);
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(await evaluate(`(() => {
2026-10-01 00:11:16 -04:00
const b = [...document.querySelectorAll('${where}')].find(${named(label)});
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
if (!b || b.disabled) return false;
b.click(); return true;
2026-10-01 00:11:16 -04:00
})()`), `enabled control: ${label}`);
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
await sleep(180);
2026-10-01 00:11:16 -04:00
await shut();
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
};
2026-10-01 01:21:55 -04:00
// UUIDs expose a mutable hash cache through clj->js; compare their identity,
// not that implementation detail, when asserting exact undo restoration.
const shot = async () => JSON.parse(JSON.stringify(await evaluate('laneSnapshot()'),
(_key, value) => value?.uuid ?? value));
2026-10-01 14:37:59 -04:00
const instances = s => Object.values(s.clip.symbols.main.nodes)
.filter(n => n.kind === 'instance')
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
.sort((a, b) => a.time.at - b.time.at);
2026-10-01 14:37:59 -04:00
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),
]);
const key = async (key, extra = {}) => {
await send('Input.dispatchKeyEvent', {type: 'keyDown', key, ...extra});
await send('Input.dispatchKeyEvent', {type: 'keyUp', key, ...extra});
await sleep(180);
};
const undo = () => key('z', {modifiers: 2});
const drag = async (selector, df, {zone = 0.5, shift = false} = {}) => {
const points = await evaluate(`(() => {
const handle = document.querySelector(${JSON.stringify(selector)});
if (!handle) return null;
const track = handle.closest('.tl-track');
const h = handle.getBoundingClientRect();
const t = track.getBoundingClientRect();
const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]);
const x = h.left + h.width * ${zone};
const y = h.top + h.height / 2;
return {x, y, end: x + t.width * ${df} / frames};
})()`);
assert(points, `drag handle exists: ${selector}`);
const modifiers = shift ? 8 : 0;
await send('Input.dispatchMouseEvent', {
type: 'mousePressed', x: points.x, y: points.y,
button: 'left', buttons: 1, clickCount: 1, modifiers,
});
await sleep(100);
await send('Input.dispatchMouseEvent', {
type: 'mouseMoved', x: points.end, y: points.y,
button: 'left', buttons: 1, modifiers,
});
await sleep(100);
await send('Input.dispatchMouseEvent', {
type: 'mouseReleased', x: points.end, y: points.y,
button: 'left', buttons: 0, clickCount: 1, modifiers,
});
await sleep(250);
};
The timeline opens the whole document Making a lane one row cost the thing a row was for. A clip stopped being a row, so there was no longer any way to open a clip and see what was inside it, and the inside of a drawing — the most ordinary thing in the document — became reachable only by opening it as its own tab. This is that capability back, from the root timeline, down as far as it goes. An expanded lane opens exactly ONE clip: the selected one. Its own keys, then the lanes and nodes of the symbol it places, then theirs, each mapped into this ruler by the recursive walk that was already there. Twelve clips in a lane still cost one row, and inspection costs one branch rather than twelve. Two things that only showed up once it ran. The portal is chosen by the whole LINEAGE of the selection and not by the selected id: selecting a shape inside the clip — or the end of its span — is still working inside that clip, and matching the id alone shut the portal the instant anything under it was touched. And selecting now waits for the pointer to come UP, because selecting on the way down re-drew the timeline before the gesture had said anything: it shut the portal holding the lane being dragged INTO, out from under the pointer. A HELD clip opens too, which the old row walk never did either. `source-time` is nil for a hold, so the walk stopped there and the contents of every drawing were invisible from here. Its rows are shown across the hold — which is when the node is on screen — and marked `:unmapped?`: no keys, and no draggable edges, because a frozen clock gives no frame inside it a place on this ruler. Refusing to place the keys is the honest half; refusing to show the rows was not. Double-clicking a clip opens the symbol it places as a tab, as double-clicking the same symbol in the pool does. That was already written and had never once run: the track captures the pointer for a slide, so the click and double-click that follow are delivered to the track and never to the block. The track now resolves them itself. Fixing the delivery exposed two more: `symbol/lineage` reported a `parent cycle` for any id in a symbol with NO nodes, because a one-element chain is longer than zero nodes — and opening a symbol left the selection pointing into the symbol being left, which the breadcrumb and the inspector then tried to resolve. The editor unmounted. Both are fixed where they were wrong, and the browser test asserts the editor is still standing afterwards. Audio is a clip in a lane like everything else. A dropped sound lands in one and is trimmed and moved by the same commands; a lane holds picture or sound and not both, which is the explicit capability the model asked for rather than a guess per frame. The refusal lives in the commands and not only in validation, because placement claims time: `blank` would have deleted the sound to make room for the picture and left a perfectly valid document behind. What is in a lane of the open symbol is drawn as a lane; what is nested inside a placed symbol is still flattened by `audio-tracks`, so no sound is on two rows. Everything that enters the timeline now enters a lane: a converted take, a symbol brought in from another project, a sound. One rule answers where — `lane-destination` — and every symbol is born with a lane for it to answer with. An unaimed drop fills an EMPTY lane rather than taking an occupied one nobody pointed at, because the alternative is trimming away what was there to make room for what was dropped. Shift during a clip-body drag means the other intention: put this node INSIDE the symbol the clip under the pointer places, through `nest/move-node`, which is what keeps the world transform and the root timing. Overlap cannot say which of the two is meant — dropping on occupied time already means claiming it — so the person says, and a label by the pointer says it back. The label asks `nest/move-refusal`, the same check the command makes, so it cannot promise what the drop would refuse. Today it refuses more than it allows: both clips have to be on screen at one frame, which two clips in one lane never are, and a held destination has no clock to move through at all. `docs/lane-nesting-notes.md` argues that the second refusal is stronger than the facts require and says what would settle it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 16:13:02 -04:00
const tabs = () => evaluate(`(() => {
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
return {tabs: cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('tabs')])).map(String),
open: String(cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('open')])))};
})()`);
// A real two-press double-click, not `.dispatchEvent`: what broke here was
// where the browser decides to deliver the click, which a synthetic event
// cannot show.
const doubleClick = async selector => {
const p = await evaluate(`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return null;
const r = el.getBoundingClientRect();
return {x: r.left + r.width / 2, y: r.top + r.height / 2};
})()`);
assert(p, `something to double-click: ${selector}`);
for (const clickCount of [1, 2]) {
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: p.x, y: p.y,
button: 'left', buttons: 1, clickCount});
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: p.x, y: p.y,
button: 'left', buttons: 0, clickCount});
await sleep(60);
}
await sleep(280);
};
A lane is a generic row of symbol clips A lane was a drawing lane: the only thing that could go in one was a one-frame held cel, and every other symbol instance stayed a permanent root row of its own. Those are not two kinds of timing, they are one kind with two creation policies. `lane/place-symbol` drops any library symbol in as a clip that plays naturally at speed one, `lane/adopt` moves an instance that is already in the document into a lane keeping its source, span, playback and corrections, and `append-drawing`/`overwrite-drawing` keep being the policy that makes a new empty symbol a one-frame hold. The child shape they produce is the same. Both new commands claim their interval through `blank` before they write, so the partition rule is unchanged and unduplicated: placing into occupied lane time trims, removes or splits the incumbents, and a lane still never stores an overlap. Real compositing overlap is another lane, where the order is explicit. Creating a symbol with nothing aimed now makes a lane and a clip in it instead of a loose root instance, and a pool drop prefers an explicitly targeted lane, then the selected one, and makes a lane only when there is neither. That is what stops the row-per-symbol growth coming back in through the drop path, and it is why `add-lane` now takes a z in front of the existing root nodes and calls what it makes a "lane" rather than "drawings". The timeline learned the two gestures that a generic lane needs. A clip body dragged over another lane's track previews there as a dashed block and lands through `::adopt-in-lane`; the track is found with `elementsFromPoint` and its selection read back off the element, because a pointer capture does not retarget. A pool drop over an existing lane previews as a dashed clip inside that lane instead of a temporary new row that appears and then vanishes -- which also needed the drag-leave check to be geometric, since inserting the preview changes the element under the pointer and Chromium then reports a leave with no related target. Lanes are renameable from their label, by double-click, F2, or the pencil, through `::rename-node`. `symbol/lane-cels` is `symbol/lane-clips`, and the vocabulary table in the handoff now separates the two words it had merged: a clip is an instance in a lane, and a cel is specifically the one-frame held source that drawing creation makes. Keeping `cel` for the policy is what lets the lane stop being about drawings at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 15:13:07 -04:00
const dropPoolSymbol = async frame => {
const points = await evaluate(`(() => {
const source = document.querySelector('.pool-row:not(.main) .pool-item[draggable="true"]');
const track = document.querySelector('.tl-track');
if (!source || !track) return null;
source.scrollIntoView({block: 'center'});
const a = source.getBoundingClientRect(), b = track.getBoundingClientRect();
const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]);
return {sx: a.left + a.width / 2, sy: a.top + a.height / 2,
tx: b.left + b.width * (${frame} + 0.25) / frames,
ty: b.top + b.height / 2};
})()`);
assert(points, 'a library symbol and lane are available to drag');
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.sx, y: points.sy});
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: points.sx, y: points.sy,
button: 'left', buttons: 1, clickCount: 1});
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.sx + 12, y: points.sy,
button: 'left', buttons: 1});
await sleep(120);
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.tx, y: points.ty,
button: 'left', buttons: 1});
await sleep(120);
assert.equal(await evaluate('document.querySelectorAll(".tl-label.ghost").length'), 0,
'targeting an existing lane does not preview a temporary new row');
const lanePreview = await evaluate(`(() => {
const db = cljs.core.deref(re_frame.db.app_db), k = cljs.core.keyword;
return {ghosts: document.querySelectorAll('.tl-track .tl-cel.ghost').length,
drop: cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('drop')]))};
})()`);
assert.equal(lanePreview.ghosts, 1,
`the pool drop preview is drawn inside the targeted lane: ${JSON.stringify(lanePreview)}`);
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: points.tx, y: points.ty,
button: 'left', buttons: 0, clickCount: 1});
await sleep(300);
};
const dragClipBetweenLanes = async frame => {
const points = await evaluate(`(() => {
const tracks = [...document.querySelectorAll('.tl-track')];
const source = tracks[1]?.querySelector('.tl-cel');
const target = tracks[0];
if (!source || !target) return null;
const a = source.getBoundingClientRect(), b = target.getBoundingClientRect();
const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]);
return {sx: a.left + a.width / 2, sy: a.top + a.height / 2,
tx: b.left + b.width * (${frame} + 0.25) / frames,
ty: b.top + b.height / 2};
})()`);
assert(points, 'two lanes and a source clip are available');
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: points.sx, y: points.sy,
button: 'left', buttons: 1, clickCount: 1});
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.tx, y: points.ty,
button: 'left', buttons: 1});
await sleep(150);
assert.equal(await evaluate('document.querySelectorAll(".tl-track")[0].querySelectorAll(".tl-cel.ghost").length'), 1,
'cross-lane movement previews in the destination lane');
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: points.tx, y: points.ty,
button: 'left', buttons: 0, clickCount: 1});
await sleep(300);
};
2026-10-01 14:37:59 -04:00
assert.equal(await evaluate('[...document.querySelectorAll(".timing-controls > button")].every(b => b.disabled)'), true,
A lane is a generic row of symbol clips A lane was a drawing lane: the only thing that could go in one was a one-frame held cel, and every other symbol instance stayed a permanent root row of its own. Those are not two kinds of timing, they are one kind with two creation policies. `lane/place-symbol` drops any library symbol in as a clip that plays naturally at speed one, `lane/adopt` moves an instance that is already in the document into a lane keeping its source, span, playback and corrections, and `append-drawing`/`overwrite-drawing` keep being the policy that makes a new empty symbol a one-frame hold. The child shape they produce is the same. Both new commands claim their interval through `blank` before they write, so the partition rule is unchanged and unduplicated: placing into occupied lane time trims, removes or splits the incumbents, and a lane still never stores an overlap. Real compositing overlap is another lane, where the order is explicit. Creating a symbol with nothing aimed now makes a lane and a clip in it instead of a loose root instance, and a pool drop prefers an explicitly targeted lane, then the selected one, and makes a lane only when there is neither. That is what stops the row-per-symbol growth coming back in through the drop path, and it is why `add-lane` now takes a z in front of the existing root nodes and calls what it makes a "lane" rather than "drawings". The timeline learned the two gestures that a generic lane needs. A clip body dragged over another lane's track previews there as a dashed block and lands through `::adopt-in-lane`; the track is found with `elementsFromPoint` and its selection read back off the element, because a pointer capture does not retarget. A pool drop over an existing lane previews as a dashed clip inside that lane instead of a temporary new row that appears and then vanishes -- which also needed the drag-leave check to be geometric, since inserting the preview changes the element under the pointer and Chromium then reports a leave with no related target. Lanes are renameable from their label, by double-click, F2, or the pencil, through `::rename-node`. `symbol/lane-cels` is `symbol/lane-clips`, and the vocabulary table in the handoff now separates the two words it had merged: a clip is an instance in a lane, and a cel is specifically the one-frame held source that drawing creation makes. Keeping `cel` for the policy is what lets the lane stop being about drawings at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 15:13:07 -04:00
'timing buttons are disabled without a symbol clip');
2026-10-01 14:37:59 -04:00
await click('inside');
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
let s = await shot();
A lane is a generic row of symbol clips A lane was a drawing lane: the only thing that could go in one was a one-frame held cel, and every other symbol instance stayed a permanent root row of its own. Those are not two kinds of timing, they are one kind with two creation policies. `lane/place-symbol` drops any library symbol in as a clip that plays naturally at speed one, `lane/adopt` moves an instance that is already in the document into a lane keeping its source, span, playback and corrections, and `append-drawing`/`overwrite-drawing` keep being the policy that makes a new empty symbol a one-frame hold. The child shape they produce is the same. Both new commands claim their interval through `blank` before they write, so the partition rule is unchanged and unduplicated: placing into occupied lane time trims, removes or splits the incumbents, and a lane still never stores an overlap. Real compositing overlap is another lane, where the order is explicit. Creating a symbol with nothing aimed now makes a lane and a clip in it instead of a loose root instance, and a pool drop prefers an explicitly targeted lane, then the selected one, and makes a lane only when there is neither. That is what stops the row-per-symbol growth coming back in through the drop path, and it is why `add-lane` now takes a z in front of the existing root nodes and calls what it makes a "lane" rather than "drawings". The timeline learned the two gestures that a generic lane needs. A clip body dragged over another lane's track previews there as a dashed block and lands through `::adopt-in-lane`; the track is found with `elementsFromPoint` and its selection read back off the element, because a pointer capture does not retarget. A pool drop over an existing lane previews as a dashed clip inside that lane instead of a temporary new row that appears and then vanishes -- which also needed the drag-leave check to be geometric, since inserting the preview changes the element under the pointer and Chromium then reports a leave with no related target. Lanes are renameable from their label, by double-click, F2, or the pencil, through `::rename-node`. `symbol/lane-cels` is `symbol/lane-clips`, and the vocabulary table in the handoff now separates the two words it had merged: a clip is an instance in a lane, and a cel is specifically the one-frame held source that drawing creation makes. Keeping `cel` for the policy is what lets the lane stop being about drawings at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 15:13:07 -04:00
assert.deepEqual(placed(s), [[0, 1]],
'new at the root automatically makes a lane and a one-frame symbol clip');
assert.equal(await evaluate('document.querySelectorAll(".tl-label .kind").length'), 1,
'new temporal content creates a lane row rather than a row per symbol');
2026-10-01 14:37:59 -04:00
assert.equal(await evaluate(`document.querySelectorAll('.cel-sheet, [aria-label="time view"]').length`), 0,
'there is one temporal interface');
assert.equal(await evaluate('document.querySelectorAll(".timing-controls > button").length'), 3,
'timing operations are direct buttons');
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
2026-10-01 14:37:59 -04:00
await drag('.tl-cel .tl-edge.out', 3);
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
s = await shot();
2026-10-01 14:37:59 -04:00
assert.deepEqual(placed(s), [[0, 4]], 'a right edge directly changes the endpoint');
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
2026-10-01 14:37:59 -04:00
for (let i = 0; i < 4; i++) await click('+1');
await click('inside');
await drag('.tl-cel:nth-of-type(2) .tl-edge.out', 2);
for (let i = 0; i < 3; i++) await click('+1');
await click('inside');
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
s = await shot();
2026-10-01 14:37:59 -04:00
assert.deepEqual(placed(s), [[0, 4], [4, 7], [7, 8]]);
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
2026-10-01 14:37:59 -04:00
await drag('.tl-cel:nth-of-type(2) .tl-junction', 1, {zone: 0.5});
assert.deepEqual(placed(await shot()), [[0, 5], [5, 7], [7, 8]],
'the middle of a junction rolls both edges');
await undo();
The shot is as long as somebody said it was Trim, move and blank, and the decision they all three walked into: is the shot's length authored, or derived from what is in it? AUTHORED. `:frames` is the symbol's window — how long the shot IS — and the occupied extent of its lanes is a different fact, read off the occurrences. A command grows the window when the caller says `:grow-symbol` and NEVER shrinks it, so blanking the end of a shot leaves a shot with empty frames at the end. That is a true statement about what somebody authored, and the alternative is deleting the last drawing and quietly shortening the film. `finish` had the right behaviour by accident — `(apply max (:frames sym) ...)` — and now says which number is which: `needed` is where the occurrences reach, `:frames` is what was authored, and the only thing that makes the second follow the first is a caller asking. The three commands turned out to be one piece of geometry, which is `split`'s. A `:span` is in the occurrence's OWN frames and `:time` says where those land in the lane, so moving an edge of an exposure is ONE WRITE to `:span` and `:time` and `:playback` are never touched. `local` and `edged` are the whole of it, and split now goes through them too. trim narrows one edge and moves nothing else. Lengthening is `extend-hold`, which carries a ripple policy and a shot-length policy because it needs them; letting trim grow as well would give one gesture two sets of rules and a way to overlap its neighbour. move one write to `:time :at`, and a destination that would overlap is REFUSED rather than rippled. Moving a drawing and re-timing the ones around it are different intentions, and a move that pushed the rest would be the second wearing the first one's name. Clear the room first. blank leaves a gap and does not close it. Wholly inside the range goes, overlapping an end is trimmed to it, spanning the range is split — the one case that needs an ID, and it asks for one instead of inventing it. Because the source clock is untouched, trimming the front of a playing insert starts it LATER INTO its animation rather than restarting it, which is the difference between trimming and slipping and the reason they stay two commands. The test samples the frames it kept and asserts they show what they showed. Blanking leaves the drawings in the library. A lane does not own its content, and a drawing whose last exposure is gone is still a drawing somebody made. Overwrite is now `blank` then `place` and needs no policy argument of its own, which is why it still is not one. 424 tests, 5,749 assertions. The browser flow trims an exposure at the playhead, moves it into the gap that made, blanks it, and checks the shot is still as long as it was authored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 16:32:59 -04:00
2026-10-01 14:37:59 -04:00
await drag('.tl-cel:nth-of-type(2) .tl-junction', 1, {zone: 0.9});
assert.deepEqual(placed(await shot()), [[0, 4], [5, 7], [7, 8]],
'the right side trims only the right clip');
await undo();
2026-10-01 14:37:59 -04:00
await drag('.tl-cel:nth-of-type(2) .tl-junction', -1, {zone: 0.1});
assert.deepEqual(placed(await shot()), [[0, 3], [4, 7], [7, 8]],
'the left side trims only the left clip');
await undo();
2026-10-01 14:37:59 -04:00
await drag('.tl-cel:nth-of-type(2) .tl-edge.out', 2, {shift: true});
assert.deepEqual(placed(await shot()), [[0, 4], [4, 9], [9, 10]],
'Shift-edge ripples every later clip on the lane');
await undo();
2026-10-01 14:37:59 -04:00
await drag('.tl-cel:first-of-type .tl-edge.out', 2);
assert.deepEqual(placed(await shot()), [[0, 6], [6, 7], [7, 8]],
'ordinary growth trims adjacent spans and never overlaps');
A lane is a generic row of symbol clips A lane was a drawing lane: the only thing that could go in one was a one-frame held cel, and every other symbol instance stayed a permanent root row of its own. Those are not two kinds of timing, they are one kind with two creation policies. `lane/place-symbol` drops any library symbol in as a clip that plays naturally at speed one, `lane/adopt` moves an instance that is already in the document into a lane keeping its source, span, playback and corrections, and `append-drawing`/`overwrite-drawing` keep being the policy that makes a new empty symbol a one-frame hold. The child shape they produce is the same. Both new commands claim their interval through `blank` before they write, so the partition rule is unchanged and unduplicated: placing into occupied lane time trims, removes or splits the incumbents, and a lane still never stores an overlap. Real compositing overlap is another lane, where the order is explicit. Creating a symbol with nothing aimed now makes a lane and a clip in it instead of a loose root instance, and a pool drop prefers an explicitly targeted lane, then the selected one, and makes a lane only when there is neither. That is what stops the row-per-symbol growth coming back in through the drop path, and it is why `add-lane` now takes a z in front of the existing root nodes and calls what it makes a "lane" rather than "drawings". The timeline learned the two gestures that a generic lane needs. A clip body dragged over another lane's track previews there as a dashed block and lands through `::adopt-in-lane`; the track is found with `elementsFromPoint` and its selection read back off the element, because a pointer capture does not retarget. A pool drop over an existing lane previews as a dashed clip inside that lane instead of a temporary new row that appears and then vanishes -- which also needed the drag-leave check to be geometric, since inserting the preview changes the element under the pointer and Chromium then reports a leave with no related target. Lanes are renameable from their label, by double-click, F2, or the pencil, through `::rename-node`. `symbol/lane-cels` is `symbol/lane-clips`, and the vocabulary table in the handoff now separates the two words it had merged: a clip is an instance in a lane, and a cel is specifically the one-frame held source that drawing creation makes. Keeping `cel` for the policy is what lets the lane stop being about drawings at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 15:13:07 -04:00
await dropPoolSymbol(10);
s = await shot();
assert.deepEqual(placed(s), [[0, 6], [6, 7], [7, 8], [10, 11]],
'an arbitrary library symbol drops into an existing lane');
assert.equal(instances(s).at(-1).playback.speed, 1,
'a dropped symbol plays naturally instead of becoming a held drawing');
await evaluate(`re_frame.core.dispatch(cljs.core.vector(cljs.core.keyword('arthur.events.ui/new-lane')))`);
await sleep(180);
s = await shot();
const renameControls = await evaluate('document.querySelectorAll(".tl-label .tl-rename").length');
assert.equal(renameControls, 2,
`both lanes expose rename controls: ${JSON.stringify(s.clip.symbols.main.nodes)}`);
await evaluate('document.querySelector(".tl-label .tl-rename").click()');
await sleep(80);
assert(await evaluate(`(() => {
const input = document.querySelector('.tl-name-input');
if (!input) return false;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'Foreground');
input.dispatchEvent(new InputEvent('input', {bubbles: true, inputType: 'insertText', data: 'Foreground'}));
input.blur(); return true;
})()`), 'lane rename editor opens');
await sleep(180);
s = await shot();
assert(Object.values(s.clip.symbols.main.nodes).some(n => n.layout === 'sequence' && n.name === 'Foreground'),
'a lane name is editable and persisted in the document');
await dragClipBetweenLanes(12);
s = await shot();
const lanes = Object.values(s.clip.symbols.main.nodes).filter(n => n.layout === 'sequence');
assert.deepEqual(lanes.map(l => instances(s).filter(n => n.parent === l.id).length).sort(), [1, 3],
'a clip body can move from one lane to another');
The timeline opens the whole document Making a lane one row cost the thing a row was for. A clip stopped being a row, so there was no longer any way to open a clip and see what was inside it, and the inside of a drawing — the most ordinary thing in the document — became reachable only by opening it as its own tab. This is that capability back, from the root timeline, down as far as it goes. An expanded lane opens exactly ONE clip: the selected one. Its own keys, then the lanes and nodes of the symbol it places, then theirs, each mapped into this ruler by the recursive walk that was already there. Twelve clips in a lane still cost one row, and inspection costs one branch rather than twelve. Two things that only showed up once it ran. The portal is chosen by the whole LINEAGE of the selection and not by the selected id: selecting a shape inside the clip — or the end of its span — is still working inside that clip, and matching the id alone shut the portal the instant anything under it was touched. And selecting now waits for the pointer to come UP, because selecting on the way down re-drew the timeline before the gesture had said anything: it shut the portal holding the lane being dragged INTO, out from under the pointer. A HELD clip opens too, which the old row walk never did either. `source-time` is nil for a hold, so the walk stopped there and the contents of every drawing were invisible from here. Its rows are shown across the hold — which is when the node is on screen — and marked `:unmapped?`: no keys, and no draggable edges, because a frozen clock gives no frame inside it a place on this ruler. Refusing to place the keys is the honest half; refusing to show the rows was not. Double-clicking a clip opens the symbol it places as a tab, as double-clicking the same symbol in the pool does. That was already written and had never once run: the track captures the pointer for a slide, so the click and double-click that follow are delivered to the track and never to the block. The track now resolves them itself. Fixing the delivery exposed two more: `symbol/lineage` reported a `parent cycle` for any id in a symbol with NO nodes, because a one-element chain is longer than zero nodes — and opening a symbol left the selection pointing into the symbol being left, which the breadcrumb and the inspector then tried to resolve. The editor unmounted. Both are fixed where they were wrong, and the browser test asserts the editor is still standing afterwards. Audio is a clip in a lane like everything else. A dropped sound lands in one and is trimmed and moved by the same commands; a lane holds picture or sound and not both, which is the explicit capability the model asked for rather than a guess per frame. The refusal lives in the commands and not only in validation, because placement claims time: `blank` would have deleted the sound to make room for the picture and left a perfectly valid document behind. What is in a lane of the open symbol is drawn as a lane; what is nested inside a placed symbol is still flattened by `audio-tracks`, so no sound is on two rows. Everything that enters the timeline now enters a lane: a converted take, a symbol brought in from another project, a sound. One rule answers where — `lane-destination` — and every symbol is born with a lane for it to answer with. An unaimed drop fills an EMPTY lane rather than taking an occupied one nobody pointed at, because the alternative is trimming away what was there to make room for what was dropped. Shift during a clip-body drag means the other intention: put this node INSIDE the symbol the clip under the pointer places, through `nest/move-node`, which is what keeps the world transform and the root timing. Overlap cannot say which of the two is meant — dropping on occupied time already means claiming it — so the person says, and a label by the pointer says it back. The label asks `nest/move-refusal`, the same check the command makes, so it cannot promise what the drop would refuse. Today it refuses more than it allows: both clips have to be on screen at one frame, which two clips in one lane never are, and a held destination has no clock to move through at all. `docs/lane-nesting-notes.md` argues that the second refusal is stronger than the facts require and says what would settle it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 16:13:02 -04:00
// EXPANDING A LANE OPENS THE SELECTED CLIP. Its own keys, and under it the
// lanes and nodes of the symbol it places, all on this ruler — which is what
// makes the whole document editable from the root timeline.
const rowLabels = () => evaluate(
`[...document.querySelectorAll('.tl-labels > .tl-label')].map(e => e.textContent.trim())`);
const twist = async i => {
assert(await evaluate(`(() => {
const t = document.querySelectorAll('.tl-labels > .tl-label .tl-twist')[${i}];
if (!t || t.disabled) return false;
t.click(); return true;
})()`), `an expander at row ${i}`);
await sleep(220);
};
await evaluate(`(() => { document.querySelector('.tl-track .tl-cel').click(); return true })()`);
await sleep(200);
const collapsed = await rowLabels();
await twist(0);
const opened = await rowLabels();
assert(opened.length > collapsed.length, 'the lane opens');
assert.equal(opened.filter(l => l.includes('instance')).length, 1,
`one clip portal, not one branch per clip: ${JSON.stringify(opened)}`);
const portalAt = opened.findIndex(l => l.includes('instance'));
await twist(portalAt);
const deep = await rowLabels();
assert(deep.length > opened.length,
`the portal opens the symbol the clip places: ${JSON.stringify(deep)}`);
// Selecting something nested must not close the portal that revealed it.
await evaluate(`(() => {
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
const sel = cljs.core.get_in(db, [k('ui'), k('selection')]);
const path = cljs.core.nth(sel, 3);
re_frame.core.dispatch(cljs.core.vector(
k('arthur.events.ui/select'),
cljs.core.vector(k('node'), cljs.core.nth(sel, 1), cljs.core.nth(sel, 2),
cljs.core.conj(path, k('made-up-child')))));
return true;
})()`);
await sleep(220);
assert.equal((await rowLabels()).filter(l => l.includes('instance')).length, 1,
'a selection under the clip keeps its portal open');
await twist(portalAt);
await twist(0);
const before = await tabs();
const tabChips = () => evaluate('document.querySelectorAll(".tabs .tab").length');
const chipsBefore = await tabChips();
await doubleClick('.tl-track .tl-cel');
const after = await tabs();
assert.equal(after.tabs.length, before.tabs.length + 1,
`double-clicking a clip opens the symbol it places, as the pool row does: ${JSON.stringify(after)}`);
assert(!before.tabs.includes(after.open) && after.tabs.includes(after.open),
`the opened symbol is the one in front: ${JSON.stringify(after)}`);
assert.equal(await tabChips(), chipsBefore + 1,
'the opened symbol is drawn as one more tab');
assert.equal(await evaluate('document.querySelectorAll("#app > *").length'), 1,
'opening from the timeline leaves the editor standing: a stale node selection ' +
'pointing into the symbol just left used to throw and unmount it');
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));
The timeline opens the whole document Making a lane one row cost the thing a row was for. A clip stopped being a row, so there was no longer any way to open a clip and see what was inside it, and the inside of a drawing — the most ordinary thing in the document — became reachable only by opening it as its own tab. This is that capability back, from the root timeline, down as far as it goes. An expanded lane opens exactly ONE clip: the selected one. Its own keys, then the lanes and nodes of the symbol it places, then theirs, each mapped into this ruler by the recursive walk that was already there. Twelve clips in a lane still cost one row, and inspection costs one branch rather than twelve. Two things that only showed up once it ran. The portal is chosen by the whole LINEAGE of the selection and not by the selected id: selecting a shape inside the clip — or the end of its span — is still working inside that clip, and matching the id alone shut the portal the instant anything under it was touched. And selecting now waits for the pointer to come UP, because selecting on the way down re-drew the timeline before the gesture had said anything: it shut the portal holding the lane being dragged INTO, out from under the pointer. A HELD clip opens too, which the old row walk never did either. `source-time` is nil for a hold, so the walk stopped there and the contents of every drawing were invisible from here. Its rows are shown across the hold — which is when the node is on screen — and marked `:unmapped?`: no keys, and no draggable edges, because a frozen clock gives no frame inside it a place on this ruler. Refusing to place the keys is the honest half; refusing to show the rows was not. Double-clicking a clip opens the symbol it places as a tab, as double-clicking the same symbol in the pool does. That was already written and had never once run: the track captures the pointer for a slide, so the click and double-click that follow are delivered to the track and never to the block. The track now resolves them itself. Fixing the delivery exposed two more: `symbol/lineage` reported a `parent cycle` for any id in a symbol with NO nodes, because a one-element chain is longer than zero nodes — and opening a symbol left the selection pointing into the symbol being left, which the breadcrumb and the inspector then tried to resolve. The editor unmounted. Both are fixed where they were wrong, and the browser test asserts the editor is still standing afterwards. Audio is a clip in a lane like everything else. A dropped sound lands in one and is trimmed and moved by the same commands; a lane holds picture or sound and not both, which is the explicit capability the model asked for rather than a guess per frame. The refusal lives in the commands and not only in validation, because placement claims time: `blank` would have deleted the sound to make room for the picture and left a perfectly valid document behind. What is in a lane of the open symbol is drawn as a lane; what is nested inside a placed symbol is still flattened by `audio-tracks`, so no sound is on two rows. Everything that enters the timeline now enters a lane: a converted take, a symbol brought in from another project, a sound. One rule answers where — `lane-destination` — and every symbol is born with a lane for it to answer with. An unaimed drop fills an EMPTY lane rather than taking an occupied one nobody pointed at, because the alternative is trimming away what was there to make room for what was dropped. Shift during a clip-body drag means the other intention: put this node INSIDE the symbol the clip under the pointer places, through `nest/move-node`, which is what keeps the world transform and the root timing. Overlap cannot say which of the two is meant — dropping on occupied time already means claiming it — so the person says, and a label by the pointer says it back. The label asks `nest/move-refusal`, the same check the command makes, so it cannot promise what the drop would refuse. Today it refuses more than it allows: both clips have to be on screen at one frame, which two clips in one lane never are, and a held destination has no clock to move through at all. `docs/lane-nesting-notes.md` argues that the second refusal is stronger than the facts require and says what would settle it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 16:13:02 -04:00
console.log('PASS: generic lanes preview, rename, move, place, open, trim, roll, and ripple clips');
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}`);
}
}