Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 34 additions & 29 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 1 addition & 15 deletions public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
154 changes: 154 additions & 0 deletions public/mdbase-atmosphere.js
Original file line number Diff line number Diff line change
@@ -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);
})();
Loading
Loading