ns-ui
Divider Teletext Mosaic
A full-width section divider rendered as a real teletext / videotex ALPHAMOSAIC graphics band: each character cell is a 6-bit code across a 2-wide x 3-tall sub-cell 'sextant' grid, the actual broadcast alphamosaic addressing (distinct from this registry's 2x2 quadrant block dividers and 2x4 braille-dot components), painted row by row on a fixed cadence the way a real teletext page arrives off the broadcast signal.
Use when Pick divider-teletext-mosaic when the surface is a section divider that should read as a genuine broadcast teletext page painting in — a 2x3 sextant sub-cell grid (6-bit alphamosaic addressing) refreshed row by row on a cadence, distinct from quadrant-block dividers (2x2) and braille-based components (2x4 dot addressing). Pick divider-petscii-vu instead when the divider should read as a live amplitude instrument rather than a paginated broadcast graphic.
Install
npx shadcn add https://design.helpmarq.com /r/divider-teletext-mosaic.jsonSource
registry/core/divider-teletext-mosaic/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// DividerTeletextMosaic — a full-width section divider rendered as a
// teletext / videotex ALPHAMOSAIC graphics band.
//
// Real broadcast teletext (World System Teletext / CEEFAX-era) never had a
// per-pixel framebuffer: each character cell is a 6-bit code selecting one
// of 64 patterns across a fixed 2-wide x 3-tall sub-cell grid ("sextant"
// addressing — top-left, top-right, mid-left, mid-right, bottom-left,
// bottom-right). That is a genuinely different sub-cell grid from anything
// else in this registry: block-element dividers quantize 2x2 quadrants,
// braille components address a 2x4 dot grid — this is 2x3, the real
// alphamosaic grid a teletext decoder actually drew, contiguous (no gaps
// between sub-cells, matching the "contiguous mosaic" mode broadcasters used
// for photo-like graphics, as opposed to "separated mosaic" which insets a
// gap per block).
//
// Sub-cells are rendered as filled canvas rects, not the Unicode sextant
// block range (U+1FB00-U+1FB3B, "Symbols for Legacy Computing") — this
// repo's mono stack (GeistMono / GeistMono Fallback / ui-monospace) does not
// carry that range, and shipping unverified glyph coverage risks silent
// tofu. Drawing the six sub-cells directly is also what a real teletext
// decoder chip did: it never asked a font for a glyph, it lit sub-cell
// segments off the 6-bit code.
//
// ALIVE AT REST: a real teletext page doesn't arrive all at once — rows
// paint in one at a time as they're received off the broadcast VBI signal.
// This band reproduces that: a write cursor sweeps top-to-bottom on a fixed
// cadence, re-sampling a slow generative field into a fresh 6-bit pattern
// per cell each time a row's turn comes, then holds that content until the
// next pass. A short sync pause follows a full pass before the page
// "refreshes" again, mirroring the real per-page transmission cycle.
// ---------------------------------------------------------------------------
const ROWS = 6; // character rows in the band
const ROW_INTERVAL_MS = 420; // time between successive row writes ("as received")
const PAUSE_MS = 600; // sync pause between a completed pass and the next
const FLASH_MS = 220; // dwell of the just-written row's highlight hairline
const DITHER_STRENGTH = 0.32; // ordered-dither spread around the 0.5 threshold
// 2x3 ordered-dither bias table, one entry per sub-cell index
// (subRow*2 + subCol), scattered rather than linear so the threshold band
// doesn't read as a simple diagonal — the same discipline real videotex
// picture-mosaic dithering used to fake tone out of a binary grid.
const DITHER_ORDER = [0, 3, 4, 1, 2, 5];
const DITHER_BIAS = DITHER_ORDER.map((k) => (k + 0.5) / 6 - 0.5);
function fieldValue(sx: number, sy: number, t: number) {
// three non-commensurate traveling components summed and normalized —
// enough structure to read as a low-res broadcast picture once
// sextant-quantized, and slow enough that a 420ms row-write cadence
// samples visibly different content on every pass.
const f =
Math.sin(sx * 0.35 + t * 0.9) +
0.6 * Math.sin(sy * 0.6 - sx * 0.12 + t * 0.53) +
0.4 * Math.sin((sx + sy) * 0.22 + t * 0.31);
const v = (f + 2) / 4; // amplitude sum is +/-2 -> 0..1
return Math.pow(Math.min(1, Math.max(0, v)), 1.2);
}
function subCellPattern(colGlobal: number, row: number, t: number) {
// returns a 6-bit mask, bit i = sub-cell i (0=top-left ... 5=bottom-right)
let bits = 0;
for (let subRow = 0; subRow < 3; subRow++) {
for (let subCol = 0; subCol < 2; subCol++) {
const idx = subRow * 2 + subCol;
const sx = colGlobal * 2 + subCol;
const sy = row * 3 + subRow;
const v = fieldValue(sx, sy, t);
const threshold = 0.5 + DITHER_BIAS[idx] * DITHER_STRENGTH;
if (v > threshold) bits |= 1 << idx;
}
}
return bits;
}
export interface DividerTeletextMosaicProps {
/** cell size in px; row height. Cell width is derived from monospace metrics. Default 14. */
cellSize?: number;
/** extra classes merged onto the rendered root element */
className?: string;
}
export function DividerTeletextMosaic({
cellSize = 14,
className = "",
}: DividerTeletextMosaicProps) {
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 = "currentColor";
let cellW = cellSize * 0.6;
const cellH = cellSize;
let cols = 0;
let sized = false;
let disposed = false;
// per-cell state: pattern (6-bit) and the timestamp (ms, component-local
// clock) it was last written, for the write-flash hairline
let patterns: Uint8Array[] = [];
let writeAt: Float32Array[] = [];
const readTokens = () => {
fg = getComputedStyle(canvas).color;
};
const allocRows = () => {
patterns = Array.from({ length: ROWS }, () => new Uint8Array(cols));
writeAt = Array.from({ length: ROWS }, () => new Float32Array(cols).fill(-Infinity));
};
// globalT: seconds, drives the generative field. Never resets — content
// keeps evolving pass over pass so no two page loads look identical.
let globalT = 0;
// step machine: stepIndex 0..ROWS-1 writes that row, stepIndex===ROWS is
// the sync pause; stepAcc accumulates ms toward the current step's
// duration, same accumulator discipline as the sibling VU divider.
let stepIndex = 0;
let stepAcc = 0;
const stepDuration = (i: number) => (i < ROWS ? ROW_INTERVAL_MS : PAUSE_MS);
const writeRow = (row: number) => {
if (!sized) return;
for (let c = 0; c < cols; c++) {
patterns[row][c] = subCellPattern(c, row, globalT);
writeAt[row][c] = globalT;
}
};
// full pass at increasing globalT, used both for the initial warm start
// (a page never opens as a blank cell grid) and after a resize (a new
// column count leaves nothing painted otherwise)
const warmPass = () => {
for (let r = 0; r < ROWS; r++) {
globalT += ROW_INTERVAL_MS / 1000;
writeRow(r);
}
stepIndex = 0;
stepAcc = 0;
};
const resize = () => {
const { width } = canvas.getBoundingClientRect();
const height = ROWS * cellH;
if (width < 2) {
sized = false;
return;
}
const dpr = Math.min(window.devicePixelRatio || 1, 2);
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);
const fontFamily = getComputedStyle(canvas).fontFamily;
const off = document.createElement("canvas").getContext("2d");
if (off) {
off.font = `${cellSize}px ${fontFamily}`;
cellW = Math.max(6, off.measureText("MMMMMMMMMM").width / 10);
}
cols = Math.max(8, Math.floor(width / cellW));
sized = true;
allocRows();
warmPass();
};
const draw = () => {
if (!sized) return;
ctx.clearRect(0, 0, cols * cellW + cellW, ROWS * cellH);
ctx.fillStyle = fg;
const subW = cellW / 2;
const subH = cellH / 3;
for (let r = 0; r < ROWS; r++) {
const rowPatterns = patterns[r];
const rowWriteAt = writeAt[r];
for (let c = 0; c < cols; c++) {
const bits = rowPatterns[c];
if (bits === 0) continue;
const x = c * cellW;
const y = r * cellH;
for (let subRow = 0; subRow < 3; subRow++) {
for (let subCol = 0; subCol < 2; subCol++) {
const idx = subRow * 2 + subCol;
if (!(bits & (1 << idx))) continue;
ctx.fillRect(
Math.round(x + subCol * subW),
Math.round(y + subRow * subH),
Math.ceil(subW) + 1,
Math.ceil(subH) + 1
);
}
}
if (!reduced) {
const age = (globalT - rowWriteAt[c]) * 1000;
if (age >= 0 && age < FLASH_MS) {
const alpha = 1 - age / FLASH_MS;
ctx.globalAlpha = alpha * 0.6;
ctx.fillRect(Math.round(x), Math.round(y), Math.ceil(cellW) + 1, 1);
ctx.globalAlpha = 1;
}
}
}
}
};
// -- loop ----------------------------------------------------------------
let raf = 0;
let last = 0;
const loop = (now: number) => {
const dtMs = last ? Math.min(250, now - last) : 1000 / 60;
last = now;
globalT += dtMs / 1000;
stepAcc += dtMs;
let guard = 0;
while (stepAcc >= stepDuration(stepIndex) && guard < ROWS + 1) {
stepAcc -= stepDuration(stepIndex);
if (stepIndex < ROWS) writeRow(stepIndex);
stepIndex = (stepIndex + 1) % (ROWS + 1);
guard++;
}
draw();
if (!document.hidden) raf = requestAnimationFrame(loop);
};
const mo = new MutationObserver(() => {
readTokens();
if (reduced) draw();
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
resize();
draw();
}, 150);
};
window.addEventListener("resize", onResize);
const onVis = () => {
cancelAnimationFrame(raf);
if (!document.hidden && !reduced && sized) {
last = 0;
raf = requestAnimationFrame(loop);
}
};
document.addEventListener("visibilitychange", onVis);
document.fonts.ready.then(() => {
if (disposed) return;
readTokens();
resize();
if (reduced) {
// freeze on a deliberately non-t0 frame: run the row-write sweep
// forward through one full extra pass past the warm start, so
// every row has been freshly rewritten at least twice and the
// mosaic is at its most legible full-page state — never the sync
// pause (which, while not empty, is the least eventful frame) and
// never t0's cold warm-fill.
for (let r = 0; r < ROWS; r++) {
globalT += ROW_INTERVAL_MS / 1000;
writeRow(r);
}
draw();
return;
}
draw();
raf = requestAnimationFrame(loop);
});
return () => {
disposed = true;
cancelAnimationFrame(raf);
if (resizeTimer) clearTimeout(resizeTimer);
mo.disconnect();
window.removeEventListener("resize", onResize);
document.removeEventListener("visibilitychange", onVis);
};
}, [cellSize]);
return (
<div
role="separator"
aria-orientation="horizontal"
className={`ns-dtm w-full font-mono ${className}`}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="block w-full text-foreground"
style={{ height: ROWS * cellSize }}
/>
</div>
);
}
Build spec
Build <DividerTeletextMosaic cellSize? className?> as a full-width <canvas> band, ROWS=6 cells tall, wrapped in a <div role="separator" aria-orientation="horizontal">, a drop-in replacement for <hr>/border-top between page sections. SOURCE, NOT INVENTED: real broadcast teletext (World System Teletext / CEEFAX-era decoders) never had a per-pixel framebuffer — each character cell holds a 6-bit code selecting one of 64 patterns across a fixed 2-wide x 3-tall sub-cell grid, addressed by position (top-left, top-right, mid-left, mid-right, bottom-left, bottom-right), the real 'alphamosaic' graphics mode of the standard. THE DISCRIMINATOR — read this before anything else: this is a genuinely different sub-cell grid from anything else in the registry. Quadrant-block dividers quantize a 2x2 sub-grid (four Unicode block-element combinations); braille components address a 2x4 dot grid. This is 2x3 — six sub-cells, a different real broadcast protocol's addressing, not a reskin of either. GLYPH ROUTE, STATED: Unicode does define a full sextant block (U+1FB00-U+1FB3B, Symbols for Legacy Computing), but this repo's mono stack (GeistMono / GeistMono Fallback / ui-monospace) does not reliably carry that range, so sub-cells are drawn as filled canvas rects directly rather than risking tofu from unverified glyph coverage — which is also what a real teletext decoder chip did: it lit sub-cell segments off the 6-bit code, it never asked a font for a glyph. CONTIGUOUS MOSAIC: sub-cells are drawn edge-to-edge with no gap between them, matching the real 'contiguous mosaic' mode broadcasters used for photo-like graphics (as opposed to 'separated mosaic', which insets a gap per block) — cellW is derived once via an offscreen canvas's measureText('MMMMMMMMMM').width / 10 in the canvas's own font family (the same house cell-metrics convention every grid-based ascii component in this repo uses), re-measured after document.fonts.ready; cellH is fixed to cellSize. CONTENT: each sub-cell's bit comes from a slow generative field — three non-commensurate traveling sine components summed over global sub-cell coordinates and the component's own continuously-advancing clock, normalized 0..1 with a 1.2 gamma — thresholded at 0.5 with a small per-sub-cell bias drawn from a scattered 2x3 ordered-dither table (spread +/-0.42, scaled by a 0.32 dither strength), the same discipline real videotex picture-mosaic dithering used to fake tone gradients out of a strictly binary 6-bit grid. ALIVE AT REST, THE MECHANIC: a real teletext page doesn't arrive all at once — it paints row by row as it's received off the broadcast VBI signal. A write cursor advances one row every 420ms, re-sampling the field into a fresh 6-bit pattern for every cell in that row and holding it there until the row's next turn; after all 6 rows have been rewritten, a 600ms sync pause elapses (page fully painted, untouched, never blank) before the sweep restarts from row 0 — the component's own clock never resets, so no two passes sample the same field state and the mosaic is never identical from one page-load to the next. The just-written row gets a brief top-edge hairline in --foreground fading from 60% alpha to 0 over 220ms — the one-frame flicker of a row actively being received — skipped entirely under prefers-reduced-motion. On mount, and after every resize (a new column count leaves nothing painted otherwise), a full warm pass writes every row once before the first paint so the opening frame is never a blank grid. TOKENS: fg is read once from getComputedStyle(canvas).color (driven by the .text-foreground class on the canvas) and re-derived on a documentElement class MutationObserver so a theme flip is live; the write-flash hairline modulates that same color's alpha only, never a second color. --border is never used as a fill or stroke (it measures under 1.2:1 against --background in light theme and would be structurally invisible as the mosaic's own ink); --ns-accent never appears — this is a resting divider with no interactive surface. prefers-reduced-motion freezes after running one additional full row-write pass past the warm start (every row rewritten at least twice, field well clear of its cold-start state), then draws once with no flash hairlines and no further updates — chosen because it's the mosaic at its most legible full-page state, never the sync pause (the least eventful frame, though not empty) and never t0's cold warm-fill. The render loop pauses on document.hidden and resumes cleanly on visibilitychange. Direct-DOM rAF, zero React state on the hot path, zero dependencies. A11Y: role=separator carries the divider's semantics with no accessible name required; the canvas is aria-hidden decoration; there is no keyboard surface because there is nothing to operate. Props: cellSize (row height px, default 14; the band is fixed at 6 rows, cell width derives from monospace metrics), className.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| cellSize? | number | 14 | cell size in px; row height. Cell width is derived from monospace metrics. Default 14. |
| className? | string | — | extra classes merged onto the rendered root element |