{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "avatar", "type": "registry:component", "title": "Dither Avatar", "description": "Generative mirrored pixel avatars in the ordered-dither texture — ~1.5 trillion combinations from a name, deterministic, with a hue override and a Bayer-ordered materialize entrance. Standalone: installs without the chart engine.", "author": "ripgrim", "categories": [ "avatars" ], "version": "0.1.0", "dependencies": [ "clsx", "tailwind-merge" ], "devDependencies": [], "registryDependencies": [], "files": [ { "path": "components/dither-kit/avatar.tsx", "type": "registry:component", "target": "components/dither-kit/avatar.tsx", "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"./lib\"\nimport { rgb } from \"./palette\"\nimport {\n BAYER4,\n clamp01,\n fnv1a,\n hueFill,\n type PixelBloom,\n pixelBloomStyle,\n pixelPrefersReducedMotion,\n xorshift32,\n} from \"./pixel\"\n\n// 8×8 cells, mirrored across one axis → 32 free pattern bits. With the mirror\n// axis bit and 180 hues that's 2^33 × 180 ≈ 1.5 trillion distinct avatars.\nconst GRID = 8\nconst CELL_PX = 4 // backing px per cell → a 32×32 canvas, scaled up pixelated\n\nexport type AvatarMirror = \"auto\" | \"horizontal\" | \"vertical\"\n\nexport type DitherAvatarProps = {\n /** The seed — same name, same avatar, every time. */\n name: string\n /** Hue override (0–360). Derived from the name when omitted. */\n hue?: number\n /** Mirror axis. \"auto\" picks one from the name — half the avatars fold\n * left/right, half fold top/bottom. */\n mirror?: AvatarMirror\n /** Square size in px. Omit to size via className (e.g. `size-12`). */\n size?: number\n /** Glow on the dither fill. */\n bloom?: PixelBloom\n /** Play the Bayer-ordered materialize entrance. */\n animate?: boolean\n animationDuration?: number\n /** Bump to replay the entrance. */\n replayToken?: number\n className?: string\n}\n\ntype AvatarModel = {\n on: boolean[] // GRID×GRID, row-major\n density: number[] // per-cell dither density for on cells\n fill: [number, number, number]\n}\n\n/**\n * Derive the full 8×8 cell grid from the name: 32 pattern bits + the mirror\n * axis + the hue + per-cell densities, all from one deterministic PRNG stream.\n * Every draw happens unconditionally so overriding `hue` or `mirror` never\n * shifts the pattern.\n */\nfunction avatarModel(\n name: string,\n hueProp: number | undefined,\n mirrorProp: AvatarMirror\n): AvatarModel {\n const rand = xorshift32(fnv1a(name))\n const bits = Array.from({ length: 32 }, () => rand() < 0.5)\n const drawnVertical = rand() < 0.5\n const drawnHue = Math.floor(rand() * 180) * 2\n const halfDensity = Array.from({ length: 32 }, () => 0.55 + rand() * 0.45)\n\n const vertical =\n mirrorProp === \"auto\" ? drawnVertical : mirrorProp === \"vertical\"\n const hue = hueProp ?? drawnHue\n\n const on = new Array(GRID * GRID)\n const density = new Array(GRID * GRID)\n for (let r = 0; r < GRID; r++) {\n for (let c = 0; c < GRID; c++) {\n // Fold across the chosen axis: left/right symmetric (\"horizontal\"\n // mirror) or top/bottom symmetric (\"vertical\").\n const i = vertical\n ? Math.min(r, GRID - 1 - r) * GRID + c\n : r * (GRID / 2) + Math.min(c, GRID - 1 - c)\n on[r * GRID + c] = bits[i]\n density[r * GRID + c] = halfDensity[i]\n }\n }\n return { on, density, fill: hueFill(hue) }\n}\n\n/**\n * Paint the avatar, optionally sweeping cells in with the Bayer-ordered\n * materialize entrance. Lives outside the component (same shape as the chart\n * canvases). Returns a cleanup that cancels the entrance loop.\n */\nfunction paintAvatar(\n canvas: HTMLCanvasElement,\n bloomCanvas: HTMLCanvasElement | null,\n model: AvatarModel,\n animate: boolean,\n duration: number\n): (() => void) | undefined {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return undefined\n const px = GRID * CELL_PX\n canvas.width = px\n canvas.height = px\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas) {\n bloomCanvas.width = px\n bloomCanvas.height = px\n }\n\n const draw = (progress: number) => {\n ctx.clearRect(0, 0, px, px)\n for (let r = 0; r < GRID; r++) {\n for (let c = 0; c < GRID; c++) {\n if (!model.on[r * GRID + c]) continue\n // Cells materialize in Bayer order — the entrance is made of the same\n // matrix as the texture.\n const start = BAYER4[r % 4][c % 4] * 0.7\n const cellAlpha = clamp01((progress - start) / 0.3)\n if (cellAlpha <= 0) continue\n const density = model.density[r * GRID + c]\n const base = 0.35 + 0.65 * density\n for (let py = 0; py < CELL_PX; py++) {\n for (let pxi = 0; pxi < CELL_PX; pxi++) {\n const gx = c * CELL_PX + pxi\n const gy = r * CELL_PX + py\n const lit = density > BAYER4[gy & 3][gx & 3]\n // On/off cells modulate alpha tiers of the one fill colour, so the\n // avatar holds up on light and dark backgrounds alike.\n const alpha = (lit ? base : base * 0.35) * cellAlpha\n ctx.fillStyle = rgb(model.fill, 1, alpha)\n ctx.fillRect(gx, gy, 1, 1)\n }\n }\n }\n }\n if (bloomCtx) {\n bloomCtx.clearRect(0, 0, px, px)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n }\n\n if (!animate || pixelPrefersReducedMotion()) {\n draw(1)\n return undefined\n }\n\n let raf = 0\n const startTime = performance.now()\n const tick = (now: number) => {\n const t = clamp01((now - startTime) / duration)\n draw(1 - (1 - t) ** 3)\n if (t < 1) raf = requestAnimationFrame(tick)\n }\n raf = requestAnimationFrame(tick)\n return () => cancelAnimationFrame(raf)\n}\n\n/**\n * Generative dithered avatar — a mirrored 8×8 pixel glyph derived from a name,\n * rendered with the ordered-dither texture the charts are made of. Same name,\n * same avatar; ~1.5 trillion combinations across pattern, mirror axis, and hue.\n */\nexport function DitherAvatar({\n name,\n hue,\n mirror = \"auto\",\n size,\n bloom = \"off\",\n animate = true,\n animationDuration = 600,\n replayToken = 0,\n className,\n}: DitherAvatarProps) {\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n return paintAvatar(\n canvas,\n bloomRef.current,\n avatarModel(name, hue, mirror),\n animate,\n animationDuration\n )\n }, [name, hue, mirror, animate, animationDuration, replayToken, bloom])\n\n const bloomStyle = pixelBloomStyle(bloom)\n\n return (\n \n \n {bloomStyle && (\n \n )}\n \n )\n}\n" }, { "path": "components/dither-kit/pixel.ts", "type": "registry:component", "target": "components/dither-kit/pixel.ts", "content": "// Standalone pixel primitives for the non-chart Dither Kit pieces (avatar,\n// gradient). Deliberately free of the chart engine so those items install\n// without `core` — only palette.ts is shared. The Bayer matrix and bloom\n// presets mirror dither-paint.ts so everything reads as one texture.\n\nimport { type DitherColor, PALETTE, type Rgb } from \"./palette\"\n\n// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds — the same matrix\n// the charts dither with.\nexport const BAYER4 = [\n [0, 8, 2, 10],\n [12, 4, 14, 6],\n [3, 11, 1, 9],\n [15, 7, 13, 5],\n].map((row) => row.map((v) => (v + 0.5) / 16))\n\nexport const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t)\n\n/** 32-bit FNV-1a hash — turns any string seed into a stable uint32. */\nexport function fnv1a(str: string): number {\n let h = 0x811c9dc5\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i)\n h = Math.imul(h, 0x01000193)\n }\n return h >>> 0\n}\n\n/** Tiny deterministic PRNG (xorshift32) — returns floats in [0, 1). */\nexport function xorshift32(seed: number): () => number {\n let s = seed || 0x9e3779b9\n return () => {\n s ^= s << 13\n s >>>= 0\n s ^= s >>> 17\n s ^= s << 5\n s >>>= 0\n return s / 0x100000000\n }\n}\n\n/** A named palette colour or a raw hue (0–360). */\nexport type PixelColor = DitherColor | number\n\n/** Hue (0–360) → an rgb fill tuned to sit alongside the chart palette. */\nexport function hueFill(hue: number): Rgb {\n const h = ((hue % 360) + 360) % 360\n const s = 0.85\n const l = 0.58\n const c = (1 - Math.abs(2 * l - 1)) * s\n const x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n const m = l - c / 2\n const [r, g, b] =\n h < 60\n ? [c, x, 0]\n : h < 120\n ? [x, c, 0]\n : h < 180\n ? [0, c, x]\n : h < 240\n ? [0, x, c]\n : h < 300\n ? [x, 0, c]\n : [c, 0, x]\n return [\n Math.round((r + m) * 255),\n Math.round((g + m) * 255),\n Math.round((b + m) * 255),\n ]\n}\n\n/** Resolve a {@link PixelColor} to its rgb fill. */\nexport function fillOf(color: PixelColor): Rgb {\n return typeof color === \"number\" ? hueFill(color) : PALETTE[color].fill\n}\n\n// Bloom — same recipe as the charts: a blurred copy of the crisp canvas,\n// composited additively so the glow stays in the dither's own colour.\nexport type PixelBloom = \"off\" | \"low\" | \"high\" | \"aura\"\n\nconst BLOOM_PRESET: Record<\n Exclude,\n { blur: number; brightness: number; opacity: number; saturate: number }\n> = {\n low: { blur: 3, brightness: 1.35, opacity: 0.7, saturate: 1.4 },\n high: { blur: 5, brightness: 1.5, opacity: 0.78, saturate: 1.5 },\n aura: { blur: 15, brightness: 2.9, opacity: 0.1, saturate: 3 },\n}\n\nexport type PixelBloomStyle = {\n filter: string\n opacity: number\n mixBlendMode: \"plus-lighter\"\n imageRendering: \"auto\"\n}\n\n/** Style for the bloom layer canvas. null when off. */\nexport function pixelBloomStyle(bloom: PixelBloom): PixelBloomStyle | null {\n if (bloom === \"off\") return null\n const cfg = BLOOM_PRESET[bloom]\n return {\n filter: `blur(${cfg.blur}px) brightness(${cfg.brightness}) saturate(${cfg.saturate})`,\n opacity: cfg.opacity,\n mixBlendMode: \"plus-lighter\",\n imageRendering: \"auto\",\n }\n}\n\n/** Whether the OS asks for reduced motion (skip entrances). */\nexport function pixelPrefersReducedMotion(): boolean {\n return (\n window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches ?? false\n )\n}\n" }, { "path": "components/dither-kit/palette.ts", "type": "registry:component", "target": "components/dither-kit/palette.ts", "content": "// Shared seed palette for the dither chart family. Mirrors the seeds in\n// `dither-chart.tsx` so a series rendered through the composable engine reads\n// with the exact same fill / line / star hues as the legacy sparkline.\n\nexport type Rgb = [number, number, number]\n\nexport type DitherColor =\n | \"green\"\n | \"blue\"\n | \"purple\"\n | \"pink\"\n | \"orange\"\n | \"red\"\n | \"grey\"\n\nexport type Seed = { fill: Rgb; line: Rgb; star: Rgb }\n\n// Each seed: the area-fill hue, the bright series line, and the star sparkle.\nexport const PALETTE: Record = {\n green: { fill: [40, 210, 110], line: [150, 255, 180], star: [200, 255, 220] },\n blue: { fill: [53, 143, 243], line: [150, 200, 255], star: [205, 228, 255] },\n purple: {\n fill: [150, 110, 255],\n line: [200, 175, 255],\n star: [225, 210, 255],\n },\n pink: { fill: [240, 90, 190], line: [255, 170, 220], star: [255, 205, 235] },\n orange: {\n fill: [255, 150, 50],\n line: [255, 195, 130],\n star: [255, 220, 175],\n },\n red: { fill: [240, 70, 70], line: [255, 150, 140], star: [255, 195, 185] },\n // No-data: a muted grey so empty metrics read as \"nothing here\".\n grey: { fill: [92, 92, 100], line: [140, 140, 150], star: [165, 165, 175] },\n}\n\nexport const rgb = ([r, g, b]: Rgb, k = 1, a = 1) =>\n `rgba(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)},${a})`\n\nexport const seedOfColor = (color: DitherColor): Seed => PALETTE[color]\n\nexport const isDitherColor = (value: unknown): value is DitherColor =>\n typeof value === \"string\" && value in PALETTE\n" }, { "path": "components/dither-kit/lib.ts", "type": "registry:component", "target": "components/dither-kit/lib.ts", "content": "import { type ClassValue, clsx } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\n/** Tailwind-aware className combiner — local copy so the chart pack is\n * self-contained and portable as a registry. */\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n" } ] }