In Words and Pictures
De-briefs · No. 3 · May 2026

Roasting Mallows
on the palette fire

I was driving slowly through my sleepy neighbourhood, hanabie.'s iconic blaring out the open windows, working hard to pretend this was perfectly normal.

This had seemed like a reasonable idea, a couple minutes ago, when the kids had asked if we could. But now we'd pulled off the highway onto our quiet streets, they were cringing in the back and ducking every time we passed someone, and I'd gone from indulgent to determined-to-see-through-what-they-started-despite-feeling-uneasy-myself.

And then it struck me.

Neonic.

That's the name.

When perfect lines are automatable, our humanity shows through the imperfections

I'd been working on this animated wordmark generator for awhile at that point, trying to understand what I really wanted. The original idea was a long name written in cursive, single line drawing like a neon sign, with a rainbow pulsing through it, snaking its way through the letters before starting again at the beginning.

It put me in mind of palette swapping tricks from retro games, and I wondered if I could do palette swapping along an svg path.

In fact the working title, at the time of that drive, was svg-to-palette-shift. That's still the directory name on my machine.

So I'm thankful for that drive. Good names are hard to find.

Plus, the svg approach didn't last long. There's no primitives for recolouring parts of a path, and the gradient operations apply across cartesian (or radial) coordinates, not the path coordinates.

The canvas, on the other hand, has a pretty efficient mechanism for palette cycling.

Getting the palette editing UI right was one of the harder parts of this. I've built a lot of UIs, but usually just to the point of nailing down the functionality, and then handing it off to a designer.

Controls for a palette monster
The final wireframe

This time I was on my own for the design side. There was probably a dozen major iterations on this palette panel alone, including a fairly intensive session with Anthropic's new design tool.

The experience definitely gave me a new appreciation for the great designers I've worked with in the past. They listen to the problem, ask questions to structure it in their language, and bring fresh solutions I would never have thought of to the table.

The LLM isn't even playing the same game they are. It didn't do any of those things.

But it is better at churning out css than I am.

And I'm happy to let it. It turns out that not writing css improves my life.

This directly contradicts the point above: letting the machine produce the css yields uniform smooth interfaces, which lack the human touches that hand crafting would leave.

On the other hand, if you've seen my brutally simplistic html-wireframe style interfaces, then you'll probably agree that having someone, anyone, even an LLM, give them a facelift is probably an improvement. My aesthetic is more something that one has to heroically overcome, rather than a royal road guiding neophytes along the happy path.

I've long argued that 99% of web pages would be better as plain text. I still think that's true. Maybe 1% of sites are improved by the design they currently have. I prefer reader mode almost always. There are pages where the design is an improvement, but they are very rare.

Highly interactive tools don't have that luxury though. They have to be used, and using something requires effort. If a machine can get 80% of the way to design efficiency -- minimizing the effort someone needs to use the tool -- then that's an improvement over the 50% I can reach alone.

Sometimes we sacrifice human touches for the sake of efficacy.

Tools like neonic maybe give us a chance to grab a little of that back.

dann
Autotext

Stage 1: Drawing the stroke

The bake pipeline downstream wants a path: a polyline with positions, tangents, and per-vertex widths. The editor's primary way of producing that path is letting you draw it with a pen or a mouse. The drawing surface streams raw pointer events through perfect-freehand, which synthesizes a pressure-sensitive outline polygon in real time as you drag. The line you see during the drag is that polygon, painted every frame; it gives the stroke its tactile, ink-on-paper feel.

On pointerup we keep the raw {x, y, pressure} samples — perfect-freehand was for the live look — and run them through two small classics:

  1. Ramer–Douglas–Peucker drops samples that don't deviate meaningfully from the line between their neighbours. A 200-point trail collapses to a 30-anchor skeleton without losing the curve's character.
  2. Catmull–Rom turns that skeleton into a smooth interpolating spline by deriving tangent handles from each anchor's neighbours. We convert the result to cubic Bézier control points so the editor's anchor handles match the convention every vector tool uses.

What lands in the editor's data model is an array of anchors carrying {x, y, h1x, h1y, h2x, h2y, width, pressure} — positions, their two cubic-Bézier handles, the display width, and the underlying pressure that derived it. Width comes from the pressure samples, scaled by the slider's base size and curved by a "thinning" parameter that controls how aggressively pressure modulates width. The rest of the article works downstream of this data — and starting now, the rest of the demos in this piece use yours.

This in-page demo trims the pipeline to its skeleton: pointer events → perfect-freehand outline (for the live look) → RDP simplify → {x, y, width} anchors with the per-anchor pressure flattened into a single width. The full editor adds the Catmull–Rom spline fit on top, so its anchors carry tangent handles and editable per-anchor pressure. Stages 2 onward sample positions along whichever path is active — the demo skips spline curvature, the editor doesn't.

You know what sounds like a good idea in real life? A bake pipeline. Can we do that IRL?
Draw a stroke (every demo below picks it up)
draw a stroke above — or click "Use sample stroke"
pointer events → anchors js
// During the drag, perfect-freehand renders the live outline.
liveStroke.points.push({ x, y, pressure });
const outline = getStrokeOutlinePoints(liveStroke.points,
                                       { size, thinning, simulatePressure });
ctx.fill(new Path2D(svgPathFromPolygon(outline)));

// On pointerup, simplify and fit a spline through the raw samples.
const trail      = liveStroke.points;
const simplified = rdpSimplify(trail, 1.5);
const anchors    = polylineToBezier(simplified);
// anchors[i] = { x, y, h1x, h1y, h2x, h2y, width }
Imagine trying to write out "svg-to-palette-shift" in this space.

Stage 2: Sample points along the path

Stage 1 produces anchors — sparse, with curvature stored in tangent handles. The bake doesn't read tangents directly; it reads positions. So between Stage 1 and Stage 3 there's a sampling step: walk the path at N evenly-spaced positions and grab an {x, y, width} at each. Sample rate is the only knob that decides curve fidelity here: too few and Bézier arcs polygonize visibly; too many and the bake gets slower for no win the eye can see. Sliding the slider in the demo below makes the trade-off concrete.

Two sampling paths in the implementation. When the user has drawn a stroke (anchors are a polyline of raw pointer points with widths), we keep a cumulative arc length per point and linearly interpolate to get the i-th evenly-spaced sample. When the source is an SVG path string (the fallback demo path), we stash it on a hidden <path> element and use the browser's getTotalLength() and getPointAtLength(). Either way, the output is the same shape: N + 1 samples of {x, y, width}.

Walk the path
walk a path js
// Stash the d on a hidden <path> so we can ask the SVG engine
// for arc-length parametrization.
const svg = document.createElementNS(NS, 'svg');
const p   = document.createElementNS(NS, 'path');
p.setAttribute('d', d);
svg.appendChild(p); document.body.appendChild(svg);

const total = p.getTotalLength();
const pts   = new Array(N + 1);
for (let i = 0; i <= N; i++) {
  const t = (i / N) * total;
  const q = p.getPointAtLength(t);
  pts[i] = { x: q.x, y: q.y };
}

Stage 3 (original): Stroke 254 colored segments → index canvas

Here's the trick. We split the sample list into N = 254 consecutive segments and stroke each one with a unique R-channel value: segment i gets rgb(i+1, 0, 0). The stroke is fat and round-capped, so adjacent segments overlap and form a continuous fill. The red channel of the resulting bitmap is our per-pixel index buffer — read straight off the canvas.

Each "segment" actually walks a few sub-segments along the path before changing index. Strictly the curve is still polygonized — there's no real continuous geometry, just a denser polyline — but the facets land below pixel resolution, so the rendered band reads as smooth.

Index encoding
stroke into the red channel js
const idx = document.createElement('canvas');
idx.width = W; idx.height = H;
const ictx = idx.getContext('2d');
ictx.lineWidth = STROKE_W;
ictx.lineCap   = 'round';
ictx.lineJoin  = 'round';

for (let i = 0; i < 254; i++) {
  ictx.strokeStyle = `rgb(${i+1}, 0, 0)`;
  ictx.beginPath();
  const base = i * SUBSEG;
  ictx.moveTo(pts[base].x, pts[base].y);
  for (let j = 1; j <= SUBSEG; j++)
    ictx.lineTo(pts[base+j].x, pts[base+j].y);
  ictx.stroke();
}
// idx canvas: red channel now encodes "where along the path"

Stage 3 (improved): Disc-stamping with per-anchor widths

The fat-stroke trick works beautifully when every segment is the same width. But the moment anchors carry different widths — and the editor's whole pressure-and-thinning apparatus exists precisely to give anchors different widths — a problem shows up at the boundaries. Round line caps balloon out perpendicular to the stroke direction; a thicker neighbour's cap can engulf a thinner segment, painting its higher-index red over the thinner segment's lower-index pixels. The clean stair-step of indices along the band breaks down.

The fix is to stop stroking and start stamping. At every sample point along the path, paint a filled disc whose radius is the local width divided by two. Two adjacent same-coloured discs overlap into a continuous fill; the disc at sample i+1 overpaints the disc at sample i at their shared boundary, so the higher-index pixel always wins. Per-anchor widths come through unchanged: a tapered stroke renders as a sequence of discs that shrink and grow as the local width does. The mask we'll paint in Stage 4 gets the same treatment, so the two layers share their footprint exactly.

Disc-stamping with a tapered profile
stamp discs into the red channel js
for (let i = 0; i < 254; i++) {
  ictx.fillStyle = `rgb(${i+1}, 0, 0)`;
  const base = i * SUBSEG;
  for (let j = 0; j < SUBSEG; j++) {
    const p = samples[base + j];  // { x, y, width }
    ictx.beginPath();
    ictx.arc(p.x, p.y, p.width / 2, 0, Math.PI * 2);
    ictx.fill();
  }
}

Stage 4: Mask & combine into a Uint8Array

The index canvas has soft anti-aliased edges where indices fade to zero, which would flicker badly under cycling. So we paint a second canvas — same path, disc-stamped the same way as Stage 3 (improved), but at slightly smaller radius (0.92× the local width) and a single solid colour. Its alpha channel is the binary "is this pixel inside the shape?" decision. We then walk every pixel once and pack the result into a flat Uint8Array: 0 means transparent, 1..254 means "this index in the palette".

One typed array, one byte per pixel. That's the entire baked artifact; everything else can be thrown away.

The bake
pack into a flat byte buffer js
// Bake into one flat byte-per-pixel buffer.
const indices = new Uint8Array(W * H);
for (let i = 0; i < W * H; i++) {
  if (mask[i*4+3] < 200) { indices[i] = 0; continue; }
  let v = idxData[i*4];
  if (v < 1)   v = 1;
  if (v > 254) v = 254;
  indices[i] = v;
}

Stage 5: Build a palette LUT

A palette has two pieces. The ramp is a flat Uint8Array of 255 × 3 bytes, one RGB triple per index — built by linearly interpolating between a small handful of stops. The LUT proper is a Uint32Array(256) of packed RGBA values that the hot loop in the next stage reads. Slot 0 of the LUT is always transparent; slots 1..254 are the cycling colors (the bake clamps index values to this range); slot 255 is written defensively but never read. Different palettes — sodium, plasma, rainbow, cyan — are just different stop lists. The whole bake stays the same; only this 1KB lookup changes.

Pick a ramp
palette LUT js
const palette = new Uint32Array(256);
palette[0] = 0;                         // transparent
for (let i = 0; i < 255; i++) {
  const r = ramp[i*3], g = ramp[i*3+1], b = ramp[i*3+2];
  palette[i + 1] = (255 << 24) | (b << 16) | (g << 8) | r;
}

Stage 6: The render loop

This is the punchline. Every frame:

  1. Bump offset by speed × dt.
  2. Rebuild the 256-entry palette LUT so slot i+1 reads from ramp[(offset - i) mod 255], with linear interpolation between the two adjacent ramp entries when offset sits between integer positions. (1KB worth of work per frame.)
  3. Walk the indices buffer once, writing data32[i] = palette[indices[i]].
  4. putImageData.

That's it. No SVG, no drawPath, no compositing. The geometry was baked once. Animation is just "shift a 1KB lookup, then do one tight pass over a typed array." The demo below shows the actual frame rate on your machine; on most laptops palette cycling is comfortably bound by requestAnimationFrame, with plenty of headroom for everything else on the page.

The technique itself isn't new — palette cycling carried entire eras of 8-bit and 16-bit graphics, and Mark Ferrari's Living Worlds pieces (the rain-on-a-village, the lava flow) are the canonical demonstration of how much motion you can pull out of one static image and a rotating LUT. We're just running it over a freshly baked SVG path instead of a hand-drawn 320×200 scene.

Live
engine paints on your device:
frame() js
function frame(now) {
  const dt = (now - last) / 1000; last = now;
  offset += speed * dt;

  // rebuild the palette LUT — one interpolated read per slot.
  for (let i = 0; i < 255; i++) {
    const pos = ((offset - i) % 255 + 255) % 255;
    const k0  = pos | 0, k1 = (k0 + 1) % 255, f = pos - k0;
    const o0  = k0 * 3, o1 = k1 * 3;
    const r = ramp[o0]     + (ramp[o1]     - ramp[o0])     * f;
    const g = ramp[o0 + 1] + (ramp[o1 + 1] - ramp[o0 + 1]) * f;
    const b = ramp[o0 + 2] + (ramp[o1 + 2] - ramp[o0 + 2]) * f;
    palette[i + 1] = 0xff000000 | ((b|0) << 16) | ((g|0) << 8) | (r|0);
  }

  // the hot loop — one lookup per pixel
  for (let i = 0; i < n; i++) data32[i] = palette[indices[i]];
  ctx.putImageData(image, 0, 0);

  requestAnimationFrame(frame);
}

Stage 7: Custom palettes from stops

The presets are seven hand-rolled lookup tables, but the format underneath is just a list of color stops at positions in [0, 1]. Linear-interpolate between adjacent stops and you have any 255-entry ramp: sodium-glow becomes plasma becomes a personal one with three teal stops crammed into the middle.

Drag the stops along the bar
stops re-flow the ramp on the fly
buildRamp(stops) js
function buildRamp(stops) {
  const sorted = stops.slice().sort((a,b) => a.t - b.t);
  const out = new Uint8Array(255 * 3);    // flat — no per-entry allocations
  for (let i = 0; i < 255; i++) {
    const t = i / 254;
    // find the two stops bracketing t
    let a = sorted[0], b = sorted[sorted.length - 1];
    for (let k = 0; k < sorted.length - 1; k++) {
      if (t >= sorted[k].t && t <= sorted[k+1].t) {
        a = sorted[k]; b = sorted[k+1]; break;
      }
    }
    const u = (t - a.t) / (b.t - a.t || 1);
    out[i*3]     = Math.round(a.color[0] + (b.color[0] - a.color[0]) * u);
    out[i*3 + 1] = Math.round(a.color[1] + (b.color[1] - a.color[1]) * u);
    out[i*3 + 2] = Math.round(a.color[2] + (b.color[2] - a.color[2]) * u);
  }
  return out;
}

Stage 8: Smooth palette transitions

Switching from one palette to another mid-cycle is jarring if you do it as a hard cut: every position on the path snaps to a new color in the same frame, even when the palettes are similar. The trick is to feed the new palette in at one position — palette[1], the head of the cycle — and let the cycle's existing motion carry it along the path.

Each frame we split the read by tape position s = offset − i. Cells past the boundary read from p2; cells behind still read from p1. As offset advances, the boundary moves through the ramp one slot at a time. After 254 ticks every visible position has been swept — the new palette is fully in, and we promote it to base.

Drag the slider — watch the boundary sweep
0/254 — fully p1
_writePalette js
// Inside _writePalette: split each palette index by tape position.
for (let i = 0; i < 255; i++) {
  const s = offset - i;
  let ramp, pos;
  if (nextPalette && s >= nextStartOff) {
    ramp = nextPalette;                      // swept — read p2
    pos  = (s - nextStartOff) % 255;
  } else {
    ramp = basePalette;                      // not swept — p1
    pos  = (s - baseStartOff) % 255;
  }
  palette[i + 1] = sample(ramp, pos);
}

// Promote when every visible position has been swept.
if (nextPalette && offset - nextStartOff >= 254) {
  basePalette = nextPalette;
  baseStartOff = nextStartOff;
  nextPalette = null;
}

Stage 9: Self-describing PNG

The finished logo wants three things to be portable: the editable Bézier anchors (the geometry), the palette stops (so the runtime can rebuild the LUT), and the cycle config (speed, mode, ordering). All of that fits comfortably in a few KB of JSON. Notably, the precomputed indices buffer is not in the file — the runtime re-derives it from the anchors at the consumer's display resolution, so a tiny embed pays tiny per-frame compute and the file itself stays small. Stage 11 expands on that.

Rather than ship a JSON sidecar, we put it inside the PNG itself. The PNG body is an RGBA snapshot of the first frame — so the file shows up in Finder, GitHub diffs, og:image previews, anywhere — and an iTXt chunk keyed neonic carries the metadata. Save it as *.neonic.png; the runtime reads the chunk, the rest of the world sees a normal PNG.

PNG layout
PNG signature  — 8 bytes
IHDR           — width × height, color type 6 (RGBA)
IDAT*          — compressed RGBA preview pixels (first frame)
iTXt           — keyword "neonic", text = JSON {
                 version, width, height,
                 anchors  (bezier control points + per-anchor pressure),
                 palettes (array of {name, stops, speed, cycles, presetName}),
                 strokeWidth, thinning,
                 scale, padding, paddingLogical, half
               }
IEND           — end of stream
(click fetch)

The static preview the file ships with is rendered from metadata.palettes[0] at offset = 0 — so what you see in Finder matches the first frame the engine paints. The encoder writes the iTXt key as neonic. Pre-v3 PNGs that still carried a precomputed indices field are accepted too — the field is just ignored on load.

Stage 10: Round-tripping the PNG

The iTXt payload is the entire editable session, in JSON: anchors with their tangent handles, per-anchor pressure values, base stroke size and thinning slider, the palette playlist (every stop, every cycle count, every speed), the play mode, the active palette index. Everything the editor needs to reconstruct the document.

This is the difference between an export and a project file. A normal SVG keeps the geometry but loses your anchors. A spritesheet PNG keeps the pixels but loses everything else. A .neonic.png keeps both — so the same file that runs your logo on a stranger's site is also what you double-click to edit it tomorrow.

Open the editor, click Import, point it at one. The drawing reappears with all its anchors editable; the palette playlist populates the right column with every stop in place; the sliders restore to where you left them. Tweak whatever, re-export, the file's been losslessly round-tripped.

What comes back from the iTXt chunk
(click inspect)

Stage 11: Embedding

The whole point of all of the above is that putting a finished logo on a page is the minimum possible:

embed snippet html
<canvas class="logo-cycle" data-src="logo.neonic.png"></canvas>
<script src="neonic-playback.js"></script>
<script>NeonicLoader.mountAll('.logo-cycle');</script>

That's the entire embed surface. Two tags and a one-liner. neonic-playback.js is one ~40 KB file with zero dependencies — engine, codec, and playlist loader concatenated. Drop the PNG next to your HTML, point the canvas's data-src at it, call mountAll. The loader decodes the iTXt chunk for the anchors and palette stops, then runs the Stage 3 / Stage 4 bake itself, sized to the canvas's actual display resolution — so a 32-px-tall embed pays 32-px-tall per-frame compute, not the full export-time bake. If the PNG carries more than one palette, the loader also runs a playlist watcher that schedules the Stage 8 feed-in at each cycle boundary, weaving the playlist together without visible cuts.

Live: that PNG, running right here

Now go draw something →