{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "heatmap-calendar-tide",
  "title": "Heatmap Calendar Tide",
  "description": "Calendar heatmap read as a tide table: one column per week, depth carried by a single-hue ramp mixed from the accent token, with arrow-key navigation.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/heatmap-calendar-tide/component.tsx",
      "content": "\"use client\";\n\nimport { useMemo, useRef, useState } from \"react\";\n\nexport type TideCell = { date: string; value: number };\n\n// A year of daily values read as a tide table: one column per week, one row per\n// weekday, depth carried by a single-hue sequential ramp rather than a rainbow.\n//\n// Every step is mixed at runtime from --ns-accent into --background, so the ramp is\n// re-stepped by the theme instead of being two hardcoded palettes: the same 5\n// stops read as ink-on-paper in light and as lit water in dark. A day with no\n// reading is not step 0 of the ramp — it is border-coloured slack, so \"nothing\n// recorded\" never looks like \"recorded, and low\".\nconst STEPS = [18, 34, 52, 74, 100];\nconst SLACK = \"color-mix(in oklab, var(--border) 70%, var(--background))\";\nconst depth = (step: number) =>\n  `color-mix(in oklab, var(--ns-accent) ${STEPS[step]}%, var(--background))`;\n\nconst DAYS = [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"];\n\nexport function TideLedger({\n  data,\n  weeks = 26,\n  cell = 13,\n  gap = 3,\n  label = \"Daily readings\",\n  onSelect,\n  className = \"\",\n}: {\n  /** one entry per day; the trailing `weeks * 7` days are shown */\n  data: TideCell[];\n  /** number of weeks (columns) rendered, most recent last */\n  weeks?: number;\n  /** px edge of one day cell */\n  cell?: number;\n  /** px gap between cells, both axes */\n  gap?: number;\n  /** caption above the grid; also the grid's accessible name */\n  label?: string;\n  /** called with the clicked day's data */\n  onSelect?: (cell: TideCell) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const [active, setActive] = useState<number | null>(null);\n  const [cursor, setCursor] = useState(0);\n  const gridRef = useRef<HTMLDivElement>(null);\n\n  // Column-major: index = week * 7 + weekday, which is the order the DOM is in,\n  // so arrow-key movement is plain index arithmetic rather than a lookup.\n  const cells = useMemo(() => data.slice(-(weeks * 7)), [data, weeks]);\n  const max = useMemo(() => Math.max(1, ...cells.map((c) => c.value)), [cells]);\n\n  const stepOf = (value: number) =>\n    value <= 0 ? -1 : Math.min(STEPS.length - 1, Math.floor((value / max) * STEPS.length));\n\n  const move = (delta: number) => {\n    const next = Math.min(cells.length - 1, Math.max(0, cursor + delta));\n    setCursor(next);\n    setActive(next);\n    (gridRef.current?.children[next] as HTMLElement | undefined)?.focus();\n  };\n\n  const shown = active !== null ? cells[active] : null;\n\n  return (\n    <figure className={[\"ns-hct-figure inline-flex flex-col gap-3\", className].join(\" \")}>\n      <figcaption className=\"flex items-baseline justify-between gap-6 font-mono text-[11px] uppercase tracking-[0.18em] text-ns-muted\">\n        <span>{label}</span>\n        {/* The readout is the direct label: one value, the hovered one, rather\n            than a number stamped on 182 cells. */}\n        <span className=\"text-foreground/80\" aria-live=\"polite\">\n          {shown ? `${shown.date} · ${shown.value}` : `peak ${max}`}\n        </span>\n      </figcaption>\n\n      <div className=\"flex gap-2\">\n        <div\n          className=\"grid shrink-0 text-[10px] text-ns-muted\"\n          style={{ gridTemplateRows: `repeat(7, ${cell}px)`, rowGap: gap }}\n          aria-hidden\n        >\n          {DAYS.map((d, i) => (\n            <span key={d} className=\"leading-none\" style={{ fontSize: 9, lineHeight: `${cell}px` }}>\n              {i % 2 === 0 ? d[0] : \"\"}\n            </span>\n          ))}\n        </div>\n\n        <div\n          ref={gridRef}\n          role=\"grid\"\n          aria-label={label}\n          className=\"grid\"\n          style={{\n            gridTemplateRows: `repeat(7, ${cell}px)`,\n            gridAutoFlow: \"column\",\n            gridAutoColumns: `${cell}px`,\n            gap,\n          }}\n          onKeyDown={(e) => {\n            const delta =\n              e.key === \"ArrowRight\" ? 7 : e.key === \"ArrowLeft\" ? -7 : e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowUp\" ? -1 : 0;\n            if (!delta) return;\n            e.preventDefault();\n            move(delta);\n          }}\n          onMouseLeave={() => setActive(null)}\n        >\n          {cells.map((c, i) => {\n            const step = stepOf(c.value);\n            return (\n              <button\n                key={c.date}\n                type=\"button\"\n                role=\"gridcell\"\n                // Roving tabindex: the grid is one tab stop, arrows walk it.\n                tabIndex={i === cursor ? 0 : -1}\n                aria-label={`${c.date}: ${c.value}`}\n                onMouseEnter={() => setActive(i)}\n                onFocus={() => {\n                  setCursor(i);\n                  setActive(i);\n                }}\n                onClick={() => onSelect?.(c)}\n                className=\"rounded-[2px] transition-[transform,box-shadow] duration-150 ease-out hover:scale-[1.35] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent motion-reduce:transition-none motion-reduce:hover:scale-100\"\n                style={{\n                  background: step < 0 ? SLACK : depth(step),\n                  // 2px surface ring so neighbouring depths never bleed into one\n                  // continuous field.\n                  boxShadow: active === i ? \"0 0 0 2px var(--background), 0 0 0 3px var(--ns-accent)\" : \"none\",\n                }}\n              />\n            );\n          })}\n        </div>\n      </div>\n\n      <div className=\"flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.18em] text-ns-muted\">\n        <span>slack</span>\n        <span className=\"h-[10px] w-[10px] rounded-[2px]\" style={{ background: SLACK }} />\n        {STEPS.map((_, i) => (\n          <span key={i} className=\"h-[10px] w-[10px] rounded-[2px]\" style={{ background: depth(i) }} />\n        ))}\n        <span>flood</span>\n      </div>\n    </figure>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/heatmap-calendar-tide.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": "loud",
    "tags": [
      "heatmap",
      "calendar",
      "data-viz",
      "chart",
      "grid",
      "keyboard-navigation",
      "colour"
    ],
    "instruction": "A calendar heatmap laid out column-major — one column per week, seven rows for weekdays — where magnitude is carried by a five-step sequential ramp of a single hue rather than a rainbow or a red-to-green scale. The ramp is not two hardcoded palettes: each step is mixed at runtime with color-mix(in oklab, var(--ns-accent) N%, var(--background)) at 18/34/52/74/100, so the same five stops resolve to ink-on-paper depths in light and to lit water in dark, and a consumer who re-tokenises the accent gets a re-stepped ramp for free. A day with no reading is deliberately NOT step zero of that ramp — it is drawn in a border-derived slack colour, because a bottom-step cell and an empty cell mean different things and a viewer cannot be asked to tell them apart by lightness alone. The legend runs slack → five depths → flood, and the only numeric readout is the caption, which reports the hovered or focused day and otherwise reports the peak: one direct label rather than a number stamped on 182 cells. Interaction is a real grid, not a wall of tooltips — the whole heatmap is one tab stop via roving tabindex, arrow keys walk it (left/right by week, up/down by weekday) with focus moved imperatively so the browser scrolls the cell into view, every cell carries an aria-label of its date and value, and the caption is aria-live so a screen-reader user hears the value the sighted user reads. The hovered or focused cell lifts 35% and takes a two-ring highlight — a background-coloured ring first, then an accent ring — so it separates from its neighbours instead of bleeding into one continuous field. Under prefers-reduced-motion the lift and its transition are dropped; the ring, the readout and the keyboard grid are untouched."
  },
  "type": "registry:ui"
}