dither-ork docs
Open the appSource

Architecture

dither-ork is a browser application that reproduces the Dither Boy feature set for still images: the spec's 61 named effects in a stackable reorderable pipeline, full colour with palette extraction, CMYK halftone, timeline animation with live playback, batch processing, and PNG / JPEG / SVG / GIF / MP4 export.

60 of those 61 are built and registered, plus 7 of the 8 preprocessing nodes (F-PP): 67 effects in the catalogue. By family: 15 error diffusion, 6 ordered, 8 pattern, 16 glitch, 16 special, 6 preprocess. By execution: 15 WASM, 52 WebGPU. By slot: 18 preprocess, 29 dither, 20 postprocess. One of the 61 is absent on purpose and it is recorded where the decision was made — F-GL-06 JPEG glitch, which needs an encoder and therefore an execution kind that does not exist. The one remaining F-PP gap is F-PP-08, masking: it is a second image edge on the graph rather than a pass, and the graph carries one edge per node. The counts are asserted by web/src/registry/catalogue.test.ts, so an effect that silently stops being discovered fails the build rather than the eye.

It is an application. web/src/app/main.tsx boots it: capability gate, registry validation, editor session, panels, viewport. The proof page that used to be the entry point is still there and still earns its place — see "The proof page" below.

Video editing is out of scope and is a separate future application. Animated output is in scope, because frames are generated from a still source — that needs an encoder and no decoder.

The constraint everything follows from

Error diffusion is inherently serial. Each pixel's value depends on error propagated from pixels already processed, so the entire family — Floyd-Steinberg through Ostromoukhov — cannot be expressed as a shader. Every other effect in the catalogue is per-pixel independent.

That splits the renderer in two. Those two are the only execution kinds, and the catalogue as built needs no third: everything that is not error diffusion is a compute pass. The one effect that would have forced a third — F-GL-06, JPEG glitch, which needs an encoder to re-compress and corrupt — is not implemented for exactly that reason, and adding it is a decision about the execution model rather than one more shader.

Stack

LayerChoiceWhy
Core algorithmsRust → WebAssembly (wasm-bindgen), SIMD128 + threads via wasm-bindgen-rayonDiffusion kernels, quantizers and the tracer are the hot path; hand-written TS is 5–10× slower and decides whether preview feels live. The core has zero web dependencies, so a native or CLI build later is packaging, not a rewrite.
Parallel effectsWebGPU compute passes, WGSLCompute gives workgroup control and storage buffers that fragment shaders do not. Required for pixel sort, block shuffle, histograms and every index-map operation.
AppTypeScript + Vite + ReactThe stack editor, timeline and palette editor are DOM-heavy UI. The viewport is not React — it owns its canvas.
ThreadingWeb Workers + OffscreenCanvas, typed postMessage RPCThe render loop never runs on the main thread. Comlink was named here and is not used; see "The render worker" for the three properties this seam needs that a call proxy cannot express.
StorageOPFS for documents, autosave and libraries; IndexedDB for small key-valueOPFS gives synchronous access handles inside workers and handles large batch intermediates.
File I/OFile System Access API where availableBatch reads a folder and writes results back; elsewhere it degrades to multi-select in and ZIP out, stated in the UI.
EncodersRust GIF/APNG/ZIP in core; WebCodecs VideoEncoder for MP4/WebMNo ffmpeg anywhere. Animated output is encode-only.

Platform support policy

Target platforms are macOS and Windows. WebGPU is a hard requirement; there is no WebGL2 fallback.

On both target platforms every major browser ships WebGPU: Chrome and Edge from 113, Safari from 26, Firefox from 141 on Windows and 145/147 on macOS. The decision therefore costs nothing where it matters.

What the alternative would have cost: WebGL2 has no compute shaders, no storage buffers and no atomics, so roughly a dozen effects — pixel sort, block shuffle, slice repeat, row/column displacement, palette histograms, and every index-map operation (dilate/erode, outline, hue-targeted recolour) — plus the SVG tracer could not run on it at all. The remaining ~36 would each need a second GLSL ES 3.0 implementation, a second graph branch, a second golden-image set and a second queue of driver bugs, paid on every future change.

Accepted consequences:

Cross-origin isolation

Mandatory. WASM threads need SharedArrayBuffer, which needs Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.

The dev server sets both (see web/vite.config.ts). Production must too.

Cross-origin isolation also blocks loading cross-origin subresources without CORP headers. The app loads none — no CDN fonts, no third-party scripts, no external images — so this costs nothing, and it must stay true.

Hosting

Cloudflare Pages. The requirement is a static host that can set arbitrary response headers; Cloudflare Pages does it from a _headers file on the free tier. Netlify and Vercel also qualify.

The file is web/public/_headers. Vite copies public/ verbatim into dist/, so it ships at the site root where Pages reads it. It has no schema and no validation, so a typo in it fails silently in production — the only symptom being the capability check rejecting the app for every visitor. CI therefore asserts both headers are present in the build output rather than trusting it.

Build settings: build command npm ci && npm run build, output directory web/dist, root directory web.

GitHub Pages does not — it serves no custom headers, so it cannot host this app with threads enabled. Recorded because it is the obvious default for an open-source project and it is the one that does not work.

Render graph

Data layout

Colour

Everything works in linear light. sRGB transfer is removed on load and reapplied on export.

Diffusing error in sRGB instead of linear light is the single most common reason naive dither implementations look muddy, and it is checked by a unit test: a flat mid-grey dithered to 1-bit must average back to its own luminance.

Palette matching uses OKLab by default. Plain sRGB Euclidean distance is also exposed — not as a fallback but as a look control, since it reproduces what period-accurate tools did.

One consequence of working in linear light is worth stating because getting it wrong is invisible in every aggregate measure. Ostromoukhov's variable coefficient table (F-ED-14) is indexed by the linear value, not by the sRGB code value. A row of that table is the triple solved so that a field of a given dot coverage comes out blue-noise, and in a linear-light pipeline the coverage a flat field settles at is its linear value. Indexing by the display-referred code instead selects a row solved for a different coverage; it leaves the mean level correct and every kernel-agnostic test passing, and it puts visible vertical stripes through the upper mid-tones. The measurement, the cause and the regression test that pins it are in level_index in core/.../diffusion.rs.

Surprise generator

Lives in core/gen, driven by a seeded PCG PRNG so a seed reproduces byte-identically on every platform and inside batch workers.

The node registry is the generator's data source: each effect declares its parameters with legal range, surprise range, sampling distribution and selection weight, plus its slot in the stack grammar. The generator contains no per-effect logic, so a newly added effect becomes eligible automatically, and missing surprise metadata is a registry validation failure rather than a runtime surprise.

Palette synthesis works in OKLab so random schemes come out with even perceptual lightness spacing instead of clumping.

Determinism

The platform qualifier is not hedging, and it was measured rather than assumed. srgb_to_linear and linear_to_oklab call powf and cbrt, so they are on every render path, and the C library is not required to round those correctly — implementations do differ between platforms and versions. The seeded integer draws feeding them are bit-identical; the transforms are not.

What that costs in practice is small and is now known: the golden set is blessed on aarch64 and passes on x86_64 in CI, within the harness tolerance. So results are reproducible to within a rounding difference across platforms and byte-identical on one. Anything that must be exact across platforms — a share URL reproducing a seed, a batch worker matching the preview — is exact because it re-runs the same pipeline on the same machine, not because the floating point agrees everywhere.

The alternative, committing to correctly-rounded implementations of the handful of transcendentals involved, buys byte-equality across platforms at the cost of owning that code forever. Not taken; revisit only if a real requirement needs cross-platform byte-equality.

Logging

Structured, levelled, namespaced by channel — app, graph, gpu, wasm, export, batch, io — with a per-render correlation id.

Repository layout

This section describes the repository as it is, not as it will be. It is the single source of truth for layout; the vault-side architecture note carries the decisions and their reasoning and does not duplicate this.

core/                     Rust workspace, zero web dependencies
  crates/dither-core/
    src/color.rs          sRGB <-> linear, OKLab, distance
    src/palette.rs        palettes, nearest-colour matching, hardware built-ins,
                          sorting and OKLab ramps
    src/diffusion.rs      error-diffusion kernels and the shared machinery
    src/quantize.rs       palette extraction: median cut, Wu, k-means
    src/noise.rs          Bayer and void-and-cluster tiles, seeded noise fields
    src/rng.rs            PCG32; the only source of randomness in the core
    src/trace.rs          index map -> SVG: region labelling, contour following,
                          Douglas-Peucker, the minimum feature filter (F-EX-08..10)
    src/fixture.rs        the generated test images the goldens are taken from
    tests/                golden-image and colour-correctness harnesses
  crates/dither-wasm/     wasm-bindgen bindings — the only web-aware crate
  fixtures/source/        the fixture images, as PNG
  fixtures/golden/        reference renders, one per fixture x palette x kernel
web/
  public/_headers         COOP/COEP for production; shipped to the site root
  fixtures/gpu/           the parallel catalogue's reference set: one generated
                          source and two renders per gpu effect
  test/gpu-golden/        the harness that produces and checks it — a Dockerfile
                          pinning one Chrome for Testing build, the browser-side
                          renderer, the Node-side comparator, and perturb.mjs,
                          which damages a shader on purpose to measure what the
                          set would catch
  src/effects/            one file per effect; the registry finds them by glob.
                          A parallel effect's file carries four things that must
                          agree byte for byte and therefore may not be separated:
                          the registry descriptor, the uniform layout, the
                          `GpuEffect` its passes live in, and the `gpu` export
                          that resolves its id to those passes. The WGSL is the
                          fifth and sits in src/shaders/ under the same id
  src/registry/           discovery, validation, search over the catalogue
  src/graph/              DAG scheduling, content hashing, node cache
  src/gpu/                device, compiler, resources, boundary, scheduler
  src/gpu/effects/        pass definitions shared by a whole family — today only
                          the five ordered dithers, which are one program with
                          five tiles and would otherwise be five copies
  src/shaders/            WGSL, one file per effect, plus CONVENTIONS.md
  src/lib/                logging, capability check
  src/types/              .dork document schema, registry, graph and GPU contracts
  src/wasm/pkg/           generated by the wasm build; not committed
  src/app/                the shell: boot gate, docked regions, panel and toolbar
                          slots, theme. It imports no panel — see "Slots" below
  src/io/                 image intake: sniff, probe, decode, linear light, limits
  src/io/document/        .dork files, presets, the preset library on OPFS, the
                          starter set, share links, download/read/clipboard
  src/export/            the picture out: colour census, PNG encoder, zlib, the
                          canvas encoders, the SVG tracer contract, nearest scale,
                          matte flatten, size estimate, destination, job
  src/state/              the live document — mutations, history, autosave,
                          .dork serialisation — and state/render/, which is
                          document -> graph -> frame against the real backends
  src/ui/theme/           the only colours in the application: tokens.css, plus
                          the element base and the shared primitives. Nothing
                          else anywhere may hold a literal colour (F-UI-09)
  src/ui/stack/           the stack editor
  src/ui/picker/          the effect picker: the matcher that says why a row is
                          on screen, the model that groups and judges the
                          catalogue, and the component. Split out of stack/
                          because nothing about it is specific to that panel
  src/ui/properties/      the properties panel, generated from the descriptor
  src/ui/palette/         the palette system: editor, library, extraction
  src/ui/timeline/        the timeline: tracks, keyframes, playhead, and the
                          preview pump it becomes while a track exists
  src/ui/export/          the export dialog, and the adapter that satisfies
                          export/'s two interfaces from the editor session
  src/ui/batch/           the batch queue over many images
  src/ui/surprise/        Surprise Me: seed, chaos, locks, history
  src/ui/documents/       save/open/presets/share, as a toolbar item and a dialog
  src/ui/help/            contextual help (F-UI-13): the `data-help` token, the
                          dwell machine, the placement solver, the article
                          resolver that reads the descriptor, and `concepts.ts`
                          — the interface ideas no descriptor is about
  src/ui/guide/           the user guide (F-UI-14): seven written chapters, and
                          an effect catalogue **generated** from the sealed
                          registry. No effect is named in that directory
  src/viewport/           the canvas. Not React; owns its own canvas and overlay
  src/worker/             the render worker: the wire format both sides import,
                          the preview/export queue and its cancellation policy,
                          the fractional preview resampler F-UI-03 needed, the
                          worker itself, and `RenderService` — the main thread's
                          one way to ask for a picture
  src/main.ts             the proof page's module — reached at /proof.html in
                          dev, not an entry point of the production build
  index.html              the application
  proof.html              the proof page. Vite builds index.html only, so this
                          is served in development and is not shipped
  vite.config.ts          COOP/COEP for the dev server
docker/                   build images, the wasm build script, the web entrypoint
docs/                     this file, API.md, DEVELOPMENT.md
.github/workflows/        CI

Directories the build order will add and that do not exist yet: core/gen (surprise generator) and core/encode (the animated formats). web/src/worker now exists and holds the render worker, its wire format, the preview/export queue and the preview resampler. The tracer landed as dither-core/src/trace.rs rather than as a crate of its own, because it shares the palette and colour types with everything else in that crate and a second crate would have been a re-export with a Cargo.toml. The starter presets ship as code (io/document/starter.ts) rather than as a presets/ directory of files, so that starter.test.ts can build them against the real catalogue and run validateStack over every one — an effect id that disappears fails the build rather than shipping a library entry that refuses to render when somebody clicks it. palettes/ is likewise absent: the hardware palettes are facts in core/…/palette.rs and reach this side through builtinPalettes().

Nothing in core/ may know a browser exists.

How the application is assembled

app/main.tsx runs five steps in an order that each of them forces, and three of the five can end the run with a screen of their own:

  1. Theme, unconditionally, so even a failure screen is the right colour.
  2. The capability gate (F-UI-12). WebGPU and SharedArrayBuffer are fatal.
  3. Registry validation, which is terminal — a build whose catalogue is wrong renders wrong documents convincingly, so it stops and lists every issue.
  4. The editor sessionstate/session.ts, which starts the render worker (and so, indirectly, acquires the GPU device and the Rust core, on that thread), restores the autosave, builds the document store, bridges the palette, installs the image intake and subscribes the renderer.
  5. Registration, then React. In the order a person reaches them: the stack panel, the properties panel, the toolbar (open, undo, redo, fit, notices), the documents toolbar (save, open, presets, share), the timeline panel, the export action, batch, Surprise Me, the guide, the history shortcuts, and contextual help. The palette panel registers on import and so has no call.

Two of those are not panels and do not take a slot in a region. The guide registers a toolbar item, because a guide is something you open, read and close rather than a fifth column — app/slots.ts closes the panel ids to the four names F-UI-08 gives. Contextual help registers nothing at all: it mounts a React root of its own on document.body and delegates from the document, because it describes controls drawn by panels that mount and unmount underneath it, and a panel inside one of those regions would be unmounted along with it.

Everything up to step 5 happens before React renders anything. That is not tidiness. Panels register themselves into slots and a duplicate registration throws, which is what stops one of two panels from being silently invisible; React in development mounts every effect twice to prove it is clean. Registering from inside the tree would therefore throw on the second mount. The one thing that does happen twice is the viewport, and attachViewport takes a viewport that can arrive, leave and arrive again.

Slots

The shell imports no panel. app/slots.ts holds two registries — panels and toolbar items — and a panel module calls registerPanel at import time. A region nothing registered into is not rendered: no empty box and no "coming soon". This is the same arrangement the effect catalogue uses, for the same reason, and it is what let the shell, the stack editor, the properties panel and the palette editor be written in parallel by people who never edited a shared file.

Where the words live (F-UI-13, F-UI-14, F-UI-15)

Three surfaces explain the application to the person using it: the picker's result list, the hover help, and the guide. All three read the same text, and none of them contains any. The descriptor next to the shader carries the effect's summary, description and keywords, and each parameter carries a description; types/registry.ts fails the whole catalogue when one is missing or when it only restates the label. That is the mechanism, and it exists because the alternative had already happened elsewhere: three hand-written copies of one sentence, two of which are wrong by the second release.

Two kinds of text have no descriptor to live on, and each has exactly one home. Family ideas — error diffusion, the index map, working resolution — are EFFECT_CONCEPTS in types/registry.ts, beside the descriptors that declare producesIndexMap. Interface ideas — what a slot is, what solo does, what the colour metric changes — are ui/help/concepts.ts. Help reaches all four kinds through one attribute, data-help="param:blur.radius", resolved with closest(), which is why annotating a control is one attribute and not a wrapper, a ref or a provider.

Search is the other half of the same problem. registry/search.ts matches over everything an effect says about itself rather than over its name — the glow effect is called Epsilon glow, so a name search does not find "glow" and every reader concludes the tool has none — and it reports why each row matched, so a result that came from a keyword is not an unexplained row. When it finds nothing, registry/unbuilt.ts is consulted: four requirements the spec names and this build does not implement, each with the reason and the closest built alternatives. search.test.ts asserts that none of the four is a registered effect, so an entry that becomes real fails the build rather than going on telling people a shipped effect does not exist.

The one piece of state, and the palette's exception

state/store.ts is the only mutable state in the application. Panels read it through useSyncExternalStore and change it by calling a command; the renderer subscribes to it and to nothing else. Two things live on the store rather than in the document because they are ways of looking at a document rather than part of one — the selection and the solo point. Solo saved in a .dork would reopen as a truncated stack with no visible reason.

The palette is the exception and it is bridged rather than owned. ui/palette holds the editor's state — swatches, locks, output mode, extraction settings — which is more than a colour list; the document holds the Palette a render reads and a .dork writes, which has to be undoable with everything else. session.ts keeps the two in step in both directions, with a re-entrance guard, because each direction's write is the other's notification.

The render worker

The render loop does not run on the main thread. web/src/worker/ owns the WebGPU device, the WASM core, the effect registry, the node cache, the DocumentRenderer and the SVG tracer. session.ts holds a RenderService and posts to it; the main thread keeps the UI, the input, the panel state and the undo stack.

Measured on this machine, on a 2400x1800 image with a stack of blur → Floyd-Steinberg → halftone: 124 parameter changes over a two-second drag, with a render issued for every one of them, and the longest main-thread block was 16.98 ms, with zero longtask entries over 7.4 seconds.

Comlink is named in the stack table and is not used. Three properties this seam needs are ones a call proxy cannot express, and all three are load-bearing: abandoning a call that is already running (a drag issues renders faster than they complete); transferring an object produced inside a call out of it (the finished frame, so it moves rather than being copied, and so the move can be measured); and a lane discipline between preview and export over one device, which is worker-side state rather than a remote object graph. The shape of the RPC is otherwise what docs/API.md section 9 describes, written out. worker/protocol.ts carries the full argument.

What crosses, and in which direction.

Two things stay on the main thread deliberately. The viewport's canvas is not transferred: it is not a render target but a compositor for a frame, a checkerboard, a reference image and a split divider, driven directly by pointer events, and moving it would put every pan and zoom through a message queue to remove a drawImage that costs nothing. OffscreenCanvas earns its place at the frame boundary instead, where it removes real per-frame work. The image decode stays for the reason above.

The GPU readback to present is still paid when the frame is GPU-resident — a 2D context cannot draw a WebGPU texture — but it is paid in the worker.

Cancellation, and one queue for two callers

DocumentRenderer holds one node cache, one surface pool and one GPU backend, and none of it is re-entrant; it had two callers, and they were kept apart by a promise chain in the export adapter that could only serialise export against itself. worker/queue.ts is now the one queue both go through, and the renderer throws on re-entry rather than documenting the rule in a comment.

Adaptive preview resolution (F-UI-03) is honoured

The viewport computes a factor (viewport/quality.ts, previewScaleFactor) and emits it on its request event; session.ts subscribes and carries the quality and the factor into every render. Below 1 the worker resamples the source to the reduced extent (worker/resample.ts, an area-average box filter in linear light — point sampling would alias a dither into a pattern that is not in the picture) and the whole graph runs there. The graph needed nothing: graph.width/graph.height are already in every content hash, so a preview and a full render key to different cache entries by construction.

The badge therefore describes something that happens. Measured: at 100% zoom on a 2400x1800 document the drag frames are 1633x1225 and the badge reads PREVIEW 68% — the 2-megapixel budget, which is the ceiling that bites on a large image — and the idle frame is 2400x1800 with the badge gone.

Export

Preview and export do not merely share a graph — they share a frame. The export panel calls renderer.render for the document that is on screen and encodes the ImageData the viewport was given, so the file cannot disagree with the picture. The size estimate encodes that same frame, which is why F-EX-14's number is measured rather than modelled: there is no formula for the size of a deflated dither, and a number that is wrong by a factor of three is worse than no number because it is believed.

Two consequences follow and both are load-bearing:

"Indexed" is a fact about the pixels, not about the graph

The graph carries an index map after a quantizing node, and it is the wrong thing to export. It describes the frame at the quantizer, and a stack can put a dozen postprocess nodes after it, each writing continuous colour over the top. So export/census.ts counts the colours in the finished frame: 256 or fewer and an indexed PNG holds it exactly, because the palette is built from the values that are there. The census bails the moment it sees a 257th colour, so a photograph pays for a few hundred pixels of it.

That same census is the SVG tracer's input, which is what makes an SVG and a PNG of the same picture agree by construction rather than by two code paths being kept in step. The tracer itself is Rust (dither-core/src/trace.rs) and emits one <g> per colour, marked as an Inkscape layer, on integer pixel corners so adjacent colours share their boundary with no seam. The consequence worth stating: a frame of more than 256 distinct colours cannot be traced, and it is refused rather than quantized a second time behind the user's back.

Who writes what

PNG is written here (export/png.ts over export/zlib.ts), because no browser will write a palette PNG. JPEG and WebP are the browser's, because nobody should write a JPEG encoder. SVG is the core's. export/encode.ts is the only file that knows which is which.

The layering

web/src/export/ may not know that a document store, a renderer or a session exist. It states what it needs as two interfaces in its own vocabulary — ExportImageSource (a frame, a subject, a change notification) and VectorTracer (an index map in, an SVG document out) — and web/src/ui/export/session.ts is the single adapter that satisfies both from an EditorSession. That is the same arrangement the panels used while the document store was being written, and it is why the export module is testable without a browser, a GPU or a WASM build.

The proof page

web/src/main.ts with web/proof.html, at /proof.html in development. It is not the application and it is not a component gallery: it renders the entire catalogue end to end through the real WASM and WebGPU paths and states, per effect, how much of the frame moved, what it did to mean luminance and standard deviation, and how far it rotated hue.

Those numbers are what a human reads against the effect's name, and that is the one check no golden image can make — a golden pins what an effect does, not that what it does matches what it is called. A levels node that does not move the tone scale and a hue control that rotates nothing both pass every golden. The page currently names four such judgements, including channel-swap being the identity at its declared defaults.

It is kept, and it is kept honest about its own limits. Its scheduler is hand-rolled and predates two features the real render path has: resolved output extents and per-node instance data. So internal-resolution, nn-upscale and curves fail on the page while working in the application. That is a defect of the page, not of the engine, and it is the page's next job.

Testing

The list below is the strategy. What is built today: 157 Rust tests including golden images for all 15 registered diffusion kernels across four fixtures and two palettes, and the GIF encoder's own set; 1,610 TypeScript tests including the catalogue test that runs the startup validator over the shipped descriptors and asserts the counts above, the .dork round trip, the document store and its history, the image intake, the animation core's clock, modulators, seam and plan, the timeline's keyframes and playback arithmetic, the batch queue and naming, the animated containers, and the pure halves of the viewport and every panel; and golden images for the parallel catalogue at two parameter sets each.

What no automated test covers, and it is still the important gap: nothing automated drives the assembled application. Every panel's model is unit-tested and every render stage is tested; the wiring between them — a click reaching a mutation reaching a frame — is checked by a person with a browser.

web/test/probe/ is the harness that person uses. It is a plain ES module loaded from the console against the DEV debug handle, and it imports the real source modules from the dev server rather than restating any of them, so what it exercises is the wiring and not a copy of it. It measures the things a unit test structurally cannot:

It is not a substitute for an automated run. A headless browser driving the real page is still the next thing the test strategy needs; the probe is what makes that run's assertions obvious once somebody writes it.

Both halves of the catalogue now have goldens. The CPU set is core/fixtures/golden/, compared byte for byte. The GPU set is web/fixtures/gpu/, produced by web/test/gpu-golden/ inside the pinned browser image and compared within one 8-bit code value — a tolerance the CPU set does not need and the GPU set does, because half the parallel catalogue writes continuous colour through exp, pow and trigonometry, which a JIT may legitimately contract differently on two machines.

Three properties of the GPU harness are worth knowing because they are what make it a check rather than a ritual:

What remains outside CI is the proof page's own judgement: web/src/main.ts renders the catalogue and states per effect how much of the frame moved, what it did to mean luminance and to its standard deviation, and how far it rotated hue. Those numbers are what a human reads against the effect's name — a levels node that does not move the tone scale and a hue control that rotates nothing both pass every golden, because a golden pins what an effect does, not that what it does matches what it is called.

Build order

  1. Colour core plus one kernel, headless, with the golden-image harness.
  2. The remaining diffusion kernels against goldens.
  3. Render graph and cache, headless, with hashing.
  4. WebGPU path: ordered dithers and halftone in WGSL, pass coalescing, boundary instrumentation.
  5. Viewport and stack UI — first point at which it is an app.
  6. Palette system: extraction, library, editor, index map, hue-targeted recolour.
  7. Pattern dithers, then special effects, then glitch effects — each with goldens.
  8. Clock, modulators, temporal variation, seam validation, live playback.
  9. Timeline editor and keyframes.
  10. Export: stills → GIF/APNG → MP4/WebM → PNG sequence.
  11. SVG tracer and embroidery/cutting preparation.
  12. Presets, documents, autosave, sharing.
  13. Batch.
  14. Themes, shortcuts, layout persistence.

Steps 1–2 are where the look is decided, so they come before any UI.

Where the repository is: steps 1 to 7 are done, and step 5 is what made it an application.

Steps 1 and 2: all fifteen kernels are built and pinned by goldens, Ostromoukhov (F-ED-14) included — its table is transcribed from Appendix I of the paper and the transcription is checked against the paper's own construction rather than against a second copy of the numbers, in exact rational arithmetic.

Step 3 is built and is now driven by a document: state/render/graph.ts compiles a .dork stack to the DAG, and the cache lives across renders, which is the whole of F-ST-01's "re-render begins at the earliest changed position" — editing node 7 leaves nodes 1–6 with unchanged hashes and the backwards walk stops immediately.

Step 4 is done, with boundary instrumentation.

Step 5 is done: the shell, the viewport, image intake, the document store with unlimited undo, the stack editor and the properties panel. Step 6 is done bar the palette-side index-map operations (F-CO-07 and F-CO-09 through 12): the editor, the hardware library, extraction with all three algorithms, sorting, OKLab ramps, output modes and the metric are built, and the two index-map stack nodes (outline F-SP-10, dilate/erode F-SP-11) were already there. Step 7 is done bar JPEG glitch (F-GL-06); F-SP-14 nearest-neighbour upscale, previously recorded here as deliberately absent, is built — as the second half of the F-PP-01 pair, which is what made it a pass rather than a resampling stage.

Still export, the tracer, documents and presets are done, out of order. They sit at steps 10 to 12 in the list above, and they were taken ahead of the clock and the timeline for one reason: an application that can make a picture and cannot give it to you is not an application. What is built is PNG (indexed automatically), JPEG, WebP and SVG with per-colour layers, an integer nearest-neighbour scale, a measured size estimate, progress with a cancel that stops work, clipboard, .dork save and open in both variants, the preset library on OPFS with a starter set, and share links.

Steps 8, 9, 13 and 14 are now built too — the clock, the modulators, the timeline, batch and Surprise Me — and with them the animated half of export (F-EX-04 through F-EX-07, plus the PNG sequence and the sprite sheet).

Three seams were left open when those landed in parallel, and all three were the same shape — a module built correctly against an interface nobody had joined:

What remains of animation is F-AN-04, temporal variation — stepping a node's seed or pattern offset per frame rather than interpolating a parameter. The evaluator is written and tested (animation/temporal.ts, TEMPORAL_MODES); nothing in the UI reaches it and .dork has no field for it, so it is a plan option that no caller sets.

graph/animate.ts's renderAnimation is also not used, and the reason is structural rather than an oversight. It hashes every frame up front, identifies the nodes whose hash never changes, and pins them so an LRU under budget pressure cannot evict the shared prefix to hold one frame's throwaway tail. Its interface is a graphForFrame callback in and an onFrame callback out, and neither survives postMessage. Using it would mean moving the animation planner into the render worker and adding a streaming channel to a protocol that is one message per call. The animated export instead renders each frame as an ordinary lane: "export" call, which gets the cache hits — ContentHashInput excludes the frame index and DocumentRenderer retains every node's output, so a node that did not move is a hit — but not the pinning guarantee.

Two gaps this section used to record are closed. Nothing resolved an effect id to its GpuEffect: now every gpu effect module exports const gpu: GpuEffectSource beside its descriptor, loadGpuEffects() collects them with the same glob that collects the descriptors, and a gpu descriptor with no source fails the catalogue the way a missing surprise range does. And nothing was an application: app/main.tsx is now the entry point and web/src/main.ts is the proof page it used to be pretending not to be.

Two gaps this section used to add are now closed.

The stack grammar knows about extents. EffectDescriptor.resamples names the two nodes that write a different extent than they read, and registry/stack.ts refuses one placed where an index map is live unless it produces the map it leaves behind. That distinction is the whole rule and it is a fact about palette indices rather than a gap in the code: an index is a name, not a quantity, so no filter means anything applied to one — nearest is the only coherent rule, and it only lines up with the colour when the colour is resampled by nearest at the same integer factor. That is exactly nn-upscale, which carries colour and index across together and therefore declares producesIndexMap; internal-resolution offers box and Lanczos and writes no map, so after a dither it is refused in the picker, naming both nodes, before the node is added. The declaration cannot drift from the passes: gpu/compiler.ts checks the two agree, both ways, every time an effect is compiled.

Per-node opacity and blend (F-ST-03) is implemented, for both execution kinds. The formulas are defined once in graph/blend.ts and applied by each backend in its own — a compute program in gpu/composite.ts over shaders/_composite.wgsl, and planar f32 arithmetic in the WASM backend — so a composite costs no boundary crossing on either side, and a diffusion node at 60% opacity looks like a blur at 60% opacity. Blending is in linear light, which is correct for the multiplicative and comparative modes and deliberately different from a gamma-space compositor for the three pivoted ones; the argument is at the top of blend.ts. Twelve modes. Two consequences are recorded where they are enforced rather than discovered: a node that resamples cannot carry a composite, because its output and its own input are different pixel grids (graph/plan.ts refuses it, the stack row hides the controls); and the index map is carried across a composite untouched, because it records which palette entry the node chose and opacity changes how much of that decision is shown rather than what it was.

Known technical risks