diff --git a/screencast/lib/fixture.mjs b/screencast/lib/fixture.mjs index aa46dc9..287a59f 100644 --- a/screencast/lib/fixture.mjs +++ b/screencast/lib/fixture.mjs @@ -129,6 +129,6 @@ WHERE { <${url}> ?slot ?block . ?block a ?type . FILTER(?type IN (ldh:XHTML, ldh export async function patchDocument({ ldh, certFile, certPassword, certPasswordFile, url, update }) { const password = certPassword ?? (certPasswordFile ? (await fs.readFile(certPasswordFile, 'utf8')).trim() : null); if (!password) throw new Error('patchDocument needs --cert-password or --cert-password-file'); - const r = await run(ldh, ['patch', url, '-f', certFile, '-p', password], update); + const r = await run(ldh, ['patch', url, '-c', certFile, '-p', password], update); return { ok: r.code === 0, out: String(r.err || r.out).split(password).join('••••').slice(0, 300) }; } diff --git a/screencast/overview/voice.mjs b/screencast/overview/voice.mjs index c77fe85..dff7e48 100644 --- a/screencast/overview/voice.mjs +++ b/screencast/overview/voice.mjs @@ -11,9 +11,10 @@ // reproducible. The with-timestamps endpoint is used, so each take also keeps its // character alignment for the subtitles. // -// node overview/voice.mjs [--api-key-file ~/.config/elevenlabs/api-key] [--dry-run] +// node overview/voice.mjs [web-algebra/SCRIPT.md] [--api-key-file ~/.config/elevenlabs/api-key] [--dry-run] // -// Writes tracks/overview/voice.json — id, text, spoken, file, seconds per line — and +// Another film's script can be named instead; its takes then go under tracks//. +// Writes tracks//voice.json — id, text, spoken, file, seconds per line — and // prints the lengths. import fs from 'node:fs/promises'; @@ -23,15 +24,15 @@ import { createHash } from 'node:crypto'; import { TRACKS } from '../lib/harness.mjs'; const HERE = path.dirname(new URL(import.meta.url).pathname); -const SCRIPT = path.join(HERE, 'SCRIPT.md'); -const OUT = path.join(TRACKS, 'overview'); +const args = process.argv.slice(2); +const SCRIPT = path.resolve(args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--api-key-file') ?? path.join(HERE, 'SCRIPT.md')); +const OUT = path.join(TRACKS, path.basename(path.dirname(SCRIPT))); const CACHE = path.join(OUT, 'voice'); const VOICE = 'onwK4e9ZLuTAKqWW03F9'; // Daniel const MODEL = 'eleven_multilingual_v2'; const FORMAT = 'mp3_44100_128'; -const args = process.argv.slice(2); const flag = (name, fallback) => { const i = args.indexOf(name); return i === -1 ? fallback : args[i + 1]; diff --git a/screencast/render/overview.mjs b/screencast/render/overview.mjs index 619f6b2..083de71 100644 --- a/screencast/render/overview.mjs +++ b/screencast/render/overview.mjs @@ -29,13 +29,14 @@ const cfg = JSON.parse(await fs.readFile(cfgPath, 'utf8')); const onlyArg = argv.indexOf('--only'); const only = onlyArg === -1 ? null : new Set(argv[onlyArg + 1].split(',')); const noAudio = argv.includes('--no-audio'); -// --reuse keeps the clips already cut (tracks/.overview/.mp4) and re-cuts only the ids +// --reuse keeps the clips already cut (tracks/./.mp4) and re-cuts only the ids // named after it, so a change to one shot does not re-encode the film's thirty. const reuseArg = argv.indexOf('--reuse'); const reuse = reuseArg === -1 ? null : new Set((argv[reuseArg + 1] ?? '').startsWith('--') || !argv[reuseArg + 1] ? [] : argv[reuseArg + 1].split(',')); const at = (p) => path.resolve(ROOT, p); const TRACKS = at('tracks'); -const TMP = path.join(TRACKS, '.overview'); +// each cut keeps its clips in its own folder, named after its output, so two cuts never share them +const TMP = path.join(TRACKS, `.${path.basename(cfg.output, '.mp4')}`); await fs.mkdir(TMP, { recursive: true }); function run(cmd, args) { @@ -137,7 +138,7 @@ async function cardHtml({ eyebrow, title, lede, links, note }, W, H) { .pill.is-primary { background: #98ccff linear-gradient(180deg, rgba(255,255,255,0.22), rgba(0,0,0,0.10)); color: #003259; border-color: transparent; } .note { font-family: 'Geist Mono', monospace; font-size: ${px(14)}; letter-spacing: 0.04em; text-transform: uppercase; color: #87929e; margin: ${px(36)} 0 0; }
-
LinkedDataHub
+
${esc(cfg.wordmark ?? 'LinkedDataHub')}
${eyebrow ? `

${esc(eyebrow)}

` : ''}

${esc(title)}

${lede ? `

${esc(lede)}

` : ''} diff --git a/screencast/scenes/probe-shacl-adm.mjs b/screencast/scenes/probe-shacl-adm.mjs new file mode 100644 index 0000000..4ff5ac2 --- /dev/null +++ b/screencast/scenes/probe-shacl-adm.mjs @@ -0,0 +1,33 @@ +// Not a scene. The admin Node shape form's target class lookup: which endpoint, which query, what comes back. +import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs'; +const opts = await resolve('/'); +const { identity, base } = opts; +const ADMIN = base.replace('://', '://admin.'); +const identities = identity ? [...identity, { ...identity[0], origin: ADMIN }] : null; +const NS_DOC = `${ADMIN}/ontologies/namespace/`; +const sugg = (page) => page.locator('.ac-cb-panel[role=listbox]:visible li.ac-cb-item').evaluateAll((ls) => ls.map((l) => [l.textContent.replace(/\s+/g, ' ').trim(), l.title])); +await runScene({ + id: 'probe-shacl-adm', target: NS_DOC, identity: identities, + geometry: geometryFrom(opts, { width: 1440, height: 900, deviceScaleFactor: 1 }), + async body({ page }) { + page.on('request', (r) => { const q = decodeURIComponent(r.url()) + (r.postData() ?? ''); if (/SELECT|DESCRIBE/.test(q) && /label|Label/.test(q)) console.log(' → ', r.method(), r.url().slice(0, 90), '|', q.replace(/\s+/g, ' ').slice(q.indexOf('SELECT'), q.indexOf('SELECT') + 400)); }); + page.on('response', async (r) => { if (/sparql|\/ns\?/.test(r.url()) && r.request().method() !== 'OPTIONS') { try { const t = await r.text(); if (/label/i.test(decodeURIComponent(r.url()))) console.log(' ← ', r.status(), t.replace(/\s+/g, ' ').slice(0, 300)); } catch {} } }); + await page.context().addCookies([{ name: 'LinkedDataHub.first-time-message', value: 'true', domain: new URL(ADMIN).hostname, path: '/' }]); + await page.goto(NS_DOC, { waitUntil: 'load' }); + await page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 30_000 }); + await sleep(2500); + await page.locator('.ldh-create-dock button.drop-toggle').first().click(); await sleep(1200); + await page.locator('.add-constructor:visible').filter({ hasText: /Node shape$/ }).first().click(); await sleep(3500); + const g = page.locator('form:visible .ldh-prop-group').filter({ hasText: /target class/i }).first(); + const input = g.locator('input.resource-combobox:visible').first(); + console.log(' input attrs:', await input.evaluate((i) => JSON.stringify({ name: i.name, cls: i.className, ds: { ...i.dataset }, list: i.getAttribute('list') }))); + console.log(' group html:', (await g.evaluate((e) => e.outerHTML)).slice(0, 1500)); + await input.click(); await input.pressSequentially('Person', { delay: 60 }); await sleep(8000); + console.log(' targetClass suggestions (Person):', JSON.stringify(await sugg(page))); + await input.fill(''); await input.pressSequentially('Namespace', { delay: 60 }); await sleep(8000); + console.log(' targetClass suggestions (Namespace):', JSON.stringify(await sugg(page))); + // a typed URI? + await input.fill(''); await input.pressSequentially('http://xmlns.com/foaf/0.1/Person', { delay: 20 }); await sleep(5000); + console.log(' after typing URI: suggestions', JSON.stringify(await sugg(page)), 'hidden ou:', await g.locator('input[name=ou]').evaluateAll((is) => is.map((i) => i.value))); + }, +}); diff --git a/screencast/scenes/probe-shacl-flow.mjs b/screencast/scenes/probe-shacl-flow.mjs new file mode 100644 index 0000000..063929c --- /dev/null +++ b/screencast/scenes/probe-shacl-flow.mjs @@ -0,0 +1,106 @@ +// Not a scene. Dry run of the SHACL flow: before (no Person type), admin creates a property +// shape and a node shape for foaf:Person, clears the ontology, after (Person offered, form +// from the shape, empty save → violation). +import { runScene, resolve, geometryFrom, sleep } from '../lib/harness.mjs'; +import { switchDocumentMode } from '../lib/blocks.mjs'; +import { patchDocument } from '../lib/fixture.mjs'; +const opts = await resolve('/'); +const { identity, base } = opts; +const ADMIN = base.replace('://', '://admin.'); +const identities = identity ? [...identity, { ...identity[0], origin: ADMIN }] : null; +const NS_DOC = `${ADMIN}/ontologies/namespace/`; +const plain = { click: async (l) => l.click() }; +const sugg = (page) => page.locator('.ac-cb-panel[role=listbox]:visible li.ac-cb-item').evaluateAll((ls) => ls.map((l) => [l.textContent.replace(/\s+/g, ' ').trim(), l.title])); +const formOf = (page) => page.locator('form:visible').filter({ has: page.locator('.ldh-prop-group') }).last(); +const dumpForm = async (page, what) => { + const groups = await formOf(page).locator('.ldh-prop-group').evaluateAll((gs) => gs.map((g) => ({ + label: (g.querySelector('.pred')?.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 30), + inputs: [...g.querySelectorAll('input:not([type=hidden]), textarea, select')].map((i) => `${i.tagName.toLowerCase()}.${i.className.split(' ')[0]}#${i.name}=${(i.value ?? '').slice(0, 30)}`), + violation: g.className.includes('violation'), text: g.className.includes('violation') ? g.textContent.replace(/\s+/g, ' ').trim().slice(0, 160) : undefined, + }))); + console.log(` ${what}:`, JSON.stringify(groups)); +}; +const group = (page, re) => formOf(page).locator('.ldh-prop-group').filter({ has: page.locator('.pred', { hasText: re }) }).first(); +const typeIn = async (page, re, text) => { const i = group(page, re).locator('input:not([type=hidden]):visible, textarea:visible').first(); await i.click(); await i.fill(''); await i.pressSequentially(text, { delay: 15 }); }; +const removeRow = async (page, re) => { const b = group(page, re).locator('button.btn-remove-property').first(); if (await b.count()) await b.click(); }; +const save = async (page) => { await formOf(page).locator('button.btn-save').first().click(); await sleep(4000); }; +const dockCreate = async (page, label) => { + await page.locator('.ldh-create-dock button.drop-toggle').first().click(); await sleep(1200); + await page.locator('.add-constructor:visible').filter({ hasText: new RegExp(`${label}$`) }).first().click(); await sleep(3500); +}; +const RESET = `PREFIX sh: +DELETE { ?s ?p ?o } WHERE { ?s a ?t . FILTER(?t IN (sh:NodeShape, sh:PropertyShape)) ?s ?p ?o }`; +const instanceTypeInput = async (page) => { const g = formOf(page).locator('.ldh-prop-group').filter({ has: page.locator('input[name=pu][value$="#type"]') }).first(); const edit = g.locator('button.add-type-combobox').first(); if (await edit.count()) { await edit.click(); await sleep(800); } return g.locator('input:not([type=hidden]):visible').first(); }; + +const reset = await patchDocument({ ldh: opts.ldh, certFile: opts.certFile, certPassword: opts.certPassword, certPasswordFile: opts.certPasswordFile, url: NS_DOC, update: RESET }); +console.log(' reset:', JSON.stringify(reset)); +await runScene({ + id: 'probe-shacl-flow', target: base + '/', identity: identities, + geometry: geometryFrom(opts, { width: 1440, height: 900, deviceScaleFactor: 1 }), + async body({ page }) { + page.on('response', async (r) => { if ([422, 400, 500].includes(r.status())) { try { console.log(' ←', r.status(), r.request().method(), r.url().slice(0, 80), (await r.text()).replace(/\s+/g, ' ').slice(0, 600)); } catch {} } }); + await page.context().addCookies([{ name: 'LinkedDataHub.first-time-message', value: 'true', domain: new URL(ADMIN).hostname, path: '/' }]); + + // ── before ── + await page.goto(base + '/', { waitUntil: 'load' }); + await page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 30_000 }); await sleep(2000); + await switchDocumentMode(page, plain, 'read-mode'); await sleep(2500); + await dockCreate(page, 'Instance'); + console.log(' Instance form html:', (await formOf(page).evaluate((f) => f.outerHTML)).slice(0, 2500)); + const tin = await instanceTypeInput(page); + console.log(' type input:', await tin.count(), await tin.evaluate((i) => `${i.tagName}.${i.className}#${i.name}`).catch(() => null)); + if (await tin.count()) { await tin.click(); await tin.pressSequentially('Person', { delay: 40 }); await sleep(6000); console.log(' BEFORE type suggestions (Person):', JSON.stringify(await sugg(page))); } + + // ── admin: property shape ── + await page.goto(NS_DOC, { waitUntil: 'load' }); + await page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 30_000 }); await sleep(2500); + await dockCreate(page, 'Property shape'); + await dumpForm(page, 'Property shape form (fresh)'); + await typeIn(page, /^Name$/, 'Name'); + await typeIn(page, /^Path$/, 'http://xmlns.com/foaf/0.1/name'); await sleep(1500); await page.keyboard.press('Escape'); + await typeIn(page, /^Min count$/, '1'); + await typeIn(page, /^Datatype$/, 'http://www.w3.org/2001/XMLSchema#string'); await sleep(1500); await page.keyboard.press('Escape'); + await removeRow(page, /^Max count$/); + await dumpForm(page, 'Property shape form (filled)'); + await save(page); + console.log(' after PS save: forms', await page.locator('form:visible').filter({ has: page.locator('.ldh-prop-group') }).count(), 'blocks', await page.locator('.ldh-pane.is-active .ldh-block').count()); + if (await page.locator('form:visible').filter({ has: page.locator('.ldh-prop-group') }).count()) await dumpForm(page, 'PS form still open'); + + // ── admin: node shape ── + await dockCreate(page, 'Node shape'); + await typeIn(page, /^Label$/, 'Person shape'); + await typeIn(page, /^Target class$/, 'http://xmlns.com/foaf/0.1/Person'); await sleep(1500); await page.keyboard.press('Escape'); + const pin = group(page, /^Property$/).locator('input:not([type=hidden]):visible').first(); + await pin.click(); await pin.pressSequentially('Name', { delay: 40 }); await sleep(6000); + const s = await sugg(page); console.log(' Property suggestions (Name):', JSON.stringify(s)); + const hit = page.locator('.ac-cb-panel[role=listbox]:visible li.ac-cb-item').filter({ hasText: /Name/ }).first(); + if (await hit.count()) await hit.click(); else console.log(' !! no property shape suggestion'); + await sleep(800); + await dumpForm(page, 'Node shape form (filled)'); + await save(page); + console.log(' after NS save: forms', await page.locator('form:visible').filter({ has: page.locator('.ldh-prop-group') }).count(), 'blocks', await page.locator('.ldh-pane.is-active .ldh-block').count()); + if (await page.locator('form:visible').filter({ has: page.locator('.ldh-prop-group') }).count()) await dumpForm(page, 'NS form still open'); + console.log(' blocks:', JSON.stringify(await page.locator('.ldh-pane.is-active .ldh-block').evaluateAll((bs) => bs.map((b) => b.textContent.replace(/\s+/g, ' ').trim().slice(0, 120))))); + + // ── clear ── + const clear = page.locator('.ldh-pane.is-active form[action$="/clear"] button').first(); + console.log(' clear button:', await clear.count()); + if (await clear.count()) { await clear.click(); await page.waitForLoadState('load'); await sleep(3000); console.log(' after clear url:', page.url()); } + + // ── after ── + await page.goto(base + '/', { waitUntil: 'load' }); + await page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 30_000 }); await sleep(2000); + await switchDocumentMode(page, plain, 'read-mode'); await sleep(2500); + await dockCreate(page, 'Instance'); + const tin2 = await instanceTypeInput(page); + await tin2.click(); await tin2.pressSequentially('Person', { delay: 40 }); await sleep(6000); + const s2 = await sugg(page); console.log(' AFTER type suggestions (Person):', JSON.stringify(s2)); + const person = page.locator('.ac-cb-panel[role=listbox]:visible li.ac-cb-item[title="http://xmlns.com/foaf/0.1/Person"]').first(); + if (await person.count()) { + await person.click(); await sleep(5000); + await dumpForm(page, 'Person form'); + await save(page); + await dumpForm(page, 'after empty save'); + } else console.log(' !! Person not offered'); + }, +}); diff --git a/screencast/scenes/shacl-person.mjs b/screencast/scenes/shacl-person.mjs new file mode 100644 index 0000000..e19a89b --- /dev/null +++ b/screencast/scenes/shacl-person.mjs @@ -0,0 +1,240 @@ +// The model builds the interface, SHACL edition: a dataspace that cannot create a Person +// (S0); in the admin dataspace a property shape — foaf:name, string, at least one — and a +// node shape targeting foaf:Person are created in the namespace ontology, with the ontology's +// own Clear pressed afterwards (A1–A3); back in the end-user dataspace Person is now a type +// on offer and its form carries the Name field the shape asked for (B1); saved without a +// name the server answers 422 with a sh:ValidationResult and the field is marked (B2); named, +// the record is saved and shown (B3). +// +// Both halves are filmed in one tab: the admin origin is the end-user origin with an +// `admin.` prefix, so the owner identity is bound to both (as overview-g-packages does). +// +// The class and property lookups on the shape forms query the admin store, which holds no +// vocabulary, so foaf:Person, foaf:name and xsd:string are typed as URIs — a vocabulary term +// the ontology's author knows, like the URI the user guide has them type for a constructor. +// Everything else is picked by label on camera: the property shape by its sh:name, Person by +// its class label. +// +// Off camera first: the shapes and any Person made by an earlier take are removed and the +// ontology cache cleared, so the "before" is real. +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { runScene, resolve, geometryFrom, sleep, until } from '../lib/harness.mjs'; +import { patchDocument } from '../lib/fixture.mjs'; +import { ui } from '../lib/dom.mjs'; +import { switchDocumentMode } from '../lib/blocks.mjs'; +import { GEOMETRY_2X, zoom2x, focus, centre, load, easeScrollTo } from '../lib/supercut.mjs'; + +const opts = await resolve('/'); +const { base, identity, ldh, certFile, certPasswordFile, certPassword } = opts; +const password = certPassword ?? (await fs.readFile(certPasswordFile, 'utf8')).trim(); +const ADMIN = base.replace('://', '://admin.'); +const NS_DOC = `${ADMIN}/ontologies/namespace/`; +const NS = `${base}/ns#`; +const ROOT = `${base}/`; +const PERSON = 'Ada Lovelace'; +const FOAF = 'http://xmlns.com/foaf/0.1/'; +const XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string'; + +// ── reset ──────────────────────────────────────────────────────────────────── +const fx = { ldh, certFile, certPassword, certPasswordFile }; +const r1 = await patchDocument({ ...fx, url: NS_DOC, update: `PREFIX sh: +DELETE { ?s ?p ?o } WHERE { ?s a ?t . FILTER(?t IN (sh:NodeShape, sh:PropertyShape)) ?s ?p ?o }` }); +if (!r1.ok) throw new Error(`reset: could not remove the shapes: ${r1.out}`); +const r2 = await patchDocument({ ...fx, url: ROOT, update: `PREFIX foaf: <${FOAF}> +DELETE { ?s ?p ?o } WHERE { ?s a foaf:Person ; ?p ?o }` }); +if (!r2.ok) throw new Error(`reset: could not remove earlier people: ${r2.out}`); +const run = (args) => new Promise((res) => { const p = spawn(ldh, args); let out = '', err = ''; p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => (err += d)); p.on('close', (code) => res({ code, out: out.trim(), err: err.trim().split(password).join('••••') })); }); +const cleared = await run(['admin', 'clear', 'ontology', ROOT, '-c', certFile, '-p', password, '--ontology', NS]); +if (cleared.code !== 0) throw new Error(`reset: could not clear the ontology: ${cleared.err.slice(0, 200)}`); +console.log(' reset: shapes and people removed, ontology cleared'); + +const identities = identity ? [...identity, { ...identity[0], origin: ADMIN }] : null; + +await runScene({ + id: 'shacl-person', target: ROOT, warm: ROOT, identity: identities, + geometry: geometryFrom(opts, GEOMETRY_2X), + async body({ page, cursor, type, marks }) { + await zoom2x(page); + await page.emulateMedia({ colorScheme: 'dark' }); + await page.context().addCookies([{ name: 'LinkedDataHub.first-time-message', value: 'true', domain: new URL(ADMIN).hostname, path: '/' }]); + + const form = () => ui(page).locator('form.ldh-edit-form:visible').last(); + const group = (re) => form().locator('.ldh-prop-group').filter({ has: page.locator('.pred', { hasText: re }) }).first(); + const sugg = () => page.locator('.ac-cb-panel[role=listbox]:visible li.ac-cb-item'); + const typeInto = async (re, text, { uri = false } = {}) => { + const input = group(re).locator('input:not([type=hidden]):visible, textarea:visible').first(); + await input.scrollIntoViewIfNeeded(); + await cursor.click(input); + await type(input, text, uri ? { base: 18, spread: 6 } : { base: 30, spread: 12 }); + // a typed URI opens the lookup panel on nothing; it is closed, the URI stays the value + if (uri) { await sleep(900); await page.keyboard.press('Escape'); await sleep(300); } + }; + const removeRow = async (re) => { + const btn = group(re).locator('button.btn-remove-property').first(); + await btn.scrollIntoViewIfNeeded(); await cursor.click(btn); await sleep(500); + }; + const saveBtn = () => form().locator('button.btn-save').first(); + const dockCreate = async (label) => { + const toggle = page.locator('.ldh-create-dock button.drop-toggle').first(); + await until(toggle.waitFor({ state: 'visible', timeout: 20_000 }), 'the Create dock'); + await cursor.moveTo(...(await centre(toggle)), { duration: 600 }); + await cursor.click(toggle); await sleep(1100); + const item = page.locator('.add-constructor:visible').filter({ hasText: new RegExp(`${label}$`) }).first(); + await until(item.waitFor({ state: 'visible', timeout: 10_000 }), `${label} in the Create menu`); + await cursor.click(item); + await until(form().waitFor({ state: 'visible', timeout: 15_000 }), `the ${label} form`); + await sleep(1500); + }; + // the Instance form's Type is a committed chip; its edit opens the class lookup + const openTypeLookup = async () => { + const g = form().locator('.ldh-prop-group').filter({ has: page.locator('input[name=pu][value$="#type"]') }).first(); + const edit = g.locator('button.add-type-combobox').first(); + await cursor.click(edit); await sleep(800); + const input = g.locator('input:not([type=hidden]):visible').first(); + await cursor.click(input); + await type(input, 'Person', { base: 60, spread: 20 }); + return input; + }; + const blocks = () => ui(page).locator('.ldh-block'); + + // ── S0 · before: no Person to create ─────────────────────────────────────── + // The root opens in Content mode on its children view; the Create dock offers the model's + // classes in Properties, so the switch is on camera (the Properties page of an empty root + // has no blocks, only the dock). + const toProperties = async () => { + await switchDocumentMode(page, cursor, 'read-mode'); + await until(page.locator('.ldh-create-dock button.drop-toggle').first().waitFor({ state: 'visible', timeout: 20_000 }), 'the Create dock in Properties'); + await sleep(1200); + }; + await load(page, ROOT, '.ldh-pane.is-active .ldh-block', 3000); + await marks.beat('S0-start', 'the dataspace, empty but for its root', await focus(blocks().first())); + await sleep(1500); + await toProperties(); + await marks.beat('S0-properties', 'Properties; Create at the foot of the page'); + await dockCreate('Instance'); + await marks.beat('S0-form', 'a new Instance; its Type, a lookup over the model\'s classes', await focus(form())); + await openTypeLookup(); + await sleep(5000); + const before = (await sugg().allInnerTexts()).map((t) => t.replace(/\s+/g, ' ').trim()); + if (before.some((t) => /^Person\b/.test(t))) throw new Error(`S0: Person is already on offer: ${JSON.stringify(before)}`); + await marks.beat('S0-end', `"Person" finds no class: the model has nothing to build a Person from (offered: ${JSON.stringify(before)})`, await focus(form())); + await sleep(1500); + await page.keyboard.press('Escape'); + + // ── A1 · admin: the property shape ───────────────────────────────────────── + await marks.beat('A1-start', 'to the admin dataspace: the namespace ontology'); + await load(page, NS_DOC, '.ldh-pane.is-active .ldh-block', 3000); + await marks.beat('A1-doc', 'the Namespace ontology, one block', await focus(blocks().first())); + await sleep(1500); + await dockCreate('Property shape'); + await marks.beat('A1-form', 'the Property shape form: name, path, counts, datatype', await focus(form())); + await typeInto(/^Name$/, 'Name'); + await typeInto(/^Path$/, `${FOAF}name`, { uri: true }); + await typeInto(/^Min count$/, '1'); + await typeInto(/^Datatype$/, XSD_STRING, { uri: true }); + await removeRow(/^Max count$/); + await marks.beat('A1-filled', 'foaf:name, a string, at least one; max count dropped', await focus(form())); + await saveBtn().scrollIntoViewIfNeeded(); + await cursor.moveTo(...(await centre(saveBtn())), { duration: 500 }); + await cursor.click(saveBtn()); + await until(page.waitForFunction(() => [...document.querySelectorAll('.ldh-pane.is-active .ldh-block')].some((b) => /Property shape/.test(b.textContent) && !b.querySelector('form')), null, { timeout: 20_000 }), 'the property shape as a block'); + await sleep(2000); + const psBlock = blocks().filter({ hasText: 'Property shape' }).first(); + await easeScrollTo(psBlock, { ms: 900, block: 'center' }); await sleep(600); + await marks.beat('A1-end', 'saved: the property shape, a resource of the ontology', await focus(psBlock)); + await sleep(1200); + + // ── A2 · admin: the node shape ───────────────────────────────────────────── + await dockCreate('Node shape'); + await marks.beat('A2-form', 'the Node shape form: label, property, target class', await focus(form())); + await typeInto(/^Label$/, 'Person shape'); + await typeInto(/^Target class$/, `${FOAF}Person`, { uri: true }); + const pin = group(/^Property$/).locator('input:not([type=hidden]):visible').first(); + await pin.scrollIntoViewIfNeeded(); + await cursor.click(pin); + await type(pin, 'Name', { base: 60, spread: 20 }); + const hit = sugg().filter({ hasText: /Name/ }).filter({ hasText: /Property shape/ }).first(); + await until(hit.waitFor({ state: 'visible', timeout: 15_000 }), 'the property shape in the lookup'); + await sleep(1200); + await marks.beat('A2-lookup', 'Property: the shape just made, found by its name', await focus(form())); + await cursor.click(hit); await sleep(800); + await marks.beat('A2-filled', 'targets foaf:Person, carries the Name property shape', await focus(form())); + await saveBtn().scrollIntoViewIfNeeded(); + await cursor.moveTo(...(await centre(saveBtn())), { duration: 500 }); + await cursor.click(saveBtn()); + await until(page.waitForFunction(() => [...document.querySelectorAll('.ldh-pane.is-active .ldh-block')].some((b) => /Node shape/.test(b.textContent) && !b.querySelector('form')), null, { timeout: 20_000 }), 'the node shape as a block'); + await sleep(2000); + const nsBlock = blocks().filter({ hasText: 'Node shape' }).first(); + await easeScrollTo(nsBlock, { ms: 900, block: 'center' }); await sleep(600); + await marks.beat('A2-end', 'saved: the node shape', await focus(nsBlock)); + await sleep(1200); + + // ── A3 · the ontology's Clear: the cached closure is rebuilt ─────────────── + const clear = ui(page).locator('form[action$="/clear"] button').first(); + await until(clear.waitFor({ state: 'visible', timeout: 10_000 }), 'the ontology\'s Clear button'); + await easeScrollTo(clear, { ms: 900, block: 'center' }); await sleep(500); + await cursor.moveTo(...(await centre(clear)), { duration: 600 }); + await marks.beat('A3-start', 'pointer on Clear: the dataspace re-reads its model', await focus(blocks().first())); + await cursor.click(clear); + await page.waitForLoadState('load').catch(() => {}); + await until(page.waitForSelector('.ldh-pane.is-active .ldh-block', { timeout: 30_000 }), 'the ontology, reloaded'); + await sleep(2000); + await marks.beat('A3-end', 'cleared and reloaded'); + + // ── B1 · after: Person on offer, its form from the shape ─────────────────── + await marks.beat('B1-start', 'back to the dataspace'); + await load(page, ROOT, '.ldh-pane.is-active .ldh-block', 3000); + await toProperties(); + await dockCreate('Instance'); + await openTypeLookup(); + const person = sugg().filter({ has: page.locator('[title="http://xmlns.com/foaf/0.1/Person"]') }).first(); + const personAlt = page.locator('.ac-cb-panel[role=listbox]:visible li.ac-cb-item[title="http://xmlns.com/foaf/0.1/Person"]').first(); + const found = await Promise.race([ + person.waitFor({ state: 'visible', timeout: 15_000 }).then(() => person, () => null), + personAlt.waitFor({ state: 'visible', timeout: 15_000 }).then(() => personAlt, () => null), + ]); + if (!found) throw new Error(`B1: Person not offered after the shape (offered: ${JSON.stringify(await sugg().allInnerTexts())})`); + await sleep(1200); + await marks.beat('B1-offered', '"Person" now finds a class: the shape made it one the dataspace can build', await focus(form())); + await cursor.click(found); + const nameField = () => group(/^Name$/); + const failure = form().locator('.ldh-failure'); + const outcome = await Promise.race([ + nameField().waitFor({ state: 'visible', timeout: 20_000 }).then(() => 'field', () => null), + failure.waitFor({ state: 'visible', timeout: 20_000 }).then(() => 'failure', () => null), + ]); + if (outcome === 'failure') throw new Error(`B1: the Person form failed to render — ${(await failure.innerText()).replace(/\s+/g, ' ').slice(0, 300)}`); + if (outcome !== 'field') throw new Error(`B1: no Name field on the Person form (groups: ${JSON.stringify(await form().locator('.pred').allInnerTexts())})`); + await sleep(2000); + await marks.beat('B1-end', 'the Person form: a Name field, from the shape — no constructor was written', await focus(form())); + await sleep(1500); + + // ── B2 · saved without a name: 422, the field marked ─────────────────────── + await saveBtn().scrollIntoViewIfNeeded(); + await cursor.moveTo(...(await centre(saveBtn())), { duration: 600 }); + await marks.beat('B2-save', 'Name empty; pointer on Save', await focus(form())); + await cursor.click(saveBtn()); + const violated = () => form().locator('.ldh-prop-group.is-violation'); + await until(violated().first().waitFor({ state: 'visible', timeout: 20_000 }), 'a violation on a field'); + await sleep(1500); + const which = (await violated().allTextContents()).map((t) => t.replace(/\s+/g, ' ').trim().slice(0, 60)); + if (!which.some((w) => /name/i.test(w))) throw new Error(`B2: the violation landed on ${JSON.stringify(which)}, not on Name`); + await marks.beat('B2-end', `refused by the shape, 422: ${JSON.stringify(which)}`, await focus(form())); + await sleep(1500); + + // ── B3 · named, saved, shown ─────────────────────────────────────────────── + await typeInto(/^Name$/, PERSON); + await saveBtn().scrollIntoViewIfNeeded(); + await cursor.moveTo(...(await centre(saveBtn())), { duration: 500 }); + await marks.beat('B3-filled', 'the name in; pointer on Save', await focus(form())); + await cursor.click(saveBtn()); + await until(page.waitForFunction((t) => (document.querySelector('.ldh-pane.is-active')?.innerText ?? '').includes(t) && !document.querySelector('.ldh-pane.is-active form.ldh-edit-form'), PERSON, { timeout: 30_000 }), 'the person on the page'); + await sleep(1500); + const personBlock = blocks().filter({ hasText: PERSON }).first(); + await easeScrollTo(personBlock, { ms: 900, block: 'center' }); await sleep(800); + await marks.beat('B3-end', `saved: ${PERSON}, a Person, on the page`, await focus(personBlock)); + await sleep(1500); + await marks.beat('end'); + }, +}); diff --git a/screencast/web-algebra/SCRIPT.md b/screencast/web-algebra/SCRIPT.md new file mode 100644 index 0000000..11c7242 --- /dev/null +++ b/screencast/web-algebra/SCRIPT.md @@ -0,0 +1,50 @@ +# Web-Algebra in action — narration script (draft 1) + +A short film for the Web-Algebra README's Demo section, replacing "Agentic Content Management +with Web-Algebra MCP" on YouTube. About two minutes, cut from the overview's Flow H takes: the +MCP half (an agent in Claude Code) leads, because the README is the MCP server's, and the +LinkedDataHub assistant follows as the same operations in a second client. + +It opens on the page the agent wrote, then shows how it was written. Voice, synthesis, +pacing and mix are the overview's (`overview/SCRIPT.md`, Production notes): + +```bash +node overview/voice.mjs web-algebra/SCRIPT.md +node render/overview.mjs web-algebra/cut.json +``` + +## The agent + +| # | On screen | Narration | +|---|---|---| +| W1 | The offers container on Northwind: the chart of offer prices | An AI agent built this page: a container of new offers, three discounted products, and a chart of their prices. It was asked in one sentence. | +| W2 | Terminal: `claude mcp add web-algebra …`, then `claude`, `/mcp` listing it connected | Web-Algebra is an open-source MCP server of composable operations for Linked Data: loading, querying and writing RDF. One command registers it with Claude Code. | +| W3 | The prompt typed, aimed at the Northwind dataspace | The request is one sentence, aimed at the Northwind dataspace: a container for new offers, a twenty percent discount on three Beverages products, and a chart of the offer prices. | +| W4 | The tool calls going by; "Items use sioc:has_container, not has_parent. Fixing the query." | The agent reads the data first: a product, then the shape of an existing offer. When its query misses, it checks the model and fixes it. | +| W5 | Claude Code's summary: the three products, list and offer prices | It reports what it wrote: three offers at twenty percent off, and the query behind the chart. | +| W6 | The browser: the offers container, its items, the chart | In LinkedDataHub they are ordinary documents. You can browse them, query them, and keep editing them by hand. | + +## Inside LinkedDataHub + +| # | On screen | Narration | +|---|---|---| +| W7 | A Northwind page; the create bar's Assistant; "Chart the five customers who ordered the most"; the steps report as the plan runs | The same operations run inside LinkedDataHub. Its assistant writes a question as a Web-Algebra plan, and a plan that only reads runs right away. | +| W8 | The answer in words; the rows as a bar chart; the trace, its three steps | The answer comes back in words, the rows drawn as a chart, and every step of the plan in its trace. | +| W9 | "Add the chart to this page"; the plan waits with its operations listed; Execute; the chart becomes a block of the page | A plan that writes waits for you to press Execute. This one adds the chart to the page. | +| Z1 | The page with the chart block; the closing card fades in | Web-Algebra: composable Linked Data operations, for agents and for people. Open source, on GitHub. | + +## Takes + +| Take | Shots | Notes | +|---|---|---| +| `overview-h-agent` | W1, W6 | the browser half of `scenes/overview-h-agent.mjs`, 2026-10-15 | +| `overview-h-agent-terminal` | W2–W5 | `tapes/overview-h-agent.tape`, Web-Algebra 2.0.0 built from the local checkout | +| `overview-h-assistant-take2` | W7–W9, Z1 | `scenes/overview-h-assistant.mjs` | + +## Pronunciation + +| Written | Spoken | +|---|---| +| LinkedDataHub | Linked Data Hub | +| MCP | M C P | +| RDF | R D F | diff --git a/screencast/web-algebra/cut.json b/screencast/web-algebra/cut.json new file mode 100644 index 0000000..7cf1658 --- /dev/null +++ b/screencast/web-algebra/cut.json @@ -0,0 +1,220 @@ +{ + "output": "tracks/web-algebra.mp4", + "width": 1920, + "height": 1080, + "fps": 25, + "crf": 20, + "source": { + "width": 2880, + "height": 1800 + }, + "viewport": { + "width": 2880, + "height": 1620 + }, + "terminal": { + "width": 1440, + "height": 810 + }, + "maxZoom": 1.7, + "minZoom": 1.15, + "leadSeconds": 0.3, + "trailSeconds": 0.1, + "lineOffset": 0.5, + "linePad": 1.0, + "card": { + "seconds": 3.4, + "fade": 0.5 + }, + "wordmark": "Web-Algebra", + "voice": "tracks/web-algebra/voice.json", + "music": { + "file": "tracks/overview/music.mp3", + "gain": -22, + "duck": -14, + "fadeOut": 2.0 + }, + "slide": { + "eyebrow": "Open-source MCP server", + "title": "Linked Data operations, composed.", + "lede": "Load, query and write RDF with operations an agent can call one by one, or compose into a plan that runs in one go.", + "links": [ + { + "label": "github.com/AtomGraph/Web-Algebra", + "primary": true + }, + { + "label": "linkeddatahub.com" + } + ], + "note": "Open source under Apache 2.0", + "fade": 1.2, + "seconds": 6, + "at": 1.0 + }, + "shots": [ + { + "id": "W1", + "line": "W1", + "track": "overview-h-agent", + "from": "H7-chart", + "to": "H7-end", + "move": "zoom", + "minZoom": 1.0 + }, + { + "id": "W2", + "line": "W2", + "track": "overview-h-agent-terminal", + "from": 1, + "to": 31, + "kind": "terminal", + "speed": 1.7 + }, + { + "id": "W3", + "line": "W3", + "track": "overview-h-agent-terminal", + "from": 31, + "to": 45, + "kind": "terminal", + "speed": 1.14 + }, + { + "id": "W3w", + "track": "overview-h-agent-terminal", + "from": 45, + "to": 98, + "kind": "terminal", + "speed": 6 + }, + { + "id": "W4", + "line": "W4", + "track": "overview-h-agent-terminal", + "from": 98, + "to": 110, + "kind": "terminal" + }, + { + "id": "W4w", + "track": "overview-h-agent-terminal", + "from": 110, + "to": 127.5, + "kind": "terminal", + "speed": 6 + }, + { + "id": "W5", + "line": "W5", + "track": "overview-h-agent-terminal", + "from": 127.5, + "to": 134, + "kind": "terminal" + }, + { + "id": "W6", + "line": "W6", + "track": "overview-h-agent", + "from": "H7-start", + "to": "H7-end", + "move": "zoom", + "via": [ + "H7-chart" + ], + "minZoom": 1.0 + }, + { + "card": "Inside LinkedDataHub", + "eyebrow": "The same operations" + }, + { + "id": "W7a", + "track": "overview-h-assistant-take2", + "from": 8.6, + "to": "H1-asked", + "move": "zoom", + "minZoom": 1.0, + "speed": 1.6 + }, + { + "id": "W7w", + "track": "overview-h-assistant-take2", + "from": "H1-asked", + "to": "H1-running", + "move": "zoom", + "minZoom": 1.0, + "speed": 6 + }, + { + "id": "W7", + "line": "W7", + "track": "overview-h-assistant-take2", + "from": "H1-running", + "to": "H1-end", + "move": "zoom", + "minZoom": 1.0, + "speed": 0.71 + }, + { + "id": "W8", + "line": "W8", + "track": "overview-h-assistant-take2", + "from": "H2-start", + "to": "H2-end", + "move": "zoom", + "via": [ + "H2-chart" + ], + "minZoom": 1.0 + }, + { + "id": "W9a", + "track": "overview-h-assistant-take2", + "from": "H3-start", + "to": "H3-asked", + "move": "zoom", + "minZoom": 1.0, + "speed": 1.3 + }, + { + "id": "W9w", + "track": "overview-h-assistant-take2", + "from": "H3-asked", + "to": "H3-plan", + "move": "zoom", + "minZoom": 1.0, + "speed": 7 + }, + { + "id": "W9", + "line": "W9", + "track": "overview-h-assistant-take2", + "from": "H3-plan", + "to": "H3-end", + "move": "zoom", + "via": [ + "H3-execute", + "H3-written" + ], + "minZoom": 1.0 + }, + { + "id": "Z1", + "line": "Z1", + "track": "overview-h-assistant-take2", + "from": "H3-end", + "to": "H3-end", + "hold": 11.2, + "move": "static", + "focus": false, + "scale": 1, + "kind": "close", + "lineOffset": 2.6 + } + ], + "tone": { + "file": "tracks/overview/tone.mp3", + "gain": -8 + } +}