{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "damask-float",
  "title": "Damask Float",
  "description": "A feature grid woven from one reversible damask cloth — figure and ground are the same thread in the same colour, differing only in float direction, so hovering a cell flips the anisotropic highlight instantly with no fade and no translate.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/damask-float/component.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  useContext,\n  useEffect,\n  useId,\n  useRef,\n  type HTMLAttributes,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// DamaskFloat — a feature grid woven from ONE cloth. Damask is figured cloth\n// made from a single thread in a single colour: the pattern exists only\n// because the ground and the figure are reciprocal satin structures (warp\n// floats over the ground, weft floats over the figure), so the two regions\n// share identical fibre and identical colour and differ only in WHICH\n// direction the exposed thread runs. That azimuth difference changes the\n// surface's specular anisotropy — nothing else — which is why this is\n// natively monochrome and why hover can flip figure and ground with no fade\n// and no translate: reversing S at a texel is reversing which structure is\n// on top, not crossfading a colour.\n//\n// Per cell, a 2D canvas evaluates a Kajiya-Kay anisotropic term per texel\n// against a FIXED light (elevation 34deg, azimuth 15deg) with a straight-on\n// view, using a satin structure grid at pitch P = 0.018 * min(w,h): a 5-end\n// satin (counter-step 2) gives every texel a binding-point mask that breaks\n// the float and darkens it, which is what stops the two regions reading as\n// flat greys. The raw anisotropic response only DRIVES the specular streak;\n// the base tone of ground vs figure is pinned to fixed per-theme luminance\n// stops (measured warp:weft ratio ~1.6-2.2:1) so contrast survives even\n// where the streak formula is weak, per the light-theme floor rule.\n//\n// Alive at rest, unconditionally: (1) TAKE-UP — the cloth's border motif\n// advances upward at TAKEUP = 0.021*min(w,h) px/s and wraps, so new cloth\n// is always entering the frame; the pinned figure stays fixed in cell space\n// so it never scrolls out. (2) LOOM SWAY — the light stays fixed but the\n// cloth's own grain azimuth oscillates +-3.5deg on an 8.7s period plus a\n// second, incommensurate +-1.2deg harmonic at 13.4s, so the specular streak\n// (a cos^26 lobe) visibly travels across the surface and the state never\n// repeats on a single beat. Neither mechanism converges; a still cloth is\n// not a state this component has.\n//\n// Hover reverses S -> 1-S for texels the pointer's entry has reached, a\n// front expanding at 0.9*min(w,h) px/s from the entry point — a real change\n// in which structure is exposed, not an opacity cross-fade. Leaving the\n// cell reverts instantly (a real damask has no \"in-between\" float either).\n//\n// Text sits on a plain-weave (tabby) patch that the front passes BEHIND —\n// tabby has no floats and no anisotropy, so it is pinned to a fixed L in\n// each theme regardless of S or the front, and it additionally sits under a\n// standard token scrim (bg-background/70 backdrop-blur) per house rule, so\n// the DOM heading/body are never depending on the canvas alone for contrast.\n//\n// Tokens: --background/--foreground read via getComputedStyle and re-read on\n// a MutationObserver watching documentElement's class; every rendered pixel\n// is a channel-wise blend of those two live RGB triples, never a literal\n// colour. --ns-accent never appears in the streak — it is interaction chrome\n// only (the focus ring), which is what the climactic-moment rule asks for.\n// ---------------------------------------------------------------------------\n\n// ---- shared numbers ---------------------------------------------------\n\nconst P_FACTOR = 0.018; // thread pitch as a fraction of the cell's smaller dimension\nconst TAKEUP_FACTOR = 0.021; // px/s, fraction of smaller dimension\nconst FRONT_SPEED_FACTOR = 0.9; // px/s, fraction of smaller dimension\nconst SATIN_PERIOD = 5; // 5-end satin\nconst SATIN_COUNTER_STEP = 2;\nconst SWAY_PERIOD_1_MS = 8700;\nconst SWAY_AMP_1_DEG = 3.5;\nconst SWAY_PERIOD_2_MS = 13400;\nconst SWAY_AMP_2_DEG = 1.2;\nconst STATIC_TIME_MS = 5400; // reduced-motion freeze frame, named in the spec\nconst LIGHT_ELEV_DEG = 34;\nconst LIGHT_AZ_DEG = 15;\nconst KD = 0.34;\nconst KS = 0.52;\nconst N_EXP = 26;\nconst DEG2RAD = Math.PI / 180;\n\nconst LIGHT_VEC = (() => {\n  const elev = LIGHT_ELEV_DEG * DEG2RAD;\n  const az = LIGHT_AZ_DEG * DEG2RAD;\n  return { x: Math.cos(elev) * Math.cos(az), y: Math.cos(elev) * Math.sin(az) };\n})();\n\n// Target luminances (0 = darkest, 1 = lightest), per theme, from the spec's\n// measured stops. Ground (warp-face) reads brighter than figure (weft-face)\n// in both themes; only the bias/contrast move between themes, never the\n// direction — see readTargetL below.\nconst GROUND_BODY_L = { light: 0.58, dark: 0.46 };\nconst FIGURE_BODY_L = { light: 0.29, dark: 0.2 };\nconst BINDING_RATIO = { light: 0.08 / 0.29, dark: 0.05 / 0.2 }; // binding point darkens the float by this fraction\nconst GROUND_STREAK_L = { light: 0.84, dark: 0.78 };\nconst STREAK_PEAK_L = { light: 0.96, dark: 0.98 };\nconst TABBY_L = { light: 0.68, dark: 0.24 };\n\nfunction clamp01(v: number): number {\n  return v < 0 ? 0 : v > 1 ? 1 : v;\n}\nfunction lerp(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\nfunction smoothstep(x: number, e0: number, e1: number): number {\n  const t = clamp01((x - e0) / (e1 - e0));\n  return t * t * (3 - 2 * t);\n}\n\n/** Deterministic integer hash -> [0,1), used for the satin twist jitter so\n * the reduced-motion frame is byte-stable — never Math.random(). */\nfunction hash2(ix: number, iy: number): number {\n  let h = (ix | 0) * 374761393 + (iy | 0) * 668265263;\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  h ^= h >>> 16;\n  return (h >>> 0) / 4294967296;\n}\n\nfunction swayAngleDeg(elapsedMs: number): number {\n  const a = Math.sin((2 * Math.PI * elapsedMs) / SWAY_PERIOD_1_MS) * SWAY_AMP_1_DEG;\n  const b = Math.sin((2 * Math.PI * elapsedMs) / SWAY_PERIOD_2_MS) * SWAY_AMP_2_DEG;\n  return a + b;\n}\n\nfunction prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\n// ---- token read (zero colour literals: always live RGB from --background/--foreground) ----\n\nlet probeCanvas: HTMLCanvasElement | null = null;\nlet probeCtx: CanvasRenderingContext2D | null = null;\n\nfunction colorToRGB(cssColor: string): [number, number, number] {\n  if (!probeCanvas) {\n    probeCanvas = document.createElement(\"canvas\");\n    probeCanvas.width = 1;\n    probeCanvas.height = 1;\n    probeCtx = probeCanvas.getContext(\"2d\", { willReadFrequently: true });\n  }\n  if (!probeCtx || !cssColor) return [0, 0, 0];\n  probeCtx.fillStyle = \"#000\";\n  probeCtx.fillStyle = cssColor;\n  probeCtx.fillRect(0, 0, 1, 1);\n  const d = probeCtx.getImageData(0, 0, 1, 1).data;\n  return [d[0], d[1], d[2]];\n}\n\ninterface TokenRGB {\n  bg: [number, number, number];\n  fg: [number, number, number];\n  bgY: number; // simple channel-average proxy for luminance, 0..1\n  fgY: number;\n  isDark: boolean;\n}\n\nfunction readTokens(): TokenRGB {\n  const cs = getComputedStyle(document.documentElement);\n  const bg = colorToRGB(cs.getPropertyValue(\"--background\").trim());\n  const fg = colorToRGB(cs.getPropertyValue(\"--foreground\").trim());\n  const bgY = (bg[0] + bg[1] + bg[2]) / 765;\n  const fgY = (fg[0] + fg[1] + fg[2]) / 765;\n  return { bg, fg, bgY, fgY, isDark: bgY < 0.5 };\n}\n\n/** target L (0=darkest,1=lightest) -> alpha of --foreground composited over\n * --background, solved from the LIVE token luminances so a re-skin (theme\n * swap, or any token change caught by the MutationObserver) is honoured\n * automatically rather than baked in. */\nfunction alphaForTargetL(targetL: number, tok: TokenRGB): number {\n  const span = tok.fgY - tok.bgY;\n  if (Math.abs(span) < 1e-4) return 0;\n  return clamp01((targetL - tok.bgY) / span);\n}\n\nfunction blendChannel(bg: number, fg: number, alpha: number): number {\n  return bg + (fg - bg) * alpha;\n}\n\n// ---- shared clock / token context (one rAF loop, N cells) --------------\n\ninterface TickPayload {\n  elapsedMs: number;\n  tokens: TokenRGB;\n  qualityScale: number; // 1 = full buffer res, shrinks under sustained slow frames\n}\n\ntype TickFn = (payload: TickPayload) => void;\n\ninterface GridApi {\n  subscribe(fn: TickFn): () => void;\n  reportFrameCost(ms: number): void;\n}\n\nconst GridContext = createContext<GridApi | null>(null);\n\nconst QUALITY_STEPS = [1, 0.75, 0.55];\nconst FRAME_BUDGET_MS = 8;\nconst SLOW_SUSTAIN_MS = 900;\n\nexport interface DamaskFloatGridProps extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n  children: ReactNode;\n}\n\nexport function DamaskFloatGrid({ children, className = \"\", ...rest }: DamaskFloatGridProps) {\n  const subsRef = useRef<Set<TickFn>>(new Set());\n  const apiRef = useRef<GridApi>({\n    subscribe(fn) {\n      subsRef.current.add(fn);\n      return () => {\n        subsRef.current.delete(fn);\n      };\n    },\n    reportFrameCost() {\n      /* replaced in the effect below */\n    },\n  });\n\n  useEffect(() => {\n    if (prefersReducedMotion() || typeof window === \"undefined\") return;\n\n    let tokens = readTokens();\n    const themeObserver = new MutationObserver(() => {\n      tokens = readTokens();\n    });\n    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    let qualityScale = 1;\n    let overBudgetSinceMs = -1;\n    let stepIndex = 0;\n    apiRef.current.reportFrameCost = (ms: number) => {\n      const now = performance.now();\n      if (ms > FRAME_BUDGET_MS) {\n        if (overBudgetSinceMs < 0) overBudgetSinceMs = now;\n        if (now - overBudgetSinceMs > SLOW_SUSTAIN_MS && stepIndex < QUALITY_STEPS.length - 1) {\n          stepIndex += 1;\n          qualityScale = QUALITY_STEPS[stepIndex];\n          overBudgetSinceMs = -1;\n        }\n      } else {\n        overBudgetSinceMs = -1;\n      }\n    };\n\n    const origin = performance.now();\n    let raf = 0;\n    const loop = () => {\n      const elapsedMs = performance.now() - origin;\n      const payload: TickPayload = { elapsedMs, tokens, qualityScale };\n      subsRef.current.forEach((fn) => fn(payload));\n      raf = requestAnimationFrame(loop);\n    };\n    raf = requestAnimationFrame(loop);\n    return () => {\n      cancelAnimationFrame(raf);\n      themeObserver.disconnect();\n    };\n  }, []);\n\n  return (\n    <GridContext.Provider value={apiRef.current}>\n      <div {...rest} className={className}>\n        {children}\n      </div>\n    </GridContext.Provider>\n  );\n}\n\n// ---- card ----------------------------------------------------------------\n\nexport interface DamaskFloatCardProps {\n  /** Card heading, drawn as real DOM text over a fixed plain-weave patch. */\n  heading: string;\n  /** Supporting copy, same patch. */\n  body: string;\n  /** SVG path `d` (0 0 24 24 viewBox) rasterised into the cloth as the\n   * pinned figure — this is the \"icon\", woven rather than printed. */\n  iconPath?: string;\n  href?: string;\n  className?: string;\n}\n\nconst DEFAULT_ICON_PATH =\n  \"M12 3l2.2 5.8L20 11l-5.8 2.2L12 19l-2.2-5.8L4 11l5.8-2.2L12 3z\";\n\ninterface PatchRect {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\nexport function DamaskFloatCard({ heading, body, iconPath, href, className = \"\" }: DamaskFloatCardProps) {\n  const reactId = useId();\n  const hostRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const patchElRef = useRef<HTMLDivElement>(null);\n  const ctxRef = useRef<CanvasRenderingContext2D | null>(null);\n  const bufCanvasRef = useRef<HTMLCanvasElement | null>(null);\n  const bufCtxRef = useRef<CanvasRenderingContext2D | null>(null);\n  const iconMaskRef = useRef<Uint8Array | null>(null);\n  const sizeRef = useRef({ cssW: 0, cssH: 0, dpr: 1, bufW: 0, bufH: 0, minDim: 1 });\n  const patchRef = useRef<PatchRect>({ x: 0, y: 0, w: 0, h: 0 });\n  const frontRef = useRef<{ ox: number; oy: number; startMs: number } | null>(null);\n  const visibleRef = useRef(true);\n  const qualityRef = useRef(1);\n  const grid = useContext(GridContext);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    const canvas = canvasRef.current;\n    if (!host || !canvas) return;\n\n    const ctx = canvas.getContext(\"2d\");\n    ctxRef.current = ctx;\n    const bufCanvas = document.createElement(\"canvas\");\n    bufCanvasRef.current = bufCanvas;\n    bufCtxRef.current = bufCanvas.getContext(\"2d\", { willReadFrequently: true });\n    if (!ctx || !bufCtxRef.current) return;\n\n    const rebuildIconMask = () => {\n      const { bufW, bufH, minDim } = sizeRef.current;\n      if (bufW <= 0 || bufH <= 0) return;\n      const iconCanvas = document.createElement(\"canvas\");\n      iconCanvas.width = bufW;\n      iconCanvas.height = bufH;\n      const ictx = iconCanvas.getContext(\"2d\");\n      if (!ictx) return;\n      const box = minDim * 0.4 * (bufW / sizeRef.current.cssW || 1);\n      const scale = box / 24;\n      ictx.save();\n      ictx.translate(bufW / 2 - box / 2, bufH * 0.16);\n      ictx.scale(scale, scale);\n      ictx.fillStyle = \"#000\";\n      ictx.fill(new Path2D(iconPath ?? DEFAULT_ICON_PATH));\n      ictx.restore();\n      const data = ictx.getImageData(0, 0, bufW, bufH).data;\n      const mask = new Uint8Array(bufW * bufH);\n      for (let i = 0; i < mask.length; i++) mask[i] = data[i * 4 + 3] > 96 ? 1 : 0;\n      iconMaskRef.current = mask;\n    };\n\n    const measure = () => {\n      const rect = host.getBoundingClientRect();\n      const cssW = Math.max(1, rect.width);\n      const cssH = Math.max(1, rect.height);\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const minDim = Math.min(cssW, cssH);\n      const q = qualityRef.current;\n      // half device resolution per the spec, further shrunk by the adaptive\n      // quality scale, and hard-capped so a large card never over-spends.\n      const bufW = Math.min(320, Math.round(cssW * dpr * 0.5 * q));\n      const bufH = Math.min(320, Math.round(cssH * dpr * 0.5 * q));\n      sizeRef.current = { cssW, cssH, dpr, bufW, bufH, minDim };\n\n      canvas.width = Math.round(cssW * dpr);\n      canvas.height = Math.round(cssH * dpr);\n      canvas.style.width = `${cssW}px`;\n      canvas.style.height = `${cssH}px`;\n      bufCanvas.width = bufW;\n      bufCanvas.height = bufH;\n\n      if (patchElRef.current) {\n        const pr = patchElRef.current.getBoundingClientRect();\n        patchRef.current = { x: pr.left - rect.left, y: pr.top - rect.top, w: pr.width, h: pr.height };\n      }\n      rebuildIconMask();\n      // NO PAINT BEFORE THE FIRST TOKEN READ: draw() below pulls tokens from\n      // the tick payload / an explicit read, never from a hardcoded default.\n      if (staticTokensRef.current) draw(staticElapsedRef.current, staticTokensRef.current);\n    };\n\n    const staticTokensRef: { current: TokenRGB | null } = { current: null };\n    const staticElapsedRef = { current: STATIC_TIME_MS };\n\n    const draw = (elapsedMs: number, tokens: TokenRGB) => {\n      const bctx = bufCtxRef.current;\n      const { bufW, bufH, cssW, minDim } = sizeRef.current;\n      if (!bctx || bufW <= 0 || bufH <= 0) return;\n\n      // Buffer pixels per CSS pixel — folds in DPR, the half-resolution rule\n      // and the adaptive quality scale all at once, so every length below\n      // (thread pitch, take-up, front radius) only has to be defined once,\n      // in CSS px, and converted through this single factor.\n      const bufPerCss = bufW / cssW;\n      const cssPerBuf = cssW / bufW;\n      const P = Math.max(1.2, P_FACTOR * minDim * bufPerCss);\n\n      const takeupBuf = TAKEUP_FACTOR * minDim * bufPerCss * (elapsedMs / 1000);\n      const repeatBuf = P * 8;\n      const swayDeg = swayAngleDeg(elapsedMs);\n\n      const front = frontRef.current;\n      const frontRadiusCss = front ? FRONT_SPEED_FACTOR * minDim * ((elapsedMs - front.startMs) / 1000) : -1;\n\n      const img = bctx.createImageData(bufW, bufH);\n      const d = img.data;\n      const iconMask = iconMaskRef.current;\n      const patch = patchRef.current;\n\n      const groundL = tokens.isDark ? GROUND_BODY_L.dark : GROUND_BODY_L.light;\n      const figureL = tokens.isDark ? FIGURE_BODY_L.dark : FIGURE_BODY_L.light;\n      const bindRatio = tokens.isDark ? BINDING_RATIO.dark : BINDING_RATIO.light;\n      const streakUnderL = tokens.isDark ? GROUND_STREAK_L.dark : GROUND_STREAK_L.light;\n      const streakPeakL = tokens.isDark ? STREAK_PEAK_L.dark : STREAK_PEAK_L.light;\n      const tabbyL = tokens.isDark ? TABBY_L.dark : TABBY_L.light;\n\n      for (let by = 0; by < bufH; by++) {\n        const yCloth = by + takeupBuf;\n        const iyBind = Math.floor(((yCloth % repeatBuf) + repeatBuf) % repeatBuf / P);\n        const xCssBase = 0;\n        const yCss = by * cssPerBuf;\n        const inPatchY = yCss >= patch.y - 4 && yCss <= patch.y + patch.h + 4;\n        for (let bx = 0; bx < bufW; bx++) {\n          const idx = (by * bufW + bx) * 4;\n          const xCss = xCssBase + bx * cssPerBuf;\n\n          if (inPatchY && xCss >= patch.x - 4 && xCss <= patch.x + patch.w + 4) {\n            const alpha = alphaForTargetL(tabbyL, tokens);\n            d[idx] = blendChannel(tokens.bg[0], tokens.fg[0], alpha);\n            d[idx + 1] = blendChannel(tokens.bg[1], tokens.fg[1], alpha);\n            d[idx + 2] = blendChannel(tokens.bg[2], tokens.fg[2], alpha);\n            d[idx + 3] = 255;\n            continue;\n          }\n\n          const ix = Math.floor(bx / P);\n          const maskIdx = by * bufW + bx;\n          const pinned = iconMask ? iconMask[maskIdx] === 1 : 0;\n          const diamondU = ((bx % repeatBuf) + repeatBuf) % repeatBuf - repeatBuf / 2;\n          const diamondV = ((yCloth % repeatBuf) + repeatBuf) % repeatBuf - repeatBuf / 2;\n          const motif = Math.abs(diamondU) + Math.abs(diamondV) < repeatBuf * 0.28 ? 1 : 0;\n          let S = pinned || motif ? 1 : 0;\n\n          if (front && frontRadiusCss >= 0) {\n            const dx = xCss - front.ox;\n            const dy = yCss - front.oy;\n            if (dx * dx + dy * dy < frontRadiusCss * frontRadiusCss) S = 1 - S;\n          }\n\n          const bindingKey = ((ix + SATIN_COUNTER_STEP * iyBind) % SATIN_PERIOD + SATIN_PERIOD) % SATIN_PERIOD === 0;\n\n          const baseAzimuthDeg = S ? 0 : 90; // ground assigned the azimuth further from the light -> brighter lobe, matching the measured warp>weft ratio\n          const jitterDeg = (hash2(ix, iyBind) - 0.5) * 8;\n          const phi = (baseAzimuthDeg + jitterDeg + swayDeg) * DEG2RAD;\n          const Tx = Math.cos(phi);\n          const Ty = Math.sin(phi);\n          const sinLT = Math.max(0, LIGHT_VEC.x * Tx + LIGHT_VEC.y * Ty);\n          const cosLT = Math.sqrt(Math.max(0, 1 - sinLT * sinLT));\n          const F = KD * sinLT + KS * Math.pow(cosLT, N_EXP);\n\n          let baseL = S ? figureL : groundL;\n          if (bindingKey) baseL *= bindRatio;\n\n          const streakFactor = smoothstep(F, 0.35, 0.86);\n          const crownFactor = smoothstep(F, 0.7, 0.86);\n          let targetL = lerp(baseL, streakUnderL, streakFactor);\n          targetL = lerp(targetL, streakPeakL, crownFactor);\n\n          const alpha = alphaForTargetL(targetL, tokens);\n          d[idx] = blendChannel(tokens.bg[0], tokens.fg[0], alpha);\n          d[idx + 1] = blendChannel(tokens.bg[1], tokens.fg[1], alpha);\n          d[idx + 2] = blendChannel(tokens.bg[2], tokens.fg[2], alpha);\n          d[idx + 3] = 255;\n        }\n      }\n\n      bctx.putImageData(img, 0, 0);\n      ctx.imageSmoothingEnabled = true;\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      ctx.drawImage(bufCanvas, 0, 0, bufW, bufH, 0, 0, canvas.width, canvas.height);\n    };\n\n    const ro = new ResizeObserver(() => measure());\n    ro.observe(host);\n\n    let io: IntersectionObserver | null = null;\n    let onVisChange: (() => void) | null = null;\n\n    if (prefersReducedMotion()) {\n      const tokens = readTokens();\n      staticTokensRef.current = tokens;\n      measure();\n      draw(STATIC_TIME_MS, tokens);\n      const themeObserver = new MutationObserver(() => {\n        const t = readTokens();\n        staticTokensRef.current = t;\n        draw(STATIC_TIME_MS, t);\n      });\n      themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n      return () => {\n        ro.disconnect();\n        themeObserver.disconnect();\n      };\n    }\n\n    // First token read happens here, before the first measure()/draw() —\n    // measure() only paints once staticTokensRef (used as the \"last known\n    // tokens\" cache for resize-triggered repaints) is populated.\n    const initialTokens = readTokens();\n    staticTokensRef.current = initialTokens;\n    staticElapsedRef.current = 0;\n    measure();\n\n    io = new IntersectionObserver((entries) => {\n      visibleRef.current = entries[0]?.isIntersecting ?? true;\n    }, { threshold: 0 });\n    io.observe(host);\n    onVisChange = () => {\n      visibleRef.current = visibleRef.current && document.visibilityState === \"visible\";\n    };\n    document.addEventListener(\"visibilitychange\", onVisChange);\n\n    const tick = ({ elapsedMs, tokens, qualityScale }: TickPayload) => {\n      staticTokensRef.current = tokens;\n      staticElapsedRef.current = elapsedMs;\n      if (!visibleRef.current || document.visibilityState !== \"visible\") return;\n      if (qualityScale !== qualityRef.current) {\n        qualityRef.current = qualityScale;\n        measure();\n      }\n      const t0 = performance.now();\n      draw(elapsedMs, tokens);\n      grid?.reportFrameCost(performance.now() - t0);\n    };\n\n    let unsubscribe: (() => void) | null = null;\n    let raf = 0;\n    if (grid) {\n      unsubscribe = grid.subscribe(tick);\n    } else {\n      // Standalone fallback: own rAF loop and own token watcher, mirroring\n      // GrazingLight's convention so a card works without a wrapping grid.\n      let tokens = initialTokens;\n      const themeObserver = new MutationObserver(() => {\n        tokens = readTokens();\n      });\n      themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n      const origin = performance.now();\n      const loop = () => {\n        tick({ elapsedMs: performance.now() - origin, tokens, qualityScale: qualityRef.current });\n        raf = requestAnimationFrame(loop);\n      };\n      raf = requestAnimationFrame(loop);\n      unsubscribe = () => {\n        cancelAnimationFrame(raf);\n        themeObserver.disconnect();\n      };\n    }\n\n    return () => {\n      ro.disconnect();\n      io?.disconnect();\n      if (onVisChange) document.removeEventListener(\"visibilitychange\", onVisChange);\n      unsubscribe?.();\n    };\n  }, [grid, iconPath]);\n\n  const onPointerEnter = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const rect = hostRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    frontRef.current = {\n      ox: e.clientX - rect.left,\n      oy: e.clientY - rect.top,\n      startMs: performance.now(),\n    };\n  };\n  const onPointerLeave = () => {\n    frontRef.current = null;\n  };\n\n  const patchId = `ns-df-patch-${reactId.replace(/:/g, \"\")}`;\n\n  return (\n    <div\n      ref={hostRef}\n      onPointerEnter={onPointerEnter}\n      onPointerLeave={onPointerLeave}\n      className={`ns-df-card relative flex min-h-[150px] flex-col overflow-hidden rounded-lg border border-border ${className}`}\n    >\n      <canvas ref={canvasRef} className=\"absolute inset-0 h-full w-full\" aria-hidden=\"true\" />\n      {href ? (\n        // Stretched-link pattern: the whole card is the hit target and the\n        // accessible name, but the host stays a plain div so the pointer\n        // events driving the front reversal are never entangled with anchor\n        // semantics or a second focus ring.\n        <a\n          href={href}\n          className=\"absolute inset-0 z-20 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent\"\n          aria-labelledby={patchId}\n        />\n      ) : null}\n      <div\n        ref={patchElRef}\n        id={patchId}\n        className=\"relative z-10 mt-auto flex flex-col gap-1 rounded-md bg-background/70 p-3.5 backdrop-blur-sm\"\n      >\n        <h3 className=\"text-[0.9375rem] font-semibold leading-snug tracking-tight text-foreground\">{heading}</h3>\n        <p className=\"text-[0.8125rem] leading-snug text-ns-muted\">{body}</p>\n      </div>\n    </div>\n  );\n}\n\nexport default DamaskFloatCard;\n",
      "type": "registry:ui",
      "target": "components/ui/damask-float.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "card",
      "feature-grid",
      "canvas",
      "textile",
      "anisotropic",
      "hover",
      "grid",
      "monochrome"
    ],
    "instruction": "Build two exports. DamaskFloatGrid<{children}> is an optional wrapping provider that owns ONE rAF loop and a MutationObserver on documentElement's class, broadcasting {elapsedMs, tokens, qualityScale} to every subscribed card each frame (same single-loop idiom as this repo's GrazingLightGrid/MagneticDock) — a card mounted without one runs the identical loop standalone. DamaskFloatCard<{heading, body, iconPath?, href?, className?}> is a single card: an absolutely-positioned 2D canvas fills the card (w-full h-full, DPR-aware backing store capped at 2, rendered into an offscreen buffer at HALF that resolution then upscaled via ctx.drawImage with imageSmoothingEnabled, ResizeObserver on the host element re-deriving every length), with the heading/body as real DOM text in a bg-background/70 backdrop-blur-sm scrim anchored to the card's bottom (an optional href renders a stretched <a> overlay for the hit target, keeping the host div's own pointer events dedicated to the hover front below).\n\nTHE MATERIAL: damask is figured cloth from ONE thread in ONE colour — ground and figure are reciprocal satin structures (5-end satin, counter-step 2) that differ only in which direction the exposed thread runs, so the whole component is monochrome by physical necessity, not by constraint. Per texel: thread pitch P = 0.018 * min(cardW, cardH) (in buffer-pixel space, after DPR and the half-resolution and adaptive-quality factors are folded in). S (0=ground/warp-face, 1=figure/weft-face) comes from a rasterised icon mask (the `iconPath`, an SVG path `d` in a 0 0 24 24 viewBox, filled via Path2D onto an offscreen canvas once per resize — pinned in cell coordinates, never scrolling) OR-ed with a procedural diamond/lozenge border motif tiled at 8*P and evaluated against a cloth-space Y that DOES scroll. Binding points break the float: bindingKey = (ix + 2*iy) mod 5 === 0 where ix/iy are integer thread indices, darkening that texel's target luminance by the ratio 0.08/0.29 (light) or 0.05/0.20 (dark) of its region's base tone — this is what stops ground/figure reading as two flat greys.\n\nSHADING: a Kajiya-Kay anisotropic term against a FIXED light (elevation 34deg, azimuth 15deg, straight-on view) with kd=0.34, ks=0.52, n=26: sinLT = max(0, L.T) where T is the thread tangent from its azimuth (ground assigned 0deg, figure 90deg, each jittered +-4deg by an integer hash of (ix,iy) — never Math.random, so the reduced-motion frame stays byte-stable), cosLT = sqrt(1-sinLT^2), F = kd*sinLT + ks*cosLT^n. F only DRIVES the specular streak (smoothstepped onto two escalating target-luminance stops, 'ground under streak' and 'streak peak on a float crown'); the base tone of ground vs figure is pinned to fixed per-theme luminance targets (ground 0.58/0.46, figure 0.29/0.20, light/dark) so the measured ~1.6-2.2:1 warp:weft contrast survives even where F is weak. Every target luminance is converted to a pixel by solving alpha = (targetL - bgY)/(fgY - bgY) against the LIVE --background/--foreground token luminances (read via getComputedStyle, re-read on the class MutationObserver) and blending each RGB channel bg+alpha*(fg-bg) — zero colour literals, and a theme swap or any token change is honoured automatically since nothing is baked in.\n\nALIVE AT REST, unconditionally: (1) take-up — the border motif's cloth-space Y advances at 0.021*min(w,h) px/s and wraps, so new cloth is always entering the frame while the pinned icon never scrolls; (2) loom sway — the light stays fixed but the cloth's own grain azimuth oscillates on two incommensurate sinusoids, +-3.5deg at an 8.7s period plus +-1.2deg at 13.4s, so the n=26 specular streak visibly travels and the state never repeats on one beat. Neither mechanism converges.\n\nHOVER: on pointerenter, record the entry point in card-local CSS px and the entry time; every texel within a front expanding from that point at 0.9*min(w,h) px/s gets S flipped to 1-S — a real change in which satin structure is exposed, computed before the luminance/streak math runs, never a post-hoc opacity crossfade. Pointerleave reverts instantly (no reverse-front, matching that a real float has no in-between state). A fixed-size 'tabby' patch — matching the DOM scrim's measured rect — is written AFTER the front/reversal logic every frame, pinned to a flat target luminance (0.68 light / 0.24 dark) regardless of S or the front, so text contrast never depends on the weave state; verify this at the frame where the front is crossing the patch boundary.\n\nHOST: prefers-reduced-motion draws exactly one frame at STATIC_TIME = 5.4s (sway near its +2.9deg extremum, off the flat t=0 minimum) and never runs the rAF loop or any listener; that frame is re-rendered on resize/theme change but is otherwise byte-stable. IntersectionObserver (threshold 0) and visibilitychange both gate the tick. An adaptive quality governor lives in DamaskFloatGrid: each card reports its own draw duration; if it stays over an 8ms budget for a sustained 900ms the shared buffer-resolution scale steps down (1 -> 0.75 -> 0.55), never on frame count or device sniffing. --ns-accent appears nowhere in the shading — only as the focus ring on an optional stretched-link href."
  },
  "type": "registry:ui"
}