{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "team-grid-timezone-rail",
  "title": "Team Grid Timezone Rail",
  "description": "A team grid that answers the question a team grid is actually asked (who is reachable right now) by running a 24-hour UTC rail above the cards with the team's shared working-hours overlap inked into it, while each card shows that person's live local time and dims when they're outside their own hours.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/team-grid-timezone-rail/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// TeamGridTimezoneRail — a team grid that answers \"who is reachable right now\".\n//\n// A 24-cell UTC hour track sits above the cards. Every cell is either dead\n// (`·` in --border) or inked (`━` in --ns-accent), and the inked run is the\n// INTERSECTION of everybody's working window — the viewer's included — mapped\n// back onto UTC. At rest that run is the whole team's shared meeting window —\n// the tightest one there is. Hover or focus one person and the run opens out\n// to just your overlap with them; leave and a 220ms relax timer pulls it back\n// to the team-wide run, so sweeping the grid doesn't strobe. A caret `▮` marks\n// the current UTC hour.\n//\n// The clock is a clock, not an animation: one timer aligned to the real minute\n// boundary drives the caret and every card's local time through direct DOM\n// writes. No rAF anywhere. Zero dependencies, tokens only.\n// ---------------------------------------------------------------------------\n\nexport interface TeamMember {\n  /** stable id, also the value of [data-member] */\n  id: string;\n  name: string;\n  role: string;\n  /** 2–3 letters for the mark */\n  initials: string;\n  /** hours ahead of UTC; fractional allowed (5.5 = UTC+5:30) */\n  utcOffset: number;\n}\n\nexport interface TeamGridTimezoneRailProps {\n  /** the team, one card each */\n  members?: TeamMember[];\n  /** local working window as [startHour, endHour), applied to everyone */\n  workingHours?: [number, number];\n  /** the viewer's own offset; defaults to the browser's, resolved after mount */\n  viewerOffset?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst DEFAULT_MEMBERS: TeamMember[] = [\n  { id: \"marcus\", name: \"Marcus Hale\", role: \"Staff Engineer\", initials: \"MH\", utcOffset: -5 },\n  { id: \"ana\", name: \"Ana Reyes\", role: \"Design Lead\", initials: \"AR\", utcOffset: -3 },\n  { id: \"joana\", name: \"Joana Duarte\", role: \"Support Lead\", initials: \"JD\", utcOffset: 0 },\n  { id: \"lukas\", name: \"Lukas Brandt\", role: \"Infrastructure\", initials: \"LB\", utcOffset: 1 },\n  { id: \"wanjiru\", name: \"Wanjiru Kimani\", role: \"Data Science\", initials: \"WK\", utcOffset: 3 },\n  { id: \"darya\", name: \"Darya Sadeghi\", role: \"QA Engineer\", initials: \"DS\", utcOffset: 3.5 },\n];\n\nconst DEFAULT_HOURS: [number, number] = [9, 18];\n\n/** ms of pointer-out before the band relaxes back to the team-wide window */\nconst RELAX_MS = 220;\n/** rail cells below this many ch of root width collapse to 2h per cell */\nconst COMPACT_CH = 30;\n\nconst mod = (n: number, m: number) => ((n % m) + m) % m;\n\nfunction insideWindow(localHour: number, start: number, end: number) {\n  const h = mod(localHour, 24);\n  return start <= end ? h >= start && h < end : h >= start || h < end;\n}\n\n/** shortest circular distance in hours from `localHour` to the working window */\nfunction distanceToWindow(localHour: number, start: number, end: number) {\n  if (insideWindow(localHour, start, end)) return 0;\n  const h = mod(localHour, 24);\n  const gap = (a: number, b: number) => {\n    const d = mod(a - b, 24);\n    return Math.min(d, 24 - d);\n  };\n  return Math.min(gap(h, mod(start, 24)), gap(h, mod(end, 24)));\n}\n\n/** live[c] = some hour in cell c is inside the window for EVERY offset */\nfunction bandFor(offsets: number[], start: number, end: number, cells: number, step: number) {\n  const out: boolean[] = [];\n  for (let c = 0; c < cells; c++) {\n    let live = false;\n    for (let k = 0; k < step && !live; k++) {\n      const h = c * step + k;\n      live = offsets.every((o) => insideWindow(h + o, start, end));\n    }\n    out.push(live);\n  }\n  return out;\n}\n\ntype Run = { start: number; length: number };\n\n/** maximal runs of set bits, wrapping across midnight */\nfunction runsOf(mask: boolean[]): Run[] {\n  const n = mask.length;\n  if (mask.every(Boolean)) return [{ start: 0, length: n }];\n  const runs: Run[] = [];\n  for (let i = 0; i < n; i++) {\n    if (!mask[i] || mask[mod(i - 1, n)]) continue;\n    let len = 1;\n    while (len < n && mask[mod(i + len, n)]) len++;\n    runs.push({ start: i, length: len });\n  }\n  return runs;\n}\n\nconst pad2 = (n: number) => String(n).padStart(2, \"0\");\n\nfunction fmtClock(totalMinutes: number) {\n  const m = mod(Math.round(totalMinutes), 1440);\n  return `${pad2(Math.floor(m / 60))}:${pad2(m % 60)}`;\n}\n\nfunction fmtOffset(o: number) {\n  const sign = o < 0 ? \"-\" : \"+\";\n  const abs = Math.abs(o);\n  const h = Math.floor(abs);\n  const mm = Math.round((abs - h) * 60);\n  return `UTC${sign}${h}${mm ? `:${pad2(mm)}` : \"\"}`;\n}\n\nexport function TeamGridTimezoneRail({\n  members = DEFAULT_MEMBERS,\n  workingHours = DEFAULT_HOURS,\n  viewerOffset,\n  className = \"\",\n}: TeamGridTimezoneRailProps) {\n  const [whStart, whEnd] = workingHours;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const chRef = useRef<HTMLSpanElement>(null);\n  const relaxRef = useRef<number | undefined>(undefined);\n\n  const [viewer, setViewer] = useState(viewerOffset ?? 0);\n  const [cols, setCols] = useState(3);\n  const [compact, setCompact] = useState(false);\n  const [hoverId, setHoverId] = useState<string | null>(null);\n  const [pinnedId, setPinnedId] = useState<string | null>(null);\n\n  // the viewer's real offset is client-only; adopting it after mount keeps the\n  // server and first client render byte-identical.\n  useEffect(() => {\n    if (viewerOffset !== undefined) {\n      setViewer(viewerOffset);\n      return;\n    }\n    setViewer(-new Date().getTimezoneOffset() / 60);\n  }, [viewerOffset]);\n\n  const activeId = hoverId ?? pinnedId;\n  const activeMember = useMemo(\n    () => members.find((m) => m.id === activeId) ?? null,\n    [members, activeId]\n  );\n\n  /** offsets currently being intersected: the whole team, or you ∩ one person\n   *  (a pair always shares at least as many hours as the team, so the band can\n   *  only open out from rest — rest is the tightest run the grid ever shows) */\n  const offsets = useMemo(\n    () =>\n      activeMember\n        ? [viewer, activeMember.utcOffset]\n        : [viewer, ...members.map((m) => m.utcOffset)],\n    [activeMember, members, viewer]\n  );\n\n  const step = compact ? 2 : 1;\n  const cells = compact ? 12 : 24;\n\n  const band = useMemo(\n    () => bandFor(offsets, whStart, whEnd, cells, step),\n    [offsets, whStart, whEnd, cells, step]\n  );\n\n  /** full-resolution band, used for the spoken readout regardless of density */\n  const hourBand = useMemo(\n    () => bandFor(offsets, whStart, whEnd, 24, 1),\n    [offsets, whStart, whEnd]\n  );\n\n  const runs = useMemo(() => runsOf(hourBand), [hourBand]);\n  const shared = runs.reduce((n, r) => n + r.length, 0);\n\n  /** when nothing intersects: the UTC hour of minimum total distance-to-window */\n  const nearest = useMemo(() => {\n    if (shared > 0) return null;\n    const people = [{ name: \"you\", utcOffset: viewer }, ...members];\n    let best = { hour: 0, cost: Infinity, who: people[0]?.name ?? \"\", gap: 0 };\n    for (let h = 0; h < 24; h++) {\n      let cost = 0;\n      let worst = { name: \"\", gap: 0 };\n      for (const p of people) {\n        const g = distanceToWindow(h + p.utcOffset, whStart, whEnd);\n        cost += g;\n        if (g > worst.gap) worst = { name: p.name.split(\" \")[0], gap: g };\n      }\n      if (cost < best.cost) best = { hour: h, cost, who: worst.name, gap: worst.gap };\n    }\n    return best;\n  }, [shared, members, viewer, whStart, whEnd]);\n\n  const readout = useMemo(() => {\n    if (nearest) {\n      return `no shared hours — nearest overlap ${pad2(nearest.hour)}:00 UTC (${nearest.who} +${nearest.gap}h)`;\n    }\n    const window =\n      shared >= 24\n        ? \"every hour UTC\"\n        : runs\n            .map((r) => `${pad2(r.start)}:00–${pad2(mod(r.start + r.length, 24))}:00 UTC`)\n            .join(\", \");\n    const who = activeMember ? `you and ${activeMember.name}` : `all ${members.length} + you`;\n    return `${who} free ${window} · ${shared}h shared`;\n  }, [nearest, runs, activeMember, members.length, shared]);\n\n  // ---- the clock: one minute-aligned timer, direct DOM writes ---------------\n\n  const paint = useCallback(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const now = new Date();\n    const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();\n\n    const utcEl = root.querySelector<HTMLElement>(\"[data-clock-utc]\");\n    if (utcEl) utcEl.textContent = `${fmtClock(utcMinutes)} UTC`;\n\n    const caret = root.querySelector<HTMLElement>(\"[data-caret]\");\n    if (caret) {\n      const idx = Math.floor(utcMinutes / 60 / step);\n      caret.style.left = `${idx}ch`;\n      caret.style.opacity = \"1\";\n    }\n\n    for (const m of members) {\n      const btn = root.querySelector<HTMLElement>(`[data-member=\"${m.id}\"]`);\n      if (!btn) continue;\n      const localMinutes = utcMinutes + m.utcOffset * 60;\n      const awake = insideWindow(localMinutes / 60, whStart, whEnd);\n      const clock = fmtClock(localMinutes);\n      const time = btn.querySelector<HTMLElement>(\"[data-time]\");\n      if (time) time.textContent = clock;\n      const dot = btn.querySelector<HTMLElement>(\"[data-dot]\");\n      if (dot) dot.textContent = awake ? \"●\" : \"○\";\n      btn.style.opacity = awake ? \"1\" : \"0.5\";\n      btn.setAttribute(\n        \"aria-label\",\n        `${m.name}, ${m.role}, local time ${clock}, ${fmtOffset(m.utcOffset)}, currently ${awake ? \"available\" : \"outside working hours\"}`\n      );\n    }\n  }, [members, whStart, whEnd, step]);\n\n  useEffect(() => {\n    paint();\n    let interval: number | undefined;\n    const timeout = window.setTimeout(\n      () => {\n        paint();\n        interval = window.setInterval(paint, 60000);\n      },\n      60000 - (Date.now() % 60000)\n    );\n    return () => {\n      window.clearTimeout(timeout);\n      if (interval !== undefined) window.clearInterval(interval);\n    };\n  }, [paint]);\n\n  // ---- responsive: columns, and a coarser rail in a narrow frame ------------\n\n  useEffect(() => {\n    const el = rootRef.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const measure = () => {\n      const w = el.clientWidth;\n      setCols(w < 480 ? 1 : w < 860 ? 2 : 3);\n      const ch = (chRef.current?.offsetWidth ?? 100) / 10;\n      setCompact(w < COMPACT_CH * ch);\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => () => window.clearTimeout(relaxRef.current), []);\n\n  const enter = (id: string) => {\n    window.clearTimeout(relaxRef.current);\n    setHoverId(id);\n  };\n  const leave = () => {\n    window.clearTimeout(relaxRef.current);\n    relaxRef.current = window.setTimeout(() => setHoverId(null), RELAX_MS);\n  };\n\n  const tickEvery = compact ? 3 : 6;\n  const tickLabels = Array.from({ length: 4 }, (_, k) => ({\n    idx: k * tickEvery,\n    text: pad2(k * 6),\n  }));\n\n  const fade =\n    \"transition-opacity duration-[180ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none\";\n\n  return (\n    <div ref={rootRef} data-tz-rail className={`relative flex w-full flex-col gap-4 ${className}`}>\n      <span\n        ref={chRef}\n        aria-hidden\n        className=\"pointer-events-none invisible absolute left-0 top-0 h-0 w-[10ch] font-mono text-[18px]\"\n      />\n\n      {/* ---- the rail ------------------------------------------------------ */}\n      <div className=\"rounded-sm border border-border bg-surface px-4 py-3\">\n        <div className=\"flex items-baseline justify-between gap-4\">\n          <span className=\"font-mono text-[10px] uppercase tracking-[0.2em] text-ns-muted\">\n            utc day\n          </span>\n          <span className=\"font-mono text-[10px] uppercase tracking-[0.2em] text-ns-muted\">\n            core {pad2(whStart)}:00–{pad2(whEnd)}:00 local\n          </span>\n        </div>\n\n        <div className=\"mt-2.5 flex items-end justify-between gap-4\">\n          <div aria-hidden className=\"relative select-none font-mono text-[18px] leading-none\">\n            <div className=\"flex whitespace-pre\">\n              {Array.from({ length: cells }, (_, i) => {\n                const divider = i % tickEvery === 0;\n                const on = band[i];\n                return (\n                  <span key={i} className=\"relative inline-block w-[1ch] text-center\">\n                    <span\n                      style={{ opacity: on ? 0 : 1 }}\n                      className={`block ${fade} ${divider ? \"text-ns-muted\" : \"text-border\"}`}\n                    >\n                      {divider ? \"│\" : \"·\"}\n                    </span>\n                    <span\n                      style={{ opacity: on ? 1 : 0 }}\n                      className={`absolute inset-0 text-ns-accent ${fade}`}\n                    >\n                      ━\n                    </span>\n                  </span>\n                );\n              })}\n            </div>\n            <span\n              data-caret\n              className=\"absolute top-0 w-[1ch] text-center leading-none text-ns-accent opacity-0\"\n            >\n              ▮\n            </span>\n            <div className=\"relative mt-1 h-[1em] text-[10px] leading-none text-ns-muted\">\n              {tickLabels.map((t) => (\n                <span\n                  key={t.idx}\n                  className=\"absolute top-0 font-mono text-[18px] leading-none\"\n                  style={{ left: `${t.idx}ch` }}\n                >\n                  <span className=\"text-[10px]\">{t.text}</span>\n                </span>\n              ))}\n            </div>\n          </div>\n\n          <span\n            data-clock-utc\n            className=\"shrink-0 font-mono text-[13px] tabular-nums text-foreground\"\n          >\n            --:-- UTC\n          </span>\n        </div>\n\n        <p\n          aria-live=\"polite\"\n          className={`mt-2 font-mono text-[11px] tabular-nums ${nearest ? \"text-ns-muted\" : \"text-foreground\"}`}\n        >\n          {readout}\n        </p>\n      </div>\n\n      {/* ---- the team ------------------------------------------------------ */}\n      <div\n        className=\"grid gap-2\"\n        style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}\n      >\n        {members.map((m) => (\n          <button\n            key={m.id}\n            type=\"button\"\n            data-member={m.id}\n            aria-pressed={pinnedId === m.id}\n            aria-label={`${m.name}, ${m.role}, ${fmtOffset(m.utcOffset)}`}\n            onPointerEnter={() => enter(m.id)}\n            onPointerLeave={leave}\n            onFocus={() => enter(m.id)}\n            onBlur={leave}\n            onClick={() => setPinnedId((p) => (p === m.id ? null : m.id))}\n            className=\"flex items-center gap-3 rounded-sm border border-border bg-surface px-3 py-3 text-left outline-none transition-[opacity,border-color] duration-200 motion-reduce:transition-none hover:border-ns-muted focus-visible:ring-2 focus-visible:ring-ns-accent aria-pressed:border-foreground\"\n          >\n            <span\n              aria-hidden\n              className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-sm border border-border font-mono text-[11px] tracking-[0.06em] text-ns-muted\"\n            >\n              {m.initials}\n            </span>\n            <span className=\"min-w-0 flex-1\">\n              <span className=\"block truncate text-[13px] text-foreground\">{m.name}</span>\n              <span className=\"block truncate text-[11px] text-ns-muted\">{m.role}</span>\n            </span>\n            <span className=\"flex shrink-0 flex-col items-end gap-0.5\">\n              <span className=\"flex items-center gap-1.5 font-mono text-[13px] tabular-nums text-foreground\">\n                <span data-time>--:--</span>\n                <span data-dot className=\"text-[9px] leading-none\">\n                  ○\n                </span>\n              </span>\n              <span className=\"font-mono text-[10px] tabular-nums text-ns-muted\">\n                {fmtOffset(m.utcOffset)}\n              </span>\n            </span>\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/team-grid-timezone-rail.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "team",
      "grid",
      "about",
      "timezone",
      "availability",
      "clock",
      "marketing"
    ],
    "instruction": "Build <TeamGridTimezoneRail members? workingHours? viewerOffset? className?> as a plain DOM component — no canvas, no rAF, zero dependencies. MEMBERS are {id, name, role, initials, utcOffset} where utcOffset is hours ahead of UTC and may be fractional (5.5 renders as 'UTC+5:30'); workingHours is a local [start, end) window applied to everyone, default [9, 18]; viewerOffset defaults to -new Date().getTimezoneOffset()/60 but is adopted in a mount effect rather than at render time, so the server and the first client render stay byte-identical instead of throwing a hydration mismatch. THE CLOCK: exactly one timer, aligned to the real minute boundary — setTimeout(60000 - (Date.now() % 60000)) and then setInterval(60000) — and it writes through DIRECT DOM mutation, not React state: on each tick it moves the rail caret, rewrites every card's local time, flips its status glyph, sets the card's opacity and rebuilds its aria-label. This is a clock, not an animation, so there is deliberately no requestAnimationFrame anywhere and no per-second re-render. THE RAIL: an aria-hidden mono track of 24 cells at 1ch pitch where the index IS the UTC hour. Base glyph is '·' in --border, with '│' in --ns-muted at indices 0/6/12/18 and an absolutely positioned label row ('00 06 12 18') beneath at the same ch pitch so the ticks line up exactly; the current UTC hour carries a '▮' caret in --ns-accent, positioned as a single absolutely placed span whose style.left is set to `${index}ch` on the minute tick — one element moves rather than 24 being re-rendered. THE OVERLAP BAND is the mechanic: a cell is live for member m when ((hour + m.utcOffset) mod 24) falls inside [workingHours[0], workingHours[1]), and the run inked in --ns-accent is the INTERSECTION across the entire team plus the viewer — so the resting rail, with nothing hovered, already answers 'when can we all meet'. Each cell is two stacked layers: the '·' base and an accent '━' overlay, cross-faded by opacity over 180ms on cubic-bezier(0.22, 1, 0.36, 1), so cells leaving the band fade back to --border while cells still shared simply hold with no animation at all. On pointerenter or focus of a member card the intersection drops to viewer ∩ that one member, which is necessarily a superset of the team-wide run, so the band visibly OPENS OUT from rest — the resting run is the tightest the rail ever shows, and hovering is how you see what a single pairing would buy you; on pointerleave/blur a 220ms relax timer (cleared by any re-entry) pulls it back to the team-wide window, so sweeping across the grid does not strobe. If the intersection is empty the rail renders no accent cells and a mono line reads 'no shared hours — nearest overlap 07:00 UTC (Ana +2h)', computed by scanning all 24 UTC hours for the minimum total circular distance between each person's local hour and their working window, then naming whoever is furthest out. THE CARDS: each member is a real <button data-member aria-pressed> — pressing PINS that person's band so it survives pointer-out and pressing again unpins, which is a genuine function and also makes every card keyboard-reachable with a visible --ns-accent focus ring. A card carries an initials mark, name, role, a tabular-nums mono local time '14:32' derived as UTC-now + utcOffset, the printed offset, and a status glyph '●' inside working hours or '○' outside; cards outside their own hours render at opacity 0.5, so half the grid is visibly asleep at a glance. The aria-label is a full sentence — 'Ana Reyes, Design Lead, local time 14:32, UTC+2, currently available' — rebuilt on every tick, and a polite live region below the rail states the shared window in words since the rail itself is aria-hidden. RESPONSIVE: a ResizeObserver on the root switches the grid to 1 / 2 / 3 columns at 480px and 860px, and below 30ch of root width the rail collapses to a compact 12-cell track at 2 hours per cell, with the caret index becoming floor(utcHour / 2) and the band recomputed on the coarser grid. REDUCED MOTION: the band snaps between states with no crossfade (motion-reduce disables the opacity transitions), but the minute clock keeps ticking, because a stopped clock would be wrong rather than calm. TOKENS ONLY — --border, --foreground, --ns-muted, --surface, --ns-accent, with accent used on the caret, the overlap band and focus rings and nowhere else, so both themes invert cleanly. Props: members (default six-person team spanning UTC-5 to UTC+3:30), workingHours (default [9, 18]), viewerOffset, className.",
    "rank": 295
  },
  "type": "registry:ui"
}