From b215a2c5bb4d54ee2fc44b1d675c77c453b48756 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 13 Sep 2026 14:29:46 +1000 Subject: [PATCH] Explain hosted sharing and refine homepage animation --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 2 +- package.json | 3 +- public/mdbase-murmuration.js | 146 +++++++++++++++++++++++++++----- scripts/home-animation.test.mjs | 106 +++++++++++++++++++++++ scripts/sync-sources.mjs | 16 +--- src/pages/apps/index.astro | 2 +- src/pages/connect/index.astro | 33 +++++--- src/pages/index.astro | 25 +++++- src/styles/home.css | 13 ++- 10 files changed, 295 insertions(+), 53 deletions(-) create mode 100644 scripts/home-animation.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f397fd2..5205f3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,7 @@ jobs: pnpm check:release-order "$BASE_SHA" - name: Type and content checks - run: pnpm test:deploy:dev && pnpm test:release-update && pnpm check:release && pnpm check + run: pnpm test:home && pnpm test:deploy:dev && pnpm test:release-update && pnpm check:release && pnpm check env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c9c0383..201091e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -103,7 +103,7 @@ jobs: MDBASE_TS_DIR: .sources/mdbase - name: Check and build website - run: pnpm check:release && pnpm check && pnpm build + run: pnpm test:home && pnpm check:release && pnpm check && pnpm build env: GH_TOKEN: ${{ github.token }} diff --git a/package.json b/package.json index f825227..004dc13 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "check:release-order": "node scripts/check-connect-release-order.mjs", "test:deploy:dev": "node --test scripts/deploy-pages-dev.test.mjs", "test:release-update": "node --test scripts/connect-release-record.test.mjs", - "test": "pnpm test:deploy:dev && pnpm test:release-update && pnpm check:release && pnpm check && pnpm build && pnpm import:spec && pnpm check:links" + "test:home": "node --test scripts/home-animation.test.mjs", + "test": "pnpm test:home && pnpm test:deploy:dev && pnpm test:release-update && pnpm check:release && pnpm check && pnpm build && pnpm import:spec && pnpm check:links" }, "dependencies": { "@astrojs/sitemap": "^3.7.3", diff --git a/public/mdbase-murmuration.js b/public/mdbase-murmuration.js index ba07068..fc930de 100644 --- a/public/mdbase-murmuration.js +++ b/public/mdbase-murmuration.js @@ -82,11 +82,13 @@ function diagramBounds() { if (width <= 820) { + const headerBottom = document.querySelector(".site-header")?.getBoundingClientRect().bottom || 76; + const top = Math.max(headerBottom + 12, height * 0.10); return { x: width * 0.07, - y: Math.max(76, height * 0.10), + y: top, w: width * 0.86, - h: Math.min(height * 0.37, 330) + h: Math.max(170, Math.min(height * 0.44 - top, 280)) }; } @@ -108,11 +110,52 @@ } function addNode(id, label, detail, box, kind = "standard") { - const node = { id, label, detail, box, kind }; + const node = { id, label, detail, box, kind, heading: fitNodeLabel(label, box) }; diagram.nodes.push(node); return node; } + // Measure once per scene, not on every animation frame. Keep labels inside + // their boxes and reserve the same measured space above record particles. + function fitNodeLabel(label, box) { + const compact = width <= 820; + const padding = compact ? 7 : 10; + const availableWidth = Math.max(1, box.w - padding * 2); + const availableHeight = Math.max(1, box.h - padding * 2); + let lines = []; + let size = compact ? 14 : 17; + for (; size >= 11; size--) { + context.font = `${size}px "Atkinson Hyperlegible", sans-serif`; + lines = []; + let line = ""; + for (const word of label.split(" ")) { + const candidate = line ? `${line} ${word}` : word; + if (line && context.measureText(candidate).width > availableWidth) { + lines.push(line); + line = word; + } else line = candidate; + } + if (line) lines.push(line); + if (lines.every((line) => context.measureText(line).width <= availableWidth) + && lines.length * Math.ceil(size * 1.15) <= availableHeight) break; + } + size = Math.max(11, size); + const lineHeight = Math.ceil(size * 1.15); + const maxLines = Math.max(1, Math.floor(availableHeight / lineHeight)); + const overflow = lines.length > maxLines; + lines = lines.slice(0, maxLines); + context.font = `${size}px "Atkinson Hyperlegible", sans-serif`; + lines = lines.map((line, index) => { + if (context.measureText(line).width <= availableWidth + && !(overflow && index === lines.length - 1)) return line; + while (line && context.measureText(`${line}…`).width > availableWidth) { + line = line.slice(0, -1); + } + return `${line}…`; + }); + return { lines, size, lineHeight, padding, height: padding + lines.length * lineHeight }; + } + function pointOnNode(node, side, offset = 0.5) { const { x, y, w, h } = node.box; if (side === "left") return { x, y: y + h * offset }; @@ -132,7 +175,7 @@ } function gridParticles(start, count, node, role = "record") { - const labelSpace = width <= 820 ? 24 : 31; + const labelSpace = node.heading.height + 5; const padding = width <= 820 ? 7 : 11; const area = { x: node.box.x + padding, @@ -449,6 +492,7 @@ } function layoutGrant(bounds) { + const compact = bounds.w < 480; const collection = addNode( "grant-collection", "Chosen collection", @@ -460,20 +504,26 @@ "grant", "Your permission", "one app · one collection", - rect(bounds, 0.57, 0.35, 0.19, 0.28), + compact + ? rect(bounds, 0.64, 0.13, 0.35, 0.28) + : rect(bounds, 0.57, 0.35, 0.19, 0.28), "service" ); const app = addNode( "grant-app", "The app", "approved access", - rect(bounds, 0.82, 0.31, 0.17, 0.36), + compact + ? rect(bounds, 0.64, 0.57, 0.35, 0.28) + : rect(bounds, 0.82, 0.31, 0.17, 0.36), "app" ); - const askPath = [pointOnNode(app, "left", 0.38), pointOnNode(grant, "right", 0.38)]; + const askPath = compact + ? [pointOnNode(app, "top"), pointOnNode(grant, "bottom")] + : [pointOnNode(app, "left", 0.38), pointOnNode(grant, "right", 0.38)]; const accessPath = [ pointOnNode(grant, "left", 0.66), - pointOnNode(collection, "right", 0.54) + pointOnNode(collection, "right", compact ? 0.20 : 0.54) ]; addEdge(askPath, null, "asks", true); @@ -483,7 +533,7 @@ pathParticles(341, 20, accessPath); } - function layoutAccess(bounds) { + function layoutRoutes(bounds) { const localBoundary = addNode( "access-local", "Local collection", @@ -560,6 +610,47 @@ pathParticles(351, 10, mirrorPath); } + function layoutSharing(bounds) { + const collection = addNode( + "sharing-collection", "Hosted collection", "one shared collection", + rect(bounds, 0.01, 0.13, 0.48, 0.72), "authority" + ); + const viewer = addNode( + "sharing-viewer", "Viewer", "can read", + rect(bounds, 0.64, 0.13, 0.35, 0.28), "service" + ); + const editor = addNode( + "sharing-editor", "Editor", "can read and edit", + rect(bounds, 0.64, 0.57, 0.35, 0.28), "service" + ); + // These are membership relationships, not directional data transfers. + const viewerPath = [pointOnNode(collection, "right", 0.20), pointOnNode(viewer, "left")]; + const editorPath = [pointOnNode(collection, "right", 0.80), pointOnNode(editor, "left")]; + addEdge(viewerPath, null, "", false).relationship = true; + addEdge(editorPath, null, "", false).relationship = true; + gridParticles(0, particleCount, collection); + } + + function layoutAccess(bounds) { + const collection = addNode( + "retained-collection", "Hosted collection", "your records remain", + rect(bounds, 0.01, 0.13, 0.48, 0.72), "authority" + ); + addNode( + "removed-app", "App removed", "no connection to the collection", + rect(bounds, 0.64, 0.13, 0.35, 0.28), "replica" + ); + const app = addNode( + "retained-app", "Approved app", "still has access", + rect(bounds, 0.64, 0.57, 0.35, 0.28), "app" + ); + const path = [pointOnNode(app, "left"), pointOnNode(collection, "right", 0.80)]; + addEdge(path, null); + // The records do not disappear or become packets when access ends. + // Preserve the sharing scene's exact formation in the same collection. + gridParticles(0, particleCount, collection); + } + function layoutLocal(bounds) { const computer = addNode( "computer", @@ -659,7 +750,9 @@ else if (name === "request") layoutRequest(bounds); else if (name === "actions") layoutActions(bounds); else if (name === "grant") layoutGrant(bounds); + else if (name === "sharing") layoutSharing(bounds); else if (name === "access") layoutAccess(bounds); + else if (name === "routes") layoutRoutes(bounds); else if (name === "local") layoutLocal(bounds); else layoutHosted(bounds); @@ -854,7 +947,7 @@ context.save(); context.strokeStyle = palette.muted; - context.globalAlpha = 0.52; + context.globalAlpha *= 0.52; context.lineWidth = 1; context.setLineDash(edge.dashed ? [3, 5] : []); context.beginPath(); @@ -863,11 +956,15 @@ context.stroke(); context.setLineDash([]); + if (edge.relationship) { + context.restore(); + return; + } const finalPoint = points[points.length - 1]; const previousPoint = points[points.length - 2]; const angle = Math.atan2(finalPoint.y - previousPoint.y, finalPoint.x - previousPoint.x); context.fillStyle = palette.muted; - context.globalAlpha = 0.62; + context.globalAlpha *= 0.62 / 0.52; context.beginPath(); context.moveTo(finalPoint.x, finalPoint.y); context.lineTo( @@ -908,7 +1005,7 @@ context.save(); context.fillStyle = palette.surface; context.strokeStyle = node.kind === "authority" ? palette.muted : palette.lineSoft; - context.globalAlpha = node.kind === "authority" ? 0.78 : 0.64; + context.globalAlpha *= node.kind === "authority" ? 0.78 : 0.64; context.lineWidth = 1; context.setLineDash( node.kind === "boundary" || node.kind === "replica" ? [3, 5] : [] @@ -922,23 +1019,28 @@ } function drawNodeLabel(node, compact) { - const { x, y } = node.box; + const { x, y, w, h } = node.box; + const { lines, size, lineHeight, padding } = node.heading; context.save(); - const labelX = x + (compact ? 7 : 10); - const labelY = y + (compact ? 14 : 16); + context.beginPath(); + context.rect(x, y, w, h); + context.clip(); context.textAlign = "left"; + context.textBaseline = "top"; context.fillStyle = palette.ink; - context.globalAlpha = 0.78; - context.font = '17px "Atkinson Hyperlegible", sans-serif'; - context.fillText(node.label, labelX, labelY); + context.globalAlpha *= 0.78; + context.font = `${size}px "Atkinson Hyperlegible", sans-serif`; + lines.forEach((line, index) => { + context.fillText(line, x + padding, y + padding + index * lineHeight); + }); context.restore(); } function drawNote(note, compact) { context.save(); context.fillStyle = palette.muted; - context.globalAlpha = 0.68; - context.font = '17px "Atkinson Hyperlegible", sans-serif'; + context.globalAlpha *= 0.68; + context.font = `${compact ? 14 : 17}px "Atkinson Hyperlegible", sans-serif`; context.textAlign = "center"; context.fillText(note.text, note.x, note.y); context.restore(); @@ -957,7 +1059,7 @@ return palette.accent; } if ( - activeScene === "access" + activeScene === "routes" && particle.index >= 151 && particle.index < 301 ) { @@ -1099,6 +1201,8 @@ particles.push(new Particle(index)); } + // Font loading can change measured line breaks after the first paint. + document.fonts.ready.then(() => buildScene(activeScene)); syncPalette(); resize(); updateScrollState(); diff --git a/scripts/home-animation.test.mjs b/scripts/home-animation.test.mjs new file mode 100644 index 0000000..eea4953 --- /dev/null +++ b/scripts/home-animation.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('../public/mdbase-murmuration.js', import.meta.url), 'utf8'); +const homepage = readFileSync(new URL('../src/pages/index.astro', import.meta.url), 'utf8'); +const scenes = [...homepage.matchAll(/data-scene="([^"]+)"/g)].map(match => match[1]); + +// Exercise the real scene builders with a deterministic canvas/browser stub. +// Real font metrics and screenshots are checked separately in Chromium. +function harness(width, height, reducedMotion = true) { + const context = new Proxy({ + globalAlpha: 1, + font: '14px sans-serif', + measureText(text) { + return { width: text.length * Number.parseFloat(this.font) * 0.5 }; + }, + }, { get(target, key) { return key in target ? target[key] : () => {}; } }); + const canvas = { getContext: () => context, style: {} }; + const chapters = scenes.map(scene => ({ + dataset: { scene }, classList: { toggle() {} }, + getBoundingClientRect: () => ({ top: 0, height }), + })); + const document = { + querySelector: selector => selector === '#diagram' ? canvas : { + getBoundingClientRect: () => ({ bottom: width <= 350 ? 137 : 107 }), + }, + querySelectorAll: () => chapters, + documentElement: { style: { setProperty() {} }, addEventListener() {}, scrollHeight: height * 8 }, + fonts: { ready: { then() {} } }, + addEventListener() {}, + }; + const sandbox = { + document, innerWidth: width, innerHeight: height, devicePixelRatio: 1, + scrollY: 0, matchMedia: () => ({ matches: reducedMotion }), + getComputedStyle: () => ({ colorScheme: 'light', getPropertyValue: () => '' }), + requestAnimationFrame: () => 1, cancelAnimationFrame() {}, addEventListener() {}, + performance: { now: () => 0 }, + }; + vm.runInNewContext(source.replace(' syncPalette();\n resize();', ` + globalThis.inspect = () => ({ diagram, particles, activeScene }); + globalThis.select = name => setScene(name, chapters.findIndex(c => c.dataset.scene === name)); + globalThis.fitLabel = fitNodeLabel; + syncPalette(); + resize();`), sandbox); + return sandbox; +} + +for (const [width, height] of [[320, 640], [390, 844], [820, 1000], [821, 900], [1024, 768], [1440, 1000]]) { + test(`all homepage scenes have bounded labels and particle targets at ${width}px`, () => { + const app = harness(width, height); + for (const scene of scenes) { + app.select(scene); + const { diagram, particles, activeScene } = app.inspect(); + assert.equal(activeScene, scene); + assert.equal(particles.length, 361); + for (const node of diagram.nodes) { + const heading = node.heading; + assert.ok(heading.height + heading.padding <= node.box.h, `${scene}: ${node.label} height`); + for (const line of heading.lines) { + assert.ok(!line.includes('…'), `${scene}: ${node.label} truncated`); + assert.ok(line.length * heading.size * 0.5 <= node.box.w - heading.padding * 2, + `${scene}: ${node.label} width`); + } + } + for (const particle of particles) { + assert.ok(Number.isFinite(particle.tx) && Number.isFinite(particle.ty)); + assert.ok(particle.tx >= 0 && particle.tx <= width); + assert.ok(particle.ty >= 0 && particle.ty <= height); + } + } + }); +} + +test('sharing is hosted-only, has two non-directional memberships, and no data-transfer particles', () => { + const app = harness(1440, 1000); + app.select('sharing'); + const { diagram, particles } = app.inspect(); + assert.deepEqual(Array.from(diagram.nodes, node => node.label), ['Hosted collection', 'Viewer', 'Editor']); + assert.equal(diagram.edges.length, 2); + assert.ok(diagram.edges.every(edge => edge.relationship)); + assert.ok(particles.every(particle => particle.path === null)); +}); + +test('removing an app preserves every record and leaves only the approved app connected', () => { + for (const reducedMotion of [true, false]) { + const app = harness(390, 844, reducedMotion); + app.select('sharing'); + const targets = Array.from(app.inspect().particles, particle => [particle.tx, particle.ty]); + app.select('access'); + const { diagram, particles } = app.inspect(); + assert.deepEqual(Array.from(particles, particle => [particle.tx, particle.ty]), targets); + assert.equal(diagram.edges.length, 1); + const approved = diagram.nodes.find(node => node.id === 'retained-app'); + assert.equal(diagram.edges[0].from[0].x, approved.box.x); + assert.ok(particles.every(particle => particle.path === null)); + } +}); + +test('an unexpectedly long label falls back to bounded ellipsis', () => { + const app = harness(320, 640); + const heading = app.fitLabel('Unexpectedlylonglabel extra words', { w: 75, h: 50 }); + assert.ok(heading.lines.some(line => line.endsWith('…'))); + assert.ok(heading.height + heading.padding <= 50); +}); diff --git a/scripts/sync-sources.mjs b/scripts/sync-sources.mjs index 7e365af..1a19f4f 100644 --- a/scripts/sync-sources.mjs +++ b/scripts/sync-sources.mjs @@ -58,22 +58,12 @@ const introduction = readFileSync(introductionSource, "utf8"); const introductionStyles = removeSceneCounterStyles( extract(introduction, /