From 25ddd58bfad2b3a58250bb9d51887f2812784222 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 10:48:29 +0000 Subject: [PATCH 1/6] feat: cap ASCII logo text at five words Replace the 16-character demo limit with a shared word clamp so the wordmark input, engine, and published registry all drop extra words. Co-authored-by: Jay Sharma --- content/docs/components/ascii-logo.mdx | 4 ++- public/r/ascii-logo-svelte.json | 2 +- public/r/ascii-logo.json | 2 +- registry/ascii-logo/ascii-logo-demo.tsx | 43 +++++++++++++++-------- registry/ascii-logo/ascii-logo-vanilla.ts | 19 +++++++++- registry/ascii-logo/ascii-logo.tsx | 6 +++- 6 files changed, 56 insertions(+), 20 deletions(-) diff --git a/content/docs/components/ascii-logo.mdx b/content/docs/components/ascii-logo.mdx index 8544ecf..82733a5 100644 --- a/content/docs/components/ascii-logo.mdx +++ b/content/docs/components/ascii-logo.mdx @@ -50,6 +50,8 @@ export function FooterMark() { +`text` is limited to 5 words. Extra words are dropped. + Pass `src` to sample an image instead of text — any raster, or a same-origin SVG. @@ -71,7 +73,7 @@ Clicks cycle `logo → scattered → fallen → returning → logo`. Hover repul | Prop | Type | Default | | ----------------- | ----------------------------- | ------------------------------- | -| `text` | `string` | `"23rd"` | +| `text` | `string` | `"23rd"` (max 5 words) | | `src` | `string` | — | | `fit` | `number` | `0.82` | | `cellSize` | `number` | `11` | diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index fe59553..8532dec 100644 --- a/public/r/ascii-logo-svelte.json +++ b/public/r/ascii-logo-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/ascii-logo.svelte" } diff --git a/public/r/ascii-logo.json b/public/r/ascii-logo.json index 48ac69f..bc36bed 100644 --- a/public/r/ascii-logo.json +++ b/public/r/ascii-logo.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many whitespace-separated words. */\nexport const MAX_TEXT_WORDS = 5\n\n/** Keep at most `maxWords` words; extra tokens are dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxWords = MAX_TEXT_WORDS\n): string {\n const words = text.trim().split(/\\s+/).filter(Boolean)\n if (words.length <= maxWords) return text\n return words.slice(0, maxWords).join(\" \")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_WORDS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-logo.tsx" } diff --git a/registry/ascii-logo/ascii-logo-demo.tsx b/registry/ascii-logo/ascii-logo-demo.tsx index baa1add..608dd19 100644 --- a/registry/ascii-logo/ascii-logo-demo.tsx +++ b/registry/ascii-logo/ascii-logo-demo.tsx @@ -15,6 +15,8 @@ import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { AsciiLogo, + MAX_TEXT_WORDS, + clampAsciiLogoText, type AsciiLogoPhase, } from "@/registry/ascii-logo/ascii-logo" @@ -160,22 +162,33 @@ export function AsciiLogoDemo() { {source === "text" ? ( -
-
) : null} Date: Sat, 5 Sep 2026 11:02:55 +0000 Subject: [PATCH 2/6] fix: ignore extra keystrokes after the fifth ASCII logo word Keep the fifth word intact when the user types past the cap, while still truncating pasted replacements to the first five words. Co-authored-by: Jay Sharma --- public/r/ascii-logo-svelte.json | 2 +- public/r/ascii-logo.json | 2 +- registry/ascii-logo/ascii-logo-demo.tsx | 4 ++-- registry/ascii-logo/ascii-logo-vanilla.ts | 24 ++++++++++++++++++++++- registry/ascii-logo/ascii-logo.tsx | 1 + 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index 8532dec..cc18b54 100644 --- a/public/r/ascii-logo-svelte.json +++ b/public/r/ascii-logo-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/ascii-logo.svelte" } diff --git a/public/r/ascii-logo.json b/public/r/ascii-logo.json index bc36bed..dc90da7 100644 --- a/public/r/ascii-logo.json +++ b/public/r/ascii-logo.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many whitespace-separated words. */\nexport const MAX_TEXT_WORDS = 5\n\n/** Keep at most `maxWords` words; extra tokens are dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxWords = MAX_TEXT_WORDS\n): string {\n const words = text.trim().split(/\\s+/).filter(Boolean)\n if (words.length <= maxWords) return text\n return words.slice(0, maxWords).join(\" \")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_WORDS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many whitespace-separated words. */\nexport const MAX_TEXT_WORDS = 5\n\nfunction wordList(text: string): string[] {\n return text.trim().split(/\\s+/).filter(Boolean)\n}\n\n/** Keep at most `maxWords` words; extra tokens are dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxWords = MAX_TEXT_WORDS\n): string {\n const words = wordList(text)\n if (words.length <= maxWords) return text\n return words.slice(0, maxWords).join(\" \")\n}\n\n/**\n * Input-safe cap: typing past the limit is ignored so the fifth word\n * is not mutated. Pastes / replacements still keep the first `maxWords`.\n */\nexport function limitAsciiLogoInput(\n next: string,\n prev: string,\n maxWords = MAX_TEXT_WORDS\n): string {\n const nextWords = wordList(next)\n if (nextWords.length <= maxWords) return next\n const prevWords = wordList(prev)\n if (prevWords.length >= maxWords && next.startsWith(prev.trimEnd())) {\n return prev.trimEnd()\n }\n return nextWords.slice(0, maxWords).join(\" \")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_WORDS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-logo.tsx" } diff --git a/registry/ascii-logo/ascii-logo-demo.tsx b/registry/ascii-logo/ascii-logo-demo.tsx index 608dd19..93f25fb 100644 --- a/registry/ascii-logo/ascii-logo-demo.tsx +++ b/registry/ascii-logo/ascii-logo-demo.tsx @@ -16,7 +16,7 @@ import { usePreviewProps } from "@/hooks/use-preview-props" import { AsciiLogo, MAX_TEXT_WORDS, - clampAsciiLogoText, + limitAsciiLogoInput, type AsciiLogoPhase, } from "@/registry/ascii-logo/ascii-logo" @@ -178,7 +178,7 @@ export function AsciiLogoDemo() { onChange={(event) => updateProp( "text", - clampAsciiLogoText(event.currentTarget.value) + limitAsciiLogoInput(event.currentTarget.value, props.text) ) } /> diff --git a/registry/ascii-logo/ascii-logo-vanilla.ts b/registry/ascii-logo/ascii-logo-vanilla.ts index 1d145f6..2f4bd8b 100644 --- a/registry/ascii-logo/ascii-logo-vanilla.ts +++ b/registry/ascii-logo/ascii-logo-vanilla.ts @@ -5,16 +5,38 @@ export type AsciiLogoTheme = "light" | "dark" | "auto" /** Wordmark `text` is capped at this many whitespace-separated words. */ export const MAX_TEXT_WORDS = 5 +function wordList(text: string): string[] { + return text.trim().split(/\s+/).filter(Boolean) +} + /** Keep at most `maxWords` words; extra tokens are dropped. */ export function clampAsciiLogoText( text: string, maxWords = MAX_TEXT_WORDS ): string { - const words = text.trim().split(/\s+/).filter(Boolean) + const words = wordList(text) if (words.length <= maxWords) return text return words.slice(0, maxWords).join(" ") } +/** + * Input-safe cap: typing past the limit is ignored so the fifth word + * is not mutated. Pastes / replacements still keep the first `maxWords`. + */ +export function limitAsciiLogoInput( + next: string, + prev: string, + maxWords = MAX_TEXT_WORDS +): string { + const nextWords = wordList(next) + if (nextWords.length <= maxWords) return next + const prevWords = wordList(prev) + if (prevWords.length >= maxWords && next.startsWith(prev.trimEnd())) { + return prev.trimEnd() + } + return nextWords.slice(0, maxWords).join(" ") +} + export type AsciiLogoOptions = { /** * Wordmark sampled into the ASCII grid. Ignored when `src` is set. diff --git a/registry/ascii-logo/ascii-logo.tsx b/registry/ascii-logo/ascii-logo.tsx index 7efce79..17b83de 100644 --- a/registry/ascii-logo/ascii-logo.tsx +++ b/registry/ascii-logo/ascii-logo.tsx @@ -15,6 +15,7 @@ export { DEFAULT_CHARSET, MAX_TEXT_WORDS, clampAsciiLogoText, + limitAsciiLogoInput, } from "./ascii-logo-vanilla" export type { AsciiLogoInstance, From 21e333a10eda9b7465a5825c68d69c364a5a1cad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 11:10:05 +0000 Subject: [PATCH 3/6] fix: keep a word boundary after the ASCII logo five-word cap Drop extra words but leave a trailing space so later keystrokes stay a rejected sixth word instead of gluing onto the fifth. Co-authored-by: Jay Sharma --- public/r/ascii-logo-svelte.json | 2 +- public/r/ascii-logo.json | 2 +- registry/ascii-logo/ascii-logo-vanilla.ts | 17 +++++++---------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index cc18b54..d113085 100644 --- a/public/r/ascii-logo-svelte.json +++ b/public/r/ascii-logo-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/ascii-logo.svelte" } diff --git a/public/r/ascii-logo.json b/public/r/ascii-logo.json index dc90da7..38e45c4 100644 --- a/public/r/ascii-logo.json +++ b/public/r/ascii-logo.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many whitespace-separated words. */\nexport const MAX_TEXT_WORDS = 5\n\nfunction wordList(text: string): string[] {\n return text.trim().split(/\\s+/).filter(Boolean)\n}\n\n/** Keep at most `maxWords` words; extra tokens are dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxWords = MAX_TEXT_WORDS\n): string {\n const words = wordList(text)\n if (words.length <= maxWords) return text\n return words.slice(0, maxWords).join(\" \")\n}\n\n/**\n * Input-safe cap: typing past the limit is ignored so the fifth word\n * is not mutated. Pastes / replacements still keep the first `maxWords`.\n */\nexport function limitAsciiLogoInput(\n next: string,\n prev: string,\n maxWords = MAX_TEXT_WORDS\n): string {\n const nextWords = wordList(next)\n if (nextWords.length <= maxWords) return next\n const prevWords = wordList(prev)\n if (prevWords.length >= maxWords && next.startsWith(prev.trimEnd())) {\n return prev.trimEnd()\n }\n return nextWords.slice(0, maxWords).join(\" \")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_WORDS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many whitespace-separated words. */\nexport const MAX_TEXT_WORDS = 5\n\nfunction wordList(text: string): string[] {\n return text.trim().split(/\\s+/).filter(Boolean)\n}\n\n/** Keep at most `maxWords` words; extra tokens are dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxWords = MAX_TEXT_WORDS\n): string {\n const words = wordList(text)\n if (words.length <= maxWords) return text\n return words.slice(0, maxWords).join(\" \")\n}\n\n/**\n * Input-safe cap. Extra words are dropped. A trailing space is kept so\n * further keystrokes stay a rejected sixth word instead of appending to\n * the fifth.\n */\nexport function limitAsciiLogoInput(\n next: string,\n _prev: string = \"\",\n maxWords = MAX_TEXT_WORDS\n): string {\n const words = wordList(next)\n if (words.length <= maxWords) return next\n return `${words.slice(0, maxWords).join(\" \")} `\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_WORDS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-logo.tsx" } diff --git a/registry/ascii-logo/ascii-logo-vanilla.ts b/registry/ascii-logo/ascii-logo-vanilla.ts index 2f4bd8b..2e83e43 100644 --- a/registry/ascii-logo/ascii-logo-vanilla.ts +++ b/registry/ascii-logo/ascii-logo-vanilla.ts @@ -20,21 +20,18 @@ export function clampAsciiLogoText( } /** - * Input-safe cap: typing past the limit is ignored so the fifth word - * is not mutated. Pastes / replacements still keep the first `maxWords`. + * Input-safe cap. Extra words are dropped. A trailing space is kept so + * further keystrokes stay a rejected sixth word instead of appending to + * the fifth. */ export function limitAsciiLogoInput( next: string, - prev: string, + _prev: string = "", maxWords = MAX_TEXT_WORDS ): string { - const nextWords = wordList(next) - if (nextWords.length <= maxWords) return next - const prevWords = wordList(prev) - if (prevWords.length >= maxWords && next.startsWith(prev.trimEnd())) { - return prev.trimEnd() - } - return nextWords.slice(0, maxWords).join(" ") + const words = wordList(next) + if (words.length <= maxWords) return next + return `${words.slice(0, maxWords).join(" ")} ` } export type AsciiLogoOptions = { From 4b142e546886fe3c857c83c52a0399dd1aae9215 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 11:21:23 +0000 Subject: [PATCH 4/6] fix: cap ASCII logo text at five letters Correct the wordmark limit from five words to five characters so the demo input, engine, and docs all drop extra letters. Co-authored-by: Jay Sharma --- content/docs/components/ascii-logo.mdx | 4 +-- public/r/ascii-logo-svelte.json | 2 +- public/r/ascii-logo.json | 2 +- registry/ascii-logo/ascii-logo-demo.tsx | 11 ++++---- registry/ascii-logo/ascii-logo-vanilla.ts | 33 +++++------------------ registry/ascii-logo/ascii-logo.tsx | 3 +-- 6 files changed, 17 insertions(+), 38 deletions(-) diff --git a/content/docs/components/ascii-logo.mdx b/content/docs/components/ascii-logo.mdx index 82733a5..a210f17 100644 --- a/content/docs/components/ascii-logo.mdx +++ b/content/docs/components/ascii-logo.mdx @@ -50,7 +50,7 @@ export function FooterMark() {
-`text` is limited to 5 words. Extra words are dropped. +`text` is limited to 5 letters. Extra characters are dropped. Pass `src` to sample an image instead of text — any raster, or a same-origin SVG. @@ -73,7 +73,7 @@ Clicks cycle `logo → scattered → fallen → returning → logo`. Hover repul | Prop | Type | Default | | ----------------- | ----------------------------- | ------------------------------- | -| `text` | `string` | `"23rd"` (max 5 words) | +| `text` | `string` | `"23rd"` (max 5 letters) | | `src` | `string` | — | | `fit` | `number` | `0.82` | | `cellSize` | `number` | `11` | diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index d113085..2241ae7 100644 --- a/public/r/ascii-logo-svelte.json +++ b/public/r/ascii-logo-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/ascii-logo.svelte" } diff --git a/public/r/ascii-logo.json b/public/r/ascii-logo.json index 38e45c4..9e62313 100644 --- a/public/r/ascii-logo.json +++ b/public/r/ascii-logo.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many whitespace-separated words. */\nexport const MAX_TEXT_WORDS = 5\n\nfunction wordList(text: string): string[] {\n return text.trim().split(/\\s+/).filter(Boolean)\n}\n\n/** Keep at most `maxWords` words; extra tokens are dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxWords = MAX_TEXT_WORDS\n): string {\n const words = wordList(text)\n if (words.length <= maxWords) return text\n return words.slice(0, maxWords).join(\" \")\n}\n\n/**\n * Input-safe cap. Extra words are dropped. A trailing space is kept so\n * further keystrokes stay a rejected sixth word instead of appending to\n * the fifth.\n */\nexport function limitAsciiLogoInput(\n next: string,\n _prev: string = \"\",\n maxWords = MAX_TEXT_WORDS\n): string {\n const words = wordList(next)\n if (words.length <= maxWords) return next\n return `${words.slice(0, maxWords).join(\" \")} `\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_WORDS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many characters. */\nexport const MAX_TEXT_LETTERS = 5\n\n/** Keep at most `maxLetters` characters; extra input is dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxLetters = MAX_TEXT_LETTERS\n): string {\n return [...text].slice(0, maxLetters).join(\"\")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_LETTERS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-logo.tsx" } diff --git a/registry/ascii-logo/ascii-logo-demo.tsx b/registry/ascii-logo/ascii-logo-demo.tsx index 93f25fb..df26174 100644 --- a/registry/ascii-logo/ascii-logo-demo.tsx +++ b/registry/ascii-logo/ascii-logo-demo.tsx @@ -15,8 +15,8 @@ import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { AsciiLogo, - MAX_TEXT_WORDS, - limitAsciiLogoInput, + MAX_TEXT_LETTERS, + clampAsciiLogoText, type AsciiLogoPhase, } from "@/registry/ascii-logo/ascii-logo" @@ -173,12 +173,13 @@ export function AsciiLogoDemo() { updateProp( "text", - limitAsciiLogoInput(event.currentTarget.value, props.text) + clampAsciiLogoText(event.currentTarget.value) ) } /> @@ -187,7 +188,7 @@ export function AsciiLogoDemo() { id="ascii-logo-text-hint" className="text-right text-xs text-muted-foreground" > - Max {MAX_TEXT_WORDS} words + Max {MAX_TEXT_LETTERS} letters

) : null} diff --git a/registry/ascii-logo/ascii-logo-vanilla.ts b/registry/ascii-logo/ascii-logo-vanilla.ts index 2e83e43..4cd27d3 100644 --- a/registry/ascii-logo/ascii-logo-vanilla.ts +++ b/registry/ascii-logo/ascii-logo-vanilla.ts @@ -2,42 +2,21 @@ export type AsciiLogoPhase = "logo" | "scattered" | "fallen" | "returning" export type AsciiLogoTheme = "light" | "dark" | "auto" -/** Wordmark `text` is capped at this many whitespace-separated words. */ -export const MAX_TEXT_WORDS = 5 +/** Wordmark `text` is capped at this many characters. */ +export const MAX_TEXT_LETTERS = 5 -function wordList(text: string): string[] { - return text.trim().split(/\s+/).filter(Boolean) -} - -/** Keep at most `maxWords` words; extra tokens are dropped. */ +/** Keep at most `maxLetters` characters; extra input is dropped. */ export function clampAsciiLogoText( text: string, - maxWords = MAX_TEXT_WORDS -): string { - const words = wordList(text) - if (words.length <= maxWords) return text - return words.slice(0, maxWords).join(" ") -} - -/** - * Input-safe cap. Extra words are dropped. A trailing space is kept so - * further keystrokes stay a rejected sixth word instead of appending to - * the fifth. - */ -export function limitAsciiLogoInput( - next: string, - _prev: string = "", - maxWords = MAX_TEXT_WORDS + maxLetters = MAX_TEXT_LETTERS ): string { - const words = wordList(next) - if (words.length <= maxWords) return next - return `${words.slice(0, maxWords).join(" ")} ` + return [...text].slice(0, maxLetters).join("") } export type AsciiLogoOptions = { /** * Wordmark sampled into the ASCII grid. Ignored when `src` is set. - * Capped at `MAX_TEXT_WORDS` (5). Default `"23rd"` + * Capped at `MAX_TEXT_LETTERS` (5). Default `"23rd"` */ text?: string /** Image URL to sample instead of `text` (any raster or same-origin SVG). */ diff --git a/registry/ascii-logo/ascii-logo.tsx b/registry/ascii-logo/ascii-logo.tsx index 17b83de..7bcc215 100644 --- a/registry/ascii-logo/ascii-logo.tsx +++ b/registry/ascii-logo/ascii-logo.tsx @@ -13,9 +13,8 @@ import { export { DEFAULT_CHARSET, - MAX_TEXT_WORDS, + MAX_TEXT_LETTERS, clampAsciiLogoText, - limitAsciiLogoInput, } from "./ascii-logo-vanilla" export type { AsciiLogoInstance, From b812bc5de1e8189a0431b45f9d7de41b86738e1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 11:26:17 +0000 Subject: [PATCH 5/6] fix: clamp ASCII logo aria-label to the five-letter text When label is omitted, announce the same capped wordmark the canvas renders so screen readers do not hear extra characters. Co-authored-by: Jay Sharma --- public/r/ascii-logo-svelte.json | 2 +- public/r/ascii-logo.json | 2 +- registry/ascii-logo/ascii-logo.svelte | 3 ++- registry/ascii-logo/ascii-logo.tsx | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index 2241ae7..1a2dba1 100644 --- a/public/r/ascii-logo-svelte.json +++ b/public/r/ascii-logo-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/ascii-logo.svelte" } diff --git a/public/r/ascii-logo.json b/public/r/ascii-logo.json index 9e62313..5962ac5 100644 --- a/public/r/ascii-logo.json +++ b/public/r/ascii-logo.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many characters. */\nexport const MAX_TEXT_LETTERS = 5\n\n/** Keep at most `maxLetters` characters; extra input is dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxLetters = MAX_TEXT_LETTERS\n): string {\n return [...text].slice(0, maxLetters).join(\"\")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_LETTERS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many characters. */\nexport const MAX_TEXT_LETTERS = 5\n\n/** Keep at most `maxLetters` characters; extra input is dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxLetters = MAX_TEXT_LETTERS\n): string {\n return [...text].slice(0, maxLetters).join(\"\")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_LETTERS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : clampAsciiLogoText(text))\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-logo.tsx" } diff --git a/registry/ascii-logo/ascii-logo.svelte b/registry/ascii-logo/ascii-logo.svelte index 7bca17e..d970b91 100644 --- a/registry/ascii-logo/ascii-logo.svelte +++ b/registry/ascii-logo/ascii-logo.svelte @@ -6,6 +6,7 @@ import { createAsciiLogo, DEFAULT_CHARSET, + clampAsciiLogoText, type AsciiLogoInstance, type AsciiLogoOptions, } from "./ascii-logo-vanilla" @@ -110,7 +111,7 @@ }) }) - const aria = $derived(label ?? (src ? "ASCII logo" : text)) + const aria = $derived(label ?? (src ? "ASCII logo" : clampAsciiLogoText(text)))
Date: Sat, 5 Sep 2026 11:32:48 +0000 Subject: [PATCH 6/6] fix: avoid duplicate text key when clamping ASCII logo options Assign the five-letter clamp after merging defaults so the production typecheck can deploy. Co-authored-by: Jay Sharma --- public/r/ascii-logo-svelte.json | 2 +- public/r/ascii-logo.json | 2 +- registry/ascii-logo/ascii-logo-vanilla.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index 1a2dba1..ffbda2e 100644 --- a/public/r/ascii-logo-svelte.json +++ b/public/r/ascii-logo-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.svelte", - "content": "\n\n\n\n\n \n
\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/ascii-logo.svelte" } diff --git a/public/r/ascii-logo.json b/public/r/ascii-logo.json index 5962ac5..021caec 100644 --- a/public/r/ascii-logo.json +++ b/public/r/ascii-logo.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many characters. */\nexport const MAX_TEXT_LETTERS = 5\n\n/** Keep at most `maxLetters` characters; extra input is dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxLetters = MAX_TEXT_LETTERS\n): string {\n return [...text].slice(0, maxLetters).join(\"\")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_LETTERS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n text: clampAsciiLogoText(initial.text ?? \"23rd\"),\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : clampAsciiLogoText(text))\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many characters. */\nexport const MAX_TEXT_LETTERS = 5\n\n/** Keep at most `maxLetters` characters; extra input is dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxLetters = MAX_TEXT_LETTERS\n): string {\n return [...text].slice(0, maxLetters).join(\"\")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_LETTERS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n options.text = clampAsciiLogoText(options.text)\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : clampAsciiLogoText(text))\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-logo.tsx" } diff --git a/registry/ascii-logo/ascii-logo-vanilla.ts b/registry/ascii-logo/ascii-logo-vanilla.ts index 4cd27d3..7987275 100644 --- a/registry/ascii-logo/ascii-logo-vanilla.ts +++ b/registry/ascii-logo/ascii-logo-vanilla.ts @@ -208,8 +208,8 @@ export function createAsciiLogo( interactive: true, theme: "auto", ...initial, - text: clampAsciiLogoText(initial.text ?? "23rd"), } + options.text = clampAsciiLogoText(options.text) const ctx = canvas.getContext("2d") if (!ctx) return null