From 72ee46010479f8e7099344714de4c4742bcfe6f3 Mon Sep 17 00:00:00 2001 From: Daniel Holdsworth Date: Mon, 13 Jul 2026 09:17:00 +0100 Subject: [PATCH 1/2] feat: dither-kit generative avatars + gradient washes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two standalone dither-kit pieces (no chart-engine dependency): - DitherAvatar — mirrored 8x8 generative pixel avatars, deterministic from a name (~1.5T combos across pattern, mirror axis, and hue), with a hue override and a Bayer-ordered materialize entrance. - DitherGradient — ordered-dither background washes that dissolve to transparent or dither-blend two colours, any direction. Adds the avatar/gradient items to the tracked /r registry route and a 'beyond charts' docs section with a hue-selector playground and a footer-wash demo. --- .../src/components/dither-kit-docs/content.ts | 35 +++ .../components/dither-kit-docs/sections.tsx | 204 ++++++++++++++++- apps/web/src/components/dither-kit/README.md | 20 ++ apps/web/src/components/dither-kit/avatar.tsx | 212 ++++++++++++++++++ .../src/components/dither-kit/gradient.tsx | 169 ++++++++++++++ apps/web/src/components/dither-kit/index.ts | 11 + apps/web/src/components/dither-kit/pixel.ts | 114 ++++++++++ apps/web/src/routes/dither-kit.tsx | 2 + apps/web/src/routes/r/$name.ts | 5 +- 9 files changed, 770 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/dither-kit/avatar.tsx create mode 100644 apps/web/src/components/dither-kit/gradient.tsx create mode 100644 apps/web/src/components/dither-kit/pixel.ts diff --git a/apps/web/src/components/dither-kit-docs/content.ts b/apps/web/src/components/dither-kit-docs/content.ts index 3545de9..3de406d 100644 --- a/apps/web/src/components/dither-kit-docs/content.ts +++ b/apps/web/src/components/dither-kit-docs/content.ts @@ -66,6 +66,10 @@ Other charts install the same way: bar-chart, pie-chart, radar-chart (or ${regIt - color: green blue purple pink orange red grey - bloom: off | low | high | aura +Two standalone extras (no chart engine, tiny installs): +- avatar — generative mirrored pixel avatars: (optional hue={0-360}) +- gradient — dithered background washes: inside a relative container + Add one chart where it fits what I'm building. Keep it minimal — don't scaffold a new page or demo unless I ask.` /* ------------------------------------------------------------------ data */ @@ -204,6 +208,37 @@ export const radarCode = (t: Tweaks): string => ` +/** Demo state for the avatar playground. */ +export type AvatarTweaks = { + name: string + hue: number | null // null = derived from the name +} + +export const avatarCode = (t: AvatarTweaks): string => + `// deterministic — same name, same avatar, ~1.5T combinations + + +// a team row — mirror axis and hue fall out of each name +{members.map((m) => ( + +))}` + +/** Demo state for the gradient playground. */ +export type GradientTweaks = { + from: string + to: string | null // null = transparent + direction: "up" | "down" | "left" | "right" +} + +export const gradientCode = (t: GradientTweaks): string => + `// fills its nearest relative ancestor — footers, section fades, cards +
+ +

…footer content…

+
` + export const PROPS: [string, string][] = [ ["variant", '"gradient" | "dotted" | "hatched" | "solid"'], [ diff --git a/apps/web/src/components/dither-kit-docs/sections.tsx b/apps/web/src/components/dither-kit-docs/sections.tsx index 44caca9..bec8953 100644 --- a/apps/web/src/components/dither-kit-docs/sections.tsx +++ b/apps/web/src/components/dither-kit-docs/sections.tsx @@ -1,6 +1,7 @@ "use client" import { MoonIcon, SparklesIcon, SunIcon } from "lucide-react" +import { useState } from "react" import { ActiveDot, Area, @@ -8,6 +9,10 @@ import { Bar, BarChart, type BloomInput, + DitherAvatar, + type DitherColor, + DitherGradient, + type GradientDirection, Legend, Line, LineChart, @@ -22,8 +27,10 @@ import { import { addCmd, areaCode, + avatarCode, barCode, config, + gradientCode, lineCode, pieCode, pieConfig, @@ -88,7 +95,8 @@ export function HeroSection({ dither-kit

- five chart types on one tiny canvas engine, no recharts + five chart types, generative avatars, and gradient washes on one + tiny canvas engine — no recharts

+ ))} + + + + ) +} + +const GRADIENT_COLORS: DitherColor[] = [ + "purple", + "blue", + "green", + "pink", + "orange", + "red", +] +const DIRECTIONS: GradientDirection[] = ["up", "down", "left", "right"] + +function GradientShowcase({ pm }: { pm: Pm }) { + const [from, setFrom] = useState("purple") + const [to, setTo] = useState(null) + const [direction, setDirection] = useState("up") + + return ( + +
+
+
+ {DIRECTIONS.map((d) => ( + setDirection(d)} + /> + ))} +
+
+ + from + + {GRADIENT_COLORS.map((c) => ( + setFrom(c)} + /> + ))} +
+
+ + to + + setTo(null)} + /> + setTo("blue")} + /> + setTo("pink")} + /> +
+
+
+ +
+ + your footer + + + the wash fills whatever relative container it sits in + +
+
+
+
+ ) +} + +export function ExtrasSection({ pm }: { pm: Pm }) { + return ( +
+
+

+ beyond charts +

+ + + standalone — neither pulls in the chart engine + +
+ + +
+ ) +} + export function KnobsSection() { return (
diff --git a/apps/web/src/components/dither-kit/README.md b/apps/web/src/components/dither-kit/README.md index 29353b3..82884a6 100644 --- a/apps/web/src/components/dither-kit/README.md +++ b/apps/web/src/components/dither-kit/README.md @@ -33,6 +33,8 @@ npx shadcn@latest add @dither-kit/area-chart # area + line (+ Sparkline) npx shadcn@latest add @dither-kit/bar-chart npx shadcn@latest add @dither-kit/pie-chart npx shadcn@latest add @dither-kit/radar-chart +npx shadcn@latest add @dither-kit/avatar # standalone, no core +npx shadcn@latest add @dither-kit/gradient # standalone, no core npx shadcn@latest add @dither-kit/dither-kit # everything at once ``` @@ -101,6 +103,24 @@ Swap `AreaChart` → `LineChart` / `BarChart` (and `Area` → `Line` / ``` +### Avatar & gradient (standalone) + +Neither pulls in the chart engine — they share only the pixel primitives +(`pixel.ts`) and the palette. + +```tsx +// Deterministic from the name: 32 mirrored pattern bits × 2 mirror axes × +// 180 hues ≈ 1.5 trillion avatars. Half fold left/right, half top/bottom. + + // hue override (0–360) + +// A dithered wash filling its nearest relative ancestor — footers, fades. +
+ +
+ // two-tone blend +``` + ## Props worth knowing - `variant`: `gradient` | `dotted` | `hatched` | `solid` (per series) diff --git a/apps/web/src/components/dither-kit/avatar.tsx b/apps/web/src/components/dither-kit/avatar.tsx new file mode 100644 index 0000000..04de93f --- /dev/null +++ b/apps/web/src/components/dither-kit/avatar.tsx @@ -0,0 +1,212 @@ +"use client" + +import { useEffect, useRef } from "react" +import { cn } from "./lib" +import { rgb } from "./palette" +import { + BAYER4, + clamp01, + fnv1a, + hueFill, + type PixelBloom, + pixelBloomStyle, + pixelPrefersReducedMotion, + xorshift32, +} from "./pixel" + +// 8×8 cells, mirrored across one axis → 32 free pattern bits. With the mirror +// axis bit and 180 hues that's 2^33 × 180 ≈ 1.5 trillion distinct avatars. +const GRID = 8 +const CELL_PX = 4 // backing px per cell → a 32×32 canvas, scaled up pixelated + +export type AvatarMirror = "auto" | "horizontal" | "vertical" + +export type DitherAvatarProps = { + /** The seed — same name, same avatar, every time. */ + name: string + /** Hue override (0–360). Derived from the name when omitted. */ + hue?: number + /** Mirror axis. "auto" picks one from the name — half the avatars fold + * left/right, half fold top/bottom. */ + mirror?: AvatarMirror + /** Square size in px. Omit to size via className (e.g. `size-12`). */ + size?: number + /** Glow on the dither fill. */ + bloom?: PixelBloom + /** Play the Bayer-ordered materialize entrance. */ + animate?: boolean + animationDuration?: number + /** Bump to replay the entrance. */ + replayToken?: number + className?: string +} + +type AvatarModel = { + on: boolean[] // GRID×GRID, row-major + density: number[] // per-cell dither density for on cells + fill: [number, number, number] +} + +/** + * Derive the full 8×8 cell grid from the name: 32 pattern bits + the mirror + * axis + the hue + per-cell densities, all from one deterministic PRNG stream. + * Every draw happens unconditionally so overriding `hue` or `mirror` never + * shifts the pattern. + */ +function avatarModel( + name: string, + hueProp: number | undefined, + mirrorProp: AvatarMirror +): AvatarModel { + const rand = xorshift32(fnv1a(name)) + const bits = Array.from({ length: 32 }, () => rand() < 0.5) + const drawnVertical = rand() < 0.5 + const drawnHue = Math.floor(rand() * 180) * 2 + const halfDensity = Array.from({ length: 32 }, () => 0.55 + rand() * 0.45) + + const vertical = + mirrorProp === "auto" ? drawnVertical : mirrorProp === "vertical" + const hue = hueProp ?? drawnHue + + const on = new Array(GRID * GRID) + const density = new Array(GRID * GRID) + for (let r = 0; r < GRID; r++) { + for (let c = 0; c < GRID; c++) { + // Fold across the chosen axis: left/right symmetric ("horizontal" + // mirror) or top/bottom symmetric ("vertical"). + const i = vertical + ? Math.min(r, GRID - 1 - r) * GRID + c + : r * (GRID / 2) + Math.min(c, GRID - 1 - c) + on[r * GRID + c] = bits[i] + density[r * GRID + c] = halfDensity[i] + } + } + return { on, density, fill: hueFill(hue) } +} + +/** + * Paint the avatar, optionally sweeping cells in with the Bayer-ordered + * materialize entrance. Lives outside the component (same shape as the chart + * canvases). Returns a cleanup that cancels the entrance loop. + */ +function paintAvatar( + canvas: HTMLCanvasElement, + bloomCanvas: HTMLCanvasElement | null, + model: AvatarModel, + animate: boolean, + duration: number +): (() => void) | undefined { + const ctx = canvas.getContext("2d") + if (!ctx) return undefined + const px = GRID * CELL_PX + canvas.width = px + canvas.height = px + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = px + bloomCanvas.height = px + } + + const draw = (progress: number) => { + ctx.clearRect(0, 0, px, px) + for (let r = 0; r < GRID; r++) { + for (let c = 0; c < GRID; c++) { + if (!model.on[r * GRID + c]) continue + // Cells materialize in Bayer order — the entrance is made of the same + // matrix as the texture. + const start = BAYER4[r % 4][c % 4] * 0.7 + const cellAlpha = clamp01((progress - start) / 0.3) + if (cellAlpha <= 0) continue + const density = model.density[r * GRID + c] + const base = 0.35 + 0.65 * density + for (let py = 0; py < CELL_PX; py++) { + for (let pxi = 0; pxi < CELL_PX; pxi++) { + const gx = c * CELL_PX + pxi + const gy = r * CELL_PX + py + const lit = density > BAYER4[gy & 3][gx & 3] + // On/off cells modulate alpha tiers of the one fill colour, so the + // avatar holds up on light and dark backgrounds alike. + const alpha = (lit ? base : base * 0.35) * cellAlpha + ctx.fillStyle = rgb(model.fill, 1, alpha) + ctx.fillRect(gx, gy, 1, 1) + } + } + } + } + if (bloomCtx) { + bloomCtx.clearRect(0, 0, px, px) + bloomCtx.drawImage(canvas, 0, 0) + } + } + + if (!animate || pixelPrefersReducedMotion()) { + draw(1) + return undefined + } + + let raf = 0 + const startTime = performance.now() + const tick = (now: number) => { + const t = clamp01((now - startTime) / duration) + draw(1 - (1 - t) ** 3) + if (t < 1) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) +} + +/** + * Generative dithered avatar — a mirrored 8×8 pixel glyph derived from a name, + * rendered with the ordered-dither texture the charts are made of. Same name, + * same avatar; ~1.5 trillion combinations across pattern, mirror axis, and hue. + */ +export function DitherAvatar({ + name, + hue, + mirror = "auto", + size, + bloom = "off", + animate = true, + animationDuration = 600, + replayToken = 0, + className, +}: DitherAvatarProps) { + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + return paintAvatar( + canvas, + bloomRef.current, + avatarModel(name, hue, mirror), + animate, + animationDuration + ) + }, [name, hue, mirror, animate, animationDuration, replayToken, bloom]) + + const bloomStyle = pixelBloomStyle(bloom) + + return ( +
+ + {bloomStyle && ( + + )} +
+ ) +} diff --git a/apps/web/src/components/dither-kit/gradient.tsx b/apps/web/src/components/dither-kit/gradient.tsx new file mode 100644 index 0000000..2eb967b --- /dev/null +++ b/apps/web/src/components/dither-kit/gradient.tsx @@ -0,0 +1,169 @@ +"use client" + +import { useEffect, useRef } from "react" +import { cn } from "./lib" +import { rgb } from "./palette" +import { + BAYER4, + fillOf, + type PixelBloom, + type PixelColor, + pixelBloomStyle, +} from "./pixel" + +// Backing-resolution caps — a background wash never needs more cells than this. +const MAX_COLS = 960 +const MAX_ROWS = 600 + +export type GradientDirection = "up" | "down" | "left" | "right" + +export type DitherGradientProps = { + /** The colour the gradient starts solid as — a palette name or a hue. */ + from: PixelColor + /** What it dissolves into: another colour for a two-tone dither blend, or + * "transparent" (default) so the background shows through. */ + to?: PixelColor | "transparent" + /** Where `to` ends up — "up" reads as a glow rising from the bottom edge. */ + direction?: GradientDirection + /** CSS px per dither cell — bigger is chunkier. */ + cell?: number + /** Overall opacity multiplier. */ + opacity?: number + /** Glow on the dither fill. */ + bloom?: PixelBloom + className?: string +} + +type PaintSpec = { + from: PixelColor + to: PixelColor | "transparent" + direction: GradientDirection + cell: number + opacity: number +} + +/** + * Paint the ordered-dither ramp onto a low-res backing canvas sized from the + * wrapper's box. Static — one paint per prop/size change, no animation loop, + * so it's free to use as a page-wide background. + */ +function paintGradient( + canvas: HTMLCanvasElement, + bloomCanvas: HTMLCanvasElement | null, + width: number, + height: number, + spec: PaintSpec +): void { + const ctx = canvas.getContext("2d") + if (!ctx || width <= 0 || height <= 0) return + const cols = Math.min(MAX_COLS, Math.max(4, Math.round(width / spec.cell))) + const rows = Math.min(MAX_ROWS, Math.max(4, Math.round(height / spec.cell))) + canvas.width = cols + canvas.height = rows + + const fromFill = fillOf(spec.from) + const toFill = spec.to === "transparent" ? null : fillOf(spec.to) + const o = spec.opacity + + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + // t runs 0 at the `from` edge → 1 at the `to` edge. + const t = + spec.direction === "up" + ? 1 - (y + 0.5) / rows + : spec.direction === "down" + ? (y + 0.5) / rows + : spec.direction === "left" + ? 1 - (x + 0.5) / cols + : (x + 0.5) / cols + const density = 1 - t + const lit = density > BAYER4[y & 3][x & 3] + if (toFill) { + // Two-tone: every cell is painted, the dither decides which colour. + ctx.fillStyle = rgb(lit ? fromFill : toFill, 1, o) + ctx.fillRect(x, y, 1, 1) + } else { + // Dissolve to transparent: lit cells carry the ramp, off cells keep a + // faint tint that also fades out, so the falloff reads smooth. + const alpha = (lit ? 0.35 + 0.65 * density : 0.12 * density) * o + if (alpha <= 0.004) continue + ctx.fillStyle = rgb(fromFill, 1, alpha) + ctx.fillRect(x, y, 1, 1) + } + } + } + + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas && bloomCtx) { + bloomCanvas.width = cols + bloomCanvas.height = rows + bloomCtx.drawImage(canvas, 0, 0) + } +} + +/** + * Dithered gradient wash — the charts' ordered-dither texture as a background. + * Fills its nearest positioned ancestor (footer glows, section fades, card + * backdrops). Dissolves to transparent by default, or dither-blends between + * two colours when `to` is set. + */ +export function DitherGradient({ + from, + to = "transparent", + direction = "up", + cell = 3, + opacity = 1, + bloom = "off", + className, +}: DitherGradientProps) { + const wrapRef = useRef(null) + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + useEffect(() => { + const wrap = wrapRef.current + const canvas = canvasRef.current + if (!wrap || !canvas) return + const paint = () => { + const box = wrap.getBoundingClientRect() + paintGradient(canvas, bloomRef.current, box.width, box.height, { + from, + to, + direction, + cell, + opacity, + }) + } + paint() + if (typeof ResizeObserver === "undefined") return + const ro = new ResizeObserver(paint) + ro.observe(wrap) + return () => ro.disconnect() + }, [from, to, direction, cell, opacity, bloom]) + + const bloomStyle = pixelBloomStyle(bloom) + + return ( +
+ + {bloomStyle && ( + + )} +
+ ) +} diff --git a/apps/web/src/components/dither-kit/index.ts b/apps/web/src/components/dither-kit/index.ts index d0b34bb..1c81f25 100644 --- a/apps/web/src/components/dither-kit/index.ts +++ b/apps/web/src/components/dither-kit/index.ts @@ -1,5 +1,10 @@ export { Area, type AreaProps, Line, type SeriesProps } from "./area" export { AreaChart, type AreaChartProps, LineChart } from "./area-chart" +export { + type AvatarMirror, + DitherAvatar, + type DitherAvatarProps, +} from "./avatar" export { Bar, type BarProps } from "./bar" export { BarChart } from "./bar-chart" export type { CartesianChartProps } from "./cartesian-root" @@ -18,9 +23,15 @@ export type { BloomLevel, } from "./dither-paint" export { ActiveDot, Dot, type DotVariant } from "./dot" +export { + DitherGradient, + type DitherGradientProps, + type GradientDirection, +} from "./gradient" export { Grid } from "./grid" export { Legend } from "./legend" export type { DitherColor } from "./palette" +export type { PixelBloom, PixelColor } from "./pixel" export { Pie, type PieProps } from "./pie" export { PieChart, type PieChartProps } from "./pie-chart" export { Radar, type RadarProps } from "./radar" diff --git a/apps/web/src/components/dither-kit/pixel.ts b/apps/web/src/components/dither-kit/pixel.ts new file mode 100644 index 0000000..6403271 --- /dev/null +++ b/apps/web/src/components/dither-kit/pixel.ts @@ -0,0 +1,114 @@ +// Standalone pixel primitives for the non-chart Dither Kit pieces (avatar, +// gradient). Deliberately free of the chart engine so those items install +// without `core` — only palette.ts is shared. The Bayer matrix and bloom +// presets mirror dither-paint.ts so everything reads as one texture. + +import { type DitherColor, PALETTE, type Rgb } from "./palette" + +// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds — the same matrix +// the charts dither with. +export const BAYER4 = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5], +].map((row) => row.map((v) => (v + 0.5) / 16)) + +export const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t) + +/** 32-bit FNV-1a hash — turns any string seed into a stable uint32. */ +export function fnv1a(str: string): number { + let h = 0x811c9dc5 + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return h >>> 0 +} + +/** Tiny deterministic PRNG (xorshift32) — returns floats in [0, 1). */ +export function xorshift32(seed: number): () => number { + let s = seed || 0x9e3779b9 + return () => { + s ^= s << 13 + s >>>= 0 + s ^= s >>> 17 + s ^= s << 5 + s >>>= 0 + return s / 0x100000000 + } +} + +/** A named palette colour or a raw hue (0–360). */ +export type PixelColor = DitherColor | number + +/** Hue (0–360) → an rgb fill tuned to sit alongside the chart palette. */ +export function hueFill(hue: number): Rgb { + const h = ((hue % 360) + 360) % 360 + const s = 0.85 + const l = 0.58 + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - c / 2 + const [r, g, b] = + h < 60 + ? [c, x, 0] + : h < 120 + ? [x, c, 0] + : h < 180 + ? [0, c, x] + : h < 240 + ? [0, x, c] + : h < 300 + ? [x, 0, c] + : [c, 0, x] + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255), + ] +} + +/** Resolve a {@link PixelColor} to its rgb fill. */ +export function fillOf(color: PixelColor): Rgb { + return typeof color === "number" ? hueFill(color) : PALETTE[color].fill +} + +// Bloom — same recipe as the charts: a blurred copy of the crisp canvas, +// composited additively so the glow stays in the dither's own colour. +export type PixelBloom = "off" | "low" | "high" | "aura" + +const BLOOM_PRESET: Record< + Exclude, + { blur: number; brightness: number; opacity: number; saturate: number } +> = { + low: { blur: 3, brightness: 1.35, opacity: 0.7, saturate: 1.4 }, + high: { blur: 5, brightness: 1.5, opacity: 0.78, saturate: 1.5 }, + aura: { blur: 15, brightness: 2.9, opacity: 0.1, saturate: 3 }, +} + +export type PixelBloomStyle = { + filter: string + opacity: number + mixBlendMode: "plus-lighter" + imageRendering: "auto" +} + +/** Style for the bloom layer canvas. null when off. */ +export function pixelBloomStyle(bloom: PixelBloom): PixelBloomStyle | null { + if (bloom === "off") return null + const cfg = BLOOM_PRESET[bloom] + return { + filter: `blur(${cfg.blur}px) brightness(${cfg.brightness}) saturate(${cfg.saturate})`, + opacity: cfg.opacity, + mixBlendMode: "plus-lighter", + imageRendering: "auto", + } +} + +/** Whether the OS asks for reduced motion (skip entrances). */ +export function pixelPrefersReducedMotion(): boolean { + return ( + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false + ) +} diff --git a/apps/web/src/routes/dither-kit.tsx b/apps/web/src/routes/dither-kit.tsx index 7420f11..868e485 100644 --- a/apps/web/src/routes/dither-kit.tsx +++ b/apps/web/src/routes/dither-kit.tsx @@ -13,6 +13,7 @@ import { import { ChartGallery, DocsFooter, + ExtrasSection, HeroSection, InstallSection, KnobsSection, @@ -129,6 +130,7 @@ function DitherKitDocs() { onReplay={replay} /> + diff --git a/apps/web/src/routes/r/$name.ts b/apps/web/src/routes/r/$name.ts index 889e48e..86308de 100644 --- a/apps/web/src/routes/r/$name.ts +++ b/apps/web/src/routes/r/$name.ts @@ -23,6 +23,8 @@ const ITEMS = new Set([ "bar-chart", "pie-chart", "radar-chart", + "avatar", + "gradient", "dither-kit", ]) @@ -94,7 +96,8 @@ async function handler({ request }: { request: Request }) { } // Fire-and-forget — never block the install. Skip the index (`registry`); - // per-item hits are the signal (core ≈ total installs, others = popularity). + // per-item hits are the signal (core ≈ chart installs — avatar and gradient + // are standalone and don't pull it — others = popularity). if (db && name !== "registry") { db.track({ name: "registry_install", From 9ee7c10b2f05787df5df9215c4f503dda696cb94 Mon Sep 17 00:00:00 2001 From: Daniel Holdsworth Date: Mon, 13 Jul 2026 10:01:18 +0100 Subject: [PATCH 2/2] feat: dither-kit dithered buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DitherButton — a native + ) +} diff --git a/apps/web/src/components/dither-kit/index.ts b/apps/web/src/components/dither-kit/index.ts index 1c81f25..c7b3548 100644 --- a/apps/web/src/components/dither-kit/index.ts +++ b/apps/web/src/components/dither-kit/index.ts @@ -7,6 +7,11 @@ export { } from "./avatar" export { Bar, type BarProps } from "./bar" export { BarChart } from "./bar-chart" +export { + type ButtonVariant, + DitherButton, + type DitherButtonProps, +} from "./button" export type { CartesianChartProps } from "./cartesian-root" export type { AreaVariant, diff --git a/apps/web/src/routes/r/$name.ts b/apps/web/src/routes/r/$name.ts index 86308de..ae70c63 100644 --- a/apps/web/src/routes/r/$name.ts +++ b/apps/web/src/routes/r/$name.ts @@ -24,6 +24,7 @@ const ITEMS = new Set([ "pie-chart", "radar-chart", "avatar", + "button", "gradient", "dither-kit", ])