07 primitives · 02 demos

Primitives are boring on purpose.

Every mechanism that isn't product-specific lives here. Pure where possible, state-machine-shaped where not, DOM-touching only when its job is DOM. Each primitive carries a @fit contract the moment it's worth proving one; demos are the acceptance tests.

01 · async
prepareImage
02 · pure
gridLayout
03 · vendor
pretext
04 · dom
pointer
05 · state
dragState
06 · pure
layoutTween
07 · dom
domCache
next · ?
your primitive
/primitives/prepareImage
Mirrors pretext's shape for images: decode once, read aspect forever.
asyncshared cache
Signature
// call at scene-build time; returns a shared handle keyed on src
function prepareImage(src: string, onReady?: () => void): PreparedImage

type PreparedImage = {
  src: string
  naturalWidth: number   // 0 until ready
  naturalHeight: number
  aspect: number         // 1 until ready (safe default)
  ready: boolean
  error: boolean
}

function layoutImage(p: PreparedImage, targetWidth: number)
  : { width: number; height: number }   // pure
@fit contract
given targetWidth: 1..8000 result.width == targetWidth result.height >= 0 result.height <= targetWidth * 100
Used by

Shared cache means ten cells pointing at the same url share one load. Render loop discovers readiness by calling layoutImage every frame.

/primitives/gridLayout
Pure flow-grid placement for items with known aspect ratios, plus a point-in-cell hit test.
pure
Signature
function gridLayout(items: GridItem[], opts: GridOptions): GridLaidOut

type GridOptions = {
  viewportWidth: number
  padding: number
  gap: number
  minCellWidth: number
  maxColumns: number
}
type GridLaidOut = {
  columns: number
  cellWidth: number
  cells: GridCell[]                       // { id, x, y, width, height }
  totalHeight: number
}

function hitTestGrid(laid: GridLaidOut, x: number, y: number)
  : string | null
@fit contract
given viewportWidth: 200..8000 given maxColumns: 1..12 result.columns >= 1 result.columns <= maxColumns result.cells.length == items.length forall c: c.x >= padding forall c: c.x + c.width <= vw − padding nondecreasing(result.cells.y)
Used by

Re-laid every frame in the gallery. The hit-test mirrors the layout so dragging over a cell is a reverse lookup, not a DOM query.

npm · @chenglou/pretext
Measure multi-line text without touching DOM. prepareWithSegments() measures once; layoutWithLines() is pure arithmetic.
vendorpurecanvas truth
Signature
function prepareWithSegments(
  text: string,
  font: string,
  options?: PrepareOptions,
): PreparedTextWithSegments

function layoutWithLines(
  p: PreparedTextWithSegments,
  maxWidth: number,
  lineHeight: number,
): LayoutLinesResult   // pure

function measureLineStats(
  p: PreparedTextWithSegments,
  maxWidth: number,
): { lineCount: number; maxLineWidth: number }

function measureNaturalWidth(
  p: PreparedTextWithSegments,
): number   // widest unforced line
@fit contract
given maxWidth: 1..8000 given lineHeight: 0..500 result.lineCount >= 0 result.height >= 0 result.lines.length == result.lineCount forall l: l.width <= maxWidth + 0.5 // fits
Used by

The one vendor primitive so far — bun add @chenglou/pretext. We don't reimplement bidi + grapheme handling when the canonical package ships it. Admission rule still holds: the second demo needed it.

/primitives/pointer
Boilerplate-free pointer wiring. Capture handled; handlers get element-local coords.
dom
Signature
function pointerOn(el: HTMLElement): PointerHandle

type PointerHandle = {
  onPress(fn): void
  onMove(fn):  void
  onRelease(fn): void
  dispose(): void
}
// handler receives: { pointerId, x, y, clientX, clientY, target }
Convention

Handlers are intentionally void-returning. They stash into your state and call schedule() — keeping every decision inside the render loop, same as every other primitive.

Used by
/primitives/dragState
Press-to-drag as a pure reducer over { idle | pressing | dragging }. Events in, state out.
state machinepure
Signature
type DragState =
  | { kind: 'idle' }
  | { kind: 'pressing',  pointerId, id, originX, originY, grabDX, grabDY }
  | { kind: 'dragging',  pointerId, id, originX, originY,
      cursorX, cursorY, grabDX, grabDY }

type DragEvent =
  | { type: 'press',   pointerId, id, x, y, grabDX, grabDY }
  | { type: 'move',    pointerId, x, y }
  | { type: 'release', pointerId }

function dragReduce(s, ev, opts): DragState
Why a reducer

No hidden fields, no __grab stashes, no escape hatches. Any render phase can read state without worrying whose side effect ordered what. The press threshold lives in opts; invariants are obvious by inspection.

Used by
/primitives/layoutTween
Capture previous positions, blend toward current. Plain data, no animation engine.
pure
Signature
type LayoutTween = {
  fromX: Map<string, number>
  fromY: Map<string, number>
  t0: number
  durationMs: number
}

function startLayoutTween(grid: GridLaidOut, now: number, durationMs: number): LayoutTween
function tweenProgress(tw: LayoutTween, now: number): number   // 0..1
function tweenCell(tw: LayoutTween, cell: GridCell, progressEased: number): { x: number; y: number }
function easeOutCubic(t: number): number
Shape

The render loop owns Tween | null as state. Reorder an item and call startLayoutTween with the grid you just laid out; next frame, tweenCell returns blended positions. Easing is a pure function you compose yourself — no spring configs, no callbacks.

Used by
/primitives/domCache
The JIT-create-on-first-sight pattern, factored. ensure() + evictStale().
dom
Signature
function ensure<T>(
  cache: Map<string, T>,
  id: string,
  create: () => T
): T

function evictStale<T>(
  cache: Map<string, T>,
  keepIds: Iterable<string>,
  dispose: (v, id) => void
): void
Why factor this

Every render loop that writes to the DOM ends with the same pair: "make nodes for every id in the scene; remove nodes for every id that's no longer there." It's short, but cross-cutting enough that writing it wrong is a source of leaks. Two helpers, no surprises.

Used by

Rules for admission

A piece of code becomes a primitive when the second demo needs it. Not the first (that's speculation), not the seventh (that's cruft). Round 1 of the gallery inlined its own drag state, tween math, and DOM cache; round 2 pulled them out because the playground's width-drag wanted the same pointer plumbing.

Every primitive carries a @fit contract the moment it's worth proving one. Contracts are human-readable preconditions + postconditions; they double as the acceptance tests when vibescript runs them.

/primitives · 07 files · ~600 LOC total