Sankofa Systems

The guide · a teaching artifact

How this gallery is built.

Everything on these walls is a few hundred lines of hand-written JavaScript drawing on a canvas. This page explains all of it — the deterministic random, the mathematics of the three algorithms, the edition export — generously enough that another designer could rebuild the show from scratch.

Sankofa Curves · seed 20 260 709 — identical on every visit
Dwennimmen Spirals · seed 20 260 709 — the same number, a different language
Kente-Grid Stochastics · seed 20 260 709 — determinism is the medium

Concept & creative direction

The brief was a fictional one-room generative art studio in Accra whose gallery rehangs itself on every visit. The creative direction is a white cube: walls in gallery white #F4F4F2, ink at #181818, and no decorative colour anywhere in the chrome — the art alone carries colour, through algorithmic palettes (indigo, ochre, ricepaper, night). Type is Instrument Serif as the gallery didone for titles and labels, IBM Plex Sans for wayfinding and controls. Works hang in thin framed mounts with plinth-style drop shadows, each with a museum label: artist, an auto-composed title, series, algorithm, seed, year.

The organising conviction: an algorithm can hold a point of view — margins it refuses to crowd, colours it returns to, gestures it repeats. The seed only decides which of its sentences it speaks tonight. Everything in the build serves that: determinism, quiet chrome, generous wall space, and an export that treats a visitor's random seed as a citable edition.

Everything visible is code-drawn

This site ships zero raster images and makes zero external requests — a constraint across the whole 26-site collection, turned into its thesis. Here that constraint is barely a constraint at all: the works must be code, because the point is that they are grown live from a seed in your browser. The paper grain is ~1,400 seeded fibre flecks and a radial vignette; the frames are CSS borders and layered box-shadows; the favicon is an inline SVG sankofa curl. The only binary assets are three woff2 font files, self-hosted, about 66 KB together.

The random that isn't: mulberry32

Math.random() cannot make an edition — you could never regrow the same work twice. So every work starts from one 32-bit integer fed to mulberry32, a tiny, well-studied PRNG that returns a function yielding floats in [0,1). Same seed in, same stream of numbers out, on every machine, forever:

function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

On top of it sits a small helper kit: range(a,b), int(a,b), pick(arr), chance(p), a Box–Muller gauss(mean,sd), and — the workhorse of Series III — weighted(pairs), which draws from a table of [value, weight] options. Two disciplines keep determinism honest:

  • Independent sub-streams. Texture that depends on resolution (paper flecks, fringe threads) uses its own PRNG, derived by XORing the seed with a constant (seed ^ 0x9E3779B9). Retina rendering can then draw more flecks without disturbing the composition's stream.
  • Stable draw order. Every grid cell consumes the same number of random draws whether or not it ends up drawing anything, so moving a parameter slider reshuffles the work gracefully instead of scrambling it.

Titles come from the same trick: the seed (XORed with another constant) picks a modifier and a series-specific noun — Quiet Return, Golden Horn, Woven Saturday — so even the label is deterministic.

Algorithm I — Sankofa Curves

The sankofa bird walks forward with its head turned back. The algorithm grows that gesture as a heading integration: instead of plotting a curve from a formula in x and y, we integrate a heading angle θ along the arc — the way a pen actually travels.

θ(t) = θ₀ + sway · ease(t / 0.62) + hook · ease((t − 0.52) / 0.48)

x' = cos θ · ds        y' = sin θ · ds
ds = (L / steps) · (1 − 0.42 t)      // the head slows down

θ₀ points nearly straight up with a slight lean. The sway term (~0.62 rad, smoothstep-eased over the first 62% of arc) is the neck's gentle S. The hook term turns the heading back through roughly −π · 1.16 · curl — about 200° at curl = 1 — easing in after t = 0.52: the backward glance. Shrinking ds toward the end tightens the curl exactly where a calligrapher would slow the stroke.

The spine is then fleshed into a tapered ribbon with offset normals: at each point, step perpendicular to the heading by half the local width, w(t) = w₀ · (0.14 + 0.86 (1−t)^1.28), building left and right outlines that close into one filled shape. Three thin paper-coloured threads drawn inside at fixed offsets give the ribbon a reed-pen texture. The head is a disc continuing the final tangent, the beak a slim triangle along it, and just past the beak sits the gold egg — the thing retrieved. A composition pass places 3–7 curves on a shared horizon rule, one dominant and the rest receding; leans alternate along the row so neighbouring hooks turn apart, and a ghost moon disc sits behind the flock.

Two details matter more than they look:

  • Nothing may leave the frame. Before drawing, each curve is probed at the origin — 72 spine points plus the head-beak-egg reach along the final tangent — to get its true horizontal extent; the curve is then slid (or, if impossibly wide, shrunk 16% and re-probed) until it fits inside the margins. Low-density works with one huge curve stay composed instead of decapitated.
  • Depth is opaque. Receding curves are not made translucent — overlapping alpha builds muddy intersection patches. Instead their ink is mixed toward the paper colour (mixHex(ink, paper, depth)) and painted solid, back to front. Crossings stay clean; distance reads as atmosphere.

Sliders: Flock maps to the number of curves (3–7); Curl scales the hook term (0.55–1.5), from a shy glance to a full curl over the back.

Algorithm II — Dwennimmen Spirals

Dwennimmen — the ram's horns — holds strength and humility in one form. Each horn is a logarithmic spiral, the curve that grows without changing shape:

r(θ) = r₀ · e^(0.20 · θ)          θ ∈ [0, turns · 2π]

width(θ) ∝ (θ / θmax)^0.82        // thick outside, thin at the curl

Given the target size and turn count, we solve backwards for r₀ = (s/2) / e^(0.20 · θmax) so the outermost point always lands exactly on the glyph's radius. The spiral is drawn outside-in as ~170 short segments, each stroked at a width that tapers with (θ/θmax)^0.82 — a brush lifting as it coils. A mirrored twin (θ negated, offset the other side of the axis) makes the pair, and a shallow quadratic arc joins them at the brow. Two horns, one line of thought.

Composition is a jittered grid with weighted sizes — cols × rows from the Field slider, each glyph nudged ±5% off its cell centre and rotated ±3.4°. A weighted table assigns scale: silence (weight 1.1), small (1.6), medium (2.2), large (1.4) — so most cells murmur and a few speak. Empty cells have a 50% chance of a small gold seed-dot instead: rests with a pulse. One focal cell near the golden-ratio point (0.618 of the width, 0.382 of the height) takes a horn at ~1.5× cell size. It is kept off the border rows so it can never clip the frame, its eight neighbours are hushed (silent or small only), and it is painted last — over the field — with a gold underline as the signature. Faint full-width baselines behind the grid make the field read as notation: a page of song, one voice louder.

Sliders: Growth sets the turn count (2.0–3.3 revolutions — tight young horns to old curled ones); Field sets the grid from 4×3 to 6×5.

Algorithm III — Kente-Grid Stochastics

Kente is woven on narrow looms in long strips, block by block, then sewn side by side — a grid that was never rigid. The algorithm honours the process, not the artifact: it builds the cloth the way a weaver does, one strip at a time, one decision at a time.

P(pattern) ∝ w(pattern) · [pattern ≠ previous in strip]
P(ground)  ∝ w(colour)  · [colour  ≠ previous ground]

Each of 5–10 vertical strips is cut into blocks of randomised height (0.6–1.8 × a base module set by the Tempo slider). Every block draws its pattern from a weighted table of eight motifs — solid, warp stripes, weft bands, babadua double-bands, checker, zigzag, teeth, dashes — under two constraints borrowed from the loom: never repeat your neighbour's pattern, never repeat your ground colour. If the table returns a repeat, we redraw from the table with that entry removed. Ground colours come from a six-colour series palette (weighted toward the dominant gold or ground of that cloth), and when a block lands on black ground it usually takes the palette's warm figure colour, keeping contrast honest.

The hand of the cloth is three texture passes: each strip is shifted vertically ±0.8% (sewn, not printed), with a dark seam and a pale selvedge highlight between strips; fine translucent scanlines run across the whole cloth as weft; and a seeded fringe of quadratic threads hangs below. A blurred wall shadow behind the cloth hangs it in the room.

Sliders: Strips sets the loom count (5–10); Tempo sets the block module — slow chunky blocks to quick busy ones.

Museum labels & the edition export

Every work's label is composed from its seed: title, series, algorithm, seed (space-grouped for reading), year 2026. Download edition re-renders the same seed and parameters onto a fresh off-screen canvas at print size (roughly 2× the on-wall resolution, e.g. 1440×1800 for Series I), mounts it on a pale sheet with a hairline keyline and a soft shadow, then typesets the label strip below in the site's own fonts — artist, italic title, series · algorithm · seed, medium line, and the gallery URL — before handing you the PNG via canvas.toDataURL(). The export waits on document.fonts.ready so the label never renders in a fallback face. Bring the seed back and the studio can regrow your work stroke for stroke — the file name carries it: sankofa-<series>-<seed>.png.

Gallery UX, motion & performance

  • No animation loops, ever. A work renders exactly once per (seed, parameters) — on load, on shuffle, on slider input, on resize. There is no rAF loop to pause because none exists; under prefers-reduced-motion the scroll reveals and refresh cross-fades are dropped too, and the gallery is fully still. Stillness is period-correct for a museum.
  • Hang a study. Each room shows three small studies from the same algorithm. Activating one hands its seed to the room's wall canvas — and a new study takes its place. It is the cheapest possible curation interface: one click, one seed.
  • Crispness without waste. Canvases render at devicePixelRatio capped at 2, re-render on debounced resize, and the whole first view — three fonts, all code, all art — is about 135 KB before compression, with zero requests leaving the page.
  • Accessibility. Each canvas carries role="img" and an aria-label composed from its live title, algorithm and seed; sliders are real labelled range inputs; everything is keyboard-reachable with visible focus rings; the commission form is a Netlify form with a honeypot.

Toolchain

Designed and art-directed by Hannah Kwakye; engineered with Fable 5 working as a designer-engineer under her direction. The site is static, hand-authored HTML, CSS and JavaScript — no framework, no build step, no library, no external request. Fonts are self-hosted woff2 (Instrument Serif roman + italic, IBM Plex Sans). All art is drawn with the 2D canvas API; all chrome is CSS. The iteration protocol below was run against headless-browser screenshots of the real pages, read and acted on.

The iteration log

Pass 1 — design critique

  • Shot mobile/tablet/desktop and rendered each algorithm as a 6-seed contact sheet across the parameter range, then judged the sheets like a portfolio review — the honest way to critique generative work is many seeds at once, not one lucky render.
  • Series I clipped at low density: a lone large curve could swing its head and egg out of the frame. Added the origin-probe fit pass (measure the spine's true reach, slide or shrink until it sits inside the margins).
  • Series II's focal horn collided with its neighbours and could clip the top edge on sparse grids. Locked the focal cell to interior rows, capped its scale, and hushed its eight neighbour cells.
  • Sections wrapped in content-visibility: auto rendered blank in full-page captures and print; the page is moderate length with render-once canvases, so the optimisation was removed rather than worked around.

Pass 2 — elevation

  • Replaced translucency-based depth in Series I with opaque paper-mixed inks — overlapping alpha was building muddy intersection patches in dense flocks; solid atmospheric tints paint clean crossings, back to front.
  • Gave Series II a true focal voice: painted last over the field, gold-underlined, golden-ratio placed — and made the field around it audibly quieter, so every seed has one work-defining moment.
  • Alternated curve leans along the horizon in Series I so neighbouring hooks turn apart — flocks now converse instead of tangling.
  • Added the three deterministic specimens to this page's hero: the same seed rendered live in all three languages, which teaches the whole thesis in one glance.

Pass 3 — ship quality

  • Exercised every control in a headless browser: shuffle, both sliders per wall, hang-a-study, and the PNG export (verified the downloaded edition's pixels and label strip at print size). Zero console errors on all routes at all three widths.
  • Verified reduced-motion: reveals and cross-fades gone, works still render once, nothing moves.
  • Checked every link resolves (in-page anchors, sibling routes, the hub) and fonts load from preload without a fallback flash.
  • Real bug № 1, caught by exercising the commission form: .commission form { display: grid } silently defeats the hidden attribute (a specified-value display beats the UA's [hidden] rule), so after submitting, the success message appeared while the empty form stayed on screen. Fixed with an explicit .commission form[hidden] { display: none; }.
  • Real bug № 2, caught by the print check: reveal-on-scroll elements start at opacity: 0 and only fade in via IntersectionObserver — on paper, where no scrolling ever happens, everything below the first viewport printed as blank pages. The print stylesheet now forces .reveal to full opacity with transforms cleared.
  • Real bug № 3, caught by reloading the gallery six times in a row: the hero's Enter Room → link is retargeted to whichever series tonight's opening hangs — but the code looked for the link inside the hero figure, where it doesn't live, so the lookup returned null and the link silently pointed at Room I every night. One selector fix; verified the link now follows the hung series on every reload.
  • Proofread all copy, labels and math against the shipped code — the formulas on this page are the ones that run.

Deploy

The site deploys to Netlify from the collection's repository — push to main, CI publishes the folder as-is. netlify.toml sets immutable caching for hashed-free assets and the usual security headers; the commission form posts to Netlify Forms with no server code. Because everything is static and deterministic, a deploy is byte-for-byte reviewable.