diff --git a/DESIGN.md b/DESIGN.md
index 92c80cc..a4bad0e 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -20,47 +20,52 @@ rounded:
## Direction
-mdbase is understated. The site uses a nearly white paper surface, fine rules,
-plain language, and small structural labels. The visual hierarchy follows the
-content and its technical structure.
+mdbase is understated. Pages sit on **Silver sky**: a pale blue-grey canvas
+(`oklch(97% 0.009 245)`, dark `oklch(20.5% 0.018 245)`) with soft cloud and fine
+grain. Text stays on clean canvas; the sky lives at the edges and never sits
+under body copy. Fine rules, plain language and small structural labels carry
+the hierarchy.
-Light and dark themes use the color roles defined in
-`mdbase-connect/packages/ui/styles.css`. The dark canvas is a soft charcoal
-(`oklch(17.5% 0.012 255)`), with raised surfaces at 19.5% and 23.5% lightness.
-The build synchronizes those values from Connect so the two sites retain the
-same theme contract.
+Colour roles come from `mdbase-connect/packages/ui/styles.css` and are
+synchronised by the build. The site overrides only the canvas, in
+`src/styles/global.css` and `src/styles/home.css`.
-The homepage is based directly on
-`mdbase-connect/docs/mdbase-configurations-v2.html`. Its signature is one field
-of 361 record particles. The field begins as a murmuration and becomes concrete
-collection, structure, authority, permission, and access diagrams as the reader
-scrolls. This is the only expressive visual element. Canvas surfaces, labels,
-edges, and particles use the same light and dark color roles as the page. The
-introductory field uses the soft text and accent roles; later scenes use accent
-particles to distinguish metadata, hosted authority, and moving packets.
+The mark is the square-bar icon (`src/components/BrandMark.astro`). It sits
+beside the wordmark in the header, and a hover ripple runs through the bars in
+reading order, matching the apps. The favicon and the social card
+(`public/og-card.png`, 1200×630) use the same mark.
+
+The homepage pairs a moving sky (`public/mdbase-atmosphere.js`) with one field
+of 361 record particles (`public/mdbase-murmuration.js`). The field begins as a
+murmuration and becomes concrete diagrams as the reader scrolls: files, apps,
+editing, sync, Cloud copy and Private, permissions, and sharing. Canvas
+surfaces, labels, edges and particles use the page's colour roles. Dashed boxes
+mark copies that are offline, locked or removed; they keep a solid surface so
+they read against the sky.
## Typography
Atkinson Hyperlegible is the reading and display face. Azeret Mono is reserved
-for paths, package names, versions, operations, and structural labels. Headings
-are compact and leave room for their explanatory text. Reading columns use a
-72ch measure. Sections can contain as much detail as their subject requires.
+for paths, package names, versions, operations, and structural labels. Page
+titles are large and regular (about 2.4rem); section headings are 1.3rem
+semibold; body text is 17px. Reading columns use a 72ch measure.
## Layout
-The homepage has six long chapters in a left reading column and a fixed canvas
-on the right. On narrow screens, the canvas moves above each chapter. Interior
-pages use low, ruled page openings and narrow documentation columns. Navigation,
-buttons, tables, callouts, and footer links remain visually quiet. A reading
-wash sits between the homepage canvas and its copy, becoming opaque before the
-mobile text region begins.
+The homepage has long chapters in a left reading column, a fixed canvas on the
+right, and a chapter index in the left gutter on wide screens. On narrow
+screens, the canvas moves above each chapter. Interior pages open with a
+visible title and lede (`.page-intro`) under a still band of sky at the top
+right; documentation pages keep their three-column layout. Navigation, buttons,
+tables, callouts, and footer links remain visually quiet.
## Motion
-Only the homepage particle field moves. Its transformations explain the current
-chapter. Reduced-motion preferences place the particles directly into each
-formation. The particle field otherwise animates on both pointer and touch
-screens. Interior pages do not use decorative animation.
+Only the homepage moves: the sky drifts and the particle field explains the
+current chapter. A "Pause motion" control stops both. Reduced-motion
+preferences stop the sky and place particles directly into each formation.
+The header mark's hover ripple is the only motion on interior pages, and it
+also respects reduced motion.
## Language
diff --git a/astro.config.mjs b/astro.config.mjs
index 5129a0d..96fcbf7 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -6,6 +6,8 @@ const site = process.env.MDBASE_SITE_ORIGIN ?? "https://mdbase.dev";
export default defineConfig({
site,
trailingSlash: "always",
+ // Compression drops the line break before an inline link, gluing it to the previous word.
+ compressHTML: false,
integrations: [sitemap()],
build: {
format: "directory"
diff --git a/public/favicon.svg b/public/favicon.svg
index 343d804..dc8543e 100644
--- a/public/favicon.svg
+++ b/public/favicon.svg
@@ -1,15 +1 @@
-
-
+
diff --git a/public/mdbase-atmosphere.js b/public/mdbase-atmosphere.js
new file mode 100644
index 0000000..6279a82
--- /dev/null
+++ b/public/mdbase-atmosphere.js
@@ -0,0 +1,154 @@
+// Silver sky: billowing mist with fine windborne grain, from the material studies.
+(() => {
+ const root = document.documentElement;
+ const canvas = document.querySelector('#atmosphere');
+ const ctx = canvas.getContext('2d', { alpha: false });
+ const grain = document.querySelector('#grain');
+ const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
+ const systemDark = matchMedia('(prefers-color-scheme: dark)');
+ const motion = 65;
+ let dark = root.dataset.theme ? root.dataset.theme === 'dark' : systemDark.matches;
+ let paused = reducedMotion.matches;
+ let phase = 0;
+ let lastFrame = 0;
+ let frameId;
+ let baseRgb = '247,248,250';
+ const colorProbe = document.createElement('canvas');
+ colorProbe.width = colorProbe.height = 1;
+ const colorContext = colorProbe.getContext('2d', { willReadFrequently: true });
+ const mistCanvas = document.createElement('canvas');
+ const mistContext = mistCanvas.getContext('2d');
+ let mistImage;
+ const noiseGrid = new Float32Array(64 * 64);
+ let seed = 14923;
+ for (let i = 0; i < noiseGrid.length; i++) {
+ seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
+ noiseGrid[i] = seed / 4294967296;
+ }
+
+ function noise(x, y) {
+ const ix = Math.floor(x), iy = Math.floor(y);
+ let fx = x - ix, fy = y - iy;
+ fx = fx * fx * (3 - 2 * fx); fy = fy * fy * (3 - 2 * fy);
+ const a = noiseGrid[((iy & 63) << 6) + (ix & 63)];
+ const b = noiseGrid[((iy & 63) << 6) + ((ix + 1) & 63)];
+ const c = noiseGrid[(((iy + 1) & 63) << 6) + (ix & 63)];
+ const d = noiseGrid[(((iy + 1) & 63) << 6) + ((ix + 1) & 63)];
+ return (a + (b - a) * fx) * (1 - fy) + (c + (d - c) * fx) * fy;
+ }
+ function fbm(x, y) {
+ return noise(x, y) * .57 + noise(x * 2.03 + 7, y * 2.03 + 11) * .28 + noise(x * 4.11 + 19, y * 4.11 + 3) * .15;
+ }
+ function mistField(u, v) {
+ const t = phase * .30;
+ const warp = fbm(u * .75 + t * .06, v * .75 - t * .04);
+ return fbm(u - t * .29 + warp * 1.6, v + t * .10 + warp * .9);
+ }
+ // The same moving noise field shades the mist and steers the flock.
+ window.mdbaseAtmosphere = {
+ wind(x, y) {
+ const u = x * 2.8, v = y * 2.8 / (canvas.width / canvas.height || 1);
+ const e = .04;
+ const dx = (mistField(u + e, v) - mistField(u - e, v)) / (2 * e);
+ const dy = (mistField(u, v + e) - mistField(u, v - e)) / (2 * e);
+ return { x: .2 + dy * 1.2, y: -.10 - dx * 1.2 };
+ }
+ };
+
+ function readBase() {
+ const base = getComputedStyle(root).getPropertyValue('--color-canvas').trim();
+ colorContext.fillStyle = base;
+ colorContext.fillRect(0, 0, 1, 1);
+ baseRgb = [...colorContext.getImageData(0, 0, 1, 1).data].slice(0, 3).join(',');
+ }
+ function resize() {
+ const scale = Math.min(1, 1100 / innerWidth);
+ canvas.width = Math.round(innerWidth * scale);
+ canvas.height = Math.round(innerHeight * scale);
+ const mistScale = Math.min(1, 200 / innerWidth, 180 / innerHeight);
+ mistCanvas.width = Math.max(1, Math.round(innerWidth * mistScale));
+ mistCanvas.height = Math.max(1, Math.round(innerHeight * mistScale));
+ mistImage = mistContext.createImageData(mistCanvas.width, mistCanvas.height);
+ draw();
+ }
+ function drawMist() {
+ const mw = mistCanvas.width, mh = mistCanvas.height;
+ const rgb = baseRgb.split(',').map(Number);
+ const data = mistImage.data;
+ const aspect = canvas.width / canvas.height;
+ for (let y = 0; y < mh; y++) {
+ for (let x = 0; x < mw; x++) {
+ const n = mistField(x / mw * 2.8, y / mh * 2.8 / aspect);
+ const density = Math.max(0, Math.min(1, (n - .27) * 2.25));
+ const shade = density * density;
+ const index = (y * mw + x) * 4;
+ for (let channel = 0; channel < 3; channel++) {
+ const variation = dark ? shade * (channel === 2 ? 48 : 36) : 6 - shade * (channel === 0 ? 59 : 51);
+ data[index + channel] = Math.max(0, Math.min(255, rgb[channel] + variation));
+ }
+ data[index + 3] = 255;
+ }
+ }
+ mistContext.putImageData(mistImage, 0, 0);
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(mistCanvas, 0, 0, canvas.width, canvas.height);
+ }
+ function glow(x, y, rx, ry, color, alpha) {
+ ctx.save();
+ ctx.translate(x, y); ctx.scale(rx, ry);
+ const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
+ gradient.addColorStop(0, `rgba(${color},${alpha})`);
+ gradient.addColorStop(.5, `rgba(${color},${alpha * .45})`);
+ gradient.addColorStop(1, `rgba(${color},0)`);
+ ctx.fillStyle = gradient; ctx.fillRect(-1, -1, 2, 2);
+ ctx.restore();
+ }
+ function draw() {
+ const w = canvas.width, h = canvas.height;
+ if (!w || !h || !mistImage) return;
+ drawMist();
+ glow(w * (.72 + Math.sin(phase * .16) * .15), h * .48, w * .35, h * .55,
+ dark ? '114,135,157' : '255,255,255', dark ? .07 : .18);
+ // Fade the atmosphere toward the reading column, not the text itself.
+ const veil = ctx.createLinearGradient(0, 0, w * .63, 0);
+ veil.addColorStop(0, `rgb(${baseRgb})`); veil.addColorStop(.63, `rgb(${baseRgb})`);
+ veil.addColorStop(1, `rgba(${baseRgb},0)`);
+ ctx.fillStyle = veil; ctx.fillRect(0, 0, w, h);
+ grain.style.backgroundPosition = `${phase * 8}px ${phase * 2.8}px`;
+ }
+ function frame(now) {
+ if (now - lastFrame > 66) {
+ const delta = Math.min(100, now - lastFrame || 66);
+ lastFrame = now;
+ if (!paused) { phase += delta / 1000 * (.15 + motion / 100 * 1.5); draw(); }
+ }
+ frameId = requestAnimationFrame(frame);
+ }
+
+ // The site's theme control owns the theme; the sky follows it.
+ const pauseControl = document.querySelector('[data-motion-control]');
+ const currentDark = () => root.dataset.theme ? root.dataset.theme === 'dark' : systemDark.matches;
+ function sync() {
+ dark = currentDark();
+ grain.style.backgroundImage = `url("/textures/${dark ? 'dark' : 'light'}-2.png")`;
+ if (pauseControl) {
+ pauseControl.textContent = paused ? 'Play motion' : 'Pause motion';
+ pauseControl.hidden = reducedMotion.matches;
+ }
+ readBase();
+ window.murmurationPreview?.pause(paused);
+ draw();
+ }
+ addEventListener('mdbase:themechange', sync);
+ systemDark.addEventListener('change', sync);
+ pauseControl?.addEventListener('click', () => { paused = !paused; sync(); });
+ reducedMotion.addEventListener('change', event => { paused = event.matches; sync(); });
+ addEventListener('resize', resize);
+ document.addEventListener('visibilitychange', () => {
+ cancelAnimationFrame(frameId);
+ if (!document.hidden) { lastFrame = performance.now(); frameId = requestAnimationFrame(frame); }
+ });
+ addEventListener('DOMContentLoaded', sync);
+ readBase(); resize(); frameId = requestAnimationFrame(frame);
+})();
diff --git a/public/mdbase-murmuration.js b/public/mdbase-murmuration.js
index fc930de..510b7d6 100644
--- a/public/mdbase-murmuration.js
+++ b/public/mdbase-murmuration.js
@@ -3,7 +3,9 @@
const canvas = document.querySelector("#diagram");
const context = canvas.getContext("2d", { alpha: true });
const chapters = [...document.querySelectorAll(".chapter")];
- const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const motionPreference = matchMedia("(prefers-reduced-motion: reduce)");
+ let reduceMotion = motionPreference.matches;
+ let refined = true;
const particleCount = 361;
const cellSize = 104;
const particles = [];
@@ -22,7 +24,12 @@
let previousTime = 0;
let animationFrame = 0;
let isAnimating = false;
+ let previewPaused = false;
let scrollFrame = 0;
+ let simulationTime = 0;
+ let sceneStartedAt = 0;
+ let introInitialized = false;
+ let flockBounds;
const palette = {
paper: "rgb(252 252 251)",
surface: "rgb(252 252 251)",
@@ -35,7 +42,8 @@
accent: "rgb(42 104 143)",
particle: "rgb(25 27 31 / 0.88)",
particleIntro: "rgb(25 27 31 / 0.88)",
- replica: "rgb(81 87 95 / 0.62)"
+ replica: "rgb(81 87 95 / 0.62)",
+ warning: "rgb(150 110 40)"
};
function syncPalette() {
@@ -59,6 +67,7 @@
palette.particleIntro = dark
? styles.getPropertyValue("--color-text-soft").trim() || palette.soft
: styles.getPropertyValue("--color-text").trim() || palette.ink;
+ palette.warning = styles.getPropertyValue("--color-warning-text").trim() || palette.warning;
palette.replica =
styles.getPropertyValue("--color-text-muted").trim() || palette.muted;
}
@@ -75,6 +84,14 @@
this.ty = this.y;
this.phase = Math.random();
this.scale = 0.78 + Math.random() * 0.44;
+ this.depth = Math.random();
+ this.response = 0.022 + Math.random() * 0.015;
+ this.releaseAt = 0;
+ this.group = 'flock';
+ this.px = this.x; this.py = this.y;
+ this.pvx = this.vx; this.pvy = this.vy;
+ this.nearest = [];
+ this.distances = [];
this.role = "record";
this.path = null;
}
@@ -88,7 +105,7 @@
x: width * 0.07,
y: top,
w: width * 0.86,
- h: Math.max(170, Math.min(height * 0.44 - top, 280))
+ h: Math.max(190, Math.min(height * 0.45 - top, 300))
};
}
@@ -100,6 +117,11 @@
};
}
+ function introBounds() {
+ if (width <= 820) return diagramBounds();
+ return { x: width * 0.45, y: height * 0.08, w: width * 0.53, h: height * 0.84 };
+ }
+
function rect(bounds, x, y, w, h) {
return {
x: bounds.x + bounds.w * x,
@@ -124,7 +146,7 @@
const availableHeight = Math.max(1, box.h - padding * 2);
let lines = [];
let size = compact ? 14 : 17;
- for (; size >= 11; size--) {
+ for (; size >= (compact ? 11 : 12); size--) {
context.font = `${size}px "Atkinson Hyperlegible", sans-serif`;
lines = [];
let line = "";
@@ -139,7 +161,7 @@
if (lines.every((line) => context.measureText(line).width <= availableWidth)
&& lines.length * Math.ceil(size * 1.15) <= availableHeight) break;
}
- size = Math.max(11, size);
+ size = Math.max(compact ? 11 : 12, size);
const lineHeight = Math.ceil(size * 1.15);
const maxLines = Math.max(1, Math.floor(availableHeight / lineHeight));
const overflow = lines.length > maxLines;
@@ -194,6 +216,7 @@
particle.tx = area.x + (column + 0.5) * area.w / columns;
particle.ty = area.y + (row + 0.5) * area.h / rows;
particle.role = role;
+ particle.group = node.id;
particle.path = null;
}
}
@@ -202,6 +225,7 @@
for (let localIndex = 0; localIndex < count; localIndex++) {
const particle = particles[start + localIndex];
particle.role = role;
+ particle.group = `path-${start}`;
particle.path = points;
particle.phase = localIndex / count;
}
@@ -216,9 +240,11 @@
particles.forEach((particle, index) => {
particle.role = "record";
+ particle.group = 'flock';
particle.path = null;
+ particle.releaseAt = 0;
- if (reduceMotion) {
+ if (reduceMotion || previewPaused) {
const angle = index * 2.399963;
const radius = Math.sqrt(index / particleCount);
particle.tx = centerX + Math.cos(angle) * bounds.w * 0.34 * radius;
@@ -297,467 +323,244 @@
gridParticles(241, 120, notes);
}
- function layoutAuthority(bounds) {
- const mainCopy = addNode(
- "main-copy",
- "Main copy",
- "source of truth",
- rect(bounds, 0.04, 0.14, 0.58, 0.72),
- "authority"
- );
- const workingCopy = addNode(
- "working-copy",
- "Working copy",
- "follows the main copy",
- rect(bounds, 0.73, 0.31, 0.26, 0.38),
- "replica"
- );
- const syncPath = [
- pointOnNode(mainCopy, "right"),
- pointOnNode(workingCopy, "left")
- ];
+ // Cards carry real text: a file, an edit, a permission. Lines are plain
+ // strings or { text, tone } where tone is add · remove · held · faint.
+ function addCard(id, label, lines, box, kind = "card") {
+ const node = addNode(id, label, "", box, kind);
+ node.lines = lines.map((line) => typeof line === "string" ? { text: line, tone: "" } : line);
+ node.mono = fitCardLines(node);
+ const needed = node.heading.height + 10 + node.lines.length * node.mono.lineHeight + node.heading.padding;
+ // Shrink or grow around the centre so text is never clipped at the size floor.
+ box.y += (box.h - needed) / 2;
+ box.h = needed;
+ return node;
+ }
- addEdge(syncPath, null, "stays in step", true);
- gridParticles(0, 301, mainCopy);
- gridParticles(301, 60, workingCopy, "replica");
+ function fitCardLines(node) {
+ const compact = width <= 820;
+ const padding = node.heading.padding;
+ const available = Math.max(1, node.box.w - padding * 2 - 8);
+ const room = Math.max(1, node.box.h - node.heading.height - padding - 4);
+ const floor = compact ? 11 : 12;
+ let size = compact ? 12 : 13;
+ for (; size > floor; size--) {
+ context.font = `${size}px "Azeret Mono", ui-monospace, monospace`;
+ const widest = Math.max(...node.lines.map((line) => context.measureText(line.text).width));
+ if (widest <= available && node.lines.length * Math.ceil(size * 1.55) <= room) break;
+ }
+ return { size, lineHeight: Math.ceil(size * 1.55) };
}
- function layoutModes(bounds) {
- const local = addNode(
- "mode-local",
- "On your computer",
- "your Markdown folder",
- rect(bounds, 0.01, 0.18, 0.43, 0.66),
- "authority"
- );
- const hosted = addNode(
- "mode-hosted",
- "Hosted by mdbase",
- "available online",
- rect(bounds, 0.56, 0.18, 0.43, 0.66),
- "authority"
- );
- addNote("or", bounds.x + bounds.w * 0.50, bounds.y + bounds.h * 0.51);
- gridParticles(0, 181, local);
- gridParticles(181, 180, hosted);
+ function ambient(start, count = particleCount - start) {
+ for (let index = start; index < start + count; index++) {
+ const particle = particles[index];
+ particle.role = "ambient";
+ particle.group = "ambient";
+ particle.path = null;
+ const bounds = diagramBounds();
+ const angle = index * 2.399963;
+ const radius = Math.sqrt((index - start + .5) / count);
+ particle.tx = bounds.x + bounds.w * (.5 + Math.cos(angle) * radius * .46);
+ particle.ty = bounds.y + bounds.h * (.5 + Math.sin(angle) * radius * .46);
+ }
}
- function layoutIdentity(bounds) {
- const collection = addNode(
- "identity-collection",
- "Your collection",
- "the same data for every app",
- rect(bounds, 0.01, 0.13, 0.46, 0.72),
- "authority"
- );
- const connect = addNode(
- "identity-connect",
- "Connect",
- "permission gate",
- rect(bounds, 0.52, 0.33, 0.18, 0.34),
- "service"
- );
- const planner = addNode(
- "identity-planner",
- "Project planner",
- "one experience",
- rect(bounds, 0.78, 0.05, 0.21, 0.18),
- "app"
- );
- const notes = addNode(
- "identity-notes",
- "Notes app",
- "another experience",
- rect(bounds, 0.78, 0.41, 0.21, 0.18),
- "app"
- );
- const assistant = addNode(
- "identity-assistant",
- "AI assistant",
- "another experience",
- rect(bounds, 0.78, 0.77, 0.21, 0.18),
- "app"
- );
- const plannerPath = [
- pointOnNode(planner, "left"),
- pointOnNode(connect, "right", 0.22)
- ];
- const notesPath = [
- pointOnNode(notes, "left"),
- pointOnNode(connect, "right", 0.50)
- ];
- const assistantPath = [
- pointOnNode(assistant, "left"),
- pointOnNode(connect, "right", 0.78)
- ];
+ function layoutMerge(bounds) {
+ const compact = bounds.w < 480;
+ const laptop = addCard("merge-laptop", "Laptop · text editor", [
+ { text: "status: active", tone: "remove" },
+ { text: "status: done", tone: "add" }
+ ], compact ? rect(bounds, 0.00, 0.00, 0.48, 0.36) : rect(bounds, 0.00, 0.06, 0.40, 0.28));
+ const phone = addCard("merge-phone", "Phone · TaskNotes", [
+ { text: "due: 2026-11-02", tone: "add" }
+ ], compact ? rect(bounds, 0.52, 0.00, 0.48, 0.36) : rect(bounds, 0.00, 0.66, 0.40, 0.28));
+ const result = addCard("merge-result", "website-redesign.md", [
+ { text: "---", tone: "faint" },
+ "title: Website redesign",
+ { text: "status: done", tone: "add" },
+ 'owner: "[[people/sam.md]]"',
+ { text: "due: 2026-11-02", tone: "add" },
+ { text: "---", tone: "faint" }
+ ], compact ? rect(bounds, 0.14, 0.50, 0.72, 0.50) : rect(bounds, 0.53, 0.25, 0.47, 0.50));
+ const fromLaptop = compact
+ ? [pointOnNode(laptop, "bottom"), pointOnNode(result, "top", 0.3)]
+ : [pointOnNode(laptop, "right"), pointOnNode(result, "left", 0.3)];
+ const fromPhone = compact
+ ? [pointOnNode(phone, "bottom"), pointOnNode(result, "top", 0.7)]
+ : [pointOnNode(phone, "right"), pointOnNode(result, "left", 0.7)];
+ addEdge(fromLaptop, null, "one field", true);
+ addEdge(fromPhone, null, "another field", true);
+ if (!compact) addNote("both changes kept", result.box.x + result.box.w / 2, result.box.y + result.box.h + 26);
+ pathParticles(0, 10, fromLaptop);
+ pathParticles(10, 10, fromPhone);
+ ambient(20);
+ }
- addEdge(plannerPath, null, "connect", true);
- addEdge(notesPath, null, "", true);
- addEdge(assistantPath, null, "", true);
- gridParticles(0, 301, collection);
- pathParticles(301, 20, plannerPath);
- pathParticles(321, 20, notesPath);
- pathParticles(341, 20, assistantPath);
+ function lockedLines(count) {
+ return Array.from({ length: count }, (_, index) =>
+ ({ text: "▒▒▒▒▒▒▒▒ locked", tone: index === count - 1 ? "add" : "faint" }));
}
- function layoutRequest(bounds) {
- const collection = addNode(
- "request-collection",
- "Your collection",
- "still private",
- rect(bounds, 0.01, 0.13, 0.48, 0.72),
- "authority"
- );
- const request = addNode(
- "request-gate",
- "Permission request",
- "waiting for you",
- rect(bounds, 0.57, 0.38, 0.18, 0.22),
- "service"
- );
- const app = addNode(
- "request-app",
- "The app",
- "asks first",
- rect(bounds, 0.81, 0.32, 0.18, 0.34),
- "app"
- );
- const requestPath = [
- pointOnNode(app, "left"),
- pointOnNode(request, "right")
+ function layoutApps(bounds) {
+ const compact = bounds.w < 480;
+ const collection = addNode("apps-collection", "one collection", "",
+ compact ? rect(bounds, 0.00, 0.00, 0.40, 1.00) : rect(bounds, 0.01, 0.13, 0.42, 0.74), "standard");
+ const apps = [
+ ["Editor", "any collection"],
+ ["TaskNotes", "tasks"],
+ ["Reader", "papers · web pages"],
+ ["Writer", "long documents"]
];
+ let next = 0;
+ const step = compact ? 0.255 : 0.235;
+ const top = compact ? 0.0 : 0.04;
+ apps.forEach(([label, line], index) => {
+ const card = addCard(`app-${index}`, label, [{ text: line, tone: "faint" }],
+ compact ? rect(bounds, 0.52, top + index * step, 0.48, 0.22) : rect(bounds, 0.62, top + index * step, 0.38, 0.19), "app");
+ const path = [pointOnNode(card, "left"), pointOnNode(collection, "right", 0.2 + index * 0.2)];
+ addEdge(path, null, "", true);
+ pathParticles(260 + next, 6, index % 2 ? path.slice().reverse() : path);
+ next += 6;
+ });
+ gridParticles(0, 260, collection);
+ ambient(260 + next);
+ }
- addEdge(requestPath, null, "asks to connect", true);
- gridParticles(0, 321, collection);
- pathParticles(321, 40, requestPath);
+ function layoutSync(bounds) {
+ const compact = bounds.w < 480;
+ const log = addCard("sync-log", "mdbase sync", lockedLines(3),
+ compact ? rect(bounds, 0.27, 0.36, 0.46, 0.30) : rect(bounds, 0.31, 0.36, 0.38, 0.28), "log");
+ const devices = [
+ ["sync-laptop", "Laptop", rect(bounds, 0.00, 0.00, 0.27, 0.30), "right", 0.15, "standard"],
+ ["sync-phone", "Phone", rect(bounds, 0.73, 0.00, 0.27, 0.30), "left", 0.15, "standard"],
+ ["sync-desktop", "Work desktop", rect(bounds, 0.00, 0.70, 0.27, 0.30), "right", 0.85, "standard"],
+ ["sync-tablet", "Tablet · offline", rect(bounds, 0.73, 0.70, 0.27, 0.30), "left", 0.85, "replica"]
+ ].map(([id, label, box, side, offset, kind]) => ({ node: addNode(id, label, "", box, kind), side, offset }));
+ let next = 0;
+ devices.forEach(({ node, side, offset }, index) => {
+ const corner = pointOnNode(node, side === "right" ? "right" : "left", 0.5);
+ const logPoint = pointOnNode(log, side === "right" ? "left" : "right", offset < 0.5 ? 0.3 : 0.7);
+ const offline = index === 3;
+ addEdge([corner, logPoint], null, "", true).relationship = offline;
+ gridParticles(next, 64, node, offline ? "replica" : "record");
+ next += 64;
+ if (!offline) {
+ // Each online device sends sealed entries and receives everyone else's.
+ pathParticles(next, 6, index % 2 ? [logPoint, corner] : [corner, logPoint]);
+ next += 6;
+ }
+ });
+ if (!compact) addNote("passes changes along,\ncan’t read them", log.box.x + log.box.w / 2, log.box.y + log.box.h + 24);
+ ambient(next);
}
- function layoutActions(bounds) {
- const collection = addNode(
- "actions-collection",
- "Your collection",
- "still private",
- rect(bounds, 0.01, 0.13, 0.43, 0.72),
- "authority"
- );
- const read = addNode(
- "action-read",
- "Read records",
- "see their contents",
- rect(bounds, 0.53, 0.09, 0.27, 0.20),
- "service"
- );
- const search = addNode(
- "action-search",
- "Search records",
- "find matches",
- rect(bounds, 0.53, 0.40, 0.27, 0.20),
- "service"
- );
- const change = addNode(
- "action-change",
- "Change records",
- "only if needed",
- rect(bounds, 0.53, 0.71, 0.27, 0.20),
- "service"
- );
- const app = addNode(
- "actions-app",
- "The app",
- "names each action",
- rect(bounds, 0.85, 0.29, 0.14, 0.42),
- "app"
- );
- const readPath = [
- pointOnNode(app, "left", 0.22),
- pointOnNode(read, "right")
- ];
- const searchPath = [
- pointOnNode(app, "left", 0.50),
- pointOnNode(search, "right")
- ];
- const changePath = [
- pointOnNode(app, "left", 0.78),
- pointOnNode(change, "right")
+ function layoutOptions(bounds) {
+ const compact = bounds.w < 480;
+ const panels = [
+ ["cloud", "Cloud copy", 0.00],
+ ["private", "Private", 0.53]
];
-
- addEdge(readPath, null, "", true);
- addEdge(searchPath, null, "", true);
- addEdge(changePath, null, "", true);
- addNote("the request", bounds.x + bounds.w * 0.665, bounds.y + bounds.h * 0.02);
- gridParticles(0, 301, collection);
- pathParticles(301, 20, readPath);
- pathParticles(321, 20, searchPath);
- pathParticles(341, 20, changePath);
+ let next = 0;
+ panels.forEach(([id, label, x]) => {
+ addNode(`${id}-panel`, label, "", rect(bounds, x, 0.02, 0.47, 0.96), "boundary");
+ const devices = addNode(`${id}-devices`, "your devices", "", rect(bounds, x + 0.04, 0.14, 0.39, 0.22), "standard");
+ const log = addCard(`${id}-log`, "mdbase sync", [{ text: "▒▒▒▒▒▒ locked", tone: "faint" }],
+ rect(bounds, x + 0.04, 0.47, 0.39, 0.15), "log");
+ const cloud = id === "cloud"
+ ? addNode(`${id}-copy`, compact ? "readable copy" : "readable copy · apps work while devices are off", "", rect(bounds, x + 0.04, 0.70, 0.39, 0.24), "accent")
+ : addCard(`${id}-copy`, compact ? "locked" : "locked · only your devices", [{ text: "apps go through", tone: "faint" }, { text: "your devices", tone: "faint" }],
+ rect(bounds, x + 0.04, 0.70, 0.39, 0.24), "replica");
+ const up = [pointOnNode(devices, "bottom"), pointOnNode(log, "top")];
+ addEdge(up, null, "", false).relationship = true;
+ gridParticles(next, 90, devices);
+ next += 90;
+ pathParticles(next, 6, up);
+ next += 6;
+ if (id === "cloud") {
+ const down = [pointOnNode(log, "bottom"), pointOnNode(cloud, "top")];
+ addEdge(down, null, "", false).relationship = true;
+ gridParticles(next, 90, cloud, "metadata");
+ next += 90;
+ pathParticles(next, 6, down);
+ next += 6;
+ }
+ });
+ ambient(next);
}
function layoutGrant(bounds) {
const compact = bounds.w < 480;
- const collection = addNode(
- "grant-collection",
- "Chosen collection",
- "source of truth",
- rect(bounds, 0.01, 0.13, 0.48, 0.72),
- "authority"
- );
- const grant = addNode(
- "grant",
- "Your permission",
- "one app · one collection",
- 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",
- compact
- ? rect(bounds, 0.64, 0.57, 0.35, 0.28)
- : rect(bounds, 0.82, 0.31, 0.17, 0.36),
- "app"
- );
+ const collection = addNode("grant-collection", "projects", "",
+ compact ? rect(bounds, 0.00, 0.00, 0.44, 1.00) : rect(bounds, 0.01, 0.13, 0.40, 0.72), "standard");
+ const grant = addCard("grant", "Project planner may", [
+ { text: "find projects", tone: "add" },
+ { text: "create projects", tone: "add" },
+ { text: "edit projects", tone: "add" },
+ { text: "delete records", tone: "blocked" }
+ ], compact ? rect(bounds, 0.54, 0.00, 0.46, 0.62) : rect(bounds, 0.52, 0.22, 0.30, 0.48));
+ const app = addNode("grant-app", "Project planner", "",
+ compact ? rect(bounds, 0.54, 0.76, 0.46, 0.24) : rect(bounds, 0.86, 0.36, 0.14, 0.20), "app");
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", compact ? 0.20 : 0.54)
- ];
-
- addEdge(askPath, null, "asks", true);
- addEdge(accessPath, null, "allowed actions", false);
- gridParticles(0, 321, collection);
- pathParticles(321, 20, askPath);
- pathParticles(341, 20, accessPath);
- }
-
- function layoutRoutes(bounds) {
- const localBoundary = addNode(
- "access-local",
- "Local collection",
- "on your computer",
- rect(bounds, 0.01, 0.08, 0.36, 0.84),
- "boundary"
- );
- const connector = addNode(
- "access-connector",
- "mdbase connector",
- "checks each request",
- rect(bounds, 0.05, 0.19, 0.28, 0.18),
- "service"
- );
- const localFiles = addNode(
- "access-files",
- "Markdown folder",
- "main copy",
- rect(bounds, 0.05, 0.56, 0.28, 0.23),
- "authority"
- );
- const app = addNode(
- "access-app",
- "Approved app",
- "allowed actions only",
- rect(bounds, 0.43, 0.35, 0.14, 0.30),
- "app"
- );
- const hostedBoundary = addNode(
- "access-hosted",
- "Hosted collection",
- "available online",
- rect(bounds, 0.63, 0.08, 0.36, 0.84),
- "boundary"
- );
- const hosting = addNode(
- "access-hosting",
- "mdbase hosting",
- "main copy",
- rect(bounds, 0.67, 0.20, 0.28, 0.25),
- "authority"
- );
- const mirror = addNode(
- "access-mirror",
- "Markdown copy",
- "optional local mirror",
- rect(bounds, 0.67, 0.62, 0.28, 0.18),
- "replica"
- );
-
- const localPath = [
- pointOnNode(app, "left", 0.38),
- pointOnNode(connector, "right"),
- pointOnNode(connector, "bottom"),
- pointOnNode(localFiles, "top")
- ];
- const hostedPath = [
- pointOnNode(app, "right", 0.38),
- pointOnNode(hosting, "left")
- ];
- const mirrorPath = [
- pointOnNode(hosting, "bottom"),
- pointOnNode(mirror, "top")
- ];
-
- addEdge(localPath, null, "checked locally", false);
- addEdge(hostedPath, null, "checked online", false);
- addEdge(mirrorPath, null, "optional sync", true);
- gridParticles(0, 151, localFiles);
- gridParticles(151, 150, hosting);
- gridParticles(301, 30, mirror, "replica");
- pathParticles(331, 10, localPath);
- pathParticles(341, 10, hostedPath);
- pathParticles(351, 10, mirrorPath);
+ : [pointOnNode(app, "left"), pointOnNode(grant, "right", 0.38)];
+ const accessPath = [pointOnNode(grant, "left", 0.5), pointOnNode(collection, "right", 0.5)];
+ addEdge(askPath, null, "", true);
+ addEdge(accessPath, null, "", false);
+ gridParticles(0, 200, collection);
+ pathParticles(200, 8, askPath);
+ pathParticles(208, 8, accessPath);
+ ambient(216);
}
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);
+ const collection = addNode("sharing-collection", "shared collection", "",
+ rect(bounds, 0.01, 0.13, 0.42, 0.72), "standard");
+ const viewer = addNode("sharing-viewer", "Viewer · reads", "",
+ rect(bounds, 0.60, 0.00, 0.40, 0.24), "standard");
+ const editor = addNode("sharing-editor", "Editor · reads and edits", "",
+ rect(bounds, 0.60, 0.31, 0.40, 0.24), "standard");
+ addCard("sharing-removed", "Member removed", [
+ { text: "keeps files they had", tone: "faint" },
+ { text: "no new changes", tone: "remove" }
+ ], rect(bounds, 0.60, 0.66, 0.40, 0.30), "replica");
+ const viewerPath = [pointOnNode(collection, "right", 0.15), pointOnNode(viewer, "left")];
+ const editorPath = [pointOnNode(collection, "right", 0.50), pointOnNode(editor, "left")];
+ addEdge(viewerPath, null, "", false);
+ addEdge(editorPath, null, "", false);
+ gridParticles(0, 160, collection);
+ gridParticles(160, 48, viewer, "replica");
+ gridParticles(208, 48, editor);
+ pathParticles(256, 6, viewerPath);
+ pathParticles(262, 8, editorPath.slice().reverse());
+ ambient(270);
}
- 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",
- "your computer",
- "where your files live",
- rect(bounds, 0.01, 0.06, 0.62, 0.88),
- "boundary"
- );
- const connector = addNode(
- "connector",
- "mdbase connector",
- "checks your permission",
- rect(bounds, 0.08, 0.13, 0.48, 0.14),
- "service"
- );
- const collection = addNode(
- "local-collection",
- "Your Markdown files",
- "source of truth",
- rect(bounds, 0.08, 0.34, 0.48, 0.49),
- "authority"
- );
- const app = addNode(
- "local-app",
- "Approved app",
- "direct or encrypted",
- rect(bounds, 0.76, 0.34, 0.23, 0.28),
- "app"
- );
- const accessPath = [
- pointOnNode(app, "left"),
- pointOnNode(connector, "right", 0.50),
- pointOnNode(collection, "top", 0.70)
- ];
-
- addEdge(accessPath, null, "permission checked here", false);
- gridParticles(0, 321, collection);
- pathParticles(321, 40, accessPath);
- }
-
- function layoutHosted(bounds) {
- const provider = addNode(
- "provider",
- "mdbase hosting",
- "source of truth",
- rect(bounds, 0.02, 0.11, 0.49, 0.72),
- "authority"
- );
- const app = addNode(
- "hosted-app",
- "Approved app",
- "allowed actions",
- rect(bounds, 0.70, 0.04, 0.27, 0.18),
- "app"
- );
- const cache = addNode(
- "cache",
- "Offline copy",
- "for this app",
- rect(bounds, 0.67, 0.38, 0.30, 0.20),
- "replica"
- );
- const mirror = addNode(
- "mirror",
- "Markdown copy",
- "optional on your computer",
- rect(bounds, 0.64, 0.72, 0.33, 0.20),
- "replica"
- );
-
- const appPath = [pointOnNode(app, "left"), pointOnNode(provider, "right", 0.24)];
- const cachePath = [pointOnNode(provider, "right", 0.50), pointOnNode(cache, "left")];
- const mirrorPath = [pointOnNode(provider, "right", 0.76), pointOnNode(mirror, "left")];
-
- addEdge(appPath, null, "approved access", false);
- addEdge(cachePath, null, "sync", true);
- addEdge(mirrorPath, null, "sync", true);
- gridParticles(0, 211, provider);
- gridParticles(211, 55, cache, "replica");
- gridParticles(266, 55, mirror, "replica");
- pathParticles(321, 14, appPath);
- pathParticles(335, 13, cachePath);
- pathParticles(348, 13, mirrorPath);
- }
-
- function buildScene(name) {
+ function buildScene(name, stagger = false) {
const bounds = diagramBounds();
diagram = { nodes: [], edges: [], notes: [] };
- diagramAlpha = 0;
-
- if (name === "intro") layoutIntro(bounds);
- else if (name === "collection") layoutCollection(bounds);
- else if (name === "structure") layoutStructure(bounds);
- else if (name === "authority") layoutAuthority(bounds);
- else if (name === "modes") layoutModes(bounds);
- else if (name === "identity") layoutIdentity(bounds);
- 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);
+ if (stagger) {
+ diagramAlpha = 0;
+ sceneStartedAt = simulationTime;
+ }
+
+ const layouts = {
+ intro: layoutIntro, collection: layoutCollection, structure: layoutStructure,
+ apps: layoutApps, merge: layoutMerge, sync: layoutSync, options: layoutOptions,
+ grant: layoutGrant, sharing: layoutSharing
+ };
+ (layouts[name] || layoutIntro)(bounds);
+
+ if (stagger && name !== 'intro') {
+ const groups = new Map();
+ particles.forEach(particle => {
+ if (particle.role === "ambient") { particle.releaseAt = 0; return; }
+ if (!groups.has(particle.group)) groups.set(particle.group, groups.size);
+ particle.releaseAt = simulationTime + (refined ? groups.get(particle.group) * 115 + particle.depth * 110 + particle.index % 11 * 8 : 0);
+ });
+ }
if (reduceMotion) {
- updateMovingTargets(0);
+ updateMovingTargets(simulationTime);
particles.forEach((particle) => {
particle.x = particle.tx;
particle.y = particle.ty;
@@ -772,6 +575,7 @@
function resize() {
const previousWidth = width || innerWidth;
const previousHeight = height || innerHeight;
+ const previousBounds = width ? diagramBounds() : null;
width = innerWidth;
height = innerHeight;
ratio = Math.max(
@@ -788,10 +592,29 @@
gridRows = Math.max(1, Math.ceil(height / cellSize));
grid.length = gridColumns * gridRows;
+ const nextBounds = diagramBounds();
particles.forEach((particle) => {
- particle.x = particle.x / previousWidth * width;
- particle.y = particle.y / previousHeight * height;
+ if (refined && previousBounds) {
+ particle.x = nextBounds.x + (particle.x - previousBounds.x) / previousBounds.w * nextBounds.w;
+ particle.y = nextBounds.y + (particle.y - previousBounds.y) / previousBounds.h * nextBounds.h;
+ particle.vx *= nextBounds.w / previousBounds.w;
+ particle.vy *= nextBounds.h / previousBounds.h;
+ } else {
+ particle.x = particle.x / previousWidth * width;
+ particle.y = particle.y / previousHeight * height;
+ }
});
+ flockBounds = nextBounds;
+ if (!introInitialized && refined && activeScene === 'intro') {
+ const bounds = diagramBounds();
+ particles.forEach((p, index) => {
+ const angle = index * 2.399963;
+ const radius = Math.sqrt((index + .5) / particleCount);
+ p.x = bounds.x + bounds.w * (.52 + Math.cos(angle) * radius * .34);
+ p.y = bounds.y + bounds.h * (.50 + Math.sin(angle) * radius * .26);
+ });
+ }
+ introInitialized = true;
buildScene(activeScene);
}
@@ -801,6 +624,10 @@
}
particles.forEach((particle, index) => {
+ // Every bird steers from the same previous-frame state, not partially
+ // updated neighbours. This removes index-order bias in the flock.
+ particle.px = particle.x; particle.py = particle.y;
+ particle.pvx = particle.vx; particle.pvy = particle.vy;
const column = Math.max(0, Math.min(gridColumns - 1, Math.floor(particle.x / cellSize)));
const row = Math.max(0, Math.min(gridRows - 1, Math.floor(particle.y / cellSize)));
const gridIndex = row * gridColumns + column;
@@ -902,6 +729,94 @@
particle.y += particle.vy * step;
}
+ function updateRefinedParticle(particle, now, step) {
+ const nearest = particle.nearest, distances = particle.distances;
+ nearest.length = 0; distances.length = 0;
+ let separationX = 0, separationY = 0;
+ const compact = width <= 820;
+ const separationRadiusSquared = compact ? 144 : 324;
+ const column = Math.max(0, Math.min(gridColumns - 1, Math.floor(particle.px / cellSize)));
+ const row = Math.max(0, Math.min(gridRows - 1, Math.floor(particle.py / cellSize)));
+ for (let dy = -1; dy <= 1; dy++) {
+ const r = row + dy;
+ if (r < 0 || r >= gridRows) continue;
+ for (let dx = -1; dx <= 1; dx++) {
+ const c = column + dx;
+ if (c < 0 || c >= gridColumns) continue;
+ for (const index of grid[r * gridColumns + c] || []) {
+ const other = particles[index];
+ if (other === particle) continue;
+ const x = other.px - particle.px, y = other.py - particle.py;
+ const distance = x * x + y * y;
+ if (distance < separationRadiusSquared && distance > .01) {
+ const pressure = .72 / Math.max(distance, 70);
+ separationX -= x * pressure; separationY -= y * pressure;
+ }
+ if (distance >= 10400) continue;
+ // Seven nearest neighbours carry a turn locally instead of
+ // averaging the entire surrounding patch into a rigid blob.
+ let slot = 0;
+ while (slot < distances.length && distances[slot] < distance) slot++;
+ if (slot < 7) {
+ distances.splice(slot, 0, distance); nearest.splice(slot, 0, index);
+ if (nearest.length > 7) { nearest.pop(); distances.pop(); }
+ }
+ }
+ }
+ }
+ let ax = separationX, ay = separationY;
+ if (nearest.length) {
+ let x = 0, y = 0, vx = 0, vy = 0;
+ nearest.forEach(index => {
+ const p = particles[index]; x += p.px; y += p.py;
+ vx += p.pvx; vy += p.pvy;
+ });
+ const count = nearest.length;
+ ax += (vx / count - particle.pvx) * particle.response + (x / count - particle.px) * .00042;
+ ay += (vy / count - particle.pvy) * particle.response + (y / count - particle.py) * .00042;
+ }
+ const bounds = flockBounds;
+ const t = now / 1000;
+ const centerX = bounds.x + bounds.w * (.52 + Math.sin(t * .17) * .06);
+ const centerY = bounds.y + bounds.h * (.50 + Math.sin(t * .23 + 1) * .08);
+ const nx = (particle.px - centerX) / (bounds.w * .44);
+ const ny = (particle.py - centerY) / (bounds.h * .37);
+ const wind = window.mdbaseAtmosphere?.wind(particle.px / width, particle.py / height)
+ || { x: Math.sin(t * .22 + ny * 2), y: Math.cos(t * .19 + nx * 2) * .6 };
+ const depthSpeed = (.85 + particle.depth * .3) * (compact ? .72 : 1);
+ let targetX = (wind.x * .75 - ny * 1.1 + Math.sin(t * .6 + ny * 3) * .45) * depthSpeed;
+ let targetY = (wind.y * .75 + nx * .85 + Math.cos(t * .43 + nx * 3) * .28) * depthSpeed;
+ const targetSpeed = Math.hypot(targetX, targetY);
+ if (targetSpeed > 1.3) { targetX *= 1.3 / targetSpeed; targetY *= 1.3 / targetSpeed; }
+ ax += (targetX - particle.pvx) * .027;
+ ay += (targetY - particle.pvy) * .027;
+ // Very weak central cohesion; soft walls protect the reading column
+ // without prescribing the flock's silhouette or teleporting a bird.
+ ax += (centerX - particle.px) * .00006;
+ ay += (centerY - particle.py) * .00006;
+ const paddingX = bounds.w * .09, paddingY = bounds.h * .09;
+ const wallStrength = compact ? .0024 : .0012;
+ ax += Math.max(0, bounds.x + paddingX - particle.px) * wallStrength;
+ ax -= Math.max(0, particle.px - (bounds.x + bounds.w - paddingX)) * wallStrength;
+ ay += Math.max(0, bounds.y + paddingY - particle.py) * wallStrength;
+ ay -= Math.max(0, particle.py - (bounds.y + bounds.h - paddingY)) * wallStrength;
+ if (pointer.active) {
+ const x = particle.px - pointer.x, y = particle.py - pointer.y;
+ const distance = Math.hypot(x, y);
+ if (distance > 1 && distance < 150) {
+ const force = (1 - distance / 150) * .12;
+ ax += x / distance * force; ay += y / distance * force;
+ }
+ }
+ const acceleration = Math.hypot(ax, ay);
+ if (acceleration > .11) { ax *= .11 / acceleration; ay *= .11 / acceleration; }
+ particle.vx += ax * step; particle.vy += ay * step;
+ const speed = Math.hypot(particle.vx, particle.vy);
+ const maximum = 1.55 + particle.depth * .3;
+ if (speed > maximum) { particle.vx *= maximum / speed; particle.vy *= maximum / speed; }
+ particle.x += particle.vx * step; particle.y += particle.vy * step;
+ }
+
function updateMovingTargets(now) {
particles.forEach((particle) => {
if (!particle.path) return;
@@ -920,9 +835,19 @@
}
function updateFormationParticle(particle, step) {
+ if (particle.role === "ambient" && !reduceMotion && !previewPaused) {
+ updateRefinedParticle(particle, simulationTime, step);
+ return;
+ }
+ if (refined && simulationTime < particle.releaseAt) {
+ updateRefinedParticle(particle, simulationTime, step);
+ return;
+ }
const dx = particle.tx - particle.x;
const dy = particle.ty - particle.y;
- const strength = particle.role === "packet" ? 0.020 : 0.013;
+ const progress = refined ? Math.min(1, Math.max(0, (simulationTime - particle.releaseAt) / 280)) : 1;
+ const ease = progress * progress * (3 - 2 * progress);
+ const strength = (particle.role === "packet" ? 0.020 : 0.013) * ease;
particle.vx += dx * strength * step;
particle.vy += dy * strength * step;
@@ -989,11 +914,10 @@
const x = (midpoint.x + next.x) / 2;
const y = (midpoint.y + next.y) / 2 - 8;
context.save();
- context.font =
- `${compact ? 8 : 9}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
+ context.font = `${compact ? 11 : 12}px "Azeret Mono", ui-monospace, monospace`;
const metrics = context.measureText(edge.label);
context.fillStyle = palette.surface;
- context.fillRect(x - metrics.width / 2 - 4, y - 7, metrics.width + 8, 12);
+ context.fillRect(x - metrics.width / 2 - 5, y - 10, metrics.width + 10, 17);
context.fillStyle = palette.muted;
context.textAlign = "center";
context.fillText(edge.label, x, y + 2);
@@ -1002,10 +926,13 @@
function drawNodeSurface(node, compact) {
const { x, y, w, h } = node.box;
+ const solid = ["card", "log", "held", "app", "accent"].includes(node.kind) || node.lines;
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.strokeStyle = node.kind === "held" ? palette.warning
+ : node.kind === "log" || node.kind === "accent" ? palette.accent
+ : node.kind === "boundary" ? palette.lineSoft : palette.muted;
+ context.globalAlpha *= node.kind === "boundary" ? 0.5 : 0.94;
context.lineWidth = 1;
context.setLineDash(
node.kind === "boundary" || node.kind === "replica" ? [3, 5] : []
@@ -1028,7 +955,7 @@
context.textAlign = "left";
context.textBaseline = "top";
context.fillStyle = palette.ink;
- context.globalAlpha *= 0.78;
+ context.globalAlpha *= 0.95;
context.font = `${size}px "Atkinson Hyperlegible", sans-serif`;
lines.forEach((line, index) => {
context.fillText(line, x + padding, y + padding + index * lineHeight);
@@ -1036,13 +963,47 @@
context.restore();
}
+ function drawCardLines(node) {
+ if (!node.lines) return;
+ const { x, y, w, h } = node.box;
+ const { size, lineHeight } = node.mono;
+ const padding = node.heading.padding;
+ context.save();
+ context.beginPath();
+ context.rect(x, y, w, h);
+ context.clip();
+ context.textBaseline = "top";
+ context.font = `${size}px "Azeret Mono", ui-monospace, monospace`;
+ node.lines.forEach((line, index) => {
+ const lineX = x + padding + 8;
+ const lineY = y + node.heading.height + 6 + index * lineHeight;
+ const colour = line.tone === "add" ? palette.accent
+ : line.tone === "held" ? palette.warning
+ : line.tone === "remove" || line.tone === "faint" || line.tone === "blocked" ? palette.muted
+ : palette.ink;
+ context.fillStyle = colour;
+ context.globalAlpha = diagramAlpha;
+ if (line.tone === "blocked") context.fillText("×", x + padding - 2, lineY);
+ if (line.tone === "add" || line.tone === "held") {
+ context.fillRect(x + padding, lineY + 1, 2, size + 1);
+ }
+ context.fillText(line.text, lineX, lineY);
+ if (line.tone === "remove") {
+ const textWidth = context.measureText(line.text).width;
+ context.fillRect(lineX, lineY + size * 0.55, textWidth, 1);
+ }
+ });
+ context.restore();
+ }
+
function drawNote(note, compact) {
context.save();
context.fillStyle = palette.muted;
- 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);
+ note.text.split("\n").forEach((line, index) =>
+ context.fillText(line, note.x, note.y + index * (compact ? 18 : 22)));
context.restore();
}
@@ -1055,30 +1016,21 @@
? palette.accent
: palette.particleIntro;
}
- if (activeScene === "modes" && particle.index >= 181) {
- return palette.accent;
- }
- if (
- activeScene === "routes"
- && particle.index >= 151
- && particle.index < 301
- ) {
- return palette.accent;
- }
return palette.particle;
}
function drawParticle(particle, compact) {
const baseSize = compact ? 1.8 : 2.25;
- const size =
- particle.role === "packet"
- ? baseSize * 1.35
- : baseSize * particle.scale;
- const x = Math.round(particle.x - size / 2);
- const y = Math.round(particle.y - size / 2);
+ const depthBlend = !refined ? 0 : activeScene === 'intro' ? 1 : 1 - Math.min(1, Math.max(0, (simulationTime - particle.releaseAt) / 750));
+ const depthScale = 1 + (particle.depth - .5) * .42 * depthBlend;
+ const size = (particle.role === "packet" ? baseSize * 1.35 : baseSize * particle.scale) * depthScale;
+ const x = refined ? particle.x - size / 2 : Math.round(particle.x - size / 2);
+ const y = refined ? particle.y - size / 2 : Math.round(particle.y - size / 2);
context.save();
- context.globalAlpha = particle.role === "packet" ? 0.82 : 0.62;
+ context.globalAlpha = particle.role === "packet" ? 0.82
+ : particle.role === "ambient" ? 0.16 + particle.depth * 0.12
+ : .62 + (particle.depth - .5) * .30 * depthBlend;
if (particle.role === "replica") {
context.strokeStyle = palette.replica;
context.lineWidth = 0.8;
@@ -1095,6 +1047,9 @@
const compact = width <= 820;
if (activeScene !== "intro") {
+ particles.forEach((particle) => {
+ if (particle.role === "ambient") drawParticle(particle, compact);
+ });
context.save();
context.globalAlpha = diagramAlpha;
diagram.edges.forEach((edge) => drawEdge(edge, compact));
@@ -1102,12 +1057,16 @@
context.restore();
}
- particles.forEach((particle) => drawParticle(particle, compact));
+ particles.forEach((particle) => {
+ if (activeScene === "intro" || particle.role !== "ambient") drawParticle(particle, compact);
+ });
if (activeScene !== "intro") {
context.save();
context.globalAlpha = diagramAlpha;
diagram.nodes.forEach((node) => drawNodeLabel(node, compact));
+ diagram.nodes.forEach((node) => drawCardLines(node));
+ diagram.edges.forEach((edge) => drawEdgeLabel(edge, compact));
diagram.notes.forEach((note) => drawNote(note, compact));
context.restore();
}
@@ -1119,16 +1078,21 @@
const elapsed = Math.min(32, now - previousTime || 16.67);
const step = elapsed / 16.67;
previousTime = now;
+ simulationTime += elapsed;
+ flockBounds = activeScene === "intro" ? introBounds() : diagramBounds();
if (activeScene === "intro") {
rebuildGrid();
particles.forEach((particle, index) => {
- updateFreeParticle(particle, index, now, step);
+ if (refined) updateRefinedParticle(particle, simulationTime, step);
+ else updateFreeParticle(particle, index, simulationTime, step);
});
} else {
- updateMovingTargets(now);
+ rebuildGrid();
+ updateMovingTargets(simulationTime);
particles.forEach((particle) => updateFormationParticle(particle, step));
- diagramAlpha += (1 - diagramAlpha) * 0.045 * step;
+ const age = simulationTime - sceneStartedAt;
+ if (!refined || age > 180) diagramAlpha += (1 - diagramAlpha) * (refined ? .026 : .045) * step;
}
draw(now);
@@ -1142,7 +1106,11 @@
chapters.forEach((chapter, chapterIndex) => {
chapter.classList.toggle("is-active", chapterIndex === index);
});
- buildScene(scene);
+ document.querySelectorAll(".chapter-index a").forEach((link, linkIndex) => {
+ if (linkIndex === index) link.setAttribute("aria-current", "step");
+ else link.removeAttribute("aria-current");
+ });
+ buildScene(scene, true);
}
function updateScrollState() {
@@ -1171,7 +1139,11 @@
const chapter = chapters[nearestIndex];
setScene(chapter.dataset.scene, nearestIndex);
- if (reduceMotion) draw(0);
+ if (reduceMotion || previewPaused) {
+ particles.forEach(p => { p.x = p.tx; p.y = p.ty; });
+ diagramAlpha = 1;
+ draw(0);
+ }
}
function scheduleScrollUpdate() {
@@ -1180,7 +1152,7 @@
}
function startAnimation() {
- if (reduceMotion || isAnimating) return;
+ if (reduceMotion || previewPaused || isAnimating || document.hidden) return;
isAnimating = true;
previousTime = performance.now();
animationFrame = requestAnimationFrame(frame);
@@ -1201,6 +1173,25 @@
particles.push(new Particle(index));
}
+ // Preview controls and acceptance tests share this narrow bridge.
+ window.murmurationPreview = {
+ particles,
+ get scene() { return activeScene; },
+ get refined() { return refined; },
+ get time() { return simulationTime; },
+ setRefined(value) {
+ if (refined === value) return;
+ refined = value;
+ particles.forEach(p => { p.releaseAt = simulationTime; });
+ draw(simulationTime);
+ },
+ pause(value) {
+ previewPaused = value;
+ if (value) stopAnimation();
+ else startAnimation();
+ }
+ };
+
// Font loading can change measured line breaks after the first paint.
document.fonts.ready.then(() => buildScene(activeScene));
syncPalette();
@@ -1227,7 +1218,12 @@
addEventListener("mdbase:themechange", () => {
syncPalette();
buildScene(activeScene);
- if (reduceMotion) draw(0);
+ if (reduceMotion || previewPaused) draw(0);
+ });
+ motionPreference.addEventListener('change', event => {
+ reduceMotion = event.matches;
+ if (reduceMotion) { stopAnimation(); buildScene(activeScene); }
+ else startAnimation();
});
addEventListener("pagehide", stopAnimation);
})();
diff --git a/public/og-card.png b/public/og-card.png
new file mode 100644
index 0000000..044e1a8
Binary files /dev/null and b/public/og-card.png differ
diff --git a/public/textures/dark-2.png b/public/textures/dark-2.png
new file mode 100644
index 0000000..358235a
Binary files /dev/null and b/public/textures/dark-2.png differ
diff --git a/public/textures/light-2.png b/public/textures/light-2.png
new file mode 100644
index 0000000..7884d22
Binary files /dev/null and b/public/textures/light-2.png differ
diff --git a/scripts/app-docs.test.mjs b/scripts/app-docs.test.mjs
index 920992c..294dfb9 100644
--- a/scripts/app-docs.test.mjs
+++ b/scripts/app-docs.test.mjs
@@ -34,7 +34,7 @@ test("Every documentation navigation entry resolves to a site page", () => {
const page = new URL(`src/pages${href}index.astro`, root);
assert.ok(existsSync(page), `Missing navigation target: ${fileURLToPath(page)}`);
}
- assert.match(sections, /value: release\.sdkVersion/);
+ assert.match(sections, /meta: { label: "@mdbase-dev\/sdk"/);
});
test("Five sample recordings have local playable files, posters and text equivalents", () => {
diff --git a/scripts/home-animation.test.mjs b/scripts/home-animation.test.mjs
index eea4953..c04509e 100644
--- a/scripts/home-animation.test.mjs
+++ b/scripts/home-animation.test.mjs
@@ -26,18 +26,19 @@ function harness(width, height, reducedMotion = true) {
querySelector: selector => selector === '#diagram' ? canvas : {
getBoundingClientRect: () => ({ bottom: width <= 350 ? 137 : 107 }),
},
- querySelectorAll: () => chapters,
+ querySelectorAll: selector => selector.includes('chapter-index') ? [] : 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 }),
+ scrollY: 0, matchMedia: () => ({ matches: reducedMotion, addEventListener() {} }),
getComputedStyle: () => ({ colorScheme: 'light', getPropertyValue: () => '' }),
requestAnimationFrame: () => 1, cancelAnimationFrame() {}, addEventListener() {},
performance: { now: () => 0 },
};
+ sandbox.window = sandbox;
vm.runInNewContext(source.replace(' syncPalette();\n resize();', `
globalThis.inspect = () => ({ diagram, particles, activeScene });
globalThis.select = name => setScene(name, chapters.findIndex(c => c.dataset.scene === name));
@@ -73,28 +74,34 @@ for (const [width, height] of [[320, 640], [390, 844], [820, 1000], [821, 900],
});
}
-test('sharing is hosted-only, has two non-directional memberships, and no data-transfer particles', () => {
+test('sharing shows both roles connected and a removed member with no connection', () => {
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']);
+ const { diagram } = app.inspect();
+ assert.deepEqual(Array.from(diagram.nodes, node => node.label),
+ ['shared collection', 'Viewer · reads', 'Editor · reads and edits', 'Member removed']);
assert.equal(diagram.edges.length, 2);
- assert.ok(diagram.edges.every(edge => edge.relationship));
- assert.ok(particles.every(particle => particle.path === null));
+ const removed = diagram.nodes.find(node => node.id === 'sharing-removed');
+ assert.equal(removed.kind, 'replica');
+ for (const edge of diagram.edges) {
+ const end = edge.from.at(-1);
+ assert.ok(!(end.x >= removed.box.x && end.y >= removed.box.y && end.y <= removed.box.y + removed.box.h));
+ }
});
-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('the sync note sits under the log, clear of every edge', () => {
+ const app = harness(1440, 1000);
+ app.select('sync');
+ const { diagram } = app.inspect();
+ const log = diagram.nodes.find(node => node.id === 'sync-log');
+ assert.equal(diagram.notes.length, 1);
+ const note = diagram.notes[0];
+ assert.ok(note.y > log.box.y + log.box.h);
+ // Each wrapped line is narrower than the log, and every edge ends on the log's sides.
+ for (const line of note.text.split('\n')) assert.ok(line.length * 17 * 0.5 < log.box.w);
+ for (const edge of diagram.edges) {
+ const end = edge.from.at(-1);
+ assert.ok(Math.abs(end.x - log.box.x) < 1 || Math.abs(end.x - (log.box.x + log.box.w)) < 1);
}
});
diff --git a/src/components/BrandMark.astro b/src/components/BrandMark.astro
index 41c8afe..c7b2134 100644
--- a/src/components/BrandMark.astro
+++ b/src/components/BrandMark.astro
@@ -4,6 +4,19 @@ interface Props {
}
const { compact = false } = Astro.props;
+// Bars in reading order; `--step` staggers the hover ripple through them.
+const bars = [
+ { x: 22, y: 22, width: 20 },
+ { x: 50, y: 22, width: 20 },
+ { x: 78, y: 22, width: 20 },
+ { x: 22, y: 44, width: 12 },
+ { x: 42, y: 44, width: 56, accent: true },
+ { x: 22, y: 66, width: 28 },
+ { x: 58, y: 66, width: 40 },
+ { x: 22, y: 88, width: 20 },
+ { x: 50, y: 88, width: 20 },
+ { x: 78, y: 88, width: 20 }
+];
---
-
diff --git a/src/components/SiteHeader.astro b/src/components/SiteHeader.astro
index 981949e..5dcc454 100644
--- a/src/components/SiteHeader.astro
+++ b/src/components/SiteHeader.astro
@@ -1,4 +1,6 @@
---
+import BrandMark from "./BrandMark.astro";
+
interface Props {
current?: "home" | "apps" | "downloads" | "connect" | "sdk" | "runtime" | "spec";
}
@@ -15,7 +17,7 @@ const links = [