Projects live at URLs, have owners, and are edited together live

A project is only ever at /p/<id>/<slug>; / is the index of the projects
you own or edit. Every project has an owner, who can name editors;
anyone with the link can view. Every edit saves itself, one request in
flight at a time, as a patch of the leaves that changed, and a websocket
(channels + daphne) carries presence and each committed write to
everyone else in the project. The first write to a leaf wins, and the
loser is told.

Undo is per person: a step undoes only if the leaves it touched still
hold what it left, so it never takes a collaborator's work with it.
Named snapshots replace saving, and restore as an ordinary write.

An empty symbol now survives the leaf round trip with `:nodes {}`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 22:04:03 -04:00
parent c17ee138f2
commit 6a53adb5e0
31 changed files with 1918 additions and 158 deletions

View file

@ -303,10 +303,27 @@ async function main() {
if (!probe) throw new Error('no canvas.stage on the page — ' +
(page.logs.slice(0, 3).join(' | ') || 'is `shadow-cljs watch app` running?'));
console.log(`\ncanvas ${probe.w}x${probe.h}, ${probe.doc}`);
// `/` is the index, and a project is only ever at its own address, so the
// suite signs up, makes one, and opens it — the page's own navigation, so
// this CDP session and its console stay attached.
const home = await page.eval(`(async () => {
const post = (url, body) => fetch(url, {method: 'POST', body: JSON.stringify(body),
headers: {'Content-Type': 'application/json',
'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)[1]}}).then(r => r.json());
await post('/api/signup', {username: 'suite-' + Date.now().toString(36), password: 'password1'});
const made = await post('/api/projects', {name: 'untitled'});
arthur.events.collab.navigate_BANG_(arthur.events.collab.project_path(made.id, made.name));
return location.pathname; })()`);
for (let i = 0; i < 100; i++) {
probe = await page.eval(PROBE);
if (/opened/.test(probe.doc)) break;
await sleep(100);
}
check(probe.drawn === 0 && /new document/.test(probe.doc),
'the app opens on a blank document',
console.log(`\ncanvas ${probe.w}x${probe.h}, ${probe.doc} at ${home}`);
check(probe.drawn === 0 && /opened untitled/.test(probe.doc),
'a new project opens on a blank document',
`${probe.drawn} px drawn — ${probe.doc}`);
check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size',
`${probe.w}x${probe.h}`);
@ -432,9 +449,10 @@ async function main() {
await sleep(150);
const sent = await sample(FRAMES);
check(await page.eval(CLICK('save')), 'save is clickable');
// There is no save: every edit saves itself, and a built-in example opened
// in a project becomes a project of its own at once.
const saved = await statusMatching(/saved r\d+/);
check(saved !== null, 'the document saves', saved ?? (await page.eval(STATUS)));
check(saved !== null, 'the take becomes a saved project by itself', saved ?? (await page.eval(STATUS)));
// Upload count may be zero when the content-addressed blocks already exist
// on this server. The saved clip must still reference them.
const savedBlocks = await page.eval(`(async () => {
@ -445,14 +463,16 @@ async function main() {
check(savedBlocks > 0, 'the saved clip references its tier 2 blocks',
`${savedBlocks} blocks`);
// Again, unchanged. Content addressing means the second save uploads nothing
// and rewrites nothing: this is the assertion that the keys are stable across
// two independent freezes of the same take, and that an unchanged leaf keeps
// its version rather than being rewritten.
check(await page.eval(CLICK('save')), 'save is clickable again');
const resaved = await statusMatching(/saved r\d+ · 0 leaves · 0 blocks/);
check(resaved !== null, 'saving an unchanged document writes nothing',
resaved ?? (await page.eval(STATUS)));
// Unchanged, nothing is written: the project's seq stands still. This is
// the assertion that the keys are stable and a clean document is clean.
const seqOf = `(async () => {
const id = location.pathname.split('/')[2];
return (await fetch('/api/projects/' + id).then(r => r.json())).seq; })()`;
const seqBefore = await page.eval(seqOf);
await sleep(800);
const seqAfter = await page.eval(seqOf);
check(seqBefore === seqAfter, 'an unchanged document writes nothing',
`seq ${seqBefore} -> ${seqAfter}`);
check(await fromMenu(MENU_PICK_NEWEST), 'open is clickable');
const opened = await statusMatching(/opened /);
@ -550,9 +570,8 @@ async function main() {
.find(el => el.textContent.startsWith('key 8 → 16'));
return label?.querySelector('select')?.value === 'linear';
})()`), 'the second drawing gap can be set to tween');
check(await page.eval(CLICK('save')), 'the painted document can be saved');
check((await statusMatching(/saved r\d+ · \d+ leaves/)) !== null,
'the painted shape is saved', await page.eval(STATUS));
check((await statusMatching(/saved r\d+/)) !== null,
'the painted shape saves by itself', await page.eval(STATUS));
check(await fromMenu(MENU_PICK_NEWEST), 'the painted document can be reopened');
check((await statusMatching(/opened /)) !== null,
'the painted shape is reopened', await page.eval(STATUS));