Files

668 lines
25 KiB
TypeScript

/**
* THE DOT-MATRIX GRAMMAR — what the lattice is doing, and how a page says it.
*
* `components/backdrop/DotMatrix` draws one field of dots under every route.
* Left alone it draws the ambient one — two slow plane waves, saying nothing.
* A page that has something to say pushes a field here and the backdrop blends
* to it, so the lattice shows what the software is doing instead of decorating
* the page while it happens.
*
* DESIGN.md §11 fixes the vocabulary and it is not negotiable:
*
* dot information, or a unit of compute
* cluster a machine, an application, a logical component
* bright active work movement data or control flowing
* split parallelism, agents join synthesis
* boundary security, locality density scale of compute
* pulse an event
*
* ── THE SHAPE OF THE API ──────────────────────────────────────────────────
*
* Everything is a `Field`: brightness 0..1 at a point, given CSS pixels and a
* clock. That is the whole interface, which is why there is no scheduler, no
* state machine and no per-page machinery in the backdrop — one loop over one
* grid, and composition is arithmetic.
*
* A verb is a `Field` factory. Every verb takes a `Beat` — `at`, when it
* starts, and `span`, how long it takes, both in seconds from the moment the
* field was shown. Before `at` a verb is zero. After `at + span` it HOLDS ITS
* END STATE. That last rule is what makes a topology assemble and stay drawn
* rather than flash past, and it is what lets `sequence` be a plain maximum:
*
* show(sequence(
* route(prompt, enso, { at: 0.0, span: 0.7 }),
* fork(enso, [agent, model], { at: 0.7, span: 0.9 }),
* join([agent, model], infra, { at: 1.5, span: 0.9 }),
* ))
*
* Positions are `Point`s in fractions of the viewport, so a scene is written
* once and is correct at every window size. Distances are measured in WIDTH
* units, so a circle is round rather than an ellipse on a phone.
*
* A page shows a scene with `show(field)` and returns the lattice to ambient
* with `show(null)` on the way out.
*
* ── THE SIX VERBS ─────────────────────────────────────────────────────────
*
* materialize(where, radius, beat) the system forms around a request
* route(from, to, beat) one path, traced dot by dot
* fork(from, [to…], beat) one becomes many — parallelism
* join([from…], to, beat) many become one — synthesis
* handoff(from, to, beat) the same work, somewhere else
* policy(gate, from, to, beat) authority travels; the secret does not
*
* And one thing the verbs operate ON:
*
* work([{ where, at }…]) the bright dot IS the work
*
* There are no others. §11.4: a field that does not correspond to something
* the software actually does is decoration, and decoration here is a bug. If a
* page cannot say which verb it is using and what its dots stand for, it does
* not get a field.
*
* `policy` carries the one claim the architecture has to make in five seconds:
* THE SECRET NEVER MOVES. It draws the credential stationary inside the
* boundary and the authorization travelling past it. An animation that moves
* the key is wrong about the product, so this verb is the one that must not be
* reimplemented per page.
*
* ── GRAVITY ───────────────────────────────────────────────────────────────
*
* Idle long enough and the loose dots fall back into the Hanzo mark, which
* this module holds as a gravity source (§11.3). Any interaction dissolves it
* back into computation. That arbitration lives here rather than in the
* backdrop or in a page, because it is the same question the rest of this file
* answers — what the lattice is doing — and two places deciding it would
* disagree the first time a page showed a field while the mark was forming.
*
* ── WHY MODULE STATE AND NOT REACT ────────────────────────────────────────
*
* The backdrop is mounted outside the providers (it reads nothing from them)
* and must survive navigation — §11.3 asks the field to REORGANISE between
* routes rather than tear down. So this is a module-level store with a
* subscription, and the canvas never remounts.
*/
/** Brightness at a point, 0..1. `x`/`y` are CSS pixels; `t` is seconds since the field was shown. */
export type Field = (x: number, y: number, t: number, w: number, h: number) => number
/** A place on the viewport, in fractions of its width and height. */
export type Point = { x: number; y: number }
/** When a verb starts and how long it takes, in seconds from the field's own zero. */
export type Beat = { at?: number; span?: number }
/* ------------------------------------------------------------------ timing */
const clock = (): number => performance.now() / 1000
const clamp = (u: number): number => (u < 0 ? 0 : u > 1 ? 1 : u)
/** Smoothstep. Verbs start and stop without a visible edge on either end. */
const ease = (u: number): number => u * u * (3 - 2 * u)
/** A verb's own progress, 0 before it starts and 1 forever after it finishes. */
const beat = (t: number, at: number, span: number): number =>
span <= 0 ? (t >= at ? 1 : 0) : ease(clamp((t - at) / span))
/* ------------------------------------------------------------------- shape */
/**
* Constants, in WIDTH units, so every scene is the same physical size at every
* window. A node is about a finger-width on a laptop; a wire is one to two
* lattice cells across, which is what makes a route read as traced rather than
* as a drawn line.
*/
/**
* Half-width of a route.
*
* It has to be wide enough that the FLAT part of its cross-section covers a row
* of cells, or an axis-aligned run falls between two rows and all but vanishes.
* Measured at 0.009: a horizontal route lit nothing on an 18px grid. At 0.013
* the half-width is 19px and the plateau is 9.4px, so the nearest row — never
* more than 9px off a horizontal line at this pitch — always gets full ink.
*/
const WIRE = 0.013
/** How much of a route the travelling head occupies. */
const HEAD = 0.22
/**
* The ink ladder, and it is aimed at the backdrop's buckets on purpose.
*
* `DotMatrix` quantises a field into eight steps and only thickens the dot in
* the top three — that is how §11.1's `·· → ▒▒ → ██` is drawn. So these are not
* free numbers: a route has to land a rung below a hub, a hub has to land where
* the dot grows, and the work has to top out. Measured with everything a rung
* too low: the whole topology sat in the flat middle of the ramp, nothing grew,
* and a drawing made of hubs and routes read as one texture.
*
* What a route holds once the head has passed. Step 4 of 8: visible, flat.
*/
const TRAIL = 0.55
/** Radius of the cluster a `fork` or `join` puts at its hub. */
export const NODE = 0.035
/** Radius of the cluster `handoff` carries. */
const CARRY = 0.05
/** How far past the front edge a `materialize` stays hot. */
const SKIN = 0.05
/** What a materialised region holds at its centre. Step 6: the dot thickens. */
const SETTLED = 0.82
/**
* The one profile every solid shape here uses: flat out to `flat` of the
* radius, then off.
*
* NOT A FALLOFF FROM THE CENTRE. That was the first version and it was wrong
* for the same reason at three sizes: the lattice samples on an 18px grid, so
* with a squared falloff whether a cluster looked solid depended on where its
* centre happened to land between cells, and a node the spec draws as `██` came
* out as a smudge that dimmed toward its own middle. A plateau reads as an
* object at every position, and the ramp on the outside is what keeps it from
* aliasing into a rectangle.
*/
const plate = (d: number, r: number, flat: number): number => {
if (d >= r) return 0
const hold = r * flat
return d <= hold ? 1 : 1 - (d - hold) / (r - hold)
}
/** `plate`, from a vector. */
const blot = (dx: number, dy: number, r: number, flat: number): number =>
plate(Math.sqrt(dx * dx + dy * dy), r, flat)
/**
* A radius in width units, with a floor in CSS PIXELS.
*
* Every size here is a fraction of the viewport width, so a scene is the same
* picture at every window — and that breaks at the small end. On a 390px phone
* a node is 14px across while the backdrop opens its pitch from 18px to 26px to
* pay for the fill, so a cluster lands on one cell and stops being a cluster and
* a route lands between cells and stops being a route. The floors are stated in
* pixels because the thing they have to beat — the cell pitch — is.
*/
const enough = (r: number, w: number, floor: number): number => Math.max(r * w, floor) / w
/** Pixel floors, one per shape, all a small multiple of the coarse 26px pitch. */
const FLOOR = { node: 40, wire: 17, seed: 21, carry: 48, wall: 15, reach: 72 } as const
/* ------------------------------------------------------------------- verbs */
/**
* MATERIALIZE — the system forms around a request.
*
* ········ ··▒▒·· ▒████▒
* ········ → ·▒██▒· → ██████
*
* A front leaves `where` and the lattice behind it STAYS LIT, so the picture is
* something being built rather than a ripple passing through. Small radii make
* a cluster; large ones make a region waking up.
*/
export function materialize(where: Point, radius = NODE, when: Beat = {}): Field {
const at = when.at ?? 0
const span = when.span ?? 0.6
return (x, y, t, w, h) => {
if (t < at) return 0
const p = beat(t, at, span)
const r = enough(radius, w, FLOOR.node)
const dx = (x - where.x * w) / w
const dy = (y - where.y * h) / w
const d = Math.sqrt(dx * dx + dy * dy)
const front = p * r
if (d > front) return 0
const lit = SETTLED * plate(d, r, 0.45)
// The hot front is what makes this read as growth, and it has to GO when
// the growing stops — held to the end it leaves a bright rim around a
// duller middle, so every cluster on the page is a ring instead of a
// cluster. It fades out over the last third of the verb.
const skin = (1 - (front - d) / SKIN) * Math.min(1, (1 - p) / 0.35)
return skin > 0 ? Math.max(lit, skin * skin) : lit
}
}
/**
* ROUTE — a request takes one path of several.
*
* ● · · · · · ○
*
* A head is traced dot by dot from `from` to `to` and the wire behind it holds.
* Nothing about the path is drawn before the head reaches it, which is the
* difference between a route and a diagram.
*/
export function route(from: Point, to: Point, when: Beat = {}): Field {
const at = when.at ?? 0
const span = when.span ?? 0.8
return (x, y, t, w, h) => {
if (t < at) return 0
const p = beat(t, at, span)
const ax = from.x * w
const ay = from.y * h
const vx = to.x * w - ax
const vy = to.y * h - ay
const len2 = vx * vx + vy * vy
if (len2 === 0) return 0
let u = ((x - ax) * vx + (y - ay) * vy) / len2
if (u < 0) u = 0
else if (u > 1) u = 1
if (u > p) return 0
const near = blot((x - (ax + vx * u)) / w, (y - (ay + vy * u)) / w, enough(WIRE, w, FLOOR.wire), 0.5)
if (near <= 0) return 0
const lead = 1 - (p - u) / HEAD
const ink = lead > 0 ? TRAIL + (1 - TRAIL) * lead * lead : TRAIL
return near * ink
}
}
/**
* FORK — one becomes many. Parallelism, agents.
*
* ●
* ╱│╲
* ● ● ●
*
* The hub materialises and the branches leave it together, because they are
* concurrent — staggering them would say pipeline, which is a different claim.
*/
export function fork(from: Point, to: Point[], when: Beat = {}): Field {
const at = when.at ?? 0
const span = when.span ?? 0.9
return sequence(
materialize(from, NODE, { at, span: span * 0.4 }),
...to.map((end) => route(from, end, { at, span })),
...to.map((end) => materialize(end, NODE, { at: at + span * 0.7, span: span * 0.4 })),
)
}
/**
* JOIN — many become one. Synthesis.
*
* ● ● ●
* ╲│╱
* ●
*
* The destination forms LAST, on the tail of the arrivals, so the reader sees
* the branches produce it rather than flow into something already there.
*/
export function join(from: Point[], to: Point, when: Beat = {}): Field {
const at = when.at ?? 0
const span = when.span ?? 0.9
return sequence(
...from.map((start) => route(start, to, { at, span })),
materialize(to, NODE, { at: at + span * 0.65, span: span * 0.5 }),
)
}
/**
* HANDOFF — the same work, somewhere else.
*
* LOCAL CLOUD
* █████ · · · → █████
*
* The cluster TRANSLATES. It is not torn down at `from` and rebuilt at `to`,
* and it leaves no trail, because the claim is that this is the same software
* in a different place. A version of this that dissolves and re-forms is
* saying something the product does not do.
*/
export function handoff(from: Point, to: Point, when: Beat = {}): Field {
const at = when.at ?? 0
const span = when.span ?? 1.4
return (x, y, t, w, h) => {
if (t < at) return 0
const p = beat(t, at, span)
const cx = (from.x + (to.x - from.x) * p) * w
const cy = (from.y + (to.y - from.y) * p) * h
return blot((x - cx) / w, (y - cy) / w, enough(CARRY, w, FLOOR.carry), 0.5) * 0.86
}
}
/**
* POLICY — authority, not secrets.
*
* agent → │ boundary │ → API
* ↑ policy
*
* A stationary bar at `gate` is the boundary. The bright dot at its centre is
* the credential, and it is drawn ONCE, at a fixed point, for the whole life of
* the field: it does not travel, fade or follow. What travels is the request,
* in on one side and out on the other.
*
* This is the verb that must not be reimplemented locally. The moment a page
* animates the key moving, the page is wrong about the architecture.
*/
export function policy(gate: Point, from: Point, to: Point, when: Beat = {}): Field {
const at = when.at ?? 0
const span = when.span ?? 1.4
const half = span * 0.45
return sequence(
boundary(gate, { at, span: span * 0.3 }),
route(from, gate, { at, span: half }),
route(gate, to, { at: at + span - half, span: half }),
// What was authorised. Without it the request crosses a wall and arrives
// nowhere, which is a picture of a boundary rather than of getting past one.
materialize(to, NODE, { at: at + span - half * 0.4, span: half }),
)
}
/**
* The wall and the thing behind it. Not a verb — the stationary half of
* `policy`, factored out so there is one place that knows a boundary is a bar
* and a secret is a dot that never moves.
*/
function boundary(gate: Point, when: Beat = {}): Field {
const at = when.at ?? 0
const span = when.span ?? 0.5
const REACH = 0.075
const THICK = 0.006
return (x, y, t, w, h) => {
if (t < at) return 0
const p = beat(t, at, span)
const gx = gate.x * w
const gy = gate.y * h
const dx = (x - gx) / w
const dy = (y - gy) / w
// The credential. Full brightness, at one fixed point, for the whole life
// of the field — it is the thing that does not move.
const secret = blot(dx, dy, enough(0.015, w, FLOOR.seed), 0.55)
const reach = enough(REACH, w, FLOOR.reach) * p
const thick = enough(THICK, w, FLOOR.wall)
const wall = Math.abs(dy) <= reach && Math.abs(dx) <= thick ? 0.45 * p : 0
return Math.max(wall, secret)
}
}
/* ---------------------------------------------------------------- the work */
/**
* THE BRIGHT DOT IS THE WORK.
*
* Not the agent, not the model, not the machine — those are clusters, and they
* are all replaceable. The single brightest dot in any Hanzo field is the piece
* of work itself, and it is the SAME dot from the first frame to the last. That
* is the one thing this site has to say: your work survives the surface.
*
* So the work is a value, not a drawing. It is an itinerary — where it is and
* when — and the verbs operate on it rather than owning it. `handoff` carries
* it between surfaces; `route` shows a request reaching it; `fork` splits the
* work it is doing without splitting the work itself. A page that draws a
* second bright dot has said there are two pieces of work, and had better mean
* it.
*
* const task = work([
* { where: APP, at: 0 },
* { where: CLI, at: 3 },
* { where: CLOUD, at: 6 },
* { where: EDITOR, at: 9 },
* ], { loop: 12 })
*
* show(sequence(surfaces, task.legs, task.seed))
*
* `task.seed` goes LAST in the sequence because `sequence` takes a maximum and
* the seed must win everywhere it lands — that is what makes it the same dot
* rather than a bright patch that happens to be nearby.
*/
/** Where the work is, and when it got there. `at` is seconds on the field's clock. */
export type Stop = { where: Point; at: number }
export type Work = {
/** Where the work is at `t`, in viewport fractions. */
where: (t: number) => Point
/** The dot. One, bright, continuous. */
seed: Field
/** The surfaces carrying it between stops — HANDOFF, once per leg. */
legs: Field
}
/** Radius of the work itself. Small: it is one dot, not a cluster. */
const SEED = 0.015
/** Seconds a leg of the journey takes, unless a caller says otherwise. */
const TRAVEL = 1.1
export function work(stops: Stop[], opts: { travel?: number; loop?: number } = {}): Work {
const travel = opts.travel ?? TRAVEL
const loop = opts.loop ?? 0
if (stops.length === 0) throw new Error('work() needs somewhere to be')
/** Fold the clock back on itself when the itinerary repeats. */
const wrap = (t: number): number => (loop > 0 ? ((t % loop) + loop) % loop : t)
const where = (raw: number): Point => {
const t = wrap(raw)
let here = stops[0]
for (let i = 1; i < stops.length; i++) {
const next = stops[i]
if (t < next.at) {
// Departure is `travel` before arrival, so a stop's own time is the
// moment the work LANDS there — which is what a caller means by "the
// work is in the cloud at t=6" and the only reading that survives a
// leg being retimed.
const p = beat(t, next.at - travel, travel)
if (p <= 0) return here.where
return {
x: here.where.x + (next.where.x - here.where.x) * p,
y: here.where.y + (next.where.y - here.where.y) * p,
}
}
here = next
}
return here.where
}
// Where the work is, worked out ONCE PER FRAME and not once per cell. A
// `Field` is called for every cell in the lattice — six thousand times at
// 30fps — and `where` returns a fresh Point, so the naive spelling allocated a
// couple of hundred thousand objects a second to answer the same question.
// The clock is the cache key because within a frame it does not move.
let seen = NaN
let px = 0
let py = 0
const seed: Field = (x, y, t, w, h) => {
if (t !== seen) {
seen = t
const at = where(t)
px = at.x
py = at.y
}
return blot((x - px * w) / w, (y - py * h) / w, enough(SEED, w, FLOOR.seed), 0.55)
}
const legs = sequence(
...stops.slice(1).map((next, i) =>
handoff(stops[i].where, next.where, { at: next.at - travel, span: travel }),
),
)
// A looping itinerary's legs have to fold too, or the last handoff holds its
// end state over the top of the first one on the second pass.
const folded: Field = loop > 0 ? (x, y, t, w, h) => legs(x, y, wrap(t), w, h) : legs
return { where, seed, legs: folded }
}
/**
* The work in flight, kept across a navigation.
*
* The lattice survives route changes (§11.3) and the work is the thing that is
* supposed to survive hardest, so the store holds it: a page that picks it up
* continues the same journey instead of starting a second one. `carry(null)`
* puts it down.
*/
let inflight: Work | null = null
export function carry(w: Work | null) {
inflight = w
}
export function carried(): Work | null {
return inflight
}
/* --------------------------------------------------------------- the glyph */
/**
* The Hanzo mark, as a mask.
*
* Four corner squares and one diagonal band, in the mark's own 67-unit box —
* the same five paths `public/favicon.svg` draws, stated as arithmetic rather
* than rasterised. A field is evaluated per cell per frame and must not touch
* an image; the mark is five rectangles, so it does not have to.
*/
const A = 22.21 / 67
const B = 44.51 / 67
const C = 22.318 / 67
const D = 44.637 / 67
const BAND_TOP_LEFT = 22.253 / 67
const BAND_TOP_RIGHT = 66.704 / 67
const BAND_LOW_LEFT = 0.0879 / 67
const BAND_LOW_RIGHT = 44.463 / 67
/** Is a point in the mark's unit box inside the mark? */
const inMark = (gx: number, gy: number): boolean => {
if (gy <= C || gy >= D) return gx <= A || gx >= B
const s = (gy - C) / (D - C)
const lo = BAND_TOP_LEFT + (BAND_LOW_LEFT - BAND_TOP_LEFT) * s
const hi = BAND_TOP_RIGHT + (BAND_LOW_RIGHT - BAND_TOP_RIGHT) * s
return gx >= lo && gx <= hi
}
/**
* GRAVITY — the mark, pulling the loose dots back in.
*
* Not one of the verbs, and deliberately: the verbs say what the software is
* doing, and this says that it is not doing anything. It forms over `SETTLE`
* seconds, which is slower than anything else on the site, because a mark that
* snaps into place reads as a logo animation rather than as the field coming
* to rest.
*/
const SETTLE = 8
export function gravity(where: Point = { x: 0.83, y: 0.72 }, size = 0.16): Field {
return (x, y, t, w, h) => {
const side = size * w
const gx = (x - (where.x * w - side / 2)) / side
const gy = (y - (where.y * h - side / 2)) / side
if (gx < 0 || gx > 1 || gy < 0 || gy > 1) return 0
return inMark(gx, gy) ? ease(clamp(t / SETTLE)) : 0
}
}
/* --------------------------------------------------------------- composing */
/**
* Several verbs as one field.
*
* The brightest wins. A sum would blow out wherever two routes cross — and
* they cross at every hub, which is exactly where the picture needs to stay
* readable — so this is a maximum, and nothing has to be normalised.
*/
export function sequence(...parts: Field[]): Field {
return (x, y, t, w, h) => {
let top = 0
for (let i = 0; i < parts.length; i++) {
const v = parts[i](x, y, t, w, h)
if (v > top) top = v
}
return top > 1 ? 1 : top
}
}
/* ------------------------------------------------------------------- store */
/** How long the backdrop takes to cross-fade in or out, in seconds. */
export const FADE = 0.9
/** Silence, in seconds, before the mark starts pulling the dots back in. */
const IDLE = 24
type State = { field: Field | null; since: number }
/** What the backdrop reads. Resolved — the arbitration below has already run. */
const state: State = { field: null, since: 0 }
/** What a page asked for. Beats gravity whenever it is set. */
let asked: Field | null = null
/** When the reader last did anything. */
let touched = 0
let timer: ReturnType<typeof setTimeout> | null = null
const listeners = new Set<() => void>()
const announce = () => {
for (const fn of listeners) fn()
}
const hold = (field: Field | null) => {
if (state.field === field) return
state.field = field
state.since = clock()
announce()
}
/**
* Decide what the lattice is doing, and say so once.
*
* A page's field wins. With none, the mark forms after `IDLE` seconds of
* silence and dissolves the moment anything happens. One function owns the
* choice, so there is no ordering between "a page showed something" and "the
* reader went quiet" for the two of them to get wrong.
*/
const settle = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
if (asked) {
hold(asked)
return
}
const quiet = clock() - touched
if (quiet >= IDLE) {
if (!state.field) hold(gravity())
return
}
hold(null)
timer = setTimeout(settle, (IDLE - quiet) * 1000)
}
/** The reader did something. Dissolves the mark; restarts the idle count. */
export function stir() {
touched = clock()
settle()
}
export function subscribe(fn: () => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
export function current(): State {
return state
}
/** Show a field. Passing null returns the lattice to ambient — or, in time, to the mark. */
export function show(field: Field | null) {
asked = field
settle()
}
/**
* Start watching for silence. Called once, by the backdrop.
*
* The events are passive and on the window, and none of them does work — each
* writes one timestamp. `stir` is exported for the rare caller that changes
* something without an input event.
*/
export function watch(): () => void {
const kinds = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'] as const
for (const kind of kinds) addEventListener(kind, stir, { passive: true })
stir()
return () => {
for (const kind of kinds) removeEventListener(kind, stir)
if (timer) clearTimeout(timer)
timer = null
}
}