P1b: SQLite project, content-addressed assets, autosave and undo

This commit is contained in:
Emil
2026-11-01 01:19:55 +03:00
parent 4f4ee05a3a
commit 0acf11ccb0
22 changed files with 1405 additions and 32 deletions
+3 -1
View File
@@ -9,7 +9,9 @@
"preview": "vite preview --port 5173 --strictPort",
"check": "svelte-check --tsconfig ./tsconfig.json",
"smoke": "node scripts/smoke.mjs",
"quarry-smoke": "node scripts/quarry-smoke.mjs"
"quarry-smoke": "node scripts/quarry-smoke.mjs",
"project-smoke": "node scripts/project-smoke.mjs",
"restored-smoke": "node scripts/restored-smoke.mjs"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.0.0",
+63
View File
@@ -0,0 +1,63 @@
import { chromium } from 'playwright-core';
const chrome = process.env.CHROME_PATH ?? '/home/emil/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome';
const browser = await chromium.launch({ executablePath: chrome, args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
const errors = [];
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
page.on('console', (message) => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); });
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'load' });
await page.waitForTimeout(2200);
const projectName = (await page.locator('.project').textContent())?.trim();
const undoInitially = await page.locator('.history-button[aria-label="undo"]').isDisabled();
await page.locator('.room', { hasText: 'quarry' }).click();
await page.waitForTimeout(400);
await page.locator('input[type=file]').setInputFiles('../fixtures/clicks-01.wav');
await page.waitForFunction(() => document.querySelector('.core-name')?.textContent?.includes('clicks-01'), null, { timeout: 20000 });
await page.waitForTimeout(700);
await page.locator('.face').nth(10).click();
await page.waitForTimeout(200);
await page.locator('.assign').click();
await page.waitForTimeout(500);
const assigned = (await page.locator('.pattern-row .pattern-face').first().textContent())?.trim();
const undoEnabled = !(await page.locator('.history-button[aria-label="undo"]').isDisabled());
await page.locator('.history-button[aria-label="undo"]').click();
await page.waitForTimeout(400);
const undone = (await page.locator('.pattern-row .pattern-face').first().textContent())?.trim();
await page.locator('.history-button[aria-label="redo"]').click();
await page.waitForTimeout(400);
const redone = (await page.locator('.pattern-row .pattern-face').first().textContent())?.trim();
if (!(await page.locator('.play.is-playing').count())) await page.locator('.play').click();
await page.waitForTimeout(1600);
await page.screenshot({ path: '../preview-p1b-quarry.png' });
const report = await page.evaluate(() => ({
project: document.querySelector('.project')?.textContent?.trim(),
cores: [...document.querySelectorAll('.core-name')].map((el) => el.textContent),
faces: document.querySelectorAll('.face').length,
patternFace: document.querySelector('.pattern-row .pattern-face')?.textContent?.trim(),
undoDisabled: document.querySelector('.history-button[aria-label="undo"]')?.hasAttribute('disabled'),
redoDisabled: document.querySelector('.history-button[aria-label="redo"]')?.hasAttribute('disabled'),
wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-6),
master: document.querySelector('.master-db')?.textContent
}));
console.log(JSON.stringify({ projectName, undoInitially, assigned, undoEnabled, undone, redone, report }, null, 2));
const failures = [];
if (!projectName) failures.push('no project name');
if (!undoInitially) failures.push('undo was enabled on a fresh project');
if (!report.cores.length) failures.push('no core imported');
if (report.faces !== 16) failures.push('expected 16 faces');
if (!assigned || assigned.startsWith('procedural')) failures.push('face was not assigned');
if (!undoEnabled) failures.push('undo did not enable');
if (!undone || !undone.startsWith('procedural')) failures.push('undo did not revert the face');
if (!redone || redone.startsWith('procedural')) failures.push('redo did not restore the face');
if (errors.length) failures.push('console errors');
await browser.close();
if (failures.length) {
console.error('PROJECT SMOKE FAILED: ' + failures.join('; '));
process.exit(1);
}
console.log('project smoke passed');
+23
View File
@@ -0,0 +1,23 @@
import { chromium } from 'playwright-core';
const chrome = process.env.CHROME_PATH ?? '/home/emil/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome';
const browser = await chromium.launch({ executablePath: chrome, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'load' });
await page.waitForTimeout(2200);
await page.locator('.room', { hasText: 'quarry' }).click();
await page.waitForTimeout(700);
const report = await page.evaluate(() => ({
project: document.querySelector('.project')?.textContent?.trim(),
cores: [...document.querySelectorAll('.core-name')].map((el) => el.textContent),
faces: document.querySelectorAll('.face').length,
patternFace: document.querySelector('.pattern-row .pattern-face')?.textContent?.trim(),
undoDisabled: document.querySelector('.history-button[aria-label="undo"]')?.hasAttribute('disabled'),
wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(0, 4)
}));
await page.screenshot({ path: '../preview-p1b-restored.png' });
console.log(JSON.stringify({ report, errors }, null, 2));
if (!report.cores.length || !report.patternFace || report.undoDisabled === false || errors.length) process.exit(1);
await browser.close();
+29 -5
View File
@@ -72,7 +72,7 @@
}
if (!snapshotLogged) {
snapshotLogged = true;
addEntry('info', 'snapshot', `${next.veins.length} veins · ${next.pattern.steps} steps · ${cores.length} cores · rev ${next.revision}`);
addEntry('info', 'snapshot', `${next.project_name} · ${next.veins.length} veins · ${next.pattern.steps} steps · ${cores.length} cores · rev ${next.revision}`);
}
}
@@ -98,6 +98,9 @@
} else if (event.kind === 'pattern_face') {
const row = project.pattern.rows[event.row];
if (row) row.face = { core: event.core, face: event.face };
} else if (event.kind === 'history') {
project.can_undo = event.can_undo;
project.can_redo = event.can_redo;
}
}
@@ -189,6 +192,14 @@
void run({ type: 'set_pattern_step', row, step, velocity: on ? 0 : 96 });
}
function undo() {
if (project?.can_undo) void run({ type: 'undo' });
}
function redo() {
if (project?.can_redo) void run({ type: 'redo' });
}
function select(vein: VeinId) {
selected = vein;
}
@@ -209,6 +220,12 @@
else play();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
if (event.shiftKey) redo();
else undo();
return;
}
if (event.key === 's' || event.key === 'S') {
event.preventDefault();
strikeSelected();
@@ -257,6 +274,7 @@
<div class="brand">
<span class="mark">ZHILA</span>
<span class="version">0.1</span>
<span class="project">{project?.project_name ?? 'descent-04'}</span>
</div>
<div class="rail-center">
<button class="room" class:is-on={view === 'nave'} aria-pressed={view === 'nave'} onclick={() => (view = 'nave')}>
@@ -276,10 +294,16 @@
: 'five veins'}
</span>
</div>
<div class="net" class:is-live={status === 'live'}>
<span class="dot" aria-hidden="true"></span>
<span class="net-label">engine</span>
<b>{status === 'live' ? 'live' : status === 'connecting' ? 'opening' : 'offline'}</b>
<div class="rail-right">
<div class="history" aria-label="history">
<button class="history-button" onclick={undo} disabled={!project?.can_undo} aria-label="undo" title="undo · ctrl+z">undo</button>
<button class="history-button" onclick={redo} disabled={!project?.can_redo} aria-label="redo" title="redo · shift+ctrl+z">redo</button>
</div>
<div class="net" class:is-live={status === 'live'}>
<span class="dot" aria-hidden="true"></span>
<span class="net-label">engine</span>
<b>{status === 'live' ? 'live' : status === 'connecting' ? 'opening' : 'offline'}</b>
</div>
</div>
</header>
+55
View File
@@ -131,6 +131,49 @@ a:focus-visible,
font-variant-numeric: tabular-nums;
}
.project {
overflow: hidden;
max-width: 190px;
padding-left: 9px;
border-left: 1px solid var(--line-soft);
font-size: 8.5px;
letter-spacing: 0.08em;
color: var(--ash-2);
white-space: nowrap;
text-overflow: ellipsis;
}
.rail-right {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.history {
display: flex;
gap: 2px;
}
.history-button {
padding: 3px 5px;
border: 1px solid rgba(155, 133, 121, 0.16);
font-size: 8px;
letter-spacing: 0.04em;
color: var(--ash);
transition: color 0.2s ease, border-color 0.2s ease;
}
.history-button:hover:not(:disabled) {
border-color: rgba(232, 64, 42, 0.4);
color: var(--ember);
}
.history-button:disabled {
opacity: 0.28;
cursor: default;
}
.rail-center {
display: flex;
align-items: center;
@@ -1669,6 +1712,9 @@ button.room {
.brand {
min-width: 0;
}
.project {
max-width: 110px;
}
.mark {
font-size: 18px;
letter-spacing: 0.2em;
@@ -1682,6 +1728,15 @@ button.room {
.rail-note {
display: none;
}
.project {
display: none;
}
.rail-right {
gap: 8px;
}
.history-button {
padding: 2px 4px;
}
.net {
min-width: 0;
gap: 5px;
+2
View File
@@ -33,6 +33,8 @@ export function commandLabel(command: Command): string {
case 'strike_face': return `strike ${command.core} f${command.face + 1}`;
case 'set_pattern_step': return `step ${command.row + 1}:${command.step + 1} at ${command.velocity}`;
case 'set_pattern_face': return `${command.core} f${command.face + 1} on row ${command.row + 1}`;
case 'undo': return 'undo';
case 'redo': return 'redo';
case 'get_snapshot': return 'snapshot';
}
}
+8 -2
View File
@@ -61,6 +61,9 @@ export interface Pattern {
export interface State {
revision: number;
project_name: string;
can_undo: boolean;
can_redo: boolean;
transport: TransportState;
veins: VeinState[];
pattern: Pattern;
@@ -76,7 +79,8 @@ export type Event =
| { kind: 'struck'; vein: VeinId; velocity: number }
| { kind: 'pattern_step'; row: number; step: number; velocity: number }
| { kind: 'pattern_face'; row: number; core: string; face: number }
| { kind: 'face_struck'; core: string; face: number; velocity: number };
| { kind: 'face_struck'; core: string; face: number; velocity: number }
| { kind: 'history'; can_undo: boolean; can_redo: boolean };
export type Command =
| { type: 'get_snapshot' }
@@ -87,7 +91,9 @@ export type Command =
| { type: 'set_vein_gain'; vein: VeinId; gain: number }
| { type: 'strike_face'; core: string; face: number; velocity: number }
| { type: 'set_pattern_step'; row: number; step: number; velocity: number }
| { type: 'set_pattern_face'; row: number; core: string; face: number };
| { type: 'set_pattern_face'; row: number; core: string; face: number }
| { type: 'undo' }
| { type: 'redo' };
export type CommandResult =
| { kind: 'snapshot'; state: State }