diff --git a/r/dither-kit.json b/r/dither-kit.json index 4dacf2e..f08a21d 100644 --- a/r/dither-kit.json +++ b/r/dither-kit.json @@ -18,14 +18,15 @@ "https://tripwire.sh/r/radar-chart.json", "https://tripwire.sh/r/avatar.json", "https://tripwire.sh/r/button.json", - "https://tripwire.sh/r/gradient.json" + "https://tripwire.sh/r/gradient.json", + "https://tripwire.sh/r/icon.json" ], "files": [ { "path": "components/dither-kit/index.ts", "type": "registry:component", "target": "components/dither-kit/index.ts", - "content": "export { Area, type AreaProps, Line, type SeriesProps } from \"./area\"\nexport { AreaChart, type AreaChartProps, LineChart } from \"./area-chart\"\nexport {\n type AvatarMirror,\n DitherAvatar,\n type DitherAvatarProps,\n} from \"./avatar\"\nexport { Bar, type BarProps } from \"./bar\"\nexport { BarChart } from \"./bar-chart\"\nexport {\n type ButtonVariant,\n DitherButton,\n type DitherButtonProps,\n} from \"./button\"\nexport type { CartesianChartProps } from \"./cartesian-root\"\nexport type {\n AreaVariant,\n ChartConfig,\n ChartType,\n Margins,\n SeriesKind,\n StrokeVariant,\n} from \"./chart-context\"\nexport type {\n BloomBlend,\n BloomConfig,\n BloomInput,\n BloomLevel,\n} from \"./dither-paint\"\nexport { ActiveDot, Dot, type DotVariant } from \"./dot\"\nexport {\n DitherGradient,\n type DitherGradientProps,\n type GradientDirection,\n} from \"./gradient\"\nexport { Grid } from \"./grid\"\nexport { Legend } from \"./legend\"\nexport type { DitherColor } from \"./palette\"\nexport type { PixelBloom, PixelColor } from \"./pixel\"\nexport { Pie, type PieProps } from \"./pie\"\nexport { PieChart, type PieChartProps } from \"./pie-chart\"\nexport { Radar, type RadarProps } from \"./radar\"\nexport { RadarChart, type RadarChartProps } from \"./radar-chart\"\nexport type { StackType } from \"./scales\"\nexport { Sparkline, type SparklineProps } from \"./sparkline\"\nexport { Tooltip, type TooltipVariant } from \"./tooltip\"\nexport { XAxis } from \"./x-axis\"\nexport { YAxis } from \"./y-axis\"\n" + "content": "export { Area, type AreaProps, Line, type SeriesProps } from \"./area\"\nexport { AreaChart, type AreaChartProps, LineChart } from \"./area-chart\"\nexport {\n type AvatarMirror,\n DitherAvatar,\n type DitherAvatarProps,\n} from \"./avatar\"\nexport { Bar, type BarProps } from \"./bar\"\nexport { BarChart } from \"./bar-chart\"\nexport {\n type ButtonVariant,\n DitherButton,\n type DitherButtonProps,\n} from \"./button\"\nexport type { CartesianChartProps } from \"./cartesian-root\"\nexport type {\n AreaVariant,\n ChartConfig,\n ChartType,\n Margins,\n SeriesKind,\n StrokeVariant,\n} from \"./chart-context\"\nexport type {\n BloomBlend,\n BloomConfig,\n BloomInput,\n BloomLevel,\n} from \"./dither-paint\"\nexport { ActiveDot, Dot, type DotVariant } from \"./dot\"\nexport {\n DitherGradient,\n type DitherGradientProps,\n type GradientDirection,\n} from \"./gradient\"\nexport { Grid } from \"./grid\"\nexport { DitherIcon, type DitherIconProps } from \"./icon\"\nexport { Legend } from \"./legend\"\nexport type { DitherColor } from \"./palette\"\nexport type { PixelBloom, PixelColor } from \"./pixel\"\nexport { Pie, type PieProps } from \"./pie\"\nexport { PieChart, type PieChartProps } from \"./pie-chart\"\nexport { Radar, type RadarProps } from \"./radar\"\nexport { RadarChart, type RadarChartProps } from \"./radar-chart\"\nexport type { StackType } from \"./scales\"\nexport { Sparkline, type SparklineProps } from \"./sparkline\"\nexport { Tooltip, type TooltipVariant } from \"./tooltip\"\nexport { XAxis } from \"./x-axis\"\nexport { YAxis } from \"./y-axis\"\n" } ] } diff --git a/r/icon.json b/r/icon.json new file mode 100644 index 0000000..61b180b --- /dev/null +++ b/r/icon.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "icon", + "type": "registry:component", + "title": "Dither Icon", + "description": "SVG-to-dither compiler - takes any SVG icon component, rasterizes it and re-emits it through the ordered-dither texture, with the Bayer materialize entrance and a controlled progress for scroll-linked reveals. Standalone: installs without the chart engine.", + "author": "ripgrim", + "categories": [ + "icons" + ], + "version": "0.1.0", + "dependencies": [ + "clsx", + "tailwind-merge" + ], + "devDependencies": [], + "registryDependencies": [], + "files": [ + { + "path": "components/dither-kit/icon.tsx", + "type": "registry:component", + "target": "components/dither-kit/icon.tsx", + "content": "\"use client\"\n\nimport { type ComponentType, useEffect, useRef } from \"react\"\nimport { cn } from \"./lib\"\nimport { rgb, type Rgb } from \"./palette\"\nimport {\n BAYER4,\n clamp01,\n fillOf,\n type PixelBloom,\n type PixelColor,\n pixelBloomStyle,\n pixelPrefersReducedMotion,\n} from \"./pixel\"\n\nexport type DitherIconProps = {\n /** The SVG icon component to compile (e.g. any lucide-react icon). */\n icon: ComponentType<{ strokeWidth?: number }>\n color?: PixelColor\n /** Square size in px. */\n size?: number\n /** CSS px per dither cell — 1 is crisp, 2+ reads chunkier/pixel-art. */\n cell?: number\n /** Stroke width passed to the source icon — thicken for coarse cells. */\n strokeWidth?: number\n /** Glow on the dither fill. */\n bloom?: PixelBloom\n /** Play the Bayer-ordered materialize entrance. */\n animate?: boolean\n animationDuration?: number\n /** Controlled reveal (0–1), e.g. scroll-linked. Overrides `animate`. */\n progress?: number\n /** Bump to replay the entrance. */\n replayToken?: number\n /** Descriptive label, or null for decorative (aria-hidden). */\n ariaLabel?: string | null\n className?: string\n}\n\n/** Sampled alpha (0–255) → cell dither density. Below the floor the cell is\n * off; above it density spans ~0.37–0.85, so even solid fills keep the\n * texture (a density of 1 would paint every cell and lose the dither). */\nexport function alphaToDensity(alpha: number): number {\n if (alpha < 24) return 0\n return 0.35 + 0.5 * (alpha / 255)\n}\n\ntype IconModel = {\n density: number[] // cols×rows, row-major\n cols: number\n rows: number\n}\n\n/**\n * Rasterize the icon's `` at cell resolution and read the alpha channel\n * — the \"compile\" step. Colours don't matter (only coverage does), so\n * currentColor is pinned to an opaque ink before serializing.\n */\nfunction sampleSvg(\n svg: SVGElement,\n cols: number,\n rows: number,\n onModel: (model: IconModel) => void\n): () => void {\n const clone = svg.cloneNode(true) as SVGElement\n clone.setAttribute(\"xmlns\", \"http://www.w3.org/2000/svg\")\n const markup = new XMLSerializer()\n .serializeToString(clone)\n .replaceAll(\"currentColor\", \"#fff\")\n\n let cancelled = false\n const img = new Image()\n img.onload = () => {\n if (cancelled) return\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const sctx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!sctx) return\n sctx.drawImage(img, 0, 0, cols, rows)\n const data = sctx.getImageData(0, 0, cols, rows).data\n const density = new Array(cols * rows)\n for (let i = 0; i < cols * rows; i++) {\n density[i] = alphaToDensity(data[i * 4 + 3])\n }\n onModel({ density, cols, rows })\n }\n img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`\n return () => {\n cancelled = true\n }\n}\n\n/** Size the backing stores once per compiled model — resizing resets the\n * canvas, so the per-frame painter must never touch width/height. */\nfunction sizeIconCanvases(\n canvas: HTMLCanvasElement,\n bloomCanvas: HTMLCanvasElement | null,\n model: IconModel\n): void {\n canvas.width = model.cols\n canvas.height = model.rows\n if (bloomCanvas) {\n bloomCanvas.width = model.cols\n bloomCanvas.height = model.rows\n }\n}\n\n/** Paint one frame at reveal `progress`, cells materializing in Bayer order\n * (same entrance as the avatar/charts). */\nfunction paintIcon(\n canvas: HTMLCanvasElement,\n bloomCanvas: HTMLCanvasElement | null,\n model: IconModel,\n fill: Rgb,\n progress: number\n): void {\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n const { density, cols, rows } = model\n ctx.clearRect(0, 0, cols, rows)\n for (let r = 0; r < rows; r++) {\n for (let c = 0; c < cols; c++) {\n const d = density[r * cols + c]\n if (d <= 0) continue\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 lit = d > BAYER4[r & 3][c & 3]\n const alpha = (lit ? 0.35 + 0.65 * d : 0.12 * d) * cellAlpha\n ctx.fillStyle = rgb(fill, 1, alpha)\n ctx.fillRect(c, r, 1, 1)\n }\n }\n const bloomCtx = bloomCanvas?.getContext(\"2d\") ?? null\n if (bloomCanvas && bloomCtx) {\n bloomCtx.clearRect(0, 0, cols, rows)\n bloomCtx.drawImage(canvas, 0, 0)\n }\n}\n\n/**\n * SVG→dither compiler — takes any SVG icon component, rasterizes it at cell\n * resolution, and re-emits it through the ordered-dither texture the rest of\n * the kit is made of. Reveals with the Bayer materialize entrance, or under\n * a controlled `progress` for scroll-linked reveals.\n */\nexport function DitherIcon({\n icon: Icon,\n color = \"grey\",\n size = 24,\n cell = 1,\n strokeWidth = 2,\n bloom = \"off\",\n animate = true,\n animationDuration = 500,\n progress,\n replayToken = 0,\n ariaLabel = null,\n className,\n}: DitherIconProps) {\n const holderRef = useRef(null)\n const canvasRef = useRef(null)\n const bloomRef = useRef(null)\n const modelRef = useRef(null)\n // The sampling effect must not re-run when only `progress` scrubs — read it\n // through a ref there instead of listing it as a dep.\n const progressRef = useRef(progress)\n progressRef.current = progress\n\n // Compile: sample the hidden SVG, then reveal (entrance loop, controlled\n // progress, or a single full paint).\n useEffect(() => {\n const holder = holderRef.current\n const canvas = canvasRef.current\n const svg = holder?.querySelector(\"svg\")\n if (!holder || !canvas || !svg) return\n\n const fill = fillOf(color)\n const cells = Math.max(4, Math.round(size / cell))\n let raf = 0\n const cancelSample = sampleSvg(svg, cells, cells, (model) => {\n modelRef.current = model\n sizeIconCanvases(canvas, bloomRef.current, model)\n const controlled = progressRef.current\n if (controlled != null) {\n paintIcon(canvas, bloomRef.current, model, fill, clamp01(controlled))\n return\n }\n if (!animate || pixelPrefersReducedMotion()) {\n paintIcon(canvas, bloomRef.current, model, fill, 1)\n return\n }\n const start = performance.now()\n const tick = (now: number) => {\n // A controlled `progress` arriving mid-entrance takes over — stop the\n // loop instead of overpainting the host's frame.\n if (progressRef.current != null) return\n const t = clamp01((now - start) / animationDuration)\n paintIcon(canvas, bloomRef.current, model, fill, 1 - (1 - t) ** 3)\n if (t < 1) raf = requestAnimationFrame(tick)\n }\n raf = requestAnimationFrame(tick)\n })\n\n return () => {\n cancelSample()\n if (raf) cancelAnimationFrame(raf)\n }\n }, [Icon, color, size, cell, strokeWidth, bloom, animate, animationDuration, replayToken])\n\n // Controlled scrub: repaint on progress once the model is compiled.\n useEffect(() => {\n const canvas = canvasRef.current\n const model = modelRef.current\n if (progress == null || !canvas || !model) return\n paintIcon(canvas, bloomRef.current, model, fillOf(color), clamp01(progress))\n }, [progress, color])\n\n const bloomStyle = pixelBloomStyle(bloom)\n\n return (\n \n {/* Hidden source SVG — the thing we compile. */}\n \n \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" + } + ] +} diff --git a/r/registry.json b/r/registry.json index cf21fbb..0e46f23 100644 --- a/r/registry.json +++ b/r/registry.json @@ -366,6 +366,44 @@ } ] }, + { + "name": "icon", + "type": "registry:component", + "title": "Dither Icon", + "description": "SVG-to-dither compiler - takes any SVG icon component, rasterizes it and re-emits it through the ordered-dither texture, with the Bayer materialize entrance and a controlled progress for scroll-linked reveals. Standalone: installs without the chart engine.", + "categories": [ + "icons" + ], + "version": "0.1.0", + "dependencies": [ + "clsx", + "tailwind-merge" + ], + "devDependencies": [], + "registryDependencies": [], + "files": [ + { + "path": "components/dither-kit/icon.tsx", + "type": "registry:component", + "target": "components/dither-kit/icon.tsx" + }, + { + "path": "components/dither-kit/pixel.ts", + "type": "registry:component", + "target": "components/dither-kit/pixel.ts" + }, + { + "path": "components/dither-kit/palette.ts", + "type": "registry:component", + "target": "components/dither-kit/palette.ts" + }, + { + "path": "components/dither-kit/lib.ts", + "type": "registry:component", + "target": "components/dither-kit/lib.ts" + } + ] + }, { "name": "dither-kit", "type": "registry:component", @@ -384,7 +422,8 @@ "https://tripwire.sh/r/radar-chart.json", "https://tripwire.sh/r/avatar.json", "https://tripwire.sh/r/button.json", - "https://tripwire.sh/r/gradient.json" + "https://tripwire.sh/r/gradient.json", + "https://tripwire.sh/r/icon.json" ], "files": [ { diff --git a/registry.json b/registry.json index 65ab4f6..e09014a 100644 --- a/registry.json +++ b/registry.json @@ -366,6 +366,44 @@ } ] }, + { + "name": "icon", + "type": "registry:component", + "title": "Dither Icon", + "description": "SVG-to-dither compiler - takes any SVG icon component, rasterizes it and re-emits it through the ordered-dither texture, with the Bayer materialize entrance and a controlled progress for scroll-linked reveals. Standalone: installs without the chart engine.", + "categories": [ + "icons" + ], + "version": "0.1.0", + "dependencies": [ + "clsx", + "tailwind-merge" + ], + "devDependencies": [], + "registryDependencies": [], + "files": [ + { + "path": "registry/dither-kit/icon.tsx", + "type": "registry:component", + "target": "components/dither-kit/icon.tsx" + }, + { + "path": "registry/dither-kit/pixel.ts", + "type": "registry:component", + "target": "components/dither-kit/pixel.ts" + }, + { + "path": "registry/dither-kit/palette.ts", + "type": "registry:component", + "target": "components/dither-kit/palette.ts" + }, + { + "path": "registry/dither-kit/lib.ts", + "type": "registry:component", + "target": "components/dither-kit/lib.ts" + } + ] + }, { "name": "dither-kit", "type": "registry:component", @@ -384,7 +422,8 @@ "Boring-Software-Inc/dither-kit/radar-chart", "Boring-Software-Inc/dither-kit/avatar", "Boring-Software-Inc/dither-kit/button", - "Boring-Software-Inc/dither-kit/gradient" + "Boring-Software-Inc/dither-kit/gradient", + "Boring-Software-Inc/dither-kit/icon" ], "files": [ { diff --git a/registry/dither-kit/COMPONENTS.md b/registry/dither-kit/COMPONENTS.md index 72761de..cf029d7 100644 --- a/registry/dither-kit/COMPONENTS.md +++ b/registry/dither-kit/COMPONENTS.md @@ -36,6 +36,7 @@ npx shadcn@latest add @dither-kit/radar-chart npx shadcn@latest add @dither-kit/avatar # standalone, no core npx shadcn@latest add @dither-kit/button # standalone, no core npx shadcn@latest add @dither-kit/gradient # standalone, no core +npx shadcn@latest add @dither-kit/icon # standalone, no core npx shadcn@latest add @dither-kit/dither-kit # everything at once ``` @@ -127,6 +128,24 @@ None of these pull in the chart engine — they share only the pixel primitives // two-tone blend ``` +### Icon (SVG-to-dither compiler, standalone) + +```tsx +import { Wallet } from "lucide-react" + +// Takes any SVG icon component, rasterizes it at cell resolution, reads the +// alpha channel, and re-emits it through the ordered-dither texture with the +// Bayer materialize entrance. + + // chunky pixel-art + +// Controlled reveal for scroll-linked entrances - overrides `animate`. + +``` + +Decorative by default (`ariaLabel={null}` renders aria-hidden); pass a +string `ariaLabel` for `role="img"` semantics. + ## Props worth knowing - `variant`: `gradient` | `dotted` | `hatched` | `solid` (per series) diff --git a/registry/dither-kit/icon.tsx b/registry/dither-kit/icon.tsx new file mode 100644 index 0000000..f0233ef --- /dev/null +++ b/registry/dither-kit/icon.tsx @@ -0,0 +1,252 @@ +"use client" + +import { type ComponentType, useEffect, useRef } from "react" +import { cn } from "./lib" +import { rgb, type Rgb } from "./palette" +import { + BAYER4, + clamp01, + fillOf, + type PixelBloom, + type PixelColor, + pixelBloomStyle, + pixelPrefersReducedMotion, +} from "./pixel" + +export type DitherIconProps = { + /** The SVG icon component to compile (e.g. any lucide-react icon). */ + icon: ComponentType<{ strokeWidth?: number }> + color?: PixelColor + /** Square size in px. */ + size?: number + /** CSS px per dither cell — 1 is crisp, 2+ reads chunkier/pixel-art. */ + cell?: number + /** Stroke width passed to the source icon — thicken for coarse cells. */ + strokeWidth?: number + /** Glow on the dither fill. */ + bloom?: PixelBloom + /** Play the Bayer-ordered materialize entrance. */ + animate?: boolean + animationDuration?: number + /** Controlled reveal (0–1), e.g. scroll-linked. Overrides `animate`. */ + progress?: number + /** Bump to replay the entrance. */ + replayToken?: number + /** Descriptive label, or null for decorative (aria-hidden). */ + ariaLabel?: string | null + className?: string +} + +/** Sampled alpha (0–255) → cell dither density. Below the floor the cell is + * off; above it density spans ~0.37–0.85, so even solid fills keep the + * texture (a density of 1 would paint every cell and lose the dither). */ +export function alphaToDensity(alpha: number): number { + if (alpha < 24) return 0 + return 0.35 + 0.5 * (alpha / 255) +} + +type IconModel = { + density: number[] // cols×rows, row-major + cols: number + rows: number +} + +/** + * Rasterize the icon's `` at cell resolution and read the alpha channel + * — the "compile" step. Colours don't matter (only coverage does), so + * currentColor is pinned to an opaque ink before serializing. + */ +function sampleSvg( + svg: SVGElement, + cols: number, + rows: number, + onModel: (model: IconModel) => void +): () => void { + const clone = svg.cloneNode(true) as SVGElement + clone.setAttribute("xmlns", "http://www.w3.org/2000/svg") + const markup = new XMLSerializer() + .serializeToString(clone) + .replaceAll("currentColor", "#fff") + + let cancelled = false + const img = new Image() + img.onload = () => { + if (cancelled) return + const sampler = document.createElement("canvas") + sampler.width = cols + sampler.height = rows + const sctx = sampler.getContext("2d", { willReadFrequently: true }) + if (!sctx) return + sctx.drawImage(img, 0, 0, cols, rows) + const data = sctx.getImageData(0, 0, cols, rows).data + const density = new Array(cols * rows) + for (let i = 0; i < cols * rows; i++) { + density[i] = alphaToDensity(data[i * 4 + 3]) + } + onModel({ density, cols, rows }) + } + img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}` + return () => { + cancelled = true + } +} + +/** Size the backing stores once per compiled model — resizing resets the + * canvas, so the per-frame painter must never touch width/height. */ +function sizeIconCanvases( + canvas: HTMLCanvasElement, + bloomCanvas: HTMLCanvasElement | null, + model: IconModel +): void { + canvas.width = model.cols + canvas.height = model.rows + if (bloomCanvas) { + bloomCanvas.width = model.cols + bloomCanvas.height = model.rows + } +} + +/** Paint one frame at reveal `progress`, cells materializing in Bayer order + * (same entrance as the avatar/charts). */ +function paintIcon( + canvas: HTMLCanvasElement, + bloomCanvas: HTMLCanvasElement | null, + model: IconModel, + fill: Rgb, + progress: number +): void { + const ctx = canvas.getContext("2d") + if (!ctx) return + const { density, cols, rows } = model + ctx.clearRect(0, 0, cols, rows) + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const d = density[r * cols + c] + if (d <= 0) continue + const start = BAYER4[r % 4][c % 4] * 0.7 + const cellAlpha = clamp01((progress - start) / 0.3) + if (cellAlpha <= 0) continue + const lit = d > BAYER4[r & 3][c & 3] + const alpha = (lit ? 0.35 + 0.65 * d : 0.12 * d) * cellAlpha + ctx.fillStyle = rgb(fill, 1, alpha) + ctx.fillRect(c, r, 1, 1) + } + } + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas && bloomCtx) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } +} + +/** + * SVG→dither compiler — takes any SVG icon component, rasterizes it at cell + * resolution, and re-emits it through the ordered-dither texture the rest of + * the kit is made of. Reveals with the Bayer materialize entrance, or under + * a controlled `progress` for scroll-linked reveals. + */ +export function DitherIcon({ + icon: Icon, + color = "grey", + size = 24, + cell = 1, + strokeWidth = 2, + bloom = "off", + animate = true, + animationDuration = 500, + progress, + replayToken = 0, + ariaLabel = null, + className, +}: DitherIconProps) { + const holderRef = useRef(null) + const canvasRef = useRef(null) + const bloomRef = useRef(null) + const modelRef = useRef(null) + // The sampling effect must not re-run when only `progress` scrubs — read it + // through a ref there instead of listing it as a dep. + const progressRef = useRef(progress) + progressRef.current = progress + + // Compile: sample the hidden SVG, then reveal (entrance loop, controlled + // progress, or a single full paint). + useEffect(() => { + const holder = holderRef.current + const canvas = canvasRef.current + const svg = holder?.querySelector("svg") + if (!holder || !canvas || !svg) return + + const fill = fillOf(color) + const cells = Math.max(4, Math.round(size / cell)) + let raf = 0 + const cancelSample = sampleSvg(svg, cells, cells, (model) => { + modelRef.current = model + sizeIconCanvases(canvas, bloomRef.current, model) + const controlled = progressRef.current + if (controlled != null) { + paintIcon(canvas, bloomRef.current, model, fill, clamp01(controlled)) + return + } + if (!animate || pixelPrefersReducedMotion()) { + paintIcon(canvas, bloomRef.current, model, fill, 1) + return + } + const start = performance.now() + const tick = (now: number) => { + // A controlled `progress` arriving mid-entrance takes over — stop the + // loop instead of overpainting the host's frame. + if (progressRef.current != null) return + const t = clamp01((now - start) / animationDuration) + paintIcon(canvas, bloomRef.current, model, fill, 1 - (1 - t) ** 3) + if (t < 1) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + }) + + return () => { + cancelSample() + if (raf) cancelAnimationFrame(raf) + } + }, [Icon, color, size, cell, strokeWidth, bloom, animate, animationDuration, replayToken]) + + // Controlled scrub: repaint on progress once the model is compiled. + useEffect(() => { + const canvas = canvasRef.current + const model = modelRef.current + if (progress == null || !canvas || !model) return + paintIcon(canvas, bloomRef.current, model, fillOf(color), clamp01(progress)) + }, [progress, color]) + + const bloomStyle = pixelBloomStyle(bloom) + + return ( + + {/* Hidden source SVG — the thing we compile. */} + + + + + {bloomStyle && ( + + )} + + ) +} diff --git a/registry/dither-kit/index.ts b/registry/dither-kit/index.ts index c7b3548..2917a6d 100644 --- a/registry/dither-kit/index.ts +++ b/registry/dither-kit/index.ts @@ -34,6 +34,7 @@ export { type GradientDirection, } from "./gradient" export { Grid } from "./grid" +export { DitherIcon, type DitherIconProps } from "./icon" export { Legend } from "./legend" export type { DitherColor } from "./palette" export type { PixelBloom, PixelColor } from "./pixel" diff --git a/scripts/build-registry.mts b/scripts/build-registry.mts index f13c9a3..100a198 100644 --- a/scripts/build-registry.mts +++ b/scripts/build-registry.mts @@ -199,6 +199,17 @@ const ITEMS: ItemDef[] = [ dependencies: ["clsx", "tailwind-merge"], devDependencies: [], }, + { + name: "icon", + title: "Dither Icon", + description: + "SVG-to-dither compiler - takes any SVG icon component, rasterizes it and re-emits it through the ordered-dither texture, with the Bayer materialize entrance and a controlled progress for scroll-linked reveals. Standalone: installs without the chart engine.", + categories: ["icons"], + files: ["icon.tsx", "pixel.ts", "palette.ts", "lib.ts"], + registryDependencies: [], + dependencies: ["clsx", "tailwind-merge"], + devDependencies: [], + }, { name: "dither-kit", title: "Dither Kit — Everything", @@ -216,6 +227,7 @@ const ITEMS: ItemDef[] = [ `${NS}/avatar`, `${NS}/button`, `${NS}/gradient`, + `${NS}/icon`, ], dependencies: [], devDependencies: [], diff --git a/tests/icon.test.tsx b/tests/icon.test.tsx new file mode 100644 index 0000000..9abd1f4 --- /dev/null +++ b/tests/icon.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest" +import { render } from "@testing-library/react" +import { alphaToDensity, DitherIcon } from "../registry/dither-kit/icon" + +// A minimal SVG icon component — no icon-library dependency in the suite. +function Glyph({ strokeWidth = 2 }: { strokeWidth?: number }) { + return ( + + + + ) +} + +describe("alphaToDensity", () => { + it("cuts below the floor", () => { + expect(alphaToDensity(0)).toBe(0) + expect(alphaToDensity(23)).toBe(0) + }) + + it("never saturates to a solid fill — texture survives full coverage", () => { + expect(alphaToDensity(255)).toBeCloseTo(0.85) + expect(alphaToDensity(255)).toBeLessThan(1) + }) + + it("scales monotonically with coverage", () => { + expect(alphaToDensity(64)).toBeLessThan(alphaToDensity(128)) + expect(alphaToDensity(128)).toBeLessThan(alphaToDensity(255)) + }) +}) + +describe("DitherIcon", () => { + it("is decorative (aria-hidden) by default", () => { + const { container } = render() + const el = container.firstElementChild + expect(el?.getAttribute("aria-hidden")).toBe("true") + expect(el?.hasAttribute("role")).toBe(false) + }) + + it("exposes role=img when labeled", () => { + const { container } = render( + + ) + const el = container.querySelector('[role="img"]') + expect(el?.getAttribute("aria-label")).toBe("crosshair") + }) + + it("renders the hidden source SVG it compiles from", () => { + const { container } = render() + expect(container.querySelector("svg")).not.toBeNull() + expect(container.querySelector("canvas")).not.toBeNull() + }) +})