{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "solar-sky-panel",
  "title": "Solar sky panel",
  "description": "Solar sky panel is a production-ready cards component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/cards/solar-sky-panel.tsx",
      "content": "\"use client\";\n\nimport {\n  memo,\n  useEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent,\n} from \"react\";\nimport { animate, useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { BJORK_PALETTE, type BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport { useBjorkTone } from \"@/components/bjork-ui/_core/tone\";\nimport { springs } from \"@/components/bjork-ui/_core/motion\";\nimport { useVisibleLoop } from \"@/components/bjork-ui/_core/loop\";\nimport { useElementSize } from \"@/components/bjork-ui/_core/canvas\";\nimport { mulberry32 } from \"@/components/bjork-ui/_core/random\";\n\nconst MS_MIN = 60_000;\nconst MS_DAY = 86_400_000;\nconst DEG = Math.PI / 180;\n\n// Horizon position as a fraction of the sky height. Elevation maps piecewise around it.\nconst HORIZON = 0.68;\nconst TOP_PAD = 12;\nconst SCRUB_PX_TO_MIN = 4;\nconst SCRUB_LIMIT = 720; // minutes, +/- 12h\nconst KEY_STEP = 15;\nconst KEY_STEP_LARGE = 60;\nconst ATTRACT_MIN_PER_S = 120; // a full 24h sweep takes 12s\nconst ATTRACT_IDLE_MS = 4000;\n\nconst MOON_SYNODIC = 29.530588853;\nconst MOON_EPOCH = Date.UTC(2000, 0, 6, 18, 14);\nconst MOON_RADIUS = 6; // half of the 12px disc\n\nconst SKY_STOPS: ReadonlyArray<readonly [number, string, string, string]> = [\n  [-18, \"#04060c\", \"#080c18\", \"#0f1424\"],\n  [-12, \"#070b1a\", \"#141a36\", \"#2a2a4a\"],\n  [-6, \"#0f1638\", \"#3a2f5c\", \"#a0566a\"],\n  [0, \"#1d2b55\", \"#b8634e\", \"#f2a560\"],\n  [6, \"#2f5a9a\", \"#d68a5c\", \"#f6c88a\"],\n  [20, \"#3d78c4\", \"#86b3e6\", \"#d3e4f2\"],\n  [45, \"#2f6fbf\", \"#6fa6e3\", \"#cfe3f5\"],\n];\n\nconst STARS = (() => {\n  const rnd = mulberry32(1729);\n  return Array.from({ length: 40 }, () => ({ x: rnd(), y: rnd() * 0.62, s: rnd() }));\n})();\n\nconst formatterCache = new Map<string, Intl.DateTimeFormat>();\nconst warnedZones = new Set<string>();\n\nexport interface SolarSkyPanelProps {\n  latitude: number;\n  longitude: number;\n  locationLabel?: string;\n  date?: Date;\n  timeZone?: string;\n  scrubbable?: boolean;\n  returnToNow?: boolean;\n  showLabels?: boolean;\n  onTimeChange?: (d: Date) => void;\n  tone?: BjorkTone;\n  attract?: boolean;\n  className?: string;\n}\n\nexport interface SolarTimes {\n  sunrise: Date | null;\n  sunset: Date | null;\n  solarNoon: Date;\n  /** Start of the evening golden hour: the moment the sun drops to 6 degrees. */\n  goldenStart: Date | null;\n}\n\nexport interface MoonPhase {\n  /** Days since the new moon, 0 to 29.53. */\n  age: number;\n  /** Illuminated fraction, 0 to 1. */\n  illumination: number;\n  waxing: boolean;\n}\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\nconst mod = (v: number, m: number) => ((v % m) + m) % m;\n\ninterface SunState {\n  /** Declination in radians. */\n  decl: number;\n  /** Equation of time in minutes. */\n  eqTime: number;\n}\n\n// NOAA solar equations: mean anomaly, equation of centre, apparent longitude, obliquity, declination and equation of time.\nfunction sunState(ms: number): SunState {\n  const T = (ms / MS_DAY + 2440587.5 - 2451545) / 36525;\n  const L0 = mod(280.46646 + T * (36000.76983 + T * 0.0003032), 360) * DEG;\n  const M = (357.52911 + T * (35999.05029 - 0.0001537 * T)) * DEG;\n  const e = 0.016708634 - T * (0.000042037 + 0.0000001267 * T);\n  const C =\n    (Math.sin(M) * (1.914602 - T * (0.004817 + 0.000014 * T)) +\n      Math.sin(2 * M) * (0.019993 - 0.000101 * T) +\n      Math.sin(3 * M) * 0.000289) *\n    DEG;\n  const trueLong = L0 + C;\n  const omega = (125.04 - 1934.136 * T) * DEG;\n  const lambda = trueLong - (0.00569 + 0.00478 * Math.sin(omega)) * DEG;\n  const meanObl = (23 + (26 + (21.448 - T * (46.815 + T * (0.00059 - T * 0.001813))) / 60) / 60) * DEG;\n  const obl = meanObl + 0.00256 * Math.cos(omega) * DEG;\n  const decl = Math.asin(Math.sin(obl) * Math.sin(lambda));\n  const y = Math.tan(obl / 2) ** 2;\n  const eqRad =\n    y * Math.sin(2 * L0) -\n    2 * e * Math.sin(M) +\n    4 * e * y * Math.sin(M) * Math.cos(2 * L0) -\n    0.5 * y * y * Math.sin(4 * L0) -\n    1.25 * e * e * Math.sin(2 * M);\n  return { decl, eqTime: (4 * eqRad) / DEG };\n}\n\n/** Sun elevation (refraction-free, degrees above the horizon) and azimuth (degrees clockwise from north). */\nexport function solarPosition(date: Date, lat: number, lon: number): { elevation: number; azimuth: number } {\n  const ms = date.getTime();\n  const { decl, eqTime } = sunState(ms);\n  const utcMinutes = mod(ms, MS_DAY) / MS_MIN;\n  const trueSolarMinutes = utcMinutes + eqTime + 4 * lon;\n  const ha = (mod(trueSolarMinutes, 1440) / 4 - 180) * DEG;\n  const phi = lat * DEG;\n  const cosZ = Math.sin(phi) * Math.sin(decl) + Math.cos(phi) * Math.cos(decl) * Math.cos(ha);\n  const elevation = 90 - Math.acos(clamp(cosZ, -1, 1)) / DEG;\n  const azimuth = mod(Math.atan2(Math.sin(ha), Math.cos(ha) * Math.sin(phi) - Math.tan(decl) * Math.cos(phi)) / DEG + 180, 360);\n  return { elevation, azimuth };\n}\n\n/**\n * Sunrise, sunset, solar noon and the evening golden-hour start for the calendar day of `date`\n * at mean solar time for `lon`. Sunrise and sunset use h0 = -0.833 degrees. Polar day and night return null.\n */\nexport function solarTimes(date: Date, lat: number, lon: number): SolarTimes {\n  const local = new Date(date.getTime() + (lon / 15) * 60 * MS_MIN);\n  const dayStart = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate());\n  let noon = dayStart + (720 - 4 * lon) * MS_MIN;\n  for (let i = 0; i < 3; i++) {\n    noon = dayStart + (720 - 4 * lon - sunState(noon).eqTime) * MS_MIN;\n  }\n  const { decl } = sunState(noon);\n  const phi = lat * DEG;\n  const cosLatDecl = Math.cos(phi) * Math.cos(decl);\n  const sinLatDecl = Math.sin(phi) * Math.sin(decl);\n  const halfArc = (cosH: number) => (cosH < -1 || cosH > 1 ? null : (Math.acos(cosH) / DEG) * 4 * MS_MIN);\n\n  const riseSet = halfArc((Math.sin(-0.833 * DEG) - sinLatDecl) / cosLatDecl);\n  const golden = halfArc((Math.sin(6 * DEG) - sinLatDecl) / cosLatDecl);\n\n  return {\n    sunrise: riseSet === null ? null : new Date(noon - riseSet),\n    sunset: riseSet === null ? null : new Date(noon + riseSet),\n    solarNoon: new Date(noon),\n    goldenStart: golden === null ? null : new Date(noon + golden),\n  };\n}\n\n/** Moon age from the new moon at 2000-01-06 18:14 UTC, and the illuminated fraction. */\nexport function moonPhase(date: Date): MoonPhase {\n  const days = (date.getTime() - MOON_EPOCH) / MS_DAY;\n  const age = mod(days, MOON_SYNODIC);\n  return {\n    age,\n    illumination: (1 - Math.cos((2 * Math.PI * age) / MOON_SYNODIC)) / 2,\n    waxing: age < MOON_SYNODIC / 2,\n  };\n}\n\n// Overlap fraction of two discs of MOON_RADIUS whose centres are `d` apart.\nfunction discOverlap(d: number) {\n  const r = MOON_RADIUS;\n  if (d >= 2 * r) return 0;\n  const area = 2 * r * r * Math.acos(d / (2 * r)) - (d / 2) * Math.sqrt(4 * r * r - d * d);\n  return area / (Math.PI * r * r);\n}\n\n// Shadow offset that leaves exactly `illumination` of the disc lit. Negative shifts the shadow left (waxing: lit on the right).\nfunction shadowOffset(illumination: number, waxing: boolean) {\n  const dark = 1 - illumination;\n  let lo = 0;\n  let hi = 2 * MOON_RADIUS;\n  for (let i = 0; i < 30; i++) {\n    const mid = (lo + hi) / 2;\n    if (discOverlap(mid) > dark) lo = mid;\n    else hi = mid;\n  }\n  const d = (lo + hi) / 2;\n  return waxing ? -d : d;\n}\n\nfunction hexRgb(hex: string): [number, number, number] {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction rgbText([r, g, b]: [number, number, number]) {\n  return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;\n}\n\nfunction skyAt(elevation: number) {\n  const first = SKY_STOPS[0];\n  const last = SKY_STOPS[SKY_STOPS.length - 1];\n  const x = clamp(elevation, first[0], last[0]);\n  let i = 0;\n  while (i < SKY_STOPS.length - 2 && x > SKY_STOPS[i + 1][0]) i++;\n  const a = SKY_STOPS[i];\n  const b = SKY_STOPS[i + 1];\n  const k = (x - a[0]) / (b[0] - a[0]);\n  const mix = (ca: string, cb: string): [number, number, number] => {\n    const A = hexRgb(ca);\n    const B = hexRgb(cb);\n    return [A[0] + (B[0] - A[0]) * k, A[1] + (B[1] - A[1]) * k, A[2] + (B[2] - A[2]) * k];\n  };\n  const horizon = mix(a[3], b[3]);\n  return {\n    top: rgbText(mix(a[1], b[1])),\n    mid: rgbText(mix(a[2], b[2])),\n    horizon: rgbText(horizon),\n    ground: rgbText([horizon[0] * 0.92, horizon[1] * 0.92, horizon[2] * 0.92]),\n  };\n}\n\n// Piecewise map of elevation to a y pixel: [-20, 0] fills the ground to the horizon, [0, maxNoon + 5] fills the sky up to 12px.\nfunction elevationY(elevation: number, height: number, maxNoon: number) {\n  const horizonY = height * HORIZON;\n  if (elevation <= 0) {\n    const k = clamp(-elevation / 20, 0, 1);\n    return horizonY + k * (height - horizonY);\n  }\n  const top = Math.max(maxNoon + 5, 1);\n  const k = clamp(elevation / top, 0, 1);\n  return horizonY - k * (horizonY - TOP_PAD);\n}\n\nfunction isValidZone(zone: string) {\n  try {\n    new Intl.DateTimeFormat(\"en-US\", { timeZone: zone });\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nfunction browserZone() {\n  try {\n    return Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\";\n  } catch {\n    return \"UTC\";\n  }\n}\n\n// Server and first client render use the given zone or UTC, so hydration text matches. The browser zone applies after mount.\nfunction resolveZone(timeZone: string | undefined, mounted: boolean) {\n  if (!mounted) return timeZone && isValidZone(timeZone) ? timeZone : \"UTC\";\n  if (!timeZone) return browserZone();\n  if (isValidZone(timeZone)) return timeZone;\n  if (!warnedZones.has(timeZone)) {\n    warnedZones.add(timeZone);\n    console.warn(`SolarSkyPanel: invalid timeZone \"${timeZone}\", falling back to the local zone.`);\n  }\n  return browserZone();\n}\n\ninterface ZoneParts {\n  y: number;\n  m: number;\n  d: number;\n  h: number;\n  min: number;\n}\n\nfunction zoneParts(ms: number, zone: string): ZoneParts {\n  let formatter = formatterCache.get(zone);\n  if (!formatter) {\n    formatter = new Intl.DateTimeFormat(\"en-US\", {\n      timeZone: zone,\n      hourCycle: \"h23\",\n      year: \"numeric\",\n      month: \"numeric\",\n      day: \"numeric\",\n      hour: \"numeric\",\n      minute: \"numeric\",\n    });\n    formatterCache.set(zone, formatter);\n  }\n  const out: Record<string, number> = {};\n  for (const part of formatter.formatToParts(ms)) {\n    if (part.type !== \"literal\") out[part.type] = Number(part.value);\n  }\n  return { y: out.year, m: out.month, d: out.day, h: out.hour % 24, min: out.minute };\n}\n\nfunction zoneOffset(ms: number, zone: string) {\n  const p = zoneParts(ms, zone);\n  return Date.UTC(p.y, p.m - 1, p.d, p.h, p.min) - Math.floor(ms / MS_MIN) * MS_MIN;\n}\n\nfunction zoneMidnight(ms: number, zone: string) {\n  const p = zoneParts(ms, zone);\n  const guess = Date.UTC(p.y, p.m - 1, p.d);\n  const first = guess - zoneOffset(guess, zone);\n  return guess - zoneOffset(first, zone);\n}\n\nfunction pad2(n: number) {\n  return String(n).padStart(2, \"0\");\n}\n\nfunction formatClock(ms: number, zone: string) {\n  const p = zoneParts(ms, zone);\n  return `${pad2(p.h)}:${pad2(p.min)}`;\n}\n\nfunction formatCoordinates(lat: number, lon: number) {\n  return `${Math.abs(lat).toFixed(2)}° ${lat >= 0 ? \"N\" : \"S\"}  ${Math.abs(lon).toFixed(2)}° ${lon >= 0 ? \"E\" : \"W\"}`;\n}\n\nconst noopSubscribe = () => () => {};\nconst mountedSnapshot = () => true;\nconst serverMountedSnapshot = () => false;\n\nfunction subscribeClock(onChange: () => void) {\n  const id = window.setInterval(onChange, MS_MIN);\n  return () => window.clearInterval(id);\n}\n\n// The minute bucket changes once a minute, so the value is stable between renders.\nfunction clockSnapshot() {\n  return Math.floor(Date.now() / MS_MIN) * MS_MIN;\n}\n\nfunction serverClockSnapshot() {\n  return 0;\n}\n\nfunction wrapOffset(v: number) {\n  return mod(v + SCRUB_LIMIT, 2 * SCRUB_LIMIT) - SCRUB_LIMIT;\n}\n\n// The day's dashed elevation curve. Memoised on primitives, so scrubbing within a day does not recompute it.\nconst SunPath = memo(function SunPath({\n  dayStart,\n  latitude,\n  longitude,\n  noonElevation,\n  width,\n  height,\n}: {\n  dayStart: number;\n  latitude: number;\n  longitude: number;\n  noonElevation: number;\n  width: number;\n  height: number;\n}) {\n  if (width <= 0 || height <= 0) return null;\n  const points: string[] = [];\n  for (let i = 0; i <= 48; i++) {\n    const elevation = solarPosition(new Date(dayStart + i * 30 * MS_MIN), latitude, longitude).elevation;\n    const x = (i / 48) * width;\n    const y = elevationY(elevation, height, noonElevation);\n    points.push(`${i === 0 ? \"M\" : \"L\"}${x.toFixed(1)} ${y.toFixed(1)}`);\n  }\n  return <path d={points.join(\" \")} fill=\"none\" stroke=\"rgba(255,255,255,0.4)\" strokeWidth={1} strokeDasharray=\"2 4\" />;\n});\n\nexport function SolarSkyPanel({\n  latitude,\n  longitude,\n  locationLabel,\n  date,\n  timeZone,\n  scrubbable = true,\n  returnToNow = true,\n  showLabels = true,\n  onTimeChange,\n  tone,\n  attract = false,\n  className,\n}: SolarSkyPanelProps) {\n  const resolvedTone = useBjorkTone(tone);\n  const palette = BJORK_PALETTE[resolvedTone];\n  const reduceMotion = useReducedMotion() ?? false;\n  const mounted = useSyncExternalStore(noopSubscribe, mountedSnapshot, serverMountedSnapshot);\n  const clock = useSyncExternalStore(subscribeClock, clockSnapshot, serverClockSnapshot);\n  const zone = resolveZone(timeZone, mounted);\n  const baseMs = date ? date.getTime() : clock;\n\n  const [offset, setOffsetState] = useState(0);\n  const offsetRef = useRef(0);\n  const [scrubbing, setScrubbing] = useState(false);\n  const [returning, setReturning] = useState(false);\n  const [interacting, setInteracting] = useState(false);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const surfaceRef = useRef<HTMLDivElement>(null);\n  const { width, height } = useElementSize(surfaceRef);\n  const dragRef = useRef<{ id: number; x: number; start: number; moved: boolean } | null>(null);\n  const returnRef = useRef<{ stop: () => void } | null>(null);\n  const idleTimerRef = useRef(0);\n  const onTimeChangeRef = useRef(onTimeChange);\n\n  useEffect(() => {\n    onTimeChangeRef.current = onTimeChange;\n  }, [onTimeChange]);\n\n  useEffect(() => {\n    const timer = idleTimerRef;\n    return () => window.clearTimeout(timer.current);\n  }, []);\n\n  const setOffset = (v: number) => {\n    offsetRef.current = v;\n    setOffsetState(v);\n  };\n\n  const commit = (v: number) => {\n    setOffset(v);\n    onTimeChangeRef.current?.(new Date(baseMs + v * MS_MIN));\n  };\n\n  const markInteracting = () => {\n    setInteracting(true);\n    window.clearTimeout(idleTimerRef.current);\n    idleTimerRef.current = window.setTimeout(() => setInteracting(false), ATTRACT_IDLE_MS);\n  };\n\n  const stopReturn = () => {\n    returnRef.current?.stop();\n    returnRef.current = null;\n    setReturning(false);\n  };\n\n  const springBack = () => {\n    if (offsetRef.current === 0) return;\n    if (reduceMotion) {\n      commit(0);\n      return;\n    }\n    setReturning(true);\n    returnRef.current = animate(offsetRef.current, 0, {\n      ...springs.soft,\n      onUpdate: (v) => commit(v),\n      onComplete: () => {\n        returnRef.current = null;\n        setReturning(false);\n      },\n    });\n  };\n\n  const attractOn = attract && !reduceMotion && !interacting;\n  const loopOn = attractOn || scrubbing || returning;\n\n  useVisibleLoop(\n    rootRef,\n    (dt) => {\n      if (attractOn) {\n        setOffset(wrapOffset(offsetRef.current + dt * ATTRACT_MIN_PER_S));\n        return true;\n      }\n      return scrubbing || returning;\n    },\n    { enabled: loopOn },\n  );\n\n  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {\n    if (!scrubbable || e.button !== 0) return;\n    markInteracting();\n    stopReturn();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    dragRef.current = { id: e.pointerId, x: e.clientX, start: offsetRef.current, moved: false };\n    setScrubbing(true);\n  };\n\n  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current;\n    if (!drag || drag.id !== e.pointerId) return;\n    if (e.clientX !== drag.x) drag.moved = true;\n    commit(clamp(drag.start + (e.clientX - drag.x) * SCRUB_PX_TO_MIN, -SCRUB_LIMIT, SCRUB_LIMIT));\n  };\n\n  const endDrag = (e: PointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current;\n    if (!drag || drag.id !== e.pointerId) return;\n    dragRef.current = null;\n    setScrubbing(false);\n    // A tap without a drag leaves the time alone. Only a scrub springs back.\n    if (returnToNow && drag.moved) springBack();\n  };\n\n  // Arrow right and up move later, the same direction as dragging right.\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (!scrubbable) return;\n    const step = e.shiftKey ? KEY_STEP_LARGE : KEY_STEP;\n    let next: number;\n    switch (e.key) {\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        next = offsetRef.current + step;\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        next = offsetRef.current - step;\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    markInteracting();\n    stopReturn();\n    commit(clamp(next, -SCRUB_LIMIT, SCRUB_LIMIT));\n  };\n\n  const instantMs = baseMs + offset * MS_MIN;\n  const instant = new Date(instantMs);\n  const sun = solarPosition(instant, latitude, longitude);\n  const times = solarTimes(instant, latitude, longitude);\n  const noonElevation = solarPosition(times.solarNoon, latitude, longitude).elevation;\n  const polarLabel = times.sunrise === null ? (noonElevation > 0 ? \"Polar day\" : \"Polar night\") : null;\n\n  const parts = zoneParts(instantMs, zone);\n  const minutesOfDay = parts.h * 60 + parts.min;\n  const clockText = `${pad2(parts.h)}:${pad2(parts.min)}`;\n  const sky = skyAt(sun.elevation);\n  const sunriseText = times.sunrise ? formatClock(times.sunrise.getTime(), zone) : \"\";\n  const sunsetText = times.sunset ? formatClock(times.sunset.getTime(), zone) : \"\";\n\n  const sunX = (minutesOfDay / 1440) * width;\n  const sunY = elevationY(sun.elevation, height, noonElevation);\n  const moonX = ((((minutesOfDay + 720) % 1440) / 1440) * width);\n  const moonY = elevationY(-sun.elevation, height, noonElevation);\n  const phase = moonPhase(instant);\n  const moonShift = shadowOffset(phase.illumination, phase.waxing);\n  const horizonY = height * HORIZON;\n\n  const dayStart = zoneMidnight(instantMs, zone);\n\n  const glowAlpha = 0.6 * Math.max(0, 1 - Math.abs(sun.elevation) / 12);\n  const valueText = `${clockText}, sun ${Math.abs(Math.round(sun.elevation))} degrees ${sun.elevation >= 0 ? \"above\" : \"below\"} the horizon`;\n  const stripBackground = resolvedTone === \"dark\" ? \"rgba(5,5,5,0.45)\" : \"rgba(255,252,246,0.65)\";\n  const borderColor = resolvedTone === \"dark\" ? \"rgba(255,255,255,0.06)\" : palette.borderStrong;\n\n  const surfaceStyle = {\n    background: `linear-gradient(to bottom, ${sky.top} 0%, ${sky.mid} 34%, ${sky.horizon} ${HORIZON * 100}%, ${sky.ground} ${HORIZON * 100}%, ${sky.ground} 100%)`,\n    borderColor,\n    cursor: scrubbable ? (scrubbing ? \"grabbing\" : \"grab\") : \"default\",\n  } as CSSProperties;\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={`Sky over ${locationLabel ?? formatCoordinates(latitude, longitude)}`}\n      className={cn(\"w-[360px] max-w-full\", className)}\n    >\n      <div\n        ref={surfaceRef}\n        role=\"slider\"\n        tabIndex={scrubbable ? 0 : -1}\n        aria-label=\"Time of day\"\n        aria-valuemin={-720}\n        aria-valuemax={720}\n        aria-valuenow={Math.round(offset)}\n        aria-valuetext={valueText}\n        aria-disabled={scrubbable ? undefined : true}\n        onKeyDown={onKeyDown}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        className=\"relative aspect-[3/2] w-full touch-none select-none overflow-hidden rounded-[20px] border outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--bjork-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bjork-ring-offset)]\"\n        style={surfaceStyle}\n      >\n        {STARS.map((star, i) => (\n          <span\n            key={i}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute h-px w-px rounded-full bg-white\"\n            style={{\n              left: `${star.x * 100}%`,\n              top: `${star.y * HORIZON * 100}%`,\n              opacity: clamp(-sun.elevation / 12, 0, 1) * 0.8 * (0.5 + star.s * 0.5),\n            }}\n          />\n        ))}\n\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0 h-px w-full bg-white/[0.22]\"\n          style={{ transform: `translate3d(0, ${horizonY}px, 0)` }}\n        />\n\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0 h-[60px] w-[240px]\"\n          style={{\n            transform: `translate3d(${sunX - 120}px, ${horizonY - 30}px, 0)`,\n            background: `radial-gradient(closest-side, rgba(255,190,120,${glowAlpha.toFixed(3)}), rgba(255,190,120,0))`,\n          }}\n        />\n\n        {/* the strip is opaque enough without blur; keep the dashes out from under it */}\n        <svg\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0\"\n          width={width}\n          height={height}\n          style={showLabels ? { clipPath: \"inset(0 0 48px 0)\" } : undefined}\n        >\n          <SunPath\n            dayStart={dayStart}\n            latitude={latitude}\n            longitude={longitude}\n            noonElevation={noonElevation}\n            width={width}\n            height={height}\n          />\n        </svg>\n\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0 h-[14px] w-[14px] rounded-full bg-[#fff6e0] transition-opacity duration-300\"\n          style={{\n            transform: `translate3d(${sunX - 7}px, ${sunY - 7}px, 0)`,\n            opacity: sun.elevation < -2 ? 0 : 1,\n            boxShadow: \"0 0 24px 6px rgba(255,200,120,0.55)\",\n          }}\n        />\n\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0 h-3 w-3 transition-opacity duration-300\"\n          style={{\n            transform: `translate3d(${moonX - 6}px, ${moonY - 6}px, 0)`,\n            opacity: sun.elevation < -4 ? 1 : 0,\n          }}\n        >\n          <div className=\"absolute inset-0 overflow-hidden rounded-full bg-[#e9edf5]\">\n            <div\n              className=\"absolute inset-0 rounded-full\"\n              style={{ background: sky.mid, transform: `translate3d(${moonShift}px, 0, 0)` }}\n            />\n          </div>\n        </div>\n\n        {showLabels && (\n          <div\n            className=\"pointer-events-none absolute left-4 top-3.5 text-white\"\n            style={{ textShadow: \"0 1px 2px rgba(0,0,0,.35)\" }}\n          >\n            {locationLabel && <div className=\"font-bjork-alpha text-[12px] font-medium leading-none\">{locationLabel}</div>}\n            <div className=\"mt-1.5 font-mono text-[10px] leading-none tabular-nums text-white/70\">\n              {formatCoordinates(latitude, longitude)}\n            </div>\n          </div>\n        )}\n\n        {showLabels && (\n          <div\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 grid h-12 grid-cols-[1fr_auto_1fr] items-center gap-2 px-4 font-mono text-[12px] tabular-nums whitespace-nowrap\"\n            style={{ background: stripBackground, color: palette.text }}\n          >\n            <span className=\"justify-self-start\">{polarLabel ?? `↑ ${sunriseText}`}</span>\n            <span>\n              {`${clockText} · ${Math.round(sun.elevation)}°`}\n              {scrubbing ? \" · scrubbing\" : \"\"}\n            </span>\n            <span className=\"justify-self-end\">{polarLabel ? \"—\" : `↓ ${sunsetText}`}</span>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/cards/solar-sky-panel.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/palette.ts",
      "content": "export type BjorkTone = \"dark\" | \"light\";\n\n// Canvas, WebGL and SVG attribute colours. DOM code uses the CSS tokens with these as fallbacks.\nexport const BJORK_PALETTE = {\n  dark: {\n    bg: \"#050505\", stage: \"#111111\", surface: \"#121212\", raised: \"#161616\", active: \"#202020\",\n    border: \"#232323\", borderStrong: \"#343434\",\n    text: \"#ededed\", textMedium: \"rgba(237,237,237,0.72)\", textMuted: \"rgba(237,237,237,0.6)\",\n    textSoft: \"rgba(237,237,237,0.5)\", textFaint: \"rgba(237,237,237,0.22)\", hair: \"rgba(237,237,237,0.12)\",\n    accent: \"#ec5c13\", accentInk: \"#ec5c13\", accentFill: \"#b84a12\", accentSoft: \"rgba(236,92,19,0.12)\", accentMuted: \"rgba(236,92,19,0.24)\",\n    accentFg: \"#fff2ea\", success: \"#4cc38a\", warning: \"#f2b544\", error: \"#ff5c4d\",\n  },\n  light: {\n    bg: \"#f7f5ef\", stage: \"#f7f5ef\", surface: \"#fffcf6\", raised: \"#f5efe3\", active: \"#efe7d8\",\n    border: \"#eee6db\", borderStrong: \"#e1d7c8\",\n    text: \"#171717\", textMedium: \"rgba(23,23,23,0.78)\", textMuted: \"rgba(23,23,23,0.68)\",\n    textSoft: \"rgba(23,23,23,0.62)\", textFaint: \"rgba(23,23,23,0.22)\", hair: \"rgba(23,23,23,0.10)\",\n    accent: \"#ec7d43\", accentInk: \"#b4531f\", accentFill: \"#ec7d43\", accentSoft: \"rgba(236,125,67,0.10)\", accentMuted: \"rgba(236,125,67,0.20)\",\n    accentFg: \"#3f2112\", success: \"#1f8a55\", warning: \"#a86b00\", error: \"#c8361f\",\n  },\n} as const;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/palette.ts"
    },
    {
      "path": "components/bjork-ui/_core/tone.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { useTheme } from \"next-themes\";\nimport type { BjorkTone } from \"./palette\";\n\nconst subscribeNoop = () => () => {};\nconst getMounted = () => true;\nconst getServerMounted = () => false;\n\n/**\n * Resolves the tone for a component. An explicit `tone` always wins.\n * Otherwise it follows next-themes' resolved theme, but only after mount,\n * so the server render and first client render agree on dark.\n */\nexport function useBjorkTone(tone?: BjorkTone): BjorkTone {\n  const { resolvedTheme } = useTheme();\n  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);\n  if (tone) return tone;\n  if (!mounted) return \"dark\";\n  return resolvedTheme === \"light\" ? \"light\" : \"dark\";\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/tone.ts"
    },
    {
      "path": "components/bjork-ui/_core/motion.ts",
      "content": "// Shared springs, easings and durations. Components import these instead of inventing values.\n\nexport const springs = {\n  press: { type: \"spring\", stiffness: 500, damping: 30, mass: 0.6 },\n  snappy: { type: \"spring\", stiffness: 400, damping: 25, mass: 1 },\n  standard: { type: \"spring\", stiffness: 300, damping: 30, mass: 0.8 },\n  soft: { type: \"spring\", stiffness: 200, damping: 25, mass: 1.2 },\n  settle: { type: \"spring\", stiffness: 380, damping: 30, mass: 0.8 },\n  blurIn: { type: \"spring\", stiffness: 400, damping: 28, mass: 0.6 },\n} as const;\n\nexport const ease = {\n  out: [0.23, 1, 0.32, 1],\n  inOut: [0.77, 0, 0.175, 1],\n  drawer: [0.32, 0.72, 0, 1],\n} as const;\n\nexport const easeCss = {\n  out: \"cubic-bezier(0.23,1,0.32,1)\",\n  inOut: \"cubic-bezier(0.77,0,0.175,1)\",\n  drawer: \"cubic-bezier(0.32,0.72,0,1)\",\n} as const;\n\nexport const durations = { press: 140, tooltip: 160, menu: 200, panel: 280 } as const; // ms\n\n// Cubic bezier easing for canvas and rAF code. Returns progress y for input x in [0, 1].\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {\n  const cx = 3 * x1;\n  const bx = 3 * (x2 - x1) - cx;\n  const ax = 1 - cx - bx;\n  const cy = 3 * y1;\n  const by = 3 * (y2 - y1) - cy;\n  const ay = 1 - cy - by;\n  const sampleX = (s: number) => ((ax * s + bx) * s + cx) * s;\n  const sampleY = (s: number) => ((ay * s + by) * s + cy) * s;\n  const sampleDX = (s: number) => (3 * ax * s + 2 * bx) * s + cx;\n\n  const solveX = (x: number) => {\n    let s = x;\n    for (let i = 0; i < 8; i++) {\n      const err = sampleX(s) - x;\n      if (Math.abs(err) < 1e-6) return s;\n      const d = sampleDX(s);\n      if (Math.abs(d) < 1e-6) break;\n      s -= err / d;\n    }\n    let lo = 0;\n    let hi = 1;\n    s = x;\n    while (lo < hi) {\n      const v = sampleX(s);\n      if (Math.abs(v - x) < 1e-6) return s;\n      if (x > v) lo = s;\n      else hi = s;\n      s = (lo + hi) / 2;\n      if (hi - lo < 1e-7) break;\n    }\n    return s;\n  };\n\n  return (t: number) => {\n    if (t <= 0) return 0;\n    if (t >= 1) return 1;\n    return sampleY(solveX(t));\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/motion.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/_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/random.ts",
      "content": "// Seeded randomness and noise. Deterministic for a given seed, so previews and tests repeat.\n\n// Small fast PRNG. Returns floats in [0, 1).\nexport function mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n// FNV-1a 32-bit.\nexport function hashString(s: string): number {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 0x01000193);\n  }\n  return h >>> 0;\n}\n\n// Integer lattice hash to [-1, 1].\nfunction latticeHash(seed: number, x: number, y = 0): number {\n  let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 2246822519);\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  h ^= h >>> 16;\n  return ((h >>> 0) / 4294967295) * 2 - 1;\n}\n\nfunction smooth(t: number): number {\n  return t * t * (3 - 2 * t);\n}\n\nexport function valueNoise1D(seed: number): (x: number) => number {\n  return (x: number) => {\n    const i = Math.floor(x);\n    const f = x - i;\n    const a = latticeHash(seed, i);\n    const b = latticeHash(seed, i + 1);\n    return a + (b - a) * smooth(f);\n  };\n}\n\nexport function fbm1D(seed: number, octaves = 3): (x: number) => number {\n  const layers = Array.from({ length: octaves }, (_, o) => valueNoise1D(seed + o * 101));\n  let norm = 0;\n  for (let o = 0; o < octaves; o++) norm += 0.5 ** o;\n  return (x: number) => {\n    let sum = 0;\n    let amp = 1;\n    let freq = 1;\n    for (let o = 0; o < octaves; o++) {\n      sum += amp * layers[o](x * freq);\n      amp *= 0.5;\n      freq *= 2;\n    }\n    return sum / norm;\n  };\n}\n\nexport function valueNoise2D(seed: number): (x: number, y: number) => number {\n  return (x: number, y: number) => {\n    const xi = Math.floor(x);\n    const yi = Math.floor(y);\n    const fx = smooth(x - xi);\n    const fy = smooth(y - yi);\n    const a = latticeHash(seed, xi, yi);\n    const b = latticeHash(seed, xi + 1, yi);\n    const c = latticeHash(seed, xi, yi + 1);\n    const d = latticeHash(seed, xi + 1, yi + 1);\n    const top = a + (b - a) * fx;\n    const bottom = c + (d - c) * fx;\n    return top + (bottom - top) * fy;\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/random.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": "Cards",
    "registryUrl": "https://ui.isaiahbjork.com/solar-sky-panel.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/solar-sky-panel.json"
  },
  "type": "registry:component"
}