{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timetable",
  "title": "Timetable",
  "description": "Timetable is a production-ready tables component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "lucide-react",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/tables/timetable.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent as ReactKeyboardEvent,\n} from \"react\";\nimport { motion, useReducedMotion } from \"framer-motion\";\nimport { useTheme } from \"next-themes\";\nimport { CalendarX2, MapPin } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { useElementSize } from \"../_core/canvas\";\nimport {\n  type BjorkTableThemeMode,\n  getBjorkSignalPalette,\n  getBjorkTablePalette,\n  useBjorkTableIsDark,\n} from \"./table-theme\";\n\nexport type TimetableTone = \"orange\" | \"green\" | \"red\" | \"neutral\";\n\nexport interface TimetableDay {\n  /** Matches `TimetableEvent.day`. */\n  id: string;\n  /** Short label for the column header, e.g. \"Wed\". */\n  label: string;\n  /** Long label for screen readers, e.g. \"Wednesday\". */\n  fullLabel?: string;\n  /** Secondary line, e.g. \"Oct 14\". */\n  date?: string;\n  /** JS weekday (0 = Sunday). Used to place the \"now\" line. */\n  weekday?: number;\n}\n\nexport interface TimetableEvent {\n  id: string;\n  title: string;\n  /** A `TimetableDay.id`. */\n  day: string;\n  /** 24h \"HH:MM\". */\n  start: string;\n  /** 24h \"HH:MM\". */\n  end: string;\n  location?: string;\n  track?: string;\n  /** Overrides the tone picked for the event's track. */\n  tone?: TimetableTone;\n}\n\nexport interface TimetableProps {\n  events?: TimetableEvent[];\n  days?: TimetableDay[];\n  startHour?: number;\n  endHour?: number;\n  slotMinutes?: number;\n  /** Pixel height of one slot in the week grid. */\n  slotHeight?: number;\n  /** Fixed clock for the \"now\" line. Omit to follow the real clock. */\n  now?: Date;\n  title?: string;\n  subtitle?: string;\n  /** Short label shown in the time gutter corner, e.g. \"CET\". */\n  timeZoneLabel?: string;\n  /** Max height of the scrolling week grid. */\n  maxHeight?: number;\n  /** Selected track filter on first render (null = all tracks). */\n  defaultTrack?: string | null;\n  /** Event selected on first render. */\n  defaultSelectedId?: string | null;\n  loading?: boolean;\n  onEventSelect?: (event: TimetableEvent) => void;\n  theme?: BjorkTableThemeMode;\n  enableAnimations?: boolean;\n  className?: string;\n}\n\nexport const TIMETABLE_SAMPLE_DAYS: TimetableDay[] = [\n  { id: \"mon\", label: \"Mon\", fullLabel: \"Monday\", date: \"Oct 12\", weekday: 1 },\n  { id: \"tue\", label: \"Tue\", fullLabel: \"Tuesday\", date: \"Oct 13\", weekday: 2 },\n  { id: \"wed\", label: \"Wed\", fullLabel: \"Wednesday\", date: \"Oct 14\", weekday: 3 },\n  { id: \"thu\", label: \"Thu\", fullLabel: \"Thursday\", date: \"Oct 15\", weekday: 4 },\n  { id: \"fri\", label: \"Fri\", fullLabel: \"Friday\", date: \"Oct 16\", weekday: 5 },\n];\n\nexport const TIMETABLE_SAMPLE: TimetableEvent[] = [\n  // Monday\n  { id: \"m1\", title: \"Doors & coffee\", day: \"mon\", start: \"09:00\", end: \"09:30\", location: \"Atrium\", track: \"Plenary\" },\n  { id: \"m2\", title: \"Opening keynote: Drawing with constraints\", day: \"mon\", start: \"09:30\", end: \"10:30\", location: \"Hall A\", track: \"Plenary\" },\n  { id: \"m3\", title: \"Variable type in product UI\", day: \"mon\", start: \"11:00\", end: \"12:00\", location: \"Hall A\", track: \"Craft\" },\n  { id: \"m4\", title: \"Token pipelines that survive rebrands\", day: \"mon\", start: \"11:00\", end: \"12:30\", location: \"Studio 3\", track: \"Systems\" },\n  { id: \"m5\", title: \"Lunch\", day: \"mon\", start: \"12:30\", end: \"13:30\", location: \"Garden Room\" },\n  { id: \"m6\", title: \"Field notes from 40 usability sessions\", day: \"mon\", start: \"14:00\", end: \"15:00\", location: \"Hall B\", track: \"Research\" },\n  { id: \"m7\", title: \"Spring physics, explained by hand\", day: \"mon\", start: \"15:30\", end: \"17:00\", location: \"Studio 3\", track: \"Craft\" },\n  // Tuesday\n  { id: \"t1\", title: \"Grids for long documents\", day: \"tue\", start: \"09:30\", end: \"10:30\", location: \"Hall A\", track: \"Craft\" },\n  { id: \"t2\", title: \"Workshop: Interface sound\", day: \"tue\", start: \"10:00\", end: \"12:00\", location: \"Studio 3\", track: \"Craft\" },\n  { id: \"t3\", title: \"Diary studies on a budget\", day: \"tue\", start: \"10:30\", end: \"11:30\", location: \"Hall B\", track: \"Research\" },\n  { id: \"t4\", title: \"Lunch\", day: \"tue\", start: \"12:30\", end: \"13:30\", location: \"Garden Room\" },\n  { id: \"t5\", title: \"Naming components with a team of 60\", day: \"tue\", start: \"13:30\", end: \"14:30\", location: \"Hall A\", track: \"Systems\" },\n  { id: \"t6\", title: \"Panel: Who owns the design system?\", day: \"tue\", start: \"15:00\", end: \"16:30\", location: \"Hall A\", track: \"Plenary\" },\n  // Wednesday\n  { id: \"w1\", title: \"Kerning at small sizes\", day: \"wed\", start: \"09:00\", end: \"10:00\", location: \"Hall A\", track: \"Craft\" },\n  { id: \"w2\", title: \"Interviewing without leading\", day: \"wed\", start: \"10:00\", end: \"11:00\", location: \"Hall B\", track: \"Research\" },\n  { id: \"w3\", title: \"Workshop: Layout motion\", day: \"wed\", start: \"10:30\", end: \"12:30\", location: \"Studio 3\", track: \"Craft\" },\n  { id: \"w4\", title: \"Dark mode is a palette, not a filter\", day: \"wed\", start: \"11:00\", end: \"12:00\", location: \"Hall A\", track: \"Systems\" },\n  { id: \"w5\", title: \"Lunch\", day: \"wed\", start: \"12:30\", end: \"13:30\", location: \"Garden Room\" },\n  { id: \"w6\", title: \"Portfolio reviews\", day: \"wed\", start: \"14:00\", end: \"16:00\", location: \"Garden Room\", track: \"Research\" },\n  { id: \"w7\", title: \"Easing curves for data viz\", day: \"wed\", start: \"16:00\", end: \"17:00\", location: \"Studio 3\", track: \"Craft\" },\n  // Thursday\n  { id: \"h1\", title: \"Accessible tables, row by row\", day: \"thu\", start: \"09:30\", end: \"10:30\", location: \"Hall A\", track: \"Systems\" },\n  { id: \"h2\", title: \"Type specimens as research tools\", day: \"thu\", start: \"11:00\", end: \"12:00\", location: \"Hall B\", track: \"Craft\" },\n  { id: \"h3\", title: \"Lunch\", day: \"thu\", start: \"12:30\", end: \"13:30\", location: \"Garden Room\" },\n  { id: \"h4\", title: \"Synthesis wall, live\", day: \"thu\", start: \"13:30\", end: \"15:00\", location: \"Studio 3\", track: \"Research\" },\n  { id: \"h5\", title: \"Choreographing page transitions\", day: \"thu\", start: \"15:30\", end: \"16:30\", location: \"Hall A\", track: \"Craft\" },\n  // Friday\n  { id: \"f1\", title: \"Hand-lettering for screens\", day: \"fri\", start: \"09:30\", end: \"11:00\", location: \"Studio 3\", track: \"Craft\" },\n  { id: \"f2\", title: \"Versioning a component library\", day: \"fri\", start: \"10:00\", end: \"11:00\", location: \"Hall A\", track: \"Systems\" },\n  { id: \"f3\", title: \"Lightning talks\", day: \"fri\", start: \"11:30\", end: \"12:30\", location: \"Hall A\", track: \"Plenary\" },\n  { id: \"f4\", title: \"Closing keynote: Slow software\", day: \"fri\", start: \"14:00\", end: \"15:00\", location: \"Hall A\", track: \"Plenary\" },\n  { id: \"f5\", title: \"Farewell drinks\", day: \"fri\", start: \"16:00\", end: \"17:30\", location: \"Atrium\" },\n];\n\nconst TRACK_TONES: TimetableTone[] = [\"orange\", \"green\", \"neutral\", \"red\"];\nconst GUTTER = 60;\nconst MIN_DAY_WIDTH = 168;\nconst COMPACT_BREAKPOINT = 560;\n\nfunction toMinutes(value: string) {\n  const [h, m] = value.split(\":\").map((part) => Number(part));\n  return (h || 0) * 60 + (m || 0);\n}\n\nfunction formatMinutes(total: number) {\n  const h = Math.floor(total / 60);\n  const m = total % 60;\n  return `${String(h).padStart(2, \"0\")}:${String(m).padStart(2, \"0\")}`;\n}\n\ninterface PlacedEvent {\n  event: TimetableEvent;\n  startMin: number;\n  endMin: number;\n  col: number;\n  cols: number;\n}\n\n/** Groups transitively overlapping events and gives each its own lane. */\nfunction layoutDay(events: TimetableEvent[]): PlacedEvent[] {\n  const sorted = events\n    .map((event) => ({ event, startMin: toMinutes(event.start), endMin: toMinutes(event.end), col: 0, cols: 1 }))\n    .filter((item) => item.endMin > item.startMin)\n    .sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin);\n\n  const placed: PlacedEvent[] = [];\n  let cluster: PlacedEvent[] = [];\n  let laneEnds: number[] = [];\n  let clusterEnd = -1;\n\n  const flush = () => {\n    for (const item of cluster) item.cols = laneEnds.length;\n    placed.push(...cluster);\n    cluster = [];\n    laneEnds = [];\n  };\n\n  for (const item of sorted) {\n    if (item.startMin >= clusterEnd && cluster.length) flush();\n    let lane = laneEnds.findIndex((end) => end <= item.startMin);\n    if (lane === -1) {\n      lane = laneEnds.length;\n      laneEnds.push(item.endMin);\n    } else {\n      laneEnds[lane] = item.endMin;\n    }\n    item.col = lane;\n    cluster.push(item);\n    clusterEnd = Math.max(clusterEnd, item.endMin);\n  }\n  if (cluster.length) flush();\n  return placed;\n}\n\nconst noopSubscribe = () => () => {};\n\nfunction useMounted() {\n  return useSyncExternalStore(noopSubscribe, () => true, () => false);\n}\n\nfunction useClock(fixed: Date | undefined) {\n  const [live, setLive] = useState<Date | null>(null);\n  useEffect(() => {\n    if (fixed) return;\n    const tick = () => setLive(new Date());\n    tick();\n    const id = window.setInterval(tick, 30_000);\n    return () => window.clearInterval(id);\n  }, [fixed]);\n  return fixed ?? live;\n}\n\nexport function Timetable({\n  events = TIMETABLE_SAMPLE,\n  days = TIMETABLE_SAMPLE_DAYS,\n  startHour = 9,\n  endHour = 18,\n  slotMinutes = 30,\n  slotHeight = 30,\n  now: nowProp,\n  title = \"Fieldwork 26\",\n  subtitle = \"Design conference · Oct 12–16 · Five rooms\",\n  timeZoneLabel = \"CET\",\n  maxHeight = 430,\n  defaultTrack = null,\n  defaultSelectedId = null,\n  loading = false,\n  onEventSelect,\n  theme = \"auto\",\n  enableAnimations = true,\n  className,\n}: TimetableProps) {\n  const mounted = useMounted();\n  const { resolvedTheme } = useTheme();\n  const forcedTheme = theme === \"auto\" ? undefined : theme;\n  const detectedIsDark = useBjorkTableIsDark(resolvedTheme, forcedTheme);\n  const isDark = !mounted && theme === \"auto\" ? true : detectedIsDark;\n  const palette = getBjorkTablePalette(isDark);\n  const shouldReduceMotion = useReducedMotion();\n  const shouldAnimate = enableAnimations && !shouldReduceMotion;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const { width } = useElementSize(rootRef);\n  const compact = width > 0 && width < COMPACT_BREAKPOINT;\n\n  const now = useClock(nowProp);\n  const nowMinutes = now ? now.getHours() * 60 + now.getMinutes() : null;\n  const todayId = now ? days.find((day) => day.weekday === now.getDay())?.id ?? null : null;\n\n  const [track, setTrack] = useState<string | null>(defaultTrack);\n  const [selectedId, setSelectedId] = useState<string | null>(defaultSelectedId);\n  const [pickedDay, setPickedDay] = useState<string | null>(null);\n  const activeDay = pickedDay ?? todayId ?? days[0]?.id ?? null;\n\n  const tracks = useMemo(() => {\n    const seen: string[] = [];\n    for (const event of events) if (event.track && !seen.includes(event.track)) seen.push(event.track);\n    return seen;\n  }, [events]);\n\n  const toneFor = useCallback(\n    (event: TimetableEvent): TimetableTone => {\n      if (event.tone) return event.tone;\n      if (!event.track) return \"neutral\";\n      const index = tracks.indexOf(event.track);\n      return TRACK_TONES[index % TRACK_TONES.length] ?? \"neutral\";\n    },\n    [tracks]\n  );\n\n  const visible = useMemo(\n    () => (track ? events.filter((event) => event.track === track) : events),\n    [events, track]\n  );\n\n  const byDay = useMemo(() => {\n    const map = new Map<string, PlacedEvent[]>();\n    for (const day of days) {\n      map.set(\n        day.id,\n        layoutDay(visible.filter((event) => event.day === day.id))\n      );\n    }\n    return map;\n  }, [days, visible]);\n\n  const rangeStart = startHour * 60;\n  const rangeEnd = endHour * 60;\n  const slotCount = Math.max(1, Math.ceil((rangeEnd - rangeStart) / slotMinutes));\n  const bodyHeight = slotCount * slotHeight;\n  const pxPerMinute = slotHeight / slotMinutes;\n  const nowInRange = nowMinutes !== null && nowMinutes >= rangeStart && nowMinutes <= rangeEnd;\n  const nowTop = nowMinutes !== null ? (nowMinutes - rangeStart) * pxPerMinute : 0;\n\n  const selected = useMemo(\n    () => events.find((event) => event.id === selectedId) ?? null,\n    [events, selectedId]\n  );\n\n  const handleSelect = (event: TimetableEvent) => {\n    setSelectedId(event.id);\n    onEventSelect?.(event);\n  };\n\n  const focusEvent = (from: HTMLElement, id: string | undefined) => {\n    if (!id) return;\n    from\n      .closest(\"[data-tt-root]\")\n      ?.querySelector<HTMLButtonElement>(`[data-event-id=\"${CSS.escape(id)}\"]`)\n      ?.focus();\n  };\n\n  const handleGridKey = (e: ReactKeyboardEvent<HTMLButtonElement>, placed: PlacedEvent) => {\n    const dayIndex = days.findIndex((day) => day.id === placed.event.day);\n    const list = byDay.get(placed.event.day) ?? [];\n    const order = [...list].sort((a, b) => a.startMin - b.startMin || a.col - b.col);\n    const index = order.findIndex((item) => item.event.id === placed.event.id);\n\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      focusEvent(e.currentTarget, order[index + (e.key === \"ArrowDown\" ? 1 : -1)]?.event.id);\n      return;\n    }\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      const step = e.key === \"ArrowRight\" ? 1 : -1;\n      for (let d = dayIndex + step; d >= 0 && d < days.length; d += step) {\n        const candidates = byDay.get(days[d].id) ?? [];\n        if (!candidates.length) continue;\n        const nearest = candidates.reduce((best, item) =>\n          Math.abs(item.startMin - placed.startMin) < Math.abs(best.startMin - placed.startMin) ? item : best\n        );\n        focusEvent(e.currentTarget, nearest.event.id);\n        return;\n      }\n    }\n  };\n\n  const describe = (event: TimetableEvent, dayLabel?: string) => {\n    const live =\n      event.day === todayId &&\n      nowMinutes !== null &&\n      nowMinutes >= toMinutes(event.start) &&\n      nowMinutes < toMinutes(event.end);\n    return [\n      event.title,\n      `${dayLabel ? `${dayLabel} ` : \"\"}${event.start} to ${event.end}`,\n      event.location,\n      event.track ? `${event.track} track` : undefined,\n      live ? \"happening now\" : undefined,\n    ]\n      .filter(Boolean)\n      .join(\", \");\n  };\n\n  const isBreak = (event: TimetableEvent) => !event.track && !event.tone;\n\n  const isLive = (event: TimetableEvent) =>\n    event.day === todayId &&\n    nowMinutes !== null &&\n    nowMinutes >= toMinutes(event.start) &&\n    nowMinutes < toMinutes(event.end);\n\n  const hourMarks = useMemo(() => {\n    const marks: number[] = [];\n    for (let m = Math.ceil(rangeStart / 60) * 60; m <= rangeEnd; m += 60) marks.push(m);\n    return marks;\n  }, [rangeStart, rangeEnd]);\n\n  const lineColor = isDark ? \"rgba(237,237,237,0.06)\" : \"rgba(23,23,23,0.06)\";\n  const hourLineColor = isDark ? \"rgba(237,237,237,0.1)\" : \"rgba(23,23,23,0.1)\";\n  const accent = isDark ? \"#ec5c13\" : \"#bd4514\";\n  const totalEvents = visible.length;\n  const roomCount = new Set(visible.map((event) => event.location).filter(Boolean)).size;\n\n  const focusRing =\n    \"outline-none focus-visible:ring-2 focus-visible:ring-[#ec5c13]/70 focus-visible:ring-offset-0\";\n\n  // ---------- header + chips ----------\n  const chips = tracks.length > 0 && (\n    <div role=\"group\" aria-label=\"Filter by track\" className=\"flex flex-wrap items-center gap-1.5\">\n      {[null, ...tracks].map((value) => {\n        const active = track === value;\n        const tone = value ? TRACK_TONES[tracks.indexOf(value) % TRACK_TONES.length] : null;\n        const signal = tone ? getBjorkSignalPalette(tone, isDark) : null;\n        return (\n          <button\n            key={value ?? \"all\"}\n            type=\"button\"\n            aria-pressed={active}\n            onClick={() => setTrack(value)}\n            className={cn(\n              \"inline-flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-[12px] font-medium transition-colors\",\n              focusRing,\n              active\n                ? cn(palette.accentBg, palette.accentBorder, palette.accent)\n                : palette.control\n            )}\n          >\n            {signal ? <span aria-hidden className={cn(\"size-1.5 rounded-full\", signal.dotColor)} /> : null}\n            {value ?? \"All tracks\"}\n          </button>\n        );\n      })}\n    </div>\n  );\n\n  const header = (\n    <div\n      className={cn(\n        \"flex gap-3 border-b px-4 py-3.5\",\n        width >= 860 ? \"flex-row items-center justify-between\" : \"flex-col\",\n        palette.divider\n      )}\n    >\n      <div className=\"min-w-0\">\n        <div className={cn(\"text-[15px] font-semibold tracking-[-0.01em]\", palette.primaryText)}>{title}</div>\n        {subtitle ? <div className={cn(\"mt-0.5 text-[12px]\", palette.secondaryText)}>{subtitle}</div> : null}\n      </div>\n      {chips}\n    </div>\n  );\n\n  // ---------- footer ----------\n  const selectedDay = selected ? days.find((day) => day.id === selected.day) : null;\n  const footer = (\n    <div\n      className={cn(\n        \"flex min-h-[46px] flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t px-4 py-2.5 text-[12px]\",\n        palette.divider\n      )}\n      aria-live=\"polite\"\n    >\n      {selected ? (\n        <>\n          <div className=\"flex min-w-0 items-center gap-2\">\n            <span aria-hidden className={cn(\"size-2 shrink-0 rounded-full\", getBjorkSignalPalette(toneFor(selected), isDark).dotColor)} />\n            <span className={cn(\"truncate text-[13px] font-medium\", palette.primaryText)}>{selected.title}</span>\n          </div>\n          <div className={cn(\"flex items-center gap-3 tabular-nums\", palette.secondaryText)}>\n            <span>\n              {selectedDay?.label} {selected.start}–{selected.end}\n            </span>\n            {selected.location ? (\n              <span className=\"inline-flex items-center gap-1\">\n                <MapPin aria-hidden className=\"size-3\" />\n                {selected.location}\n              </span>\n            ) : null}\n            {selected.track ? <span>{selected.track}</span> : null}\n          </div>\n        </>\n      ) : (\n        <>\n          <span className={cn(\"tabular-nums\", palette.secondaryText)}>\n            {totalEvents} sessions · {roomCount} rooms\n          </span>\n          <span className={palette.secondaryText}>Select a session for details</span>\n        </>\n      )}\n    </div>\n  );\n\n  const emptyState = (\n    <div className={cn(\"flex flex-col items-center justify-center gap-2 px-6 py-10 text-center\", palette.secondaryText)}>\n      <CalendarX2 aria-hidden className=\"size-5 opacity-70\" />\n      <div className={cn(\"text-[13px] font-medium\", palette.primaryText)}>No sessions scheduled</div>\n      <div className=\"text-[12px]\">\n        {track ? \"Nothing in this track yet. Try another filter.\" : \"Add events to fill the week.\"}\n      </div>\n    </div>\n  );\n\n  // ---------- compact (agenda) ----------\n  const renderCompact = () => {\n    const dayEvents = (byDay.get(activeDay ?? \"\") ?? []).slice().sort((a, b) => a.startMin - b.startMin);\n    const showNow = activeDay === todayId && nowMinutes !== null;\n    const nowIndex = showNow ? dayEvents.findIndex((item) => item.startMin > (nowMinutes ?? 0)) : -1;\n    const activeDayInfo = days.find((day) => day.id === activeDay);\n\n    const onTabKey = (e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {\n      let next = -1;\n      if (e.key === \"ArrowRight\") next = (index + 1) % days.length;\n      if (e.key === \"ArrowLeft\") next = (index - 1 + days.length) % days.length;\n      if (e.key === \"Home\") next = 0;\n      if (e.key === \"End\") next = days.length - 1;\n      if (next < 0) return;\n      e.preventDefault();\n      setPickedDay(days[next].id);\n      e.currentTarget.parentElement\n        ?.querySelector<HTMLButtonElement>(`[data-day-tab=\"${CSS.escape(days[next].id)}\"]`)\n        ?.focus();\n    };\n\n    const nowRow = (\n      <li key=\"now\" className=\"relative flex items-center gap-3 py-1\" aria-label={`Now, ${formatMinutes(nowMinutes ?? 0)}`}>\n        <span className=\"w-[52px] text-right text-[11px] font-semibold tabular-nums\" style={{ color: accent }}>\n          {formatMinutes(nowMinutes ?? 0)}\n        </span>\n        <span aria-hidden className=\"relative h-px flex-1\" style={{ background: accent }}>\n          <span className=\"absolute -left-1 -top-[3px] size-[7px] rounded-full\" style={{ background: accent }} />\n        </span>\n      </li>\n    );\n\n    return (\n      <>\n        <div\n          role=\"tablist\"\n          aria-label=\"Day\"\n          className={cn(\"grid gap-1 border-b p-2\", palette.divider)}\n          style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }}\n        >\n          {days.map((day, index) => {\n            const active = day.id === activeDay;\n            const count = (byDay.get(day.id) ?? []).length;\n            return (\n              <button\n                key={day.id}\n                type=\"button\"\n                role=\"tab\"\n                data-day-tab={day.id}\n                id={`tt-tab-${day.id}`}\n                aria-selected={active}\n                aria-controls=\"tt-agenda\"\n                tabIndex={active ? 0 : -1}\n                onClick={() => setPickedDay(day.id)}\n                onKeyDown={(e) => onTabKey(e, index)}\n                className={cn(\n                  \"flex flex-col items-center rounded-[10px] py-1.5 transition-colors\",\n                  focusRing,\n                  active ? cn(palette.accentBg, palette.accent) : cn(palette.secondaryText, palette.menuItem)\n                )}\n              >\n                <span className=\"text-[11px] font-medium uppercase tracking-[0.08em]\">{day.label}</span>\n                <span className={cn(\"text-[13px] font-semibold tabular-nums\", active ? palette.accent : palette.primaryText)}>\n                  {day.date?.split(\" \").pop() ?? count}\n                </span>\n                <span\n                  aria-hidden\n                  className={cn(\"mt-0.5 size-1 rounded-full\", day.id === todayId ? \"\" : \"opacity-0\")}\n                  style={{ background: accent }}\n                />\n              </button>\n            );\n          })}\n        </div>\n        <div\n          role=\"tabpanel\"\n          id=\"tt-agenda\"\n          aria-labelledby={activeDay ? `tt-tab-${activeDay}` : undefined}\n          className=\"overflow-y-auto px-3 py-3\"\n          style={{ maxHeight }}\n        >\n          {loading ? (\n            <ul aria-busy=\"true\" aria-label=\"Loading sessions\" className=\"flex flex-col gap-2\">\n              {[0, 1, 2, 3].map((i) => (\n                <li key={i} className=\"flex gap-3\">\n                  <span className={cn(\"h-4 w-[52px] rounded\", palette.mutedSurface, !shouldReduceMotion && \"animate-pulse\")} />\n                  <span className={cn(\"h-14 flex-1 rounded-[10px]\", palette.mutedSurface, !shouldReduceMotion && \"animate-pulse\")} />\n                </li>\n              ))}\n            </ul>\n          ) : dayEvents.length === 0 ? (\n            emptyState\n          ) : (\n            <ol aria-label={`${activeDayInfo?.fullLabel ?? activeDayInfo?.label ?? \"\"} sessions`} className=\"flex flex-col gap-1.5\">\n              {dayEvents.map((item, index) => {\n                const signal = getBjorkSignalPalette(toneFor(item.event), isDark);\n                const live = isLive(item.event);\n                const isSelected = item.event.id === selectedId;\n                return [\n                  showNow && index === nowIndex && !live ? nowRow : null,\n                  <li key={item.event.id} className=\"flex items-stretch gap-3\">\n                    <div className=\"flex w-[52px] shrink-0 flex-col items-end pt-2 tabular-nums\">\n                      <span className={cn(\"text-[13px] font-medium\", palette.primaryText)}>{item.event.start}</span>\n                      <span className={cn(\"text-[11px]\", palette.secondaryText)}>{item.event.end}</span>\n                    </div>\n                    <button\n                      type=\"button\"\n                      aria-label={describe(item.event)}\n                      aria-pressed={isSelected}\n                      onClick={() => handleSelect(item.event)}\n                      className={cn(\n                        \"relative flex min-w-0 flex-1 flex-col items-start gap-0.5 overflow-hidden rounded-[10px] border py-2 pl-3.5 pr-3 text-left transition-colors\",\n                        isBreak(item.event)\n                          ? cn(\"border-dashed\", isDark ? \"border-[#ededed]/14 bg-[#ededed]/[0.03]\" : \"border-[#171717]/16 bg-[#171717]/[0.025]\")\n                          : cn(signal.bgColor, signal.borderColor),\n                        focusRing,\n                        isSelected && \"ring-2 ring-[#ec5c13]/60\"\n                      )}\n                    >\n                      {isBreak(item.event) ? null : (\n                        <span aria-hidden className={cn(\"absolute inset-y-0 left-0 w-[3px]\", signal.dotColor)} />\n                      )}\n                      <span className={cn(\"w-full truncate text-[13px] font-medium\", palette.primaryText)}>\n                        {item.event.title}\n                      </span>\n                      <span className={cn(\"flex w-full items-center gap-1.5 truncate text-[12px]\", palette.secondaryText)}>\n                        {item.event.location ? <span>{item.event.location}</span> : null}\n                        {item.event.location && item.event.track ? <span aria-hidden>·</span> : null}\n                        {item.event.track ? <span>{item.event.track}</span> : null}\n                        {live ? (\n                          <span\n                            className=\"ml-auto rounded-full px-1.5 text-[11px] font-semibold uppercase tracking-[0.08em]\"\n                            style={{ color: accent, background: isDark ? \"rgba(236,92,19,0.14)\" : \"rgba(236,92,19,0.12)\" }}\n                          >\n                            Live\n                          </span>\n                        ) : null}\n                      </span>\n                    </button>\n                  </li>,\n                ];\n              })}\n              {showNow && nowIndex === -1 && !dayEvents.some((item) => isLive(item.event)) ? nowRow : null}\n            </ol>\n          )}\n        </div>\n      </>\n    );\n  };\n\n  // ---------- wide (week grid) ----------\n  const gridColumns = `${GUTTER}px repeat(${days.length}, minmax(${MIN_DAY_WIDTH}px, 1fr))`;\n  const slotBackground = `repeating-linear-gradient(to bottom, transparent 0, transparent ${slotHeight - 1}px, ${lineColor} ${slotHeight - 1}px, ${lineColor} ${slotHeight}px)`;\n\n  const renderWide = () => (\n    <div\n      role=\"region\"\n      aria-label={`${title} weekly schedule`}\n      tabIndex={0}\n      className={cn(\"relative overflow-auto\", focusRing)}\n      style={{ maxHeight }}\n    >\n      <div\n        className=\"grid\"\n        style={{ gridTemplateColumns: gridColumns, minWidth: GUTTER + days.length * MIN_DAY_WIDTH }}\n      >\n        {/* corner */}\n        <div\n          aria-hidden\n          className={cn(\n            \"sticky left-0 top-0 z-30 flex items-end justify-end border-b border-r px-2 pb-2 text-[11px] font-medium uppercase tracking-[0.08em]\",\n            palette.header\n          )}\n        >\n          {timeZoneLabel}\n        </div>\n        {/* day headers */}\n        {days.map((day, index) => {\n          const isToday = day.id === todayId;\n          return (\n            <div\n              key={day.id}\n              id={`tt-day-${day.id}`}\n              className={cn(\n                \"sticky top-0 z-20 flex items-baseline gap-2 border-b px-3 py-2.5\",\n                index < days.length - 1 && \"border-r\",\n                palette.header\n              )}\n            >\n              <span className=\"sr-only\">\n                {day.fullLabel ?? day.label}\n                {day.date ? `, ${day.date}` : \"\"}\n                {isToday ? \", today\" : \"\"}\n              </span>\n              <span\n                aria-hidden\n                className={cn(\"text-[11px] font-medium uppercase tracking-[0.08em]\", isToday && palette.accent)}\n              >\n                {day.label}\n              </span>\n              {day.date ? (\n                <span\n                  aria-hidden\n                  className={cn(\"text-[13px] font-semibold tabular-nums\", isToday ? palette.accent : palette.primaryText)}\n                >\n                  {day.date}\n                </span>\n              ) : null}\n              {isToday ? (\n                <span aria-hidden className=\"ml-auto size-1.5 self-center rounded-full\" style={{ background: accent }} />\n              ) : null}\n            </div>\n          );\n        })}\n\n        {/* time gutter */}\n        <div\n          aria-hidden\n          className={cn(\"sticky left-0 z-10 border-r\", palette.divider, isDark ? \"bg-[#111]\" : \"bg-[#fffcf6]\")}\n          style={{ height: bodyHeight }}\n        >\n          {hourMarks.map((mark) => (\n            <span\n              key={mark}\n              className={cn(\"absolute right-2 -translate-y-1/2 text-[11px] tabular-nums\", palette.secondaryText)}\n              style={{ top: Math.max(7, Math.min(bodyHeight - 7, (mark - rangeStart) * pxPerMinute)) }}\n            >\n              {formatMinutes(mark)}\n            </span>\n          ))}\n          {nowInRange && todayId ? (\n            <span\n              className=\"absolute right-1 z-10 -translate-y-1/2 rounded-full px-1.5 py-px text-[11px] font-semibold tabular-nums text-white\"\n              style={{ top: nowTop, background: accent }}\n            >\n              {formatMinutes(nowMinutes ?? 0)}\n            </span>\n          ) : null}\n        </div>\n\n        {/* day columns */}\n        {days.map((day, dayIndex) => {\n          const placed = byDay.get(day.id) ?? [];\n          const isToday = day.id === todayId;\n          return (\n            <section\n              key={day.id}\n              aria-labelledby={`tt-day-${day.id}`}\n              className={cn(\"relative\", dayIndex < days.length - 1 && \"border-r\", palette.divider)}\n              style={{\n                height: bodyHeight,\n                backgroundImage: slotBackground,\n                backgroundColor: isToday ? (isDark ? \"rgba(236,92,19,0.025)\" : \"rgba(236,92,19,0.03)\") : undefined,\n              }}\n            >\n              {hourMarks.map((mark) => (\n                <span\n                  key={mark}\n                  aria-hidden\n                  className=\"pointer-events-none absolute inset-x-0 h-px\"\n                  style={{ top: (mark - rangeStart) * pxPerMinute - 1, background: hourLineColor }}\n                />\n              ))}\n\n              {loading ? (\n                <div aria-hidden className=\"absolute inset-0\">\n                  {[0, 1, 2].map((i) => (\n                    <span\n                      key={i}\n                      className={cn(\n                        \"absolute inset-x-1.5 rounded-[8px]\",\n                        palette.mutedSurface,\n                        !shouldReduceMotion && \"animate-pulse\"\n                      )}\n                      style={{\n                        top: ((dayIndex * 2 + i * 5) % 12) * slotHeight + 4,\n                        height: slotHeight * (2 + ((dayIndex + i) % 2)) - 4,\n                      }}\n                    />\n                  ))}\n                </div>\n              ) : (\n                <ol className=\"absolute inset-0 m-0 list-none p-0\">\n                  {placed.map((item, index) => {\n                    const startMin = Math.max(item.startMin, rangeStart);\n                    const endMin = Math.min(item.endMin, rangeEnd);\n                    if (endMin <= startMin) return null;\n                    const top = (startMin - rangeStart) * pxPerMinute;\n                    const height = (endMin - startMin) * pxPerMinute;\n                    const signal = getBjorkSignalPalette(toneFor(item.event), isDark);\n                    const isSelected = item.event.id === selectedId;\n                    const live = isLive(item.event);\n                    const short = height < 44;\n                    const narrowLane = item.cols > 1;\n                    const roomy = height >= 92 && !narrowLane;\n                    // Gap (2) + borders (2) + padding (8) + time line (14) + optional room line (15), 15px per title line.\n                    const titleLines = Math.max(1, Math.min(5, Math.floor((height - 2 - 2 - 8 - 14 - (roomy ? 15 : 0)) / 15)));\n                    return (\n                      <motion.li\n                        key={item.event.id}\n                        className={cn(\"absolute rounded-[8px]\", isDark ? \"bg-[#111]\" : \"bg-[#fffcf6]\")}\n                        style={{\n                          top: top + 1,\n                          height: height - 2,\n                          left: `calc(${(item.col / item.cols) * 100}% + 3px)`,\n                          width: `calc(${100 / item.cols}% - 5px)`,\n                        }}\n                        initial={shouldAnimate ? { opacity: 0, y: 6 } : false}\n                        animate={{ opacity: 1, y: 0 }}\n                        transition={\n                          shouldAnimate\n                            ? { delay: 0.04 * dayIndex + 0.025 * index, type: \"spring\", stiffness: 420, damping: 34 }\n                            : { duration: 0 }\n                        }\n                      >\n                        <motion.button\n                          data-event-id={item.event.id}\n                          type=\"button\"\n                          aria-label={describe(item.event)}\n                          aria-pressed={isSelected}\n                          onClick={() => handleSelect(item.event)}\n                          onKeyDown={(e) => handleGridKey(e, item)}\n                          whileHover={shouldAnimate ? { y: -1 } : undefined}\n                          transition={{ type: \"spring\", stiffness: 500, damping: 30 }}\n                          className={cn(\n                            \"relative flex h-full w-full flex-col items-start overflow-hidden rounded-[8px] border pl-2.5 pr-1.5 text-left\",\n                            short ? \"justify-center py-0\" : \"py-1\",\n                            isBreak(item.event)\n                              ? cn(\"border-dashed\", isDark ? \"border-[#ededed]/14 bg-[#ededed]/[0.03]\" : \"border-[#171717]/16 bg-[#171717]/[0.025]\")\n                              : cn(signal.bgColor, signal.borderColor),\n                            focusRing,\n                            isSelected && (isDark ? \"ring-2 ring-[#ec5c13]/70\" : \"ring-2 ring-[#bd4514]/60\"),\n                            live && !isSelected && (isDark ? \"ring-1 ring-[#ec5c13]/45\" : \"ring-1 ring-[#bd4514]/40\")\n                          )}\n                        >\n                          {isBreak(item.event) ? null : (\n                            <span aria-hidden className={cn(\"absolute inset-y-1 left-1 w-[2px] rounded-full\", signal.dotColor)} />\n                          )}\n                          {live ? (\n                            <span\n                              aria-hidden\n                              className=\"absolute right-1.5 top-1.5 size-1.5 rounded-full\"\n                              style={{ background: accent }}\n                            />\n                          ) : null}\n                          {short ? (\n                            <span className=\"flex w-full min-w-0 items-baseline gap-1.5\">\n                              <span className={cn(\"shrink-0 text-[11px] tabular-nums\", signal.textColor)}>{item.event.start}</span>\n                              <span className={cn(\"truncate text-[12px] font-medium leading-tight\", palette.primaryText)}>\n                                {item.event.title}\n                              </span>\n                            </span>\n                          ) : (\n                            <>\n                              <span\n                                className={cn(\n                                  \"w-full truncate whitespace-nowrap text-[11px] tabular-nums leading-[13px]\",\n                                  signal.textColor\n                                )}\n                              >\n                                {narrowLane ? item.event.start : `${item.event.start}–${item.event.end}`}\n                              </span>\n                              <span\n                                className={cn(\n                                  \"mt-px w-full text-[12px] font-medium leading-[15px]\",\n                                  narrowLane && \"hyphens-auto\",\n                                  titleLines > 1 ? \"overflow-hidden\" : \"truncate\",\n                                  palette.primaryText\n                                )}\n                                style={\n                                  titleLines > 1\n                                    ? { display: \"-webkit-box\", WebkitLineClamp: titleLines, WebkitBoxOrient: \"vertical\" }\n                                    : undefined\n                                }\n                              >\n                                {item.event.title}\n                              </span>\n                              {item.event.location && roomy ? (\n                                <span className={cn(\"mt-auto w-full truncate text-[11px]\", palette.secondaryText)}>\n                                  {item.event.location}\n                                </span>\n                              ) : null}\n                            </>\n                          )}\n                        </motion.button>\n                      </motion.li>\n                    );\n                  })}\n                </ol>\n              )}\n\n              {isToday && nowInRange ? (\n                <div\n                  aria-hidden\n                  className=\"pointer-events-none absolute inset-x-0 z-10 h-[2px]\"\n                  style={{ top: nowTop - 1, background: accent }}\n                >\n                  <span className=\"absolute -left-[4px] -top-[3px] size-2 rounded-full\" style={{ background: accent }} />\n                </div>\n              ) : null}\n            </section>\n          );\n        })}\n      </div>\n      {!loading && visible.length === 0 ? (\n        <div className={cn(\"absolute inset-x-0 top-[44px] z-20 flex justify-center\")}>\n          <div className={cn(\"mt-10 rounded-[14px] border\", palette.menu)}>{emptyState}</div>\n        </div>\n      ) : null}\n    </div>\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      data-tt-root=\"\"\n      className={cn(\"mx-auto w-full max-w-[1120px] overflow-hidden rounded-[18px] border\", palette.container, className)}\n    >\n      {header}\n      {compact ? renderCompact() : renderWide()}\n      {footer}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/tables/timetable.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n",
      "type": "registry:lib",
      "target": "@lib/utils.ts"
    },
    {
      "path": "components/bjork-ui/_core/canvas.ts",
      "content": "\"use client\";\n\nimport { useEffect, useState, type RefObject } from \"react\";\nimport { isCoarsePointer } from \"./loop\";\n\n// Caps DPR at 1.5 on coarse pointers and 2 otherwise.\nexport function defaultMaxDpr(): number {\n  return isCoarsePointer() ? 1.5 : 2;\n}\n\n// Sets the backing size for a CSS box and returns the DPR used.\nexport function sizeCanvas(\n  canvas: HTMLCanvasElement,\n  cssW: number,\n  cssH: number,\n  maxDpr: number = defaultMaxDpr(),\n): number {\n  const dpr = Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, maxDpr);\n  canvas.width = Math.max(1, Math.round(cssW * dpr));\n  canvas.height = Math.max(1, Math.round(cssH * dpr));\n  canvas.style.width = `${cssW}px`;\n  canvas.style.height = `${cssH}px`;\n  return dpr;\n}\n\n// Rounded size of an element, kept current with a ResizeObserver.\nexport function useElementSize(ref: RefObject<HTMLElement | null>): { width: number; height: number } {\n  const [size, setSize] = useState({ width: 0, height: 0 });\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver((entries) => {\n      const box = entries[0]?.contentRect;\n      if (!box) return;\n      const width = Math.round(box.width);\n      const height = Math.round(box.height);\n      setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n\n  return size;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/canvas.ts"
    },
    {
      "path": "components/bjork-ui/_core/loop.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, type RefObject } from \"react\";\n\nexport interface VisibleLoopOptions {\n  enabled?: boolean;\n  fpsCap?: number;\n  rootMargin?: string;\n}\n\n/**\n * Runs `frame(dt, t)` on requestAnimationFrame while the element is near the viewport,\n * the tab is visible and `enabled` is not false. Return `false` from `frame` to go idle\n * until `wake()` is called. Writes `data-loop` on the element: running, idle or paused.\n */\nexport function useVisibleLoop(\n  ref: RefObject<HTMLElement | null>,\n  frame: (dt: number, t: number) => boolean | void,\n  opts?: VisibleLoopOptions,\n): { wake: () => void } {\n  const enabled = opts?.enabled !== false;\n  const fpsCap = opts?.fpsCap ?? 0;\n  const rootMargin = opts?.rootMargin ?? \"96px\";\n\n  const frameRef = useRef(frame);\n  useEffect(() => {\n    frameRef.current = frame;\n  }, [frame]);\n\n  const wakeRef = useRef<() => void>(() => {});\n  const wake = useCallback(() => wakeRef.current(), []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof window === \"undefined\") return;\n\n    let wants = true; // false once the frame callback returns false\n    let inView = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const minFrame = fpsCap > 0 ? 1000 / fpsCap : 0;\n\n    const canRun = () => enabled && inView && document.visibilityState === \"visible\";\n    const stop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const update = () => {\n      if (!enabled) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      if (!wants) {\n        stop();\n        el.dataset.loop = \"idle\";\n        return;\n      }\n      if (!canRun()) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      el.dataset.loop = \"running\";\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!canRun() || !wants) {\n        update();\n        return;\n      }\n      const elapsed = now - last;\n      if (minFrame && elapsed < minFrame - 0.5) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      last = now;\n      const dt = Math.min(0.05, Math.max(0, elapsed / 1000));\n      t += dt;\n      const keep = frameRef.current(dt, t);\n      if (keep === false) {\n        wants = false;\n        update();\n        return;\n      }\n      raf = requestAnimationFrame(tick);\n    };\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { rootMargin },\n    );\n    observer.observe(el);\n\n    const onVisibility = () => update();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    wakeRef.current = () => {\n      wants = true;\n      update();\n    };\n\n    update();\n\n    return () => {\n      stop();\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      wakeRef.current = () => {};\n    };\n  }, [ref, enabled, fpsCap, rootMargin]);\n\n  return useMemo(() => ({ wake }), [wake]);\n}\n\n// matchMedia based check. False on the server.\nexport function isCoarsePointer(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n  return window.matchMedia(\"(pointer: coarse)\").matches;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/loop.ts"
    },
    {
      "path": "components/bjork-ui/tables/table-theme.ts",
      "content": "import { useEffect, useState } from \"react\";\n\nexport type BjorkTableThemeMode = \"light\" | \"dark\" | \"auto\";\n\nexport function useBjorkTableIsDark(\n  resolvedTheme?: string,\n  forcedTheme?: Exclude<BjorkTableThemeMode, \"auto\">\n) {\n  const [documentTheme, setDocumentTheme] = useState<\"light\" | \"dark\" | null>(null);\n  const [previewTheme, setPreviewTheme] = useState<\"light\" | \"dark\" | null>(null);\n  // Server and first client render must agree, otherwise React keeps the\n  // server-rendered (dark) classes when the real theme resolves to light.\n  const [mounted, setMounted] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n    const syncDocumentTheme = () => {\n      const root = document.documentElement;\n      const searchParams = new URLSearchParams(window.location.search);\n      const theme = searchParams.get(\"theme\");\n\n      setPreviewTheme(theme === \"light\" || theme === \"dark\" ? theme : null);\n\n      setDocumentTheme(\n        root.classList.contains(\"light\")\n          ? \"light\"\n          : root.classList.contains(\"dark\")\n            ? \"dark\"\n            : null\n      );\n    };\n\n    syncDocumentTheme();\n\n    const observer = new MutationObserver(syncDocumentTheme);\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  const activeTheme =\n    forcedTheme ?? previewTheme ?? documentTheme ?? (mounted ? resolvedTheme : undefined);\n\n  return activeTheme !== \"light\";\n}\n\nexport function getBjorkTablePalette(isDark: boolean) {\n  return {\n    container: isDark\n      ? \"border-[#1c1c1c] bg-[#121212] shadow-[inset_0_7px_14px_rgba(255,255,255,0.03),inset_0_0.5px_0.5px_rgba(255,255,255,0.06),0_14px_20px_-6px_rgba(0,0,0,0.45)]\"\n      : \"border-[#f5ede2] bg-[#fffcf6] shadow-[inset_0_7px_14px_rgba(88,72,49,0.045),inset_0_0.5px_0.5px_rgba(255,255,255,0.92),inset_1px_0_0_rgba(88,72,49,0.026),inset_-1px_0_0_rgba(255,255,255,0.68),0_14px_22px_-9px_rgba(66,52,33,0.11)]\",\n    header: isDark\n      ? \"border-[#232323] bg-[#181818] text-[#ededed]/42\"\n      : \"border-[#f1e8dc] bg-[#fbf7ef] text-[#171717]/60\",\n    row: isDark\n      ? \"border-[#232323] hover:bg-[#181818]/70\"\n      : \"border-[#f1e8dc] hover:bg-[#f8f2e7]/72\",\n    selectedRow: isDark\n      ? \"border-[#2a2a2a] bg-[#181818]\"\n      : \"border-[#eadfce] bg-[#f8f2e7]\",\n    control: isDark\n      ? \"border-[#232323] bg-[#181818] text-[#ededed]/70 hover:bg-[#202020] shadow-[inset_0_1px_0_rgba(255,255,255,0.045)]\"\n      : \"border-[#eadfce] bg-[#fffcf6] text-[#171717]/68 shadow-[var(--bjork-shadow-soft)] hover:bg-[#f8f2e7]\",\n    menu: isDark\n      ? \"border-[#232323] bg-[#181818] text-[#ededed]/76 shadow-[inset_0_1px_0_rgba(255,255,255,0.055),inset_0_12px_24px_rgba(255,255,255,0.018),0_22px_42px_-22px_rgba(0,0,0,0.9)]\"\n      : \"border-[#eadfce] bg-[#fffcf6] text-[#171717]/76 shadow-[inset_0_1px_0_rgba(88,72,49,0.04),inset_0_12px_24px_rgba(88,72,49,0.022),0_22px_44px_-26px_rgba(66,52,33,0.22)]\",\n    menuItem: isDark ? \"hover:bg-[#232323]\" : \"hover:bg-[#f8f2e7]\",\n    menuActive: isDark ? \"bg-[#232323]\" : \"bg-[#f8f2e7]\",\n    divider: isDark ? \"border-[#232323]\" : \"border-[#f1e8dc]\",\n    primaryText: isDark ? \"text-[#ededed]/90\" : \"text-[#171717]/86\",\n    secondaryText: isDark ? \"text-[#ededed]/38\" : \"text-[#171717]/60\",\n    mutedSurface: isDark ? \"bg-[#181818]\" : \"bg-[#f8f2e7]\",\n    popover: isDark ? \"bg-[#111] text-[#ededed]\" : \"bg-[#fffcf6] text-[#171717]\",\n    accent: isDark ? \"text-[#d86a2c]\" : \"text-[#bd4514]\",\n    accentBg: isDark ? \"bg-[#ec5c13]/12\" : \"bg-[#ec5c13]/14\",\n    accentBorder: isDark ? \"border-[#ec5c13]/24\" : \"border-[#bd4514]/22\",\n    checkboxAccent: isDark ? \"#d86a2c\" : \"#bd4514\",\n  };\n}\n\nexport function getBjorkSignalPalette(\n  tone: \"green\" | \"red\" | \"orange\" | \"neutral\",\n  isDark: boolean\n) {\n  const palettes = {\n    green: {\n      bgColor: isDark ? \"bg-[#3e6f52]/18\" : \"bg-[#dce8dc]\",\n      borderColor: isDark ? \"border-[#5f9f78]/28\" : \"border-[#bad0bd]\",\n      textColor: isDark ? \"text-[#77b58b]\" : \"text-[#47785c]\",\n      dotColor: isDark ? \"bg-[#77b58b]\" : \"bg-[#47785c]\",\n      color: isDark ? \"#77b58b\" : \"#47785c\",\n    },\n    red: {\n      bgColor: isDark ? \"bg-[#87463f]/18\" : \"bg-[#eadad5]\",\n      borderColor: isDark ? \"border-[#c46a61]/28\" : \"border-[#d7bcb4]\",\n      textColor: isDark ? \"text-[#cf7c72]\" : \"text-[#9d5149]\",\n      dotColor: isDark ? \"bg-[#cf7c72]\" : \"bg-[#9d5149]\",\n      color: isDark ? \"#cf7c72\" : \"#9d5149\",\n    },\n    orange: {\n      bgColor: isDark ? \"bg-[#ec5c13]/14\" : \"bg-[#ead9cb]\",\n      borderColor: isDark ? \"border-[#ec5c13]/28\" : \"border-[#d5b59f]\",\n      textColor: isDark ? \"text-[#d86a2c]\" : \"text-[#9f4317]\",\n      dotColor: isDark ? \"bg-[#d86a2c]\" : \"bg-[#9f4317]\",\n      color: isDark ? \"#d86a2c\" : \"#9f4317\",\n    },\n    neutral: {\n      bgColor: isDark ? \"bg-[#ededed]/8\" : \"bg-[#ded7ca]\",\n      borderColor: isDark ? \"border-[#ededed]/14\" : \"border-[#cfc6b7]\",\n      textColor: isDark ? \"text-[#ededed]/58\" : \"text-[#171717]/60\",\n      dotColor: isDark ? \"bg-[#ededed]/45\" : \"bg-[#171717]/42\",\n      color: isDark ? \"#a3a3a3\" : \"#706b62\",\n    },\n  } as const;\n\n  return palettes[tone];\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/tables/table-theme.ts"
    }
  ],
  "cssVars": {
    "light": {
      "bjork-bg": "#f7f3ea",
      "bjork-surface": "#fffcf6",
      "bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
      "bjork-surface-hover": "#f5efe3",
      "bjork-surface-active": "#efe7d8",
      "bjork-panel": "#f8f2e7",
      "bjork-menu": "rgba(255, 252, 246, 0.98)",
      "bjork-field": "rgba(255, 252, 246, 0.96)",
      "bjork-field-muted": "rgba(248, 242, 231, 0.82)",
      "bjork-field-inset": "rgba(239, 231, 216, 0.72)",
      "bjork-error-bg": "#fff0e8",
      "bjork-text": "#171717",
      "bjork-text-strong": "rgba(23, 23, 23, 0.9)",
      "bjork-text-medium": "rgba(23, 23, 23, 0.78)",
      "bjork-text-muted": "rgba(23, 23, 23, 0.68)",
      "bjork-text-soft": "rgba(23, 23, 23, 0.62)",
      "bjork-text-faint": "rgba(23, 23, 23, 0.22)",
      "bjork-inverted-text": "#fff8f0",
      "bjork-border": "#eee6db",
      "bjork-border-muted": "#f5ede2",
      "bjork-border-strong": "#e1d7c8",
      "bjork-thumb": "#a49b8e",
      "bjork-track": "#ded6ca",
      "bjork-accent": "#ec7d43",
      "bjork-accent-hover": "#f0935f",
      "bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
      "bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
      "bjork-accent-badge": "rgba(236, 125, 67, 0.16)",
      "bjork-accent-badge-foreground": "#8d421d",
      "bjork-accent-foreground": "#3f2112",
      "bjork-accent-fill": "#ec7d43",
      "bjork-accent-fill-hover": "#f0935f",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
      "bjork-ring-offset": "#f7f3ea"
    },
    "dark": {
      "bjork-bg": "#111111",
      "bjork-surface": "#121212",
      "bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
      "bjork-surface-hover": "#161616",
      "bjork-surface-active": "#202020",
      "bjork-panel": "#0d0d0d",
      "bjork-menu": "rgba(18, 18, 18, 0.98)",
      "bjork-field": "rgba(18, 18, 18, 0.95)",
      "bjork-field-muted": "rgba(18, 18, 18, 0.55)",
      "bjork-field-inset": "rgba(9, 9, 9, 0.75)",
      "bjork-error-bg": "#130d0a",
      "bjork-text": "#ededed",
      "bjork-text-strong": "rgba(237, 237, 237, 0.9)",
      "bjork-text-medium": "rgba(237, 237, 237, 0.72)",
      "bjork-text-muted": "rgba(237, 237, 237, 0.6)",
      "bjork-text-soft": "rgba(237, 237, 237, 0.5)",
      "bjork-text-faint": "rgba(237, 237, 237, 0.22)",
      "bjork-inverted-text": "#080808",
      "bjork-border": "#232323",
      "bjork-border-muted": "#1c1c1c",
      "bjork-border-strong": "#343434",
      "bjork-thumb": "#626262",
      "bjork-track": "#090909",
      "bjork-accent": "#ec5c13",
      "bjork-accent-hover": "#f07832",
      "bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
      "bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
      "bjork-accent-badge": "#b84a12",
      "bjork-accent-badge-foreground": "#fff2ea",
      "bjork-accent-foreground": "#fff2ea",
      "bjork-accent-fill": "#b84a12",
      "bjork-accent-fill-hover": "#a84a14",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
      "bjork-ring-offset": "#080808"
    }
  },
  "css": {
    ".bjork-range": {
      "box-shadow": "inset 0 1px 1px rgba(255, 255, 255, 0.04), 0 0 14px rgba(189, 69, 20, 0.06)"
    },
    ".bjork-range::-webkit-slider-thumb": {
      "appearance": "none",
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-range::-moz-range-thumb": {
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-layered-button-accent, .bjork-layered-button-raised": {
      "position": "relative",
      "isolation": "isolate",
      "appearance": "none",
      "overflow": "hidden",
      "border": "0",
      "border-radius": "13px",
      "box-shadow": "inset 0 1px 0 #ffffff38, 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a",
      "transition": "filter 150ms ease-out, box-shadow 150ms ease-out"
    },
    ".bjork-layered-button-accent::after, .bjork-layered-button-raised::after": {
      "position": "absolute",
      "inset": "0",
      "pointer-events": "none",
      "content": "\"\"",
      "border": "1.5px solid transparent",
      "border-radius": "inherit",
      "background": "linear-gradient(#ffffffb8, #0000003d 41% 75%, #ffffff47) border-box",
      "mix-blend-mode": "overlay",
      "-webkit-mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "-webkit-mask-clip": "padding-box, border-box",
      "mask-clip": "padding-box, border-box",
      "-webkit-mask-origin": "padding-box, border-box",
      "mask-origin": "padding-box, border-box",
      "-webkit-mask-composite": "xor",
      "mask-composite": "exclude"
    },
    ".bjork-layered-button-accent": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000040",
      "background": "radial-gradient(ellipse at -20px top, rgba(255, 255, 255, 0.14), transparent), linear-gradient(180deg, #ec5c13 0%, #ec5c13 62%, color-mix(in oklab, #ec5c13 84%, black 16%) 100%)"
    },
    ".bjork-layered-button-raised": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, oklch(0.1881 0.006 265), oklch(0.5493 0.0081 265))"
    },
    ".dark .bjork-layered-button-raised": {
      "color": "oklch(0.2574 0.0056 265)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, color-mix(in oklab, oklch(0.9674 0.0013 265) 80%, oklch(0.2574 0.0056 265) 20%), color-mix(in oklab, oklch(0.669 0.0107 265) 80%, oklch(0.2574 0.0056 265) 20%))"
    },
    "@media (hover: hover)": {
      ".bjork-layered-button-accent:hover": {
        "filter": "brightness(1.08) saturate(1.04)"
      },
      ".bjork-layered-button-raised:hover": {
        "filter": "brightness(1.08) contrast(0.9)"
      }
    }
  },
  "meta": {
    "collection": "Tables",
    "registryUrl": "https://ui.isaiahbjork.com/timetable.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/timetable.json"
  },
  "type": "registry:component"
}