Skip to main content

ns-ui / loud

Not Found Attribute Clash

A 404 built from the ZX Spectrum's real colour-cell attribute clash: each 8x8 hardware cell could carry only one ink/paper colour pair, so image detail finer than the cell budget visibly bled and blocked. This translates that as a monochrome glyph+weight budget (one glyph and one font-weight per cell), so the numerals' own curved edges snap and bleed at the seams, with a rolling scanline that re-decides a handful of rows per frame like attribute RAM racing the beam.

Use when a 404 whose graphic literalizes a real hardware detail-budget limit — cells forced to pick one glyph+weight pair snapping and bleeding at the numerals' own edges — as opposed to not-found-knockout's carved-void impact gag or not-found-postmark's multi-hop routing narrative; pick this one when the joke should read as a rendering constraint fighting the content, not a metaphor illustrated on top of it.

Install

npx shadcn add https://design.helpmarq.com/r/not-found-attribute-clash.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/loud/not-found-attribute-clash/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// AttributeClash404 — the ZX Spectrum's colour-cell attribute clash, ported
// to a monochrome glyph grid. Real Spectrum hardware split the screen into
// 8x8-pixel cells that could each carry only ONE ink/paper colour pair; any
// image content with finer detail than that budget forced the whole cell to
// snap to a single winner, and the loser's colour bled/blocked wherever it
// lost. There is no hue here, so the translated budget is ONE glyph+weight
// pair per cell instead of one colour pair — the artifact is the same shape:
// wherever the underlying "404" glyph shape needs sub-cell resolution (every
// curved edge of the numerals), the cell is forced to snap fully toward
// "ink" or fully toward "paper", and the digits visibly block and bleed at
// their own edges. This is the joke made mechanical, not a decorative
// filter — a normal glyph-density field (see background-ascii-plasma) always
// renders as much detail as its grid allows; this one is built to render
// LESS than the field wants and show the seam.
//
// The "attribute RAM racing the beam" line in the brief is translated as a
// literal rolling scanline: each frame only the handful of rows the beam
// has newly crossed get their ink/paper decision recomputed from the
// current field time, so the rows just above the beam are fresh and the
// rows still waiting below hold a slightly older decision — a rolling seam
// instead of a single full-field flash (prefers-reduced-motion cares about
// exactly that distinction). Cells that sit near the 50/50 ink/paper split
// (the true clash zone, i.e. numeral edges) additionally get a small
// per-sweep jitter added to their decision threshold, so it is precisely
// the contested cells — never the whole field — that flicker between
// states pass to pass.
//
// Static once per resize: an offscreen "404" raster (read only for alpha,
// any fill colour) is supersampled into a per-cell ink-fraction buffer.
// Dynamic every frame: only the field noise driving glyph choice inside
// each already-decided cell, and the rolling beam that gates when a cell's
// ink/paper decision itself gets re-evaluated. Canvas 2D, direct-DOM rAF,
// no React state on the hot path.
// ---------------------------------------------------------------------------

const SUB = 3; // ink-mask supersample factor per cell, each axis (9 samples/cell)
const MIN_CELL_PX = 8;
const MAX_CELL_PX = 64;
const CLASH_ZONE = 0.62; // clash = 1 - |2*inkFrac-1|; band around the 50/50 edge
const JITTER_AMOUNT = 0.9; // decision-threshold jitter applied only inside CLASH_ZONE
const INK_RAMP = "#%@";
const PAPER_RAMP = " .:-";
const CLASH_RAMP = "*+?";
const STATIC_T = 2.35; // reduced-motion freeze time — see comment at its use below

// small, cheap 2-octave field over grid-index coordinates (not pixels) —
// only needed for glyph *choice* within an already-decided cell and for the
// clash-zone jitter, never for the ink/paper decision itself, which comes
// solely from the static "404" raster.
function fieldValue(gx: number, gy: number, t: number): number {
  const a =
    Math.sin(gx * 0.5 + t * 0.6) +
    Math.sin(gy * 0.5 - t * 0.5) +
    Math.sin((gx + gy) * 0.35 + t * 0.33);
  const b = Math.sin(gx * 1.3 - t * 1.1) + Math.sin(gy * 1.1 + t * 0.9);
  const v = a * 0.6 + b * 0.4;
  return v / 6 + 0.5; // rough-normalize to ~0..1
}

export interface AttributeClash404Props {
  /** target glyph-cell count across the container's smaller dimension */
  cellsAcrossMinDim?: number;
  /** ms for one full top-to-bottom beam pass */
  sweepMs?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function AttributeClash404({
  cellsAcrossMinDim = 26,
  sweepMs = 1100,
  className = "",
}: AttributeClash404Props) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    let fg = "";
    let muted = "";
    let cellW = 0;
    let cellH = 0;
    let cols = 0;
    let rows = 0;
    let dpr = 1;
    let fontFamily = "monospace";
    let fontBold = "";
    let fontReg = "";

    // static per resize/fonts-ready: the "404" glyph's own required detail
    let inkFraction = new Float32Array(0);
    // dynamic, gated by the beam sweep below
    let charBuf = new Uint8Array(0); // char code per cell
    let alphaBuf = new Float32Array(0);
    let boldBuf = new Uint8Array(0);

    let sized = false;
    let ready = false;
    let disposed = false;
    let raf = 0;
    let last = 0;
    let t = 0;
    let beamAcc = 0; // unwrapped cumulative row position of the beam
    let visible = true;

    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      fg = cs.getPropertyValue("--foreground").trim();
      muted = cs.getPropertyValue("--ns-muted").trim();
    };

    const buildInkMask = () => {
      if (cols < 1 || rows < 1) return;
      const off = document.createElement("canvas");
      off.width = cols * SUB;
      off.height = rows * SUB;
      const octx = off.getContext("2d");
      if (!octx) return;
      octx.clearRect(0, 0, off.width, off.height);
      octx.fillStyle = "#000"; // alpha channel only is read below, fill colour is irrelevant
      octx.textAlign = "center";
      octx.textBaseline = "middle";

      // fit "404" to the offscreen raster with a real margin on each side —
      // never let the numeral shape run edge-to-edge, and never shrink it
      // below legibility either
      let fontPx = off.height * 0.72;
      octx.font = `900 ${fontPx}px ${fontFamily}`;
      const maxW = off.width * 0.86;
      const w0 = octx.measureText("404").width;
      if (w0 > maxW) fontPx *= maxW / w0;
      octx.font = `900 ${fontPx}px ${fontFamily}`;
      octx.fillText("404", off.width / 2, off.height / 2);

      const data = octx.getImageData(0, 0, off.width, off.height).data;
      const frac = new Float32Array(cols * rows);
      for (let gy = 0; gy < rows; gy++) {
        for (let gx = 0; gx < cols; gx++) {
          let sum = 0;
          for (let sy = 0; sy < SUB; sy++) {
            const py = gy * SUB + sy;
            for (let sx = 0; sx < SUB; sx++) {
              const px = gx * SUB + sx;
              sum += data[(py * off.width + px) * 4 + 3]; // alpha channel
            }
          }
          frac[gy * cols + gx] = sum / (SUB * SUB * 255);
        }
      }
      inkFraction = frac;
    };

    const measureCell = () => {
      const off = document.createElement("canvas");
      const octx = off.getContext("2d");
      if (!octx) return 1;
      octx.font = `700 ${cellH}px ${fontFamily}`;
      return Math.max(4, octx.measureText("MM").width / 2);
    };

    const resize = () => {
      const { width, height } = canvas.getBoundingClientRect();
      if (width < 2 || height < 2) {
        sized = false;
        return;
      }
      const isCard = !!canvas.closest("[data-autoplay-root]");
      dpr = isCard
        ? Math.min(0.6, window.devicePixelRatio || 1)
        : Math.min(window.devicePixelRatio || 1, 1.5);
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

      fontFamily = getComputedStyle(canvas).fontFamily;
      const minDim = Math.min(width, height);
      const target = minDim / cellsAcrossMinDim;
      cellH = Math.max(MIN_CELL_PX, Math.min(MAX_CELL_PX, target));
      cellW = measureCell();
      fontBold = `700 ${cellH}px ${fontFamily}`;
      fontReg = `400 ${cellH}px ${fontFamily}`;

      cols = Math.max(1, Math.ceil(width / cellW));
      rows = Math.max(1, Math.ceil(height / cellH));
      const n = cols * rows;
      charBuf = new Uint8Array(n);
      alphaBuf = new Float32Array(n);
      boldBuf = new Uint8Array(n);
      buildInkMask();
      beamAcc = 0;
      sized = true;
    };

    // recompute one row's ink/paper decision + glyph choice at time `tt` —
    // this is the ONLY place a cell's attribute pair is re-decided; the
    // beam sweep below gates how often that happens per row
    const updateRow = (gy: number, tt: number) => {
      const base = gy * cols;
      for (let gx = 0; gx < cols; gx++) {
        const i = base + gx;
        const inkFrac = inkFraction[i] ?? 0;
        const clash = 1 - Math.abs(inkFrac * 2 - 1);
        const field = fieldValue(gx, gy, tt);
        const jitter = clash > CLASH_ZONE ? (field - 0.5) * JITTER_AMOUNT : 0;
        const bold = inkFrac + jitter >= 0.5;
        let ramp: string;
        if (clash > CLASH_ZONE) ramp = CLASH_RAMP;
        else if (bold) ramp = INK_RAMP;
        else ramp = PAPER_RAMP;
        const ci = Math.min(
          ramp.length - 1,
          Math.floor(field * ramp.length)
        );
        charBuf[i] = ramp.charCodeAt(ci);
        boldBuf[i] = bold ? 1 : 0;
        alphaBuf[i] = bold ? 0.55 + 0.45 * field : 0.12 + 0.28 * field;
      }
    };

    const drawAll = () => {
      if (!sized) return;
      const w = cols * cellW;
      const h = rows * cellH;
      ctx.clearRect(0, 0, w, h);
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillStyle = fg;
      let i = 0;
      for (let gy = 0; gy < rows; gy++) {
        for (let gx = 0; gx < cols; gx++, i++) {
          const a = alphaBuf[i];
          if (a <= 0.01) continue;
          ctx.globalAlpha = a;
          ctx.font = boldBuf[i] ? fontBold : fontReg;
          ctx.fillText(
            String.fromCharCode(charBuf[i]),
            gx * cellW + cellW / 2,
            gy * cellH + cellH / 2
          );
        }
      }
      ctx.globalAlpha = 1;

      if (!reduced) {
        // faint racing-beam line — structural readout of the mechanism,
        // never accent (this is ambient, not interaction chrome)
        const beamRow = beamAcc % rows;
        ctx.strokeStyle = muted;
        ctx.globalAlpha = 0.3;
        ctx.lineWidth = 1;
        ctx.beginPath();
        const y = beamRow * cellH;
        ctx.moveTo(0, y);
        ctx.lineTo(w, y);
        ctx.stroke();
        ctx.globalAlpha = 1;
      }
    };

    const loop = (now: number) => {
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
      last = now;
      t += dt;

      const rowsPerSec = rows / (sweepMs / 1000);
      const prevFloor = Math.floor(beamAcc);
      beamAcc += rowsPerSec * dt;
      const newFloor = Math.floor(beamAcc);
      const steps = Math.min(newFloor - prevFloor, rows);
      for (let k = 1; k <= steps; k++) {
        updateRow((prevFloor + k) % rows, t);
      }
      if (beamAcc >= rows * 2) beamAcc -= rows; // keep the accumulator bounded

      drawAll();
      if (visible && !document.hidden) raf = requestAnimationFrame(loop);
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const ro = new ResizeObserver(() => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (ready) {
          if (reduced) {
            for (let gy = 0; gy < rows; gy++) updateRow(gy, STATIC_T);
            drawAll();
          } else {
            for (let gy = 0; gy < rows; gy++) updateRow(gy, t);
          }
        }
      }, 150);
    });
    ro.observe(canvas);

    const io = new IntersectionObserver(
      ([entry]) => {
        visible = entry.isIntersecting;
        if (visible && !reduced && ready && !document.hidden) {
          last = 0;
          raf = requestAnimationFrame(loop);
        }
      },
      { threshold: 0 }
    );
    io.observe(canvas);

    const onVis = () => {
      if (!document.hidden && !reduced && ready && visible) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced && ready) drawAll();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    // ink mask depends on the resolved mono/sans font metrics, so wait for
    // the webfont before the first raster + measurement pass
    document.fonts.ready.then(() => {
      if (disposed) return;
      readTokens();
      resize();
      ready = true;
      if (reduced) {
        // Freeze on a full completed sweep (every row decided once) at a
        // fixed, non-zero field time rather than t=0: the ink/paper split
        // itself is static (it comes only from the "404" raster), but the
        // glyph choice inside each already-decided cell and the jitter
        // that marks the clash-zone cells both depend on field time, and
        // t=0 is a degenerate phase where several of the field's summed
        // sine terms start at exactly zero. STATIC_T=2.35 lands mid-cycle
        // so clash-zone cells show a spread of ink/paper snap decisions
        // instead of all agreeing — the single frame with the most visible
        // block/bleed structure. No rAF, no beam sweep, no pointer.
        for (let gy = 0; gy < rows; gy++) updateRow(gy, STATIC_T);
        drawAll();
      } else {
        for (let gy = 0; gy < rows; gy++) updateRow(gy, 0);
        raf = requestAnimationFrame(loop);
      }
    });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [cellsAcrossMinDim, sweepMs]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      className={`block h-full w-full font-mono ${className}`}
    />
  );
}
Build spec

Build a full 404 background whose mechanic is sourced from real ZX Spectrum hardware, not invented: the machine's screen was split into 8x8-pixel cells that could each carry only ONE ink/paper colour pair, so any image content needing finer detail than that per-cell budget forced the whole cell to snap toward one colour, and the loser's detail bled/blocked wherever it lost — the well-documented 'colour clash' artifact. THIS IS A MONOCHROME ADAPTATION, NOT A FIDELITY CLAIM: state plainly that the original clash is a COLOUR artifact (one ink/paper pair per cell) and this translation is a glyph/weight-budget artifact (one glyph character + one font-weight per cell) — there is no hue anywhere in this component. Build it as: (1) once per resize, after document.fonts.ready, rasterize '404' at 900-weight onto a small offscreen canvas supersampled 3x per output cell, fit to the offscreen raster with real margin on each side (never edge-to-edge, never shrunk below legibility), and read its alpha channel into a per-cell ink-fraction float (0..1) — this fraction is the ONLY thing that decides ink vs paper per cell, and it never changes with time, only with resize/refit; (2) every frame, a rolling beam sweeps top to bottom over roughly sweepMs (default 1100ms) and only the handful of rows it newly crosses that frame get their ink/paper DECISION re-evaluated — most of the grid is deliberately left showing its previous decision, which is the literal translation of 'attribute RAM racing the beam' as a rolling seam rather than a full-field flash; (3) for cells whose ink-fraction sits within a clash band around 50/50 (the numerals' own edges), a small per-sweep jitter is added to the decision threshold from a cheap two-octave grid-index field, so specifically those contested cells flicker between full-ink and full-paper pass to pass — cells far from any edge never flicker, because they were never in a real budget dispute on the source hardware either. Each cell renders exactly one character (dense glyphs '#%@' for ink cells, sparse ' .:-' for paper cells, a third distinct ramp '*+?' for cells currently inside the clash band) at exactly one font-weight (700 for ink, 400 for paper) and one alpha derived from the same field value — the glyph+weight pair literalizes the one-attribute-per-cell budget. Colour is --foreground only, alpha-only, read via getComputedStyle(document.documentElement) at mount with a MutationObserver on documentElement's class for theme flips; a faint --ns-muted (never --ns-accent — this is ambient structure, not interaction chrome) horizontal line traces the beam's current row as a structural readout of the mechanism. Cell size derives from Math.min(width,height)/cellsAcrossMinDim (default 26), clamped 8-64px, so the component reads as a recognizable block grid at both small registry-card scale and full-bleed rather than either pixel-soup or one giant cell. prefers-reduced-motion freezes on one completed sweep (every row decided once) at a fixed non-zero field time chosen specifically because t=0 is a degenerate phase for the summed field terms — the frozen frame must show the clash-zone cells already disagreeing with each other, not a coincidentally-uniform snap; no rAF, no beam sweep runs after that. The canvas is aria-hidden decoration only: the demo composes a real h1 reading '404 — PAGE NOT FOUND', a short paragraph, and a real focusable 'Take me home' link in normal DOM with correct focus order, entirely independent of the glyph art. Props: cellsAcrossMinDim (default 26), sweepMs (default 1100), className.

Props

PropTypeDefaultDescription
cellsAcrossMinDim?number26target glyph-cell count across the container's smaller dimension
sweepMs?number1100ms for one full top-to-bottom beam pass
className?stringextra classes merged onto the rendered root element