{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wick-theme",
  "title": "Wick Theme",
  "description": "A hook that builds a Wick Charts theme from your shadcn/ui CSS variables and follows dark mode.",
  "dependencies": [
    "@wick-charts/react@^0.9.2"
  ],
  "files": [
    {
      "path": "hooks/use-wick-theme.ts",
      "content": "\"use client\"\n\nimport {\n  type RefObject,\n  useCallback,\n  useMemo,\n  useSyncExternalStore,\n} from \"react\"\n\nimport {\n  type ChartTheme,\n  type ThemeConfig,\n  createTheme,\n} from \"@wick-charts/react\"\n\n/** CSS variables the theme is built from, read as raw computed strings. */\nexport interface WickThemeTokens {\n  /** Painted backgrounds behind the chart, nearest first, up to the first opaque one. */\n  surfaces: string[]\n  /** `--background`: the base when every surface is translucent or none is painted. */\n  background: string\n  foreground: string\n  mutedForeground: string\n  border: string\n  /** `--chart-1` … `--chart-5`, empty ones dropped. */\n  chart: string[]\n  fontFamily: string\n}\n\ntype Rgba = [number, number, number, number]\ntype Defined<T> = { [K in keyof T]?: Exclude<T[K], undefined> }\n\nconst CHART_TOKENS = [\n  \"--chart-1\",\n  \"--chart-2\",\n  \"--chart-3\",\n  \"--chart-4\",\n  \"--chart-5\",\n]\nconst OBSERVED_ATTRIBUTES = [\"class\", \"style\", \"data-theme\"]\nconst TRANSPARENT = /^transparent$|^rgba\\(0, 0, 0, 0\\)$|\\/\\s*0%?\\s*\\)$/\n// Computed colors spell out an alpha only below 1: `rgba(...)` or `... / a)`.\nconst TRANSLUCENT = /^rgba\\(|\\//\nconst WHITE: Rgba = [255, 255, 255, 1]\n// A chart color under this contrast with the card fades into it, as the dark end of a ramp does on a dark card.\nconst MIN_SERIES_CONTRAST = 2\n// Lifted colors spread up to this contrast, so they keep their order and stay apart.\nconst LIFT_BELOW = 3\n\nlet fallbackTheme: ChartTheme | null = null\nlet probe: CanvasRenderingContext2D | null | undefined\n\n/**\n * A Wick Charts theme built from the shadcn/ui CSS variables. It follows\n * light/dark switches and token edits (`class`, `style`, `data-theme`) on the\n * chart's ancestors, `<html>` and `<body>` included.\n *\n * shadcn themes fill `--chart-1` … `--chart-5` with a ramp of one hue, light to\n * dark, in both modes. Series take the ramp in order. A single line, the heatmap\n * and \"down\" take the end of the ramp that stands out more on the card; \"up\"\n * takes `--chart-2`. Candles and order book sides stay in the theme's hue\n * instead of green / red. The price badge follows the candles and takes\n * `--muted-foreground` when the price has not moved.\n *\n * Pass the chart's wrapper `ref` to read the tokens where the chart is (a\n * scoped `.dark` subtree) and to match the card or panel behind it.\n */\nexport function useWickTheme(ref?: RefObject<HTMLElement | null>): ChartTheme {\n  // React subscribes after the commit, when `ref.current` is set.\n  const subscribe = useCallback(\n    (onChange: () => void) => subscribeTo(ref?.current ?? null, onChange),\n    [ref]\n  )\n  const snapshot = useSyncExternalStore(\n    subscribe,\n    () => readSnapshot(ref?.current ?? null),\n    () => \"\"\n  )\n\n  return useMemo(() => {\n    if (snapshot === \"\") return getFallbackTheme()\n\n    const tokens: WickThemeTokens = JSON.parse(snapshot)\n\n    return themeFromTokens(tokens)\n  }, [snapshot])\n}\n\nexport function themeFromTokens(tokens: WickThemeTokens): ChartTheme {\n  const surface = flattenSurfaces(tokens)\n  if (!surface) return getFallbackTheme()\n\n  const foreground = parseColor(tokens.foreground)\n  const muted = parseColor(tokens.mutedForeground)\n  const border = parseColor(tokens.border)\n  const chartColors = liftWeakColors({\n    colors: tokens.chart.map(parseColor).filter(isColor),\n    surface,\n    foreground,\n  })\n  const palette = chartColors.map((color) => formatColor(color))\n  const background = formatColor(surface)\n  const text = cssColor(foreground)\n  const dim = cssColor(muted)\n\n  // --chart-1 is the light end: it fades on a light card but stands out on a dark one.\n  const strongIndex = strongerEnd(chartColors, surface)\n  const strong = palette[strongIndex] ?? text\n  const softIndex = strongIndex === 1 ? 0 : 1\n  const soft = palette[softIndex] ?? dim\n  // One text colour serves the up, down and neutral badge; the ramp's mid-tones can sit close to the card colour.\n  const badgeFills = [\n    chartColors[softIndex] ?? muted,\n    chartColors[strongIndex] ?? foreground,\n    muted,\n  ]\n  const badgeText = readableOn(badgeFills, surface, foreground)\n\n  // A token that does not parse stays out of the config, and createTheme derives that colour.\n  const config: ThemeConfig = {\n    name: \"shadcn\",\n    background,\n    typography: definedOnly({ fontFamily: tokens.fontFamily || undefined }),\n    line: definedOnly({ color: strong }),\n    bar: definedOnly({ color: palette[0] }),\n    candlestick: {\n      up: definedOnly({ body: soft }),\n      down: definedOnly({ body: strong }),\n    },\n    // The price badge takes the candle colours, so a falling last candle never gets the rising one.\n    yLabel: definedOnly({\n      upBackground: soft,\n      downBackground: strong,\n      neutralBackground: dim,\n      textColor: badgeText,\n    }),\n    grid: definedOnly({ color: cssColor(border, 0.5) }),\n    // shadcn chart ticks are `text-xs`.\n    axis: definedOnly({ textColor: dim, fontSize: 12 }),\n    crosshair: definedOnly({\n      color: cssColor(muted, 0.5),\n      labelBackground: text,\n      labelTextColor: background,\n    }),\n    tooltip: definedOnly({\n      background,\n      textColor: text,\n      borderColor: cssColor(border),\n      // The default --chart-1 is a light grey: series-coloured values would be unreadable on a light card.\n      valueColor: text,\n    }),\n  }\n  if (palette.length > 0) config.seriesColors = palette\n\n  return createTheme(config).theme\n}\n\nfunction subscribeTo(\n  element: HTMLElement | null,\n  onChange: () => void\n): () => void {\n  const observer = new MutationObserver(onChange)\n  const options = { attributes: true, attributeFilter: OBSERVED_ATTRIBUTES }\n  // A `.dark` panel or token edits on any ancestor change what the chart reads, not only <html>.\n  for (\n    let node: Element | null = element ?? document.body;\n    node;\n    node = node.parentElement\n  ) {\n    observer.observe(node, options)\n  }\n\n  const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\")\n  scheme.addEventListener(\"change\", onChange)\n\n  return () => {\n    observer.disconnect()\n    scheme.removeEventListener(\"change\", onChange)\n  }\n}\n\n/** A string, so an unchanged theme compares equal and causes no update. */\nfunction readSnapshot(element: HTMLElement | null): string {\n  const target = element ?? document.body\n  const style = getComputedStyle(target)\n  const token = (name: string) => style.getPropertyValue(name).trim()\n\n  const tokens: WickThemeTokens = {\n    surfaces: findSurfaces(target),\n    background: token(\"--background\"),\n    foreground: token(\"--foreground\"),\n    mutedForeground: token(\"--muted-foreground\"),\n    border: token(\"--border\"),\n    chart: CHART_TOKENS.map(token).filter(Boolean),\n    fontFamily: style.fontFamily,\n  }\n\n  return JSON.stringify(tokens)\n}\n\nfunction findSurfaces(element: Element): string[] {\n  const surfaces: string[] = []\n  for (let node: Element | null = element; node; node = node.parentElement) {\n    const color = getComputedStyle(node).backgroundColor\n    if (TRANSPARENT.test(color)) continue\n\n    surfaces.push(color)\n    if (!TRANSLUCENT.test(color)) break\n  }\n\n  return surfaces\n}\n\n/**\n * The opaque color the chart sits on, which createTheme derives every tint from: a\n * translucent card (`bg-card/50`) blended over what is behind it, down to `--background`.\n */\nfunction flattenSurfaces(tokens: WickThemeTokens): Rgba | null {\n  // Outermost first, so each nearer layer paints over the ones behind it.\n  const layers = [tokens.background, ...[...tokens.surfaces].reverse()]\n    .map(parseColor)\n    .filter(isColor)\n  if (layers.length === 0) return null\n\n  return layers.reduce(paintOver, WHITE)\n}\n\nfunction paintOver(below: Rgba, layer: Rgba): Rgba {\n  const alpha = layer[3]\n  const blend = (top: number, bottom: number) =>\n    Math.round(top * alpha + bottom * (1 - alpha))\n\n  return [\n    blend(layer[0], below[0]),\n    blend(layer[1], below[1]),\n    blend(layer[2], below[2]),\n    1,\n  ]\n}\n\n/**\n * Tokens are often `oklch()`; painting one pixel turns any CSS color into sRGB channels.\n * Tailwind 3 themes store bare HSL channels (`12 76% 61%`) and wrap them in `hsl()` at use.\n */\nfunction parseColor(token: string): Rgba | null {\n  if (token === \"\") return null\n\n  const color = CSS.supports(\"color\", token) ? token : `hsl(${token})`\n  if (!CSS.supports(\"color\", color)) return null\n\n  const context = getProbe()\n  if (!context) return null\n\n  context.clearRect(0, 0, 1, 1)\n  context.fillStyle = color\n  context.fillRect(0, 0, 1, 1)\n  const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data\n\n  return [r, g, b, a / 255]\n}\n\nfunction formatColor(color: Rgba, alpha = 1): string {\n  const [r, g, b, a] = color\n  const opacity = Math.round(a * alpha * 1000) / 1000\n  if (opacity >= 1) return `#${toHex(r)}${toHex(g)}${toHex(b)}`\n\n  return `rgba(${r}, ${g}, ${b}, ${opacity})`\n}\n\nfunction cssColor(color: Rgba | null, alpha = 1): string | undefined {\n  return color ? formatColor(color, alpha) : undefined\n}\n\nfunction isColor(color: Rgba | null): color is Rgba {\n  return color !== null\n}\n\n/** `values` without the undefined ones, which `exactOptionalPropertyTypes` rejects. */\nfunction definedOnly<T extends object>(values: T): Defined<T> {\n  const entries = Object.entries(values).filter(\n    ([, value]) => value !== undefined\n  )\n\n  return Object.fromEntries(entries) as Defined<T>\n}\n\n/**\n * Mixes the colors that sink into the card toward `foreground`: the weakest reaches\n * MIN_SERIES_CONTRAST, the others under LIFT_BELOW spread above it in their order.\n */\nfunction liftWeakColors(args: {\n  colors: Rgba[]\n  surface: Rgba\n  foreground: Rgba | null\n}): Rgba[] {\n  const { colors, surface, foreground } = args\n  if (!foreground || colors.length === 0) return colors\n\n  const weakest = Math.min(...colors.map((color) => contrast(color, surface)))\n  if (weakest >= MIN_SERIES_CONTRAST) return colors\n\n  return colors.map((color) => {\n    const current = contrast(color, surface)\n    if (current >= LIFT_BELOW) return color\n\n    const share = (current - weakest) / (LIFT_BELOW - weakest)\n    const target =\n      MIN_SERIES_CONTRAST + share * (LIFT_BELOW - MIN_SERIES_CONTRAST)\n\n    return mixToContrast({ color, toward: foreground, surface, target })\n  })\n}\n\n/** `color` mixed toward `toward` in 2 % steps until its contrast with `surface` reaches `target`. */\nfunction mixToContrast(args: {\n  color: Rgba\n  toward: Rgba\n  surface: Rgba\n  target: number\n}): Rgba {\n  for (let step = 1; step <= 50; step++) {\n    const mixed = mix(args.color, args.toward, step / 50)\n    if (contrast(mixed, args.surface) >= args.target) return mixed\n  }\n\n  return args.toward\n}\n\nfunction mix(from: Rgba, to: Rgba, amount: number): Rgba {\n  const channel = (a: number, b: number) => Math.round(a + (b - a) * amount)\n\n  return [\n    channel(from[0], to[0]),\n    channel(from[1], to[1]),\n    channel(from[2], to[2]),\n    from[3],\n  ]\n}\n\n/** Index of the first or the last colour, whichever contrasts more with `surface`; -1 when empty. */\nfunction strongerEnd(colors: Rgba[], surface: Rgba): number {\n  const first = colors[0]\n  const last = colors[colors.length - 1]\n  if (!first || !last) return -1\n\n  return contrast(first, surface) >= contrast(last, surface)\n    ? 0\n    : colors.length - 1\n}\n\n/** `surface` or `foreground`, whichever contrasts more with the closest of `fills`. */\nfunction readableOn(\n  fills: (Rgba | null)[],\n  surface: Rgba,\n  foreground: Rgba | null\n): string {\n  const colors = fills.filter(isColor)\n  if (colors.length === 0 || !foreground) return formatColor(surface)\n\n  const weakest = (text: Rgba) =>\n    Math.min(...colors.map((fill) => contrast(text, fill)))\n\n  return formatColor(\n    weakest(surface) >= weakest(foreground) ? surface : foreground\n  )\n}\n\n/** WCAG contrast ratio, 1 to 21. */\nfunction contrast(a: Rgba, b: Rgba): number {\n  const lighter = Math.max(luminance(a), luminance(b))\n  const darker = Math.min(luminance(a), luminance(b))\n\n  return (lighter + 0.05) / (darker + 0.05)\n}\n\nfunction luminance(color: Rgba): number {\n  const linear = (channel: number) => {\n    const value = channel / 255\n\n    return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4\n  }\n\n  return (\n    0.2126 * linear(color[0]) +\n    0.7152 * linear(color[1]) +\n    0.0722 * linear(color[2])\n  )\n}\n\nfunction toHex(channel: number): string {\n  return channel.toString(16).padStart(2, \"0\")\n}\n\nfunction getProbe(): CanvasRenderingContext2D | null {\n  if (probe !== undefined) return probe\n\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = 1\n  canvas.height = 1\n  probe = canvas.getContext(\"2d\", { willReadFrequently: true })\n\n  return probe\n}\n\n/** Used on the server and before the tokens can be read. */\nfunction getFallbackTheme(): ChartTheme {\n  if (!fallbackTheme)\n    fallbackTheme = createTheme({ name: \"shadcn\", background: \"#ffffff\" }).theme\n\n  return fallbackTheme\n}\n",
      "type": "registry:hook"
    }
  ],
  "categories": [
    "charts",
    "theme"
  ],
  "type": "registry:hook"
}