diff --git a/apps/web/src/components/dither-kit-docs/content.ts b/apps/web/src/components/dither-kit-docs/content.ts index 3545de9..239eb87 100644 --- a/apps/web/src/components/dither-kit-docs/content.ts +++ b/apps/web/src/components/dither-kit-docs/content.ts @@ -66,6 +66,11 @@ 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 +Three standalone extras (no chart engine, tiny installs): +- avatar — generative mirrored pixel avatars: (optional hue={0-360}) +- button — dithered native buttons: label +- 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 +209,52 @@ 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 button playground. */ +export type ButtonTweaks = { + color: string + variant: string + bloom: boolean +} + +export const buttonCode = (t: ButtonTweaks): string => + `// a native + ))} + + + + ) +} + +const BUTTON_VARIANTS: ButtonVariant[] = [ + "gradient", + "dotted", + "hatched", + "solid", +] +const BUTTON_COLORS: DitherColor[] = [ + "blue", + "purple", + "green", + "pink", + "orange", + "red", +] + +function ButtonShowcase({ pm }: { pm: Pm }) { + const [color, setColor] = useState("blue") + const [variant, setVariant] = useState("gradient") + const [withBloom, setWithBloom] = useState(true) + + return ( + +
+
+
+ {BUTTON_VARIANTS.map((v) => ( + setVariant(v)} + /> + ))} +
+
+ {BUTTON_COLORS.map((c) => ( + setColor(c)} + /> + ))} +
+ setWithBloom(!withBloom)} + /> +
+
+ + save changes + + + deploy → + + + disabled + +
+ + a real <button> — the + dither eases denser on hover, denser still while pressed. size it + with className like any button. + +
+
+ ) +} + +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 — none of these pull 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..72761de 100644 --- a/apps/web/src/components/dither-kit/README.md +++ b/apps/web/src/components/dither-kit/README.md @@ -33,6 +33,9 @@ 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/button # 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 +104,29 @@ Swap `AreaChart` → `LineChart` / `BarChart` (and `Area` → `Line` / ``` +### Avatar, button & gradient (standalone) + +None of these pull 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 real + ) +} 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..c7b3548 100644 --- a/apps/web/src/components/dither-kit/index.ts +++ b/apps/web/src/components/dither-kit/index.ts @@ -1,7 +1,17 @@ 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 ButtonVariant, + DitherButton, + type DitherButtonProps, +} from "./button" export type { CartesianChartProps } from "./cartesian-root" export type { AreaVariant, @@ -18,9 +28,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..ae70c63 100644 --- a/apps/web/src/routes/r/$name.ts +++ b/apps/web/src/routes/r/$name.ts @@ -23,6 +23,9 @@ const ITEMS = new Set([ "bar-chart", "pie-chart", "radar-chart", + "avatar", + "button", + "gradient", "dither-kit", ]) @@ -94,7 +97,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",