Skip to main content

ns-ui

Chargen ROM Slice

A status glyph that reconstructs a real 1970s-80s CRT character-generator ROM read pattern: each glyph in a short string is built top-to-bottom from horizontal scanline slices arriving on a beam-synced sweep, so most on-screen glyphs are only ever a clean stack of fetched rows sitting above a hard-edged gap, whole for a single instant per cycle.

Use when a small status/loader glyph area that should read as literal hardware fetch cadence — a fixed bitmap-font string built row-by-row from a scanline sweep, whole only for an instant between rebuilds. Pick text-decrypt instead when the moment calls for a scramble-to-legible character-lock reveal (randomised glyph substitution settling left-to-right) rather than a row-address fetch simulation — text-decrypt's mechanic is cryptographic/typewriter, not a hardware scanline read, and it does not hold a clean assembled state before restarting.

Install

npx shadcn add https://design.helpmarq.com/r/chargen-rom-slice.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/core/chargen-rom-slice/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// ChargenRomSlice — a status glyph that reconstructs the way 1970s-80s CRT
// text-mode video controllers actually drew characters: a character-generator
// ROM stores each glyph as an 8-row bitmap, but the controller never fetches
// a whole glyph in one go. It reads the ROM one scanline row at a time, in
// step with the beam, shifts that row of bits out across the row, then drops
// to the next row and re-addresses the ROM for the next slice. A glyph is
// only ever "whole" for an instant, once per frame — every other instant it
// is a stack of independently-fetched horizontal slices with a hard gap
// below the last one fetched.
//
// Each on-screen character gets its own 35ms-per-row sweep (row N becomes
// visible at N*35ms into that character's build), 8 rows -> 280ms per glyph.
// Characters start staggered 60ms apart left-to-right, so a 6-character
// string is fully clean ~580ms after the cycle starts. It then holds fully
// assembled for 500ms before every row resets to 0 and the sweep restarts —
// a single fixed 1080ms cycle, run on real elapsed time (not frame count) so
// speed never depends on paint rate.
// ---------------------------------------------------------------------------

// Built-in 5x7 block font (uppercase A-Z, 0-9, space, punctuation used by the
// default string) with a blank 8th row appended — real chargen ROMs commonly
// address an 8- or 9-row cell for a 7-row glyph, leaving a descender row
// blank. Hand-drawn geometric shapes, not a traced copy of a real ROM dump.
const FONT: Record<string, string[]> = {
  A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001", "00000"],
  B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110", "00000"],
  C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111", "00000"],
  D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110", "00000"],
  E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111", "00000"],
  F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000", "00000"],
  G: ["01111", "10000", "10000", "10011", "10001", "10001", "01110", "00000"],
  H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001", "00000"],
  I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110", "00000"],
  J: ["00111", "00010", "00010", "00010", "00010", "10010", "01100", "00000"],
  K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001", "00000"],
  L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111", "00000"],
  M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001", "00000"],
  N: ["10001", "11001", "10101", "10101", "10011", "10001", "10001", "00000"],
  O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110", "00000"],
  P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000", "00000"],
  Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101", "00000"],
  R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001", "00000"],
  S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110", "00000"],
  T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100", "00000"],
  U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110", "00000"],
  V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100", "00000"],
  W: ["10001", "10001", "10001", "10101", "10101", "11011", "10001", "00000"],
  X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001", "00000"],
  Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100", "00000"],
  Z: ["11111", "00010", "00100", "01000", "10000", "10000", "11111", "00000"],
  "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110", "00000"],
  "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110", "00000"],
  "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111", "00000"],
  "3": ["11111", "00010", "00100", "00010", "00001", "10001", "01110", "00000"],
  "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010", "00000"],
  "5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110", "00000"],
  "6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110", "00000"],
  "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000", "00000"],
  "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110", "00000"],
  "9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100", "00000"],
  " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000", "00000"],
  ".": ["00000", "00000", "00000", "00000", "00000", "01100", "01100", "00000"],
  "_": ["00000", "00000", "00000", "00000", "00000", "00000", "11111", "00000"],
};

const GLYPH_W = 5;
const GLYPH_H = 8;
const GLYPH_GAP = 1; // blank columns between characters
const ROW_MS = 35; // one ROM scanline slice revealed every 35ms (~28 rows/s)
const CHAR_STAGGER_MS = 60; // each character's sweep starts 60ms after the previous
const HOLD_MS = 500; // full-string clean hold before the sweep resets to row 0

// Frozen prefers-reduced-motion frame: the live sweep's row-reveal math
// evaluated at a fixed 300ms cycle position — an early character fully
// assembled, the next mid-sweep with a visible hard-edged slice seam, later
// ones still thin or untouched. A named cycle position (not a hardcoded
// per-index array) so it stays a genuine sweep frame for any string length.
const FREEZE_MS = 300;

// Live sweep at t0 is seeded this far into its own cycle rather than at 0 so
// the very first paint is already mid-sweep (per the resting-loop spec: t0
// shows glyphs with only their top rows revealed, not a blank canvas). 170
// is chosen so the cycle-phase-locked t0/2.5s/5s samples (1080ms cycle, 2.5s
// and 5s land 340ms/680ms further into the cycle) land in three distinct
// states — mid-sweep, a later mid-sweep, and the clean hold — rather than
// two of the three sample points both landing inside the 500ms hold.
const T0_PHASE_MS = 170;

export interface ChargenRomSliceProps {
  /** status string; unsupported characters render as a blank cell */
  text?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function ChargenRomSlice({
  text = "BUFFER",
  className = "",
}: ChargenRomSliceProps) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

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

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const chars = Array.from(text.toUpperCase()).slice(0, 12);
    const cols = chars.length * GLYPH_W + Math.max(0, chars.length - 1) * GLYPH_GAP;
    const rows = GLYPH_H;
    const glyphMs = GLYPH_H * ROW_MS; // 280ms — one full glyph sweep
    const buildMs = (chars.length - 1) * CHAR_STAGGER_MS + glyphMs; // full-string assembly
    const cycleMs = buildMs + HOLD_MS;

    // resolve which grid cells are "on" once — the font map never changes
    const bitmap: boolean[][] = Array.from({ length: rows }, () => new Array(cols).fill(false));
    let cx = 0;
    for (const ch of chars) {
      const glyph = FONT[ch] ?? FONT[" "];
      for (let r = 0; r < GLYPH_H; r++) {
        for (let c = 0; c < GLYPH_W; c++) {
          if (glyph[r][c] === "1") bitmap[r][cx + c] = true;
        }
      }
      cx += GLYPH_W + GLYPH_GAP;
    }

    let fg = getComputedStyle(canvas).color;
    let cellPx = 12;
    let dpr = 1;
    let raf = 0;
    let cycleStart = 0;
    let resizeTimer: ReturnType<typeof setTimeout> | null = null;

    // rows currently revealed for each character, 0..GLYPH_H
    const revealed = new Array(chars.length).fill(0);

    const computeRevealed = (cyclePos: number) => {
      if (cyclePos >= buildMs) {
        revealed.fill(GLYPH_H);
        return;
      }
      for (let i = 0; i < chars.length; i++) {
        const elapsed = cyclePos - i * CHAR_STAGGER_MS;
        revealed[i] = elapsed <= 0 ? 0 : Math.min(GLYPH_H, Math.floor(elapsed / ROW_MS));
      }
    };

    const draw = () => {
      const width = cols * cellPx;
      const height = rows * cellPx;
      ctx.clearRect(0, 0, width, height);
      ctx.fillStyle = fg;
      let cxCol = 0;
      for (let i = 0; i < chars.length; i++) {
        const n = revealed[i];
        for (let r = 0; r < n; r++) {
          for (let c = 0; c < GLYPH_W; c++) {
            if (!bitmap[r][cxCol + c]) continue;
            // full cellPx, no inter-row gap — the only horizontal
            // discontinuity in a glyph must be the sweep seam itself
            ctx.fillRect((cxCol + c) * cellPx, r * cellPx, cellPx, cellPx);
          }
        }
        cxCol += GLYPH_W + GLYPH_GAP;
      }
    };

    const resize = () => {
      const availW = wrap.clientWidth;
      const availH = wrap.clientHeight || availW;
      const minDim = Math.min(availW, availH);
      cellPx = Math.min(16, Math.max(8, Math.round(minDim / 20)));
      // the wrap can be smaller than what the geometry formula wants (e.g. a
      // narrow card) — never let the canvas overflow its own container
      cellPx = Math.max(1, Math.min(cellPx, Math.floor(availW / cols), Math.floor(availH / rows)));
      const width = cols * cellPx;
      const height = rows * cellPx;
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = width * dpr;
      canvas.height = height * dpr;
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.imageSmoothingEnabled = false;
      const cyclePos = reduced ? FREEZE_MS : (performance.now() - cycleStart + cycleMs) % cycleMs;
      computeRevealed(cyclePos);
      draw();
    };

    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(resize, 100);
    };
    const ro = new ResizeObserver(onResize);
    ro.observe(wrap);

    // the site's theme toggle flips a `.dark` class on <html> live, with no
    // remount — watch it so the glyph colour updates without a page reload
    const themeObserver = new MutationObserver(() => {
      fg = getComputedStyle(canvas).color;
      draw();
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    let visible = true;
    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !reduced && !raf) raf = requestAnimationFrame(loop);
    });
    io.observe(wrap);

    function loop() {
      if (!visible) {
        raf = 0;
        return;
      }
      const cyclePos = (performance.now() - cycleStart) % cycleMs;
      computeRevealed(cyclePos);
      draw();
      raf = requestAnimationFrame(loop);
    }

    // seed the cycle already mid-sweep so the very first paint (t0) shows
    // partially-built glyphs, not a blank canvas
    cycleStart = performance.now() - T0_PHASE_MS;
    resize();
    window.addEventListener("resize", onResize);

    if (!reduced) raf = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      window.removeEventListener("resize", onResize);
      ro.disconnect();
      io.disconnect();
      themeObserver.disconnect();
    };
  }, [text]);

  return (
    <div
      ref={wrapRef}
      className={`ns-crs-wrap flex h-full w-full items-center justify-center ${className}`}
    >
      <canvas ref={canvasRef} aria-hidden className="block text-foreground" />
      <span className="sr-only">{text}</span>
    </div>
  );
}
Build spec

A card-scale status glyph, canvas-rendered, that simulates how a real VT100/Commodore/Apple II-class video controller reads a character-generator ROM: one scanline row at a time, in sync with the beam, rather than fetching a whole glyph at once. Each character in the (uppercase, 12-char max) `text` prop is drawn from a self-contained hand-drawn 5x8 bitmap font table (5 cols wide, 8 rows tall, the 8th row a blank descender row matching real chargen cell conventions) baked once into a `boolean[8][cols]` bitmap grid at mount. Geometry: `cellPx = clamp(round(min(wrap.clientWidth, wrap.clientHeight) / 20), 8, 16)`, then further clamped to `min(cellPx, floor(availableWidth / cols), floor(availableHeight / rows))` so a wrap smaller than the formula wants never overflows its own container; derived from the wrap's own measured box (the component's root fills `h-full w-full` of whatever the consumer sizes) via ResizeObserver (debounced 100ms) so it reads correctly at card scale. The canvas itself is sized in JS (`canvas.width/height` plus matching `style.width/height`, scaled by a capped devicePixelRatio) rather than relying on intrinsic size, with `ctx.imageSmoothingEnabled = false` and each revealed cell painted as a full, contiguous `cellPx x cellPx` block — no inter-row gap — so the sweep boundary between fetched and unfetched rows is the only horizontal discontinuity in a glyph. The sweep: each character's row-reveal starts `i * 60ms` after the shared cycle start (left-to-right stagger), reveals one additional row every 35ms (`Math.floor(elapsed / 35)`, clamped 0-8), so a single glyph fully assembles in 280ms and the whole string finishes roughly `(chars.length - 1) * 60 + 280` ms in; once every character has all 8 rows, the assembled string holds fully clean for 500ms, after which the cycle position wraps (`(now - cycleStart) % cycleMs`, cycleMs = build time + 500) and every row count resets to 0 — real elapsed time drives the math (via `performance.now()`), not frame count, so speed never depends on paint rate. `cycleStart` is seeded 170ms into the past at mount (not `now`) so t0 itself already reads mid-sweep instead of a blank canvas, and that 170ms offset is chosen specifically so the resting-loop's t0/2.5s/5s sample points — locked 340ms/680ms apart by the fixed 1080ms cycle — land in three visibly distinct states (an early mid-sweep, a later mid-sweep with most glyphs whole, and the clean hold) rather than two of the three both landing inside the hold. Colour is read once via `getComputedStyle(canvas).color` (the canvas carries `text-foreground`) and re-read on a `MutationObserver` watching `document.documentElement`'s `class` attribute so a live theme flip repaints without remount; there is no separate background fill — unrevealed rows are simply unpainted canvas showing the page's own `--background` through, so light and dark read identically (1-bit foreground-on-background, no tokens beyond `--foreground`/the page background, `--ns-accent` and `--border` are never touched). An `IntersectionObserver` on the wrap element halts and resumes the rAF loop as it leaves/enters viewport; a `ResizeObserver` re-measures and redraws on layout change; `prefers-reduced-motion` skips the rAF loop entirely and paints one fixed frame by running the same `computeRevealed` sweep math at a named `FREEZE_MS = 300` cycle position instead of live time — an early character fully assembled, the next mid-sweep with a visible hard slice seam, later ones thin or untouched — the single most legible static frame of the mechanic, deliberately not the clean hold and not the empty t0 state, and one that stays a genuine sweep frame (not a hardcoded per-index array) for any string length. `autoplay.mode` is `"none"`: the sweep runs on its own internal clock regardless of pointer/scroll/press input, so there is nothing for the site's synthetic-input driver to trigger. No interaction is implemented — the spec allows an optional hover/focus restart-from-row-0, but resetting `cycleStart` to `now` would blank the canvas for a beat before the 580ms rebuild (the `elapsed <= 0` branch reports zero rows for every character at the instant of reset), which is the wrong risk to carry for a feature the spec itself marks not required; the wrap carries no `tabIndex` since the canvas is `aria-hidden` and there is nothing keyboard-operable to expose. Cleanup on unmount cancels the rAF, disconnects the ResizeObserver, IntersectionObserver and MutationObserver, and removes the pointer/resize listeners. Zero dependencies, canvas + CSS only, no new colours (`--foreground` and the ambient page background only). The component's own root is `h-full w-full` and expects the consumer to size the parent container (see demo: a `h-40 w-80` bordered box), not to pass a sizing `className` onto the component itself.

Props

PropTypeDefaultDescription
text?string"BUFFER"status string; unsupported characters render as a blank cell
className?stringextra classes merged onto the rendered root element