{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-effect-04",
  "title": "Glyph art",
  "description": "A word or an icon drawn in characters lit from the top left, where a band of light crosses it, the characters travel to their new cells when the shape changes, the pointer lights those near it and a press sends a ripple.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/segments.json",
    "https://ui.ndekocode.dev/r/speech-envelope.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-agent-run.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/text-effect/text-effect-04.tsx",
      "content": "'use client';\n\nimport { useEffect, useImperativeHandle, useRef, useState } from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { noiseAt } from '@/registry/ndeko/lib/speech-envelope';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Segments, type Segment } from '@/registry/ndeko/ui/segments';\n\nimport { useHover } from './_parts/use-hover';\nimport { usePlayWhileSeen } from './_parts/use-play-while-seen';\n\nexport type GlyphShape =\n  | { kind: 'word'; text: string }\n  | {\n      kind: 'icon';\n      /** One SVG path, drawn in a square of `size` units, as an icon of 24 by 24 is. */\n      path: string;\n      size: number;\n      /** What the icon shows, read by screen readers. */\n      label: string;\n    };\n\n/** Where the grid of characters sits in the art, in CSS pixels. */\ntype Layout = {\n  width: number;\n  height: number;\n  cols: number;\n  rows: number;\n  originX: number;\n  originY: number;\n};\n\n/** The covered cells of a shape, from left to right, and how lit each one is. */\ntype Field = {\n  cells: number[];\n  brightness: number[];\n};\n\n/** One character per covered cell. Each one travels from where it was to its cell when the shape changes. */\ntype Swarm = {\n  count: number;\n  fromX: Float32Array;\n  fromY: Float32Array;\n  fromLight: Float32Array;\n  toX: Float32Array;\n  toY: Float32Array;\n  toLight: Float32Array;\n  delay: Float32Array;\n  phase: Float32Array;\n};\n\n/** The characters a smaller shape has no cell for: they fade where they stood. */\ntype Ghosts = {\n  count: number;\n  x: Float32Array;\n  y: Float32Array;\n  light: Float32Array;\n};\n\ntype Ripple = {\n  x: number;\n  y: number;\n  startedAt: number;\n};\n\ntype Pointer = {\n  x: number;\n  y: number;\n  movedAt: number;\n};\n\ntype Atlas = {\n  canvas: HTMLCanvasElement;\n  glyphCount: number;\n  ratio: number;\n};\n\ntype EngineOptions = {\n  canvas: HTMLCanvasElement;\n  box: HTMLElement;\n  overlay: HTMLElement;\n  ramp: string;\n  shape: GlyphShape;\n  isStill: boolean;\n  isPlaying: boolean;\n};\n\ntype GlyphEngine = {\n  setShape: (shape: GlyphShape) => void;\n  setPlaying: (isPlaying: boolean) => void;\n  pointAt: (x: number, y: number) => void;\n  release: () => void;\n  ripple: (x: number, y: number) => void;\n  dispose: () => void;\n};\n\nconst DEFAULT_RAMP = '·:-=+*#%@';\n\nconst CELL_W = 8;\n\nconst CELL_H = 12;\n\nconst GLYPH_FONT_PX = 11;\n\nconst GLYPH_FONT = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';\n\n/** A backing store above two device pixels a point costs four times the fill for a picture nobody sees sharper. */\nconst MAX_PIXEL_RATIO = 2;\n\n/** A word fills this share of the height of the art, less when it is long; an icon this share of its shorter side. */\nconst WORD_HEIGHT_SHARE = 0.78;\n\nconst WORD_WIDTH_SHARE = 0.9;\n\nconst ICON_SHARE = 0.86;\n\n/** A cell this much covered by the shape holds a character. */\nconst COVERED_FROM = 0.3;\n\n/** The light comes from the top left: a slope that faces it is lit, a slope that turns away is in shade. */\nconst LIGHT_ACROSS = -Math.SQRT1_2;\n\nconst LIGHT_DOWN = -Math.SQRT1_2;\n\nconst SLOPE_GAIN = 6;\n\nconst FLAT_LIGHT = 0.55;\n\nconst SHIMMER = 0.08;\n\nconst SHIMMER_HERTZ = 0.22;\n\n/** A band of light crosses the art from the top left every few seconds. */\nconst SWEEP_PERIOD_S = 5.5;\n\nconst SWEEP_GAIN = 0.32;\n\nconst SWEEP_WIDTH = 0.09;\n\nconst POINTER_REACH_PX = 70;\n\nconst POINTER_GAIN = 0.55;\n\nconst RIPPLE_SPEED_PX_S = 260;\n\nconst RIPPLE_WIDTH_PX = 18;\n\nconst RIPPLE_GAIN = 0.6;\n\nconst RIPPLE_LIFE_MS = 1300;\n\nconst MORPH_MS = 760;\n\n/** Characters set off from left to right, the last this long after the first. */\nconst MORPH_SWEEP_MS = 260;\n\nconst GHOST_FADE_MS = 320;\n\n/** While something moves the art draws thirty frames a second, and twenty while it only shimmers. */\nconst BUSY_FRAME_MS = 33;\n\nconst IDLE_FRAME_MS = 50;\n\n/** A pointer that stopped this long ago only shimmers. */\nconst POINTER_SETTLED_MS = 400;\n\nconst TAU = Math.PI * 2;\n\nfunction pixelRatioOf() {\n  const screenRatio = window.devicePixelRatio || 1;\n  return Math.min(screenRatio, MAX_PIXEL_RATIO);\n}\n\nfunction clampUnit(value: number) {\n  const atLeast = Math.max(0, value);\n  return Math.min(1, atLeast);\n}\n\nfunction smoothStep(from: number, to: number, value: number) {\n  const progress = clampUnit((value - from) / (to - from));\n  return progress * progress * (3 - 2 * progress);\n}\n\nfunction easeInOut(progress: number) {\n  if (progress < 0.5) {\n    return 4 * progress * progress * progress;\n  }\n  const fromEnd = -2 * progress + 2;\n  return 1 - (fromEnd * fromEnd * fromEnd) / 2;\n}\n\nfunction shapeKeyOf(shape: GlyphShape) {\n  if (shape.kind === 'word') {\n    return `word:${shape.text}`;\n  }\n  return `icon:${shape.size}:${shape.path}`;\n}\n\nfunction layoutOf(width: number, height: number): Layout {\n  const cols = Math.max(1, Math.floor(width / CELL_W));\n  const rows = Math.max(1, Math.floor(height / CELL_H));\n  const originX = (width - cols * CELL_W) / 2;\n  const originY = (height - rows * CELL_H) / 2;\n  return { width, height, cols, rows, originX, originY };\n}\n\n/** Draws the word as large as the art allows and hands back its size, so the readable copy over the art can match it. */\nfunction drawWord(\n  pen: CanvasRenderingContext2D,\n  text: string,\n  width: number,\n  height: number,\n  family: string\n) {\n  const tallest = height * WORD_HEIGHT_SHARE;\n  pen.font = `600 ${tallest}px ${family}`;\n  const measured = pen.measureText(text).width;\n  const room = width * WORD_WIDTH_SHARE;\n  let size = tallest;\n  if (measured > room) {\n    size = (tallest * room) / measured;\n  }\n  pen.font = `600 ${size}px ${family}`;\n  pen.textAlign = 'center';\n  pen.textBaseline = 'middle';\n  pen.fillText(text, width / 2, height / 2);\n  return size;\n}\n\nfunction drawIcon(\n  pen: CanvasRenderingContext2D,\n  path: string,\n  size: number,\n  width: number,\n  height: number\n) {\n  const side = Math.min(width, height) * ICON_SHARE;\n  const scale = side / size;\n  const left = (width - side) / 2;\n  const top = (height - side) / 2;\n  pen.setTransform(scale, 0, 0, scale, left, top);\n  pen.fill(new Path2D(path));\n}\n\n/** How much of each cell the shape covers, from 0 to 1, read from a drawing of it at the size of the grid. */\nfunction coverageOf(shape: GlyphShape, layout: Layout, family: string) {\n  const width = layout.cols * CELL_W;\n  const height = layout.rows * CELL_H;\n  const coverage = new Float32Array(layout.cols * layout.rows);\n  const sketch = document.createElement('canvas');\n  sketch.width = width;\n  sketch.height = height;\n  const pen = sketch.getContext('2d', { willReadFrequently: true });\n  if (!pen) {\n    return { coverage, wordSize: 0 };\n  }\n  pen.fillStyle = 'black';\n  let wordSize = 0;\n  if (shape.kind === 'word') {\n    wordSize = drawWord(pen, shape.text, width, height, family);\n  }\n  if (shape.kind === 'icon') {\n    drawIcon(pen, shape.path, shape.size, width, height);\n  }\n  const pixels = pen.getImageData(0, 0, width, height).data;\n  const samplesAcross = 4;\n  const samplesDown = 4;\n  const sampleCount = samplesAcross * samplesDown;\n  for (let row = 0; row < layout.rows; row += 1) {\n    for (let col = 0; col < layout.cols; col += 1) {\n      let total = 0;\n      for (let down = 0; down < samplesDown; down += 1) {\n        const y = Math.floor(\n          row * CELL_H + ((down + 0.5) * CELL_H) / samplesDown\n        );\n        for (let across = 0; across < samplesAcross; across += 1) {\n          const x = Math.floor(\n            col * CELL_W + ((across + 0.5) * CELL_W) / samplesAcross\n          );\n          const alphaIndex = (y * width + x) * 4 + 3;\n          total += pixels[alphaIndex];\n        }\n      }\n      coverage[row * layout.cols + col] = total / (sampleCount * 255);\n    }\n  }\n  return { coverage, wordSize };\n}\n\nfunction coverageAt(\n  coverage: Float32Array,\n  layout: Layout,\n  col: number,\n  row: number\n) {\n  const boundedCol = Math.min(layout.cols - 1, Math.max(0, col));\n  const boundedRow = Math.min(layout.rows - 1, Math.max(0, row));\n  return coverage[boundedRow * layout.cols + boundedCol];\n}\n\n/** The shape as a gentle hill: each cell is the mean of its neighbours, so the slopes run over two or three cells. */\nfunction softened(coverage: Float32Array, layout: Layout) {\n  const soft = new Float32Array(coverage.length);\n  for (let row = 0; row < layout.rows; row += 1) {\n    for (let col = 0; col < layout.cols; col += 1) {\n      let total = 0;\n      for (let down = -1; down <= 1; down += 1) {\n        for (let across = -1; across <= 1; across += 1) {\n          total += coverageAt(coverage, layout, col + across, row + down);\n        }\n      }\n      soft[row * layout.cols + col] = total / 9;\n    }\n  }\n  return soft;\n}\n\n/** The covered cells, from left to right, each lit by how much its slope faces the light. */\nfunction fieldOf(coverage: Float32Array, layout: Layout): Field {\n  const hill = softened(softened(coverage, layout), layout);\n  const cells: number[] = [];\n  const brightness: number[] = [];\n  for (let col = 0; col < layout.cols; col += 1) {\n    for (let row = 0; row < layout.rows; row += 1) {\n      const index = row * layout.cols + col;\n      const covered = coverage[index];\n      if (covered < COVERED_FROM) {\n        continue;\n      }\n      const left = coverageAt(hill, layout, col - 1, row);\n      const right = coverageAt(hill, layout, col + 1, row);\n      const above = coverageAt(hill, layout, col, row - 1);\n      const below = coverageAt(hill, layout, col, row + 1);\n      const slopeAcross = (right - left) / (2 * CELL_W);\n      const slopeDown = (below - above) / (2 * CELL_H);\n      const facing = -(slopeAcross * LIGHT_ACROSS + slopeDown * LIGHT_DOWN);\n      const lit = clampUnit(FLAT_LIGHT + facing * SLOPE_GAIN * CELL_W);\n      const edgeSoftening = 0.75 + 0.25 * covered;\n      cells.push(index);\n      brightness.push(lit * edgeSoftening);\n    }\n  }\n  return { cells, brightness };\n}\n\nfunction emptySwarm(count: number): Swarm {\n  return {\n    count,\n    fromX: new Float32Array(count),\n    fromY: new Float32Array(count),\n    fromLight: new Float32Array(count),\n    toX: new Float32Array(count),\n    toY: new Float32Array(count),\n    toLight: new Float32Array(count),\n    delay: new Float32Array(count),\n    phase: new Float32Array(count),\n  };\n}\n\nfunction emptyGhosts(count: number): Ghosts {\n  return {\n    count,\n    x: new Float32Array(count),\n    y: new Float32Array(count),\n    light: new Float32Array(count),\n  };\n}\n\nfunction cellLeftOf(layout: Layout, cell: number) {\n  const col = cell % layout.cols;\n  return layout.originX + col * CELL_W;\n}\n\nfunction cellTopOf(layout: Layout, cell: number) {\n  const row = Math.floor(cell / layout.cols);\n  return layout.originY + row * CELL_H;\n}\n\n/** How far a character has gone on its way to its cell, from 0 to 1. */\nfunction travelOf(swarm: Swarm, index: number, sinceMorph: number) {\n  const started = sinceMorph - swarm.delay[index];\n  const progress = clampUnit(started / MORPH_MS);\n  return easeInOut(progress);\n}\n\n/** Every character standing in its cell at once, with no travel. */\nfunction settledSwarmOf(field: Field, layout: Layout) {\n  const count = field.cells.length;\n  const swarm = emptySwarm(count);\n  for (let index = 0; index < count; index += 1) {\n    const cell = field.cells[index];\n    const x = cellLeftOf(layout, cell);\n    const y = cellTopOf(layout, cell);\n    const light = field.brightness[index];\n    swarm.fromX[index] = x;\n    swarm.toX[index] = x;\n    swarm.fromY[index] = y;\n    swarm.toY[index] = y;\n    swarm.fromLight[index] = light;\n    swarm.toLight[index] = light;\n    swarm.phase[index] = noiseAt(index, 7) * TAU;\n  }\n  return swarm;\n}\n\n/** Where each character of a swarm stands now, and how lit it is, sorted from left to right. */\nfunction standingOf(swarm: Swarm, sinceMorph: number) {\n  const x = new Float32Array(swarm.count);\n  const y = new Float32Array(swarm.count);\n  const light = new Float32Array(swarm.count);\n  const order: number[] = [];\n  for (let index = 0; index < swarm.count; index += 1) {\n    const travel = travelOf(swarm, index, sinceMorph);\n    x[index] =\n      swarm.fromX[index] + (swarm.toX[index] - swarm.fromX[index]) * travel;\n    y[index] =\n      swarm.fromY[index] + (swarm.toY[index] - swarm.fromY[index]) * travel;\n    light[index] =\n      swarm.fromLight[index] +\n      (swarm.toLight[index] - swarm.fromLight[index]) * travel;\n    order.push(index);\n  }\n  order.sort((first, second) => {\n    const across = x[first] - x[second];\n    if (across !== 0) {\n      return across;\n    }\n    return y[first] - y[second];\n  });\n  return { x, y, light, order };\n}\n\n/**\n * The characters of the old shape set off for the cells of the new one, the leftmost to the\n * leftmost, so the shape re-forms across rather than in a scramble. When the new shape has more\n * cells, some characters split in two; when it has fewer, the ones left over fade where they stand.\n */\nfunction morphedSwarmOf(\n  current: Swarm,\n  sinceMorph: number,\n  field: Field,\n  layout: Layout\n) {\n  const count = field.cells.length;\n  const swarm = emptySwarm(count);\n  const standing = standingOf(current, sinceMorph);\n  const oldCount = current.count;\n  const isTaken = new Uint8Array(oldCount);\n  for (let index = 0; index < count; index += 1) {\n    const cell = field.cells[index];\n    const toX = cellLeftOf(layout, cell);\n    const toY = cellTopOf(layout, cell);\n    const toLight = field.brightness[index];\n    swarm.toX[index] = toX;\n    swarm.toY[index] = toY;\n    swarm.toLight[index] = toLight;\n    swarm.phase[index] = noiseAt(index, 7) * TAU;\n    swarm.delay[index] = (toX / Math.max(1, layout.width)) * MORPH_SWEEP_MS;\n    if (oldCount === 0) {\n      swarm.fromX[index] = toX;\n      swarm.fromY[index] = toY;\n      continue;\n    }\n    const share = Math.floor((index * oldCount) / count);\n    const source = standing.order[share];\n    isTaken[source] = 1;\n    swarm.fromX[index] = standing.x[source];\n    swarm.fromY[index] = standing.y[source];\n    swarm.fromLight[index] = standing.light[source];\n  }\n  const leftOver: number[] = [];\n  for (let index = 0; index < oldCount; index += 1) {\n    if (isTaken[index] === 0) {\n      leftOver.push(index);\n    }\n  }\n  const ghosts = emptyGhosts(leftOver.length);\n  leftOver.forEach((source, index) => {\n    ghosts.x[index] = standing.x[source];\n    ghosts.y[index] = standing.y[source];\n    ghosts.light[index] = standing.light[source];\n  });\n  return { swarm, ghosts };\n}\n\nfunction atlasOf(glyphs: string[], ink: string, lit: string): Atlas | null {\n  const ratio = pixelRatioOf();\n  const cellWidth = CELL_W * ratio;\n  const cellHeight = CELL_H * ratio;\n  const canvas = document.createElement('canvas');\n  canvas.width = Math.ceil(glyphs.length * cellWidth);\n  canvas.height = Math.ceil(2 * cellHeight);\n  const pen = canvas.getContext('2d');\n  if (!pen) {\n    return null;\n  }\n  pen.font = `500 ${GLYPH_FONT_PX * ratio}px ${GLYPH_FONT}`;\n  pen.textAlign = 'center';\n  pen.textBaseline = 'middle';\n  glyphs.forEach((glyph, index) => {\n    const x = index * cellWidth + cellWidth / 2;\n    pen.fillStyle = ink;\n    pen.fillText(glyph, x, cellHeight / 2);\n    pen.fillStyle = lit;\n    pen.fillText(glyph, x, cellHeight + cellHeight / 2);\n  });\n  return { canvas, glyphCount: glyphs.length, ratio };\n}\n\nfunction inkOf(canvas: HTMLCanvasElement) {\n  const styles = getComputedStyle(canvas);\n  const lit = styles.getPropertyValue('--primary').trim();\n  return { ink: styles.color, lit: lit || styles.color };\n}\n\nfunction watchTheme(onChange: () => void) {\n  const observer = new MutationObserver(onChange);\n  observer.observe(document.documentElement, {\n    attributes: true,\n    attributeFilter: ['class', 'style', 'data-theme'],\n  });\n  return () => observer.disconnect();\n}\n\nfunction sweepAt(seconds: number, x: number, y: number, layout: Layout) {\n  const cycle = (seconds % SWEEP_PERIOD_S) / SWEEP_PERIOD_S;\n  const centre = cycle * 1.6 - 0.3;\n  const across = x / Math.max(1, layout.width);\n  const down = y / Math.max(1, layout.height);\n  const diagonal = (across + down) / 2;\n  const gap = (diagonal - centre) / SWEEP_WIDTH;\n  return SWEEP_GAIN * Math.exp(-gap * gap);\n}\n\nfunction pointerGlowAt(pointer: Pointer | null, x: number, y: number) {\n  if (!pointer) {\n    return 0;\n  }\n  const distance = Math.hypot(x - pointer.x, y - pointer.y);\n  const nearness = Math.max(0, 1 - distance / POINTER_REACH_PX);\n  return POINTER_GAIN * nearness * nearness;\n}\n\nfunction rippleAt(ripples: Ripple[], now: number, x: number, y: number) {\n  let total = 0;\n  for (const ripple of ripples) {\n    const age = now - ripple.startedAt;\n    const reach = (age / 1000) * RIPPLE_SPEED_PX_S;\n    const distance = Math.hypot(x - ripple.x, y - ripple.y);\n    const gap = (distance - reach) / RIPPLE_WIDTH_PX;\n    const fading = 1 - age / RIPPLE_LIFE_MS;\n    total += RIPPLE_GAIN * Math.exp(-gap * gap) * Math.max(0, fading);\n  }\n  return total;\n}\n\nfunction drawGlyph(\n  pen: CanvasRenderingContext2D,\n  atlas: Atlas,\n  light: number,\n  alphaScale: number,\n  x: number,\n  y: number\n) {\n  const bounded = clampUnit(light);\n  const glyph = Math.min(\n    atlas.glyphCount - 1,\n    Math.floor(bounded * atlas.glyphCount)\n  );\n  const alpha = (0.3 + 0.7 * bounded) * alphaScale;\n  const litShare = smoothStep(0.7, 0.95, bounded);\n  const inkAlpha = alpha * (1 - litShare);\n  const litAlpha = alpha * litShare;\n  const sourceWidth = CELL_W * atlas.ratio;\n  const sourceHeight = CELL_H * atlas.ratio;\n  const sourceX = glyph * sourceWidth;\n  if (inkAlpha > 0.02) {\n    pen.globalAlpha = inkAlpha;\n    pen.drawImage(\n      atlas.canvas,\n      sourceX,\n      0,\n      sourceWidth,\n      sourceHeight,\n      x,\n      y,\n      CELL_W,\n      CELL_H\n    );\n  }\n  if (litAlpha > 0.02) {\n    pen.globalAlpha = litAlpha;\n    pen.drawImage(\n      atlas.canvas,\n      sourceX,\n      sourceHeight,\n      sourceWidth,\n      sourceHeight,\n      x,\n      y,\n      CELL_W,\n      CELL_H\n    );\n  }\n}\n\nfunction frameWaitOf(isBusy: boolean) {\n  if (isBusy) {\n    return BUSY_FRAME_MS;\n  }\n  return IDLE_FRAME_MS;\n}\n\nfunction overlayFontOf(shape: GlyphShape, size: number) {\n  if (shape.kind === 'word') {\n    return `${Math.round(size * 100) / 100}px`;\n  }\n  return '';\n}\n\n/**\n * The art on its canvas. It owns every frame: React hands it a shape and whether it may play, and\n * it asks for frames only while it plays, thirty a second while something moves and twenty while\n * it only shimmers, none off screen. Under reduced motion it draws a still frame on each change.\n */\nfunction createGlyphEngine(options: EngineOptions): GlyphEngine {\n  const { canvas, box, overlay, isStill } = options;\n  const glyphs = Array.from(options.ramp);\n  const ripples: Ripple[] = [];\n  let shape = options.shape;\n  let shapeKey = shapeKeyOf(shape);\n  let isPlaying = options.isPlaying;\n  let layout = layoutOf(box.clientWidth, box.clientHeight);\n  let backingRatio = pixelRatioOf();\n  let swarm = emptySwarm(0);\n  let ghosts = emptyGhosts(0);\n  let atlas: Atlas | null = null;\n  let pointer: Pointer | null = null;\n  let morphAt = Number.NEGATIVE_INFINITY;\n  let ghostsAt = Number.NEGATIVE_INFINITY;\n  let shimmerSeconds = 0;\n  let lastFrameAt = 0;\n  let frame = 0;\n  let timer: ReturnType<typeof setTimeout> | undefined;\n  const pen = canvas.getContext('2d');\n\n  function rebuildAtlas() {\n    const colours = inkOf(canvas);\n    atlas = atlasOf(glyphs, colours.ink, colours.lit);\n  }\n\n  /** A zoom changes the ratio without resizing the box: frames keep the ratio the canvas was sized with until the next resize. */\n  function sizeCanvas() {\n    backingRatio = pixelRatioOf();\n    const width = Math.round(layout.width * backingRatio);\n    const height = Math.round(layout.height * backingRatio);\n    if (canvas.width !== width) {\n      canvas.width = width;\n    }\n    if (canvas.height !== height) {\n      canvas.height = height;\n    }\n  }\n\n  function fieldForShape() {\n    const family = getComputedStyle(box).fontFamily;\n    const drawn = coverageOf(shape, layout, family);\n    overlay.style.fontSize = overlayFontOf(shape, drawn.wordSize);\n    return fieldOf(drawn.coverage, layout);\n  }\n\n  function settle() {\n    swarm = settledSwarmOf(fieldForShape(), layout);\n    ghosts = emptyGhosts(0);\n    morphAt = Number.NEGATIVE_INFINITY;\n  }\n\n  function isMorphing(now: number) {\n    const sinceMorph = now - morphAt;\n    return sinceMorph < MORPH_MS + MORPH_SWEEP_MS;\n  }\n\n  function dropOldRipples(now: number) {\n    for (let index = ripples.length - 1; index >= 0; index -= 1) {\n      const age = now - ripples[index].startedAt;\n      if (age > RIPPLE_LIFE_MS) {\n        ripples.splice(index, 1);\n      }\n    }\n  }\n\n  function isBusyAt(now: number) {\n    const isPointerMoving =\n      pointer !== null && now - pointer.movedAt < POINTER_SETTLED_MS;\n    const hasRipples = ripples.length > 0;\n    return isMorphing(now) || hasRipples || isPointerMoving;\n  }\n\n  function drawGhosts(\n    context: CanvasRenderingContext2D,\n    glyphAtlas: Atlas,\n    now: number\n  ) {\n    const fading = 1 - (now - ghostsAt) / GHOST_FADE_MS;\n    if (fading <= 0) {\n      ghosts = emptyGhosts(0);\n      return;\n    }\n    for (let index = 0; index < ghosts.count; index += 1) {\n      drawGlyph(\n        context,\n        glyphAtlas,\n        ghosts.light[index],\n        fading,\n        ghosts.x[index],\n        ghosts.y[index]\n      );\n    }\n  }\n\n  function draw(now: number) {\n    if (!pen || !atlas) {\n      return;\n    }\n    pen.setTransform(backingRatio, 0, 0, backingRatio, 0, 0);\n    pen.clearRect(0, 0, layout.width, layout.height);\n    dropOldRipples(now);\n    drawGhosts(pen, atlas, now);\n    const sinceMorph = now - morphAt;\n    const isMoving = !isStill;\n    for (let index = 0; index < swarm.count; index += 1) {\n      const travel = travelOf(swarm, index, sinceMorph);\n      const x =\n        swarm.fromX[index] + (swarm.toX[index] - swarm.fromX[index]) * travel;\n      const y =\n        swarm.fromY[index] + (swarm.toY[index] - swarm.fromY[index]) * travel;\n      let light =\n        swarm.fromLight[index] +\n        (swarm.toLight[index] - swarm.fromLight[index]) * travel;\n      light += pointerGlowAt(pointer, x + CELL_W / 2, y + CELL_H / 2);\n      if (isMoving) {\n        const wave = Math.sin(\n          shimmerSeconds * SHIMMER_HERTZ * TAU + swarm.phase[index]\n        );\n        light += SHIMMER * wave;\n        light += sweepAt(shimmerSeconds, x, y, layout);\n        light += rippleAt(ripples, now, x + CELL_W / 2, y + CELL_H / 2);\n      }\n      drawGlyph(pen, atlas, light, 1, x, y);\n    }\n    pen.globalAlpha = 1;\n  }\n\n  function clearPending() {\n    cancelAnimationFrame(frame);\n    clearTimeout(timer);\n    frame = 0;\n    timer = undefined;\n  }\n\n  function askForFrame() {\n    timer = undefined;\n    frame = requestAnimationFrame(tick);\n  }\n\n  function scheduleIn(wait: number) {\n    clearTimeout(timer);\n    timer = setTimeout(askForFrame, wait);\n  }\n\n  function tick(now: number) {\n    frame = 0;\n    const elapsed = Math.max(0, now - lastFrameAt);\n    lastFrameAt = now;\n    shimmerSeconds += Math.min(elapsed, 100) / 1000;\n    draw(now);\n    if (!isPlaying) {\n      return;\n    }\n    scheduleIn(frameWaitOf(isBusyAt(now)));\n  }\n\n  function start() {\n    clearPending();\n    lastFrameAt = performance.now();\n    frame = requestAnimationFrame(tick);\n  }\n\n  /**\n   * Something changed: the next frame comes as soon as the cap allows, so a pointer moving at the\n   * rate of the screen draws thirty frames a second at most, the still frame of reduced motion too.\n   */\n  function wake() {\n    if (frame !== 0) {\n      return;\n    }\n    const sinceFrame = performance.now() - lastFrameAt;\n    const wait = Math.max(0, BUSY_FRAME_MS - sinceFrame);\n    scheduleIn(wait);\n  }\n\n  function relayout() {\n    layout = layoutOf(box.clientWidth, box.clientHeight);\n    sizeCanvas();\n    rebuildAtlas();\n    settle();\n    wake();\n  }\n\n  function setShape(next: GlyphShape) {\n    const nextKey = shapeKeyOf(next);\n    if (nextKey === shapeKey) {\n      return;\n    }\n    shape = next;\n    shapeKey = nextKey;\n    if (isStill) {\n      settle();\n      wake();\n      return;\n    }\n    const now = performance.now();\n    const morphed = morphedSwarmOf(\n      swarm,\n      now - morphAt,\n      fieldForShape(),\n      layout\n    );\n    swarm = morphed.swarm;\n    ghosts = morphed.ghosts;\n    morphAt = now;\n    ghostsAt = now;\n    wake();\n  }\n\n  /** The engine is born with the first value, so hearing it again must not cancel the frame it asked for. */\n  function setPlaying(next: boolean) {\n    if (next === isPlaying) {\n      return;\n    }\n    isPlaying = next;\n    if (next) {\n      start();\n      return;\n    }\n    clearPending();\n  }\n\n  function pointAt(x: number, y: number) {\n    pointer = { x, y, movedAt: performance.now() };\n    wake();\n  }\n\n  function release() {\n    pointer = null;\n    wake();\n  }\n\n  function ripple(x: number, y: number) {\n    if (isStill) {\n      return;\n    }\n    ripples.push({ x, y, startedAt: performance.now() });\n    wake();\n  }\n\n  const resizer = new ResizeObserver(relayout);\n  resizer.observe(box);\n  const stopWatchingTheme = watchTheme(() => {\n    rebuildAtlas();\n    wake();\n  });\n  relayout();\n\n  function dispose() {\n    clearPending();\n    resizer.disconnect();\n    stopWatchingTheme();\n  }\n\n  return { setShape, setPlaying, pointAt, release, ripple, dispose };\n}\n\nfunction roleOf(shape: GlyphShape) {\n  if (shape.kind === 'icon') {\n    return 'img';\n  }\n  return undefined;\n}\n\nfunction labelOf(shape: GlyphShape) {\n  if (shape.kind === 'icon') {\n    return shape.label;\n  }\n  return undefined;\n}\n\nfunction wordOf(shape: GlyphShape) {\n  if (shape.kind === 'word') {\n    return shape.text;\n  }\n  return '';\n}\n\nexport type GlyphArtHandle = {\n  /** Lights the characters around a point given in shares of the art, as a pointer there would. */\n  pointAt: (across: number, down: number) => void;\n  release: () => void;\n  /** Sends a ripple from a point given in shares of the art. */\n  ripple: (across: number, down: number) => void;\n};\n\nexport type GlyphArtProps = {\n  shape: GlyphShape;\n  /** The characters, from the faintest to the densest. */\n  ramp?: string;\n  /** Lights the art from outside, for a demonstration. */\n  handleRef?: React.Ref<GlyphArtHandle>;\n  className?: string;\n};\n\n/**\n * A word or an icon drawn in characters on one canvas, lit from the top left: slopes that face the\n * light take dense characters in the brand ink, slopes in shade take faint ones, and a band of light\n * crosses it now and then. A new shape sends the characters travelling to their new cells; the\n * pointer lights the characters near it and a press sends a ripple. A word stays in the page as\n * text that can be selected and read; an icon is read by its label.\n */\nexport function GlyphArt({\n  shape,\n  ramp = DEFAULT_RAMP,\n  handleRef,\n  className,\n}: GlyphArtProps) {\n  const { ref, isPlaying, isStill } = usePlayWhileSeen<HTMLDivElement>();\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const overlayRef = useRef<HTMLSpanElement>(null);\n  const engine = useRef<GlyphEngine | null>(null);\n  const latestShape = useRef(shape);\n  const latestPlaying = useRef(isPlaying);\n  const isWord = shape.kind === 'word';\n\n  useEffect(() => {\n    latestShape.current = shape;\n    latestPlaying.current = isPlaying;\n  });\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const box = ref.current;\n    const overlay = overlayRef.current;\n    const hasCanvas = canvas !== null;\n    const hasBox = box !== null;\n    const hasOverlay = overlay !== null;\n    const hasParts = hasCanvas && hasBox && hasOverlay;\n    if (!hasParts) {\n      return;\n    }\n    const created = createGlyphEngine({\n      canvas,\n      box,\n      overlay,\n      ramp,\n      isStill,\n      shape: latestShape.current,\n      isPlaying: latestPlaying.current,\n    });\n    engine.current = created;\n    return () => {\n      created.dispose();\n      engine.current = null;\n    };\n  }, [ref, ramp, isStill]);\n\n  useEffect(() => {\n    engine.current?.setShape(shape);\n  }, [shape]);\n\n  useEffect(() => {\n    engine.current?.setPlaying(isPlaying);\n  }, [isPlaying]);\n\n  function pointFromEvent(event: React.PointerEvent<HTMLDivElement>) {\n    if (event.pointerType === 'touch') {\n      return;\n    }\n    const box = event.currentTarget.getBoundingClientRect();\n    engine.current?.pointAt(event.clientX - box.left, event.clientY - box.top);\n  }\n\n  function rippleFromPress(event: React.PointerEvent<HTMLDivElement>) {\n    const box = event.currentTarget.getBoundingClientRect();\n    engine.current?.ripple(event.clientX - box.left, event.clientY - box.top);\n  }\n\n  function release() {\n    engine.current?.release();\n  }\n\n  useImperativeHandle(handleRef, () => ({\n    pointAt(across: number, down: number) {\n      const box = ref.current;\n      if (!box) {\n        return;\n      }\n      engine.current?.pointAt(\n        across * box.clientWidth,\n        down * box.clientHeight\n      );\n    },\n    release,\n    ripple(across: number, down: number) {\n      const box = ref.current;\n      if (!box) {\n        return;\n      }\n      engine.current?.ripple(across * box.clientWidth, down * box.clientHeight);\n    },\n  }));\n\n  return (\n    <div\n      ref={ref}\n      role={roleOf(shape)}\n      aria-label={labelOf(shape)}\n      onPointerMove={pointFromEvent}\n      onPointerLeave={release}\n      onPointerDown={rippleFromPress}\n      className={cn('relative aspect-[2/1] w-full', className)}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden='true'\n        className={cn(\n          'text-foreground absolute inset-0 size-full',\n          isWord && 'forced-colors:hidden'\n        )}\n      />\n      <span\n        ref={overlayRef}\n        className='selection:bg-primary/20 absolute inset-0 flex items-center justify-center leading-none font-semibold whitespace-nowrap text-transparent selection:text-transparent forced-colors:text-[color:CanvasText]'\n      >\n        {wordOf(shape)}\n      </span>\n    </div>\n  );\n}\n\ntype ShapeChoice = 'word' | 'bolt' | 'drop';\n\ntype GlyphBeat =\n  | { after: number; kind: 'shape'; shape: ShapeChoice }\n  | { after: number; kind: 'ripple'; across: number; down: number }\n  | { after: number; kind: 'point'; across: number; down: number }\n  | { after: number; kind: 'release' };\n\nconst SHAPES: Record<ShapeChoice, GlyphShape> = {\n  word: { kind: 'word', text: 'Light' },\n  bolt: {\n    kind: 'icon',\n    path: 'M13.2 1.8 4.6 13.4h6.1l-1.9 8.8 9.6-12.6h-6.3l1.1-7.8Z',\n    size: 24,\n    label: 'A lightning bolt drawn in characters',\n  },\n  drop: {\n    kind: 'icon',\n    path: 'M12 2.5c4 4.8 6.5 8 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10.5 8 7.3 12 2.5Z',\n    size: 24,\n    label: 'A water drop drawn in characters',\n  },\n};\n\nconst CHOICES: Segment<ShapeChoice>[] = [\n  { value: 'word', label: 'Word' },\n  { value: 'bolt', label: 'Bolt' },\n  { value: 'drop', label: 'Drop' },\n];\n\n/** A pointer passing across the middle of the art from left to right. */\nconst PASS: GlyphBeat[] = [0.18, 0.3, 0.42, 0.54, 0.66, 0.78].map(\n  (across): GlyphBeat => ({ after: 110, kind: 'point', across, down: 0.5 })\n);\n\nconst GLYPH_BEATS: GlyphBeat[] = [\n  { after: 1600, kind: 'ripple', across: 0.32, down: 0.45 },\n  { after: 2400, kind: 'shape', shape: 'bolt' },\n  { after: 2200, kind: 'point', across: 0.06, down: 0.5 },\n  ...PASS,\n  { after: 300, kind: 'release' },\n  { after: 1600, kind: 'shape', shape: 'drop' },\n  { after: 2000, kind: 'ripple', across: 0.5, down: 0.62 },\n  { after: 2400, kind: 'shape', shape: 'word' },\n];\n\nexport default function TextEffect04() {\n  const [choice, setChoice] = useState<ShapeChoice>('word');\n  const art = useRef<GlyphArtHandle>(null);\n  const { isHovered, hoverHandlers } = useHover();\n  const steps: AutoplayStep[] = GLYPH_BEATS.map((beat) => ({\n    after: beat.after,\n    run: () => playBeat(beat),\n  }));\n  const { ref, isAutoplaying } = useAutoplay(steps, {\n    loopAfter: 2200,\n    onReset: () => setChoice('word'),\n    isEnabled: !isHovered,\n  });\n  const isDemoStopped = !isAutoplaying && !isHovered;\n\n  /** A light the demonstration's pointer left on the art goes out once a person takes over. */\n  useEffect(() => {\n    if (isDemoStopped) {\n      art.current?.release();\n    }\n  }, [isDemoStopped]);\n\n  function playBeat(beat: GlyphBeat) {\n    if (beat.kind === 'shape') {\n      setChoice(beat.shape);\n      return;\n    }\n    if (beat.kind === 'ripple') {\n      art.current?.ripple(beat.across, beat.down);\n      return;\n    }\n    if (beat.kind === 'point') {\n      art.current?.pointAt(beat.across, beat.down);\n      return;\n    }\n    art.current?.release();\n  }\n\n  return (\n    <div\n      ref={ref}\n      className='flex w-full max-w-[36rem] flex-col items-center gap-4'\n    >\n      <div className='w-full' {...hoverHandlers}>\n        <GlyphArt handleRef={art} shape={SHAPES[choice]} />\n      </div>\n      <Segments\n        value={choice}\n        segments={CHOICES}\n        onValueChange={setChoice}\n        label='Shape'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/text-effect/text-effect-04.tsx"
    },
    {
      "path": "registry/ndeko/components/text-effect/_parts/use-hover.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\n/** Whether a mouse or a pen rests on an element. A finger does not hover, so a touch never counts. */\nexport function useHover() {\n  const [isHovered, setIsHovered] = useState(false);\n\n  function hoverStarts(event: React.PointerEvent<HTMLElement>) {\n    if (event.pointerType !== 'touch') {\n      setIsHovered(true);\n    }\n  }\n\n  function hoverEnds() {\n    setIsHovered(false);\n  }\n\n  const hoverHandlers = {\n    onPointerEnter: hoverStarts,\n    onPointerLeave: hoverEnds,\n  };\n\n  return { isHovered, hoverHandlers };\n}\n",
      "type": "registry:component",
      "target": "components/text-effect/_parts/use-hover.ts"
    },
    {
      "path": "registry/ndeko/components/text-effect/_parts/use-play-while-seen.ts",
      "content": "'use client';\n\nimport { useInView } from 'motion/react';\nimport { useRef } from 'react';\n\nimport { useDocumentVisible } from '@/registry/ndeko/hooks/use-agent-run';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\n\n/**\n * A text effect plays only while someone can see it: on screen, in the tab that is shown, without\n * reduced motion. Everything that repeats hangs on `isPlaying`, and `isStill` asks for the still\n * frame, which must read as the finished text.\n */\nexport function usePlayWhileSeen<Element extends HTMLElement = HTMLElement>() {\n  const ref = useRef<Element>(null);\n  const isInView = useInView(ref);\n  const isTabVisible = useDocumentVisible();\n  const isStill = usePrefersReducedMotion();\n  const isOnScreen = isInView && isTabVisible;\n  const isPlaying = isOnScreen && !isStill;\n\n  return { ref, isPlaying, isStill };\n}\n",
      "type": "registry:component",
      "target": "components/text-effect/_parts/use-play-while-seen.ts"
    }
  ],
  "categories": [
    "components",
    "text-effect"
  ],
  "type": "registry:component"
}