{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sun-switch","type":"registry:ui","title":"SunSwitch","description":"A theme switch drawn as a pixel sun. Hover it for a lens on its true colours; click it and the new theme spreads from the sun in a dithered, grid-aligned wave.","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["buttons"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/sun-switch/SunSwitch.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useRef, useState } from \"react\";\nimport type {\n\tComponentPropsWithoutRef,\n\tCSSProperties,\n\tFocusEvent as ReactFocusEvent,\n\tKeyboardEvent as ReactKeyboardEvent,\n\tMouseEvent as ReactMouseEvent,\n\tPointerEvent as ReactPointerEvent,\n} from \"react\";\nimport { flushSync } from \"react-dom\";\nimport { cn } from \"../../utils\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { observeInView } from \"../../internals/motion/in-view\";\nimport { useReducedMotion } from \"../../internals/motion/media-query\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport { flipWait, isWaving, markFlip, pixelWave } from \"./pixel-wave-core\";\nimport { SUN_PALETTE, createSunPainter } from \"./sun-switch-core\";\nimport type { SunFront, SunPainter, SunPalette } from \"./sun-switch-core\";\nimport {\n\tSUN_HDR_RESTART_MS,\n\tSUN_HDR_RESTARTS,\n\tstartSunHdr,\n\tsunHdrAvailable,\n} from \"./sun-switch-hdr\";\nimport \"./sun-switch.css\";\n\ninterface SunSwitchOwnProps {\n\t/**\n\t * Day (true) or night (false). Controlled when supplied: pair it with\n\t * `onCheckedChange`, the React counterpart of the Svelte source's\n\t * `bind:checked`.\n\t */\n\tchecked?: boolean;\n\t/** Initial day/night state when uncontrolled (no `checked` prop given). */\n\tdefaultChecked?: boolean;\n\t/**\n\t * Called with the new value from inside the view transition's update\n\t * callback. Flip your theme here; the component never changes classes on\n\t * the document. It runs inside `flushSync`, so a React state update made\n\t * here is committed before the transition takes its \"new\" snapshot.\n\t */\n\tonCheckedChange?: (checked: boolean) => void;\n\t/** Sun radius in cells. The canvas is 2 × radius + 1 cells square. */\n\tradius?: number;\n\t/** Grid pitch in CSS pixels, shared by the sun, the wave front and its halo. */\n\tcell?: number;\n\t/** Size of each square dot in CSS pixels (at most `cell`). */\n\tdot?: number;\n\t/** Four colours from the top of the sun to the bottom. */\n\tpalette?: SunPalette;\n\t/** Monochrome colour of the scene outside the lens. */\n\tink?: string;\n\t/** Opacity of the ink sun at night. */\n\tnightAlpha?: number;\n\t/** Draws a few sparse rows of dots under the sun for the lens to reveal. */\n\thorizon?: boolean;\n\t/** On hover, a soft circular lens shows the true colours under the pointer. */\n\tlens?: boolean;\n\t/** Lens radius in cells. */\n\tlensRadius?: number;\n\t/** `document`: view transition over the page. `contained`: painted over `scope`. */\n\tmode?: \"document\" | \"contained\";\n\t/** The element the wave covers in contained mode. */\n\tscope?: HTMLElement | null;\n\t/** Colour of the old-state sheet in the fallback and contained paths. */\n\tfill?: string;\n\t/** Duration of the wave in milliseconds. */\n\tduration?: number;\n\t/** Thickness of the dithered halo leading the wave front, in cells. */\n\thaloCells?: number;\n\t/** On HDR displays, the sun and the wave halo glow above SDR white (WebGPU). */\n\thdr?: boolean;\n\t/** Plays the toggle cue through the sound controller. */\n\tsound?: boolean;\n\t/** Accessible name of the switch. */\n\tlabel?: string;\n\t/** Blocks interaction. */\n\tdisabled?: boolean;\n\t/** Additional CSS classes on the button. */\n\tclassName?: string;\n}\n\nexport interface SunSwitchProps\n\textends\n\t\tOmit<\n\t\t\tComponentPropsWithoutRef<\"button\">,\n\t\t\tkeyof SunSwitchOwnProps | \"type\" | \"role\" | \"aria-checked\" | \"onChange\"\n\t\t>,\n\t\tSunSwitchOwnProps {}\n\n/**\n * A theme switch drawn as a pixel sun. Hover it and a soft lens shows its true\n * colours; click it and the new theme spreads from the sun in a grid-aligned\n * staircase, led by a dithered halo, as a view transition.\n *\n * The element reference arrives through the ref channel (the Svelte source\n * declares `ref = $bindable(null)`), pointing at the native `<button>`.\n */\nexport const SunSwitch = forwardRef<HTMLButtonElement, SunSwitchProps>(\n\t(\n\t\t{\n\t\t\tchecked: checkedProp,\n\t\t\tdefaultChecked = true,\n\t\t\tonCheckedChange,\n\t\t\tradius = 10,\n\t\t\tcell = 5,\n\t\t\tdot = 4,\n\t\t\tpalette = SUN_PALETTE,\n\t\t\tink = \"currentColor\",\n\t\t\tnightAlpha = 0.42,\n\t\t\thorizon = true,\n\t\t\tlens = true,\n\t\t\tlensRadius = 7,\n\t\t\tmode = \"document\",\n\t\t\tscope = null,\n\t\t\tfill,\n\t\t\tduration = 1300,\n\t\t\thaloCells = 9,\n\t\t\thdr = true,\n\t\t\tsound = false,\n\t\t\tlabel = \"Day mode\",\n\t\t\tdisabled = false,\n\t\t\tclassName,\n\t\t\tstyle,\n\t\t\tonClick,\n\t\t\tonPointerEnter,\n\t\t\tonPointerMove,\n\t\t\tonPointerDown,\n\t\t\tonPointerUp,\n\t\t\tonPointerLeave,\n\t\t\tonPointerCancel,\n\t\t\tonFocus,\n\t\t\tonBlur,\n\t\t\tonKeyDown,\n\t\t\t...rest\n\t\t},\n\t\tforwardedRef\n\t) => {\n\t\tconst isControlled = checkedProp !== undefined;\n\t\tconst [uncontrolledChecked, setUncontrolledChecked] = useState(defaultChecked);\n\t\tconst checked = isControlled ? checkedProp : uncontrolledChecked;\n\n\t\tconst reduced = useReducedMotion();\n\t\tconst playCue = useSoundCue(sound);\n\n\t\tconst buttonRef = useRef<HTMLButtonElement | null>(null);\n\t\tconst composedRef = useComposedRefs(forwardedRef, buttonRef);\n\t\tconst canvasRef = useRef<HTMLCanvasElement | null>(null);\n\t\t/** The canvas painter; null before mount and under jsdom (no 2D context). */\n\t\tconst painterRef = useRef<SunPainter | null>(null);\n\t\t/** Set once the painter exists, so the HDR effect can attach to it. */\n\t\tconst [painterReady, setPainterReady] = useState(false);\n\t\tconst [hdrCanvas, hdrCanvasRef] = useElementRef<HTMLCanvasElement>();\n\t\t/** False on the server and the first client render: the HDR canvas appears only after mount. */\n\t\tconst [hdrCapable, setHdrCapable] = useState(false);\n\t\tconst [hdrOn, setHdrOn] = useState(false);\n\t\t/** Bumped to start the HDR layer again after a device loss; the effect below depends on it. */\n\t\tconst [hdrRun, setHdrRun] = useState(0);\n\t\tconst hdrRestarts = useRef(0);\n\t\t/** True while THIS instance's wave runs (drives `aria-busy` / `data-waving`). */\n\t\tconst [waving, setWavingState] = useState(false);\n\t\tconst wavingRef = useRef(false);\n\t\tconst setWaving = useCallback((value: boolean) => {\n\t\t\tif (wavingRef.current === value) return;\n\t\t\twavingRef.current = value;\n\t\t\tsetWavingState(value);\n\t\t}, []);\n\n\t\t// Long-lived closures (the mount effect, the wave's callbacks) read the\n\t\t// latest render through this ref instead of being rebuilt.\n\t\tconst live = useLiveRef({\n\t\t\tchecked,\n\t\t\tisControlled,\n\t\t\tonCheckedChange,\n\t\t\tradius,\n\t\t\tcell,\n\t\t\tdot,\n\t\t\tpalette,\n\t\t\tnightAlpha,\n\t\t\thorizon,\n\t\t\tlens,\n\t\t\tlensRadius,\n\t\t\tmode,\n\t\t\tscope,\n\t\t\tfill,\n\t\t\tduration,\n\t\t\thaloCells,\n\t\t\thdr,\n\t\t\thdrCanvas,\n\t\t\tdisabled,\n\t\t\treduced,\n\t\t});\n\n\t\t/** Ink re-read bookkeeping: `asleep` while the painter sleeps, `stale` when a\n\t\t * re-read was asked for meanwhile, `frame` for the coalescing rAF. */\n\t\tconst inkState = useRef({ asleep: false, stale: false, frame: 0 });\n\n\t\t/** Re-reads the resolved ink (computed `color` of the canvas, which is `ink`). */\n\t\tconst readInk = useCallback(() => {\n\t\t\tinkState.current.stale = false;\n\t\t\tconst painter = painterRef.current;\n\t\t\tconst canvas = canvasRef.current;\n\t\t\tif (!painter || !canvas) return;\n\t\t\tpainter.setInk(getComputedStyle(canvas).color);\n\t\t}, []);\n\n\t\t/** Coalesces ink re-reads to one per frame (each forces a style flush), and\n\t\t * defers them while the painter sleeps. */\n\t\tconst scheduleInk = useCallback(() => {\n\t\t\tconst s = inkState.current;\n\t\t\tif (s.asleep) {\n\t\t\t\ts.stale = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (s.frame || typeof requestAnimationFrame !== \"function\") return;\n\t\t\ts.frame = requestAnimationFrame(() => {\n\t\t\t\ts.frame = 0;\n\t\t\t\treadInk();\n\t\t\t});\n\t\t}, [readInk]);\n\n\t\t// Mount only: the painter lives for the component's lifetime. Prop\n\t\t// changes reach it through the forwarding effects below.\n\t\tuseEffect(() => {\n\t\t\tconst canvas = canvasRef.current;\n\t\t\tif (!canvas) return;\n\t\t\tconst v = live.current;\n\t\t\tconst p = createSunPainter(canvas, {\n\t\t\t\tradius: v.radius,\n\t\t\t\tcell: v.cell,\n\t\t\t\tdot: v.dot,\n\t\t\t\tpalette: v.palette,\n\t\t\t\tnightAlpha: v.nightAlpha,\n\t\t\t\thorizon: v.horizon,\n\t\t\t\tlens: v.lens,\n\t\t\t\tlensRadius: v.lensRadius,\n\t\t\t\tchecked: v.checked,\n\t\t\t\treducedMotion: v.reduced,\n\t\t\t});\n\t\t\tif (!p) return;\n\t\t\tpainterRef.current = p;\n\t\t\tsetPainterReady(true);\n\t\t\treadInk();\n\n\t\t\tlet visible = true;\n\t\t\tlet hidden = document.visibilityState === \"hidden\";\n\t\t\tconst ink = inkState.current;\n\t\t\tconst sync = () => {\n\t\t\t\tink.asleep = !(visible && !hidden);\n\t\t\t\tif (ink.asleep) {\n\t\t\t\t\tp.sleep();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (ink.stale) readInk();\n\t\t\t\tp.wake();\n\t\t\t};\n\n\t\t\tlet ro: ResizeObserver | undefined;\n\t\t\tif (typeof ResizeObserver !== \"undefined\") {\n\t\t\t\tro = new ResizeObserver(() => p.resize());\n\t\t\t\ttry {\n\t\t\t\t\t// Also fires on a device-pixel-ratio change (zoom, moving screens).\n\t\t\t\t\tro.observe(canvas, { box: \"device-pixel-content-box\" });\n\t\t\t\t} catch {\n\t\t\t\t\tro.observe(canvas);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst io = observeInView(canvas, {\n\t\t\t\tonce: false,\n\t\t\t\tthreshold: 0,\n\t\t\t\tonChange: (intersecting) => {\n\t\t\t\t\tvisible = intersecting;\n\t\t\t\t\tsync();\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst onVisibility = () => {\n\t\t\t\thidden = document.visibilityState === \"hidden\";\n\t\t\t\tsync();\n\t\t\t};\n\t\t\tdocument.addEventListener(\"visibilitychange\", onVisibility);\n\n\t\t\t// The ink follows the theme: re-read it when the document's theme\n\t\t\t// hooks or the colour scheme change, whatever system flips them, and\n\t\t\t// again when a `color` transition settles (the computed colour lags\n\t\t\t// behind the flip).\n\t\t\tlet mo: MutationObserver | undefined;\n\t\t\tif (typeof MutationObserver !== \"undefined\") {\n\t\t\t\tmo = new MutationObserver(scheduleInk);\n\t\t\t\tconst attributeFilter = [\"class\", \"style\", \"data-theme\", \"data-mode\"];\n\t\t\t\tmo.observe(document.documentElement, { attributes: true, attributeFilter });\n\t\t\t\tmo.observe(document.body, { attributes: true, attributeFilter });\n\t\t\t}\n\t\t\tconst scheme =\n\t\t\t\ttypeof window.matchMedia === \"function\"\n\t\t\t\t\t? window.matchMedia(\"(prefers-color-scheme: dark)\")\n\t\t\t\t\t: null;\n\t\t\tscheme?.addEventListener(\"change\", scheduleInk);\n\t\t\tconst onColorSettled = (event: TransitionEvent) => {\n\t\t\t\t// Only transitions on the canvas or its ancestors can change its colour.\n\t\t\t\tconst target = event.target;\n\t\t\t\tif (event.propertyName !== \"color\" || !(target instanceof Node)) return;\n\t\t\t\tif (target.contains(canvas)) scheduleInk();\n\t\t\t};\n\t\t\tdocument.addEventListener(\"transitionend\", onColorSettled, true);\n\t\t\tdocument.addEventListener(\"transitioncancel\", onColorSettled, true);\n\n\t\t\tsync();\n\n\t\t\treturn () => {\n\t\t\t\tro?.disconnect();\n\t\t\t\tio.destroy();\n\t\t\t\tmo?.disconnect();\n\t\t\t\tscheme?.removeEventListener(\"change\", scheduleInk);\n\t\t\t\tdocument.removeEventListener(\"transitionend\", onColorSettled, true);\n\t\t\t\tdocument.removeEventListener(\"transitioncancel\", onColorSettled, true);\n\t\t\t\tdocument.removeEventListener(\"visibilitychange\", onVisibility);\n\t\t\t\tif (ink.frame && typeof cancelAnimationFrame === \"function\") {\n\t\t\t\t\tcancelAnimationFrame(ink.frame);\n\t\t\t\t}\n\t\t\t\tink.frame = 0;\n\t\t\t\tp.destroy();\n\t\t\t\tif (painterRef.current === p) painterRef.current = null;\n\t\t\t\tsetPainterReady(false);\n\t\t\t};\n\t\t}, [live, readInk, scheduleInk]);\n\n\t\t// HDR: decided after mount only, so SSR and hydration render the 2D canvas alone.\n\t\tuseEffect(() => {\n\t\t\tsetHdrCapable(hdr && sunHdrAvailable());\n\t\t}, [hdr]);\n\n\t\tuseEffect(() => {\n\t\t\tconst p = painterRef.current;\n\t\t\tif (!hdrCanvas || !painterReady || !hdrCapable || !p) return;\n\t\t\tlet cancelled = false;\n\t\t\tlet stop: (() => void) | null = null;\n\t\t\tlet retry: ReturnType<typeof setTimeout> | null = null;\n\t\t\t// A lost device usually comes back (GPU process restarted, driver reset):\n\t\t\t// start again after a growing pause, a few times, before giving the layer up.\n\t\t\tconst restart = () => {\n\t\t\t\tif (hdrRestarts.current >= SUN_HDR_RESTARTS) {\n\t\t\t\t\tsetHdrCapable(false);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\thdrRestarts.current++;\n\t\t\t\tretry = setTimeout(() => {\n\t\t\t\t\tretry = null;\n\t\t\t\t\tsetHdrRun((n) => n + 1);\n\t\t\t\t}, SUN_HDR_RESTART_MS * hdrRestarts.current);\n\t\t\t};\n\t\t\tvoid startSunHdr(hdrCanvas).then((layer) => {\n\t\t\t\tif (cancelled || painterRef.current !== p) {\n\t\t\t\t\tlayer?.destroy();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (!layer) {\n\t\t\t\t\t// Nothing to start on the first try (no adapter, tone mapping refused): give\n\t\t\t\t\t// up at once. During a restart the GPU may still be on its way back.\n\t\t\t\t\tif (hdrRestarts.current === 0) setHdrCapable(false);\n\t\t\t\t\telse restart();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tp.setGlowTarget(layer);\n\t\t\t\tsetHdrOn(true);\n\t\t\t\tstop = () => {\n\t\t\t\t\tp.setGlowTarget(null);\n\t\t\t\t\tlayer.destroy();\n\t\t\t\t\tsetHdrOn(false);\n\t\t\t\t};\n\t\t\t\tvoid layer.lost.then(() => {\n\t\t\t\t\tif (cancelled) return;\n\t\t\t\t\tstop?.();\n\t\t\t\t\tstop = null;\n\t\t\t\t\trestart();\n\t\t\t\t});\n\t\t\t});\n\t\t\treturn () => {\n\t\t\t\tcancelled = true;\n\t\t\t\tstop?.();\n\t\t\t\tstop = null;\n\t\t\t\tif (retry !== null) clearTimeout(retry);\n\t\t\t};\n\t\t\t// hdrRun is read by no line above: it re-runs the effect after a device loss.\n\t\t}, [hdrCanvas, painterReady, hdrCapable, hdrRun]);\n\n\t\t// Layout props: re-raster and resize.\n\t\tuseEffect(() => {\n\t\t\tpainterRef.current?.setOptions({ radius, cell, dot, horizon });\n\t\t}, [radius, cell, dot, horizon]);\n\n\t\t// Appearance props: repaint in place.\n\t\tuseEffect(() => {\n\t\t\tpainterRef.current?.setOptions({ palette, nightAlpha, lens, lensRadius });\n\t\t}, [palette, nightAlpha, lens, lensRadius]);\n\n\t\tuseEffect(() => {\n\t\t\tpainterRef.current?.setOptions({ checked });\n\t\t}, [checked]);\n\n\t\tuseEffect(() => {\n\t\t\tpainterRef.current?.setOptions({ reducedMotion: reduced });\n\t\t}, [reduced]);\n\n\t\t// `ink` lands on the canvas as `color`; effects run after the DOM\n\t\t// update, so the computed value is already the new one here.\n\t\tuseEffect(() => {\n\t\t\treadInk();\n\t\t}, [ink, readInk]);\n\n\t\t// Lens state. The lens opens while the pointer is over the button (or\n\t\t// the button has keyboard focus) and stays open through this instance's\n\t\t// own wave, whatever the pointer does meanwhile (a view transition\n\t\t// re-targets hit testing, so a `pointerleave` may arrive mid-wave).\n\t\tconst lensState = useRef({\n\t\t\thovering: false,\n\t\t\tfocused: false,\n\t\t\topen: false,\n\t\t\t/** Last lens target in canvas cells. */\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\tcloseTimer: undefined as ReturnType<typeof setTimeout> | undefined,\n\t\t});\n\n\t\t/** Side of the sun canvas in cells (the core rounds the radius the same way). */\n\t\tconst sizeCells = useCallback(\n\t\t\t() => 2 * Math.max(1, Math.round(live.current.radius)) + 1,\n\t\t\t[live]\n\t\t);\n\n\t\t/** Disc centre in canvas cells. */\n\t\tconst centreCells = useCallback(\n\t\t\t() => Math.max(1, Math.round(live.current.radius)) + 0.5,\n\t\t\t[live]\n\t\t);\n\n\t\t/** Pointer position in canvas cells, robust to CSS scaling of the button. */\n\t\tconst trackPointer = useCallback(\n\t\t\t(event: { clientX: number; clientY: number }) => {\n\t\t\t\tconst canvas = canvasRef.current;\n\t\t\t\tif (!canvas) return;\n\t\t\t\tconst r = canvas.getBoundingClientRect();\n\t\t\t\tconst size = sizeCells();\n\t\t\t\tconst sx = r.width > 0 ? size / r.width : 1 / live.current.cell;\n\t\t\t\tconst sy = r.height > 0 ? size / r.height : 1 / live.current.cell;\n\t\t\t\tlensState.current.x = (event.clientX - r.left) * sx;\n\t\t\t\tlensState.current.y = (event.clientY - r.top) * sy;\n\t\t\t},\n\t\t\t[live, sizeCells]\n\t\t);\n\n\t\tconst syncLens = useCallback(() => {\n\t\t\tconst s = lensState.current;\n\t\t\tconst { lens: lensOn, disabled: off } = live.current;\n\t\t\tconst want = lensOn && !off && (s.hovering || s.focused);\n\t\t\ts.open = want || (wavingRef.current && s.open);\n\t\t\tif (s.focused && !s.hovering) {\n\t\t\t\ts.x = centreCells();\n\t\t\t\ts.y = centreCells();\n\t\t\t}\n\t\t\tpainterRef.current?.pointer(s.x, s.y, s.open);\n\t\t}, [live, centreCells]);\n\n\t\t/** Closes on the next task, so a touch release that is followed by its\n\t\t * `click` (same task) keeps the lens open for the wave that click starts. */\n\t\tconst syncLensSoon = useCallback(() => {\n\t\t\tconst s = lensState.current;\n\t\t\tclearTimeout(s.closeTimer);\n\t\t\ts.closeTimer = setTimeout(() => {\n\t\t\t\ts.closeTimer = undefined;\n\t\t\t\tsyncLens();\n\t\t\t}, 0);\n\t\t}, [syncLens]);\n\n\t\tuseEffect(() => {\n\t\t\tconst s = lensState.current;\n\t\t\treturn () => clearTimeout(s.closeTimer);\n\t\t}, []);\n\n\t\t// `lens` / `disabled` changes open or close the lens at once.\n\t\tuseEffect(() => {\n\t\t\tsyncLens();\n\t\t}, [lens, disabled, syncLens]);\n\n\t\tfunction handlePointerEnter(event: ReactPointerEvent<HTMLButtonElement>) {\n\t\t\tlensState.current.hovering = true;\n\t\t\ttrackPointer(event);\n\t\t\tsyncLens();\n\t\t\tonPointerEnter?.(event);\n\t\t}\n\n\t\tfunction handlePointerMove(event: ReactPointerEvent<HTMLButtonElement>) {\n\t\t\tlensState.current.hovering = true;\n\t\t\ttrackPointer(event);\n\t\t\tsyncLens();\n\t\t\tonPointerMove?.(event);\n\t\t}\n\n\t\t// Touch opens the lens at the touch point (no hover before a tap).\n\t\tfunction handlePointerDown(event: ReactPointerEvent<HTMLButtonElement>) {\n\t\t\tlensState.current.hovering = true;\n\t\t\ttrackPointer(event);\n\t\t\tsyncLens();\n\t\t\tonPointerDown?.(event);\n\t\t}\n\n\t\t// A touch release ends the \"hover\".\n\t\tfunction handlePointerUp(event: ReactPointerEvent<HTMLButtonElement>) {\n\t\t\tif (event.pointerType === \"touch\") {\n\t\t\t\tlensState.current.hovering = false;\n\t\t\t\tsyncLensSoon();\n\t\t\t}\n\t\t\tonPointerUp?.(event);\n\t\t}\n\n\t\t// Closes the lens (held open while this instance is waving).\n\t\tfunction handlePointerLeave(event: ReactPointerEvent<HTMLButtonElement>) {\n\t\t\tlensState.current.hovering = false;\n\t\t\tsyncLensSoon();\n\t\t\tonPointerLeave?.(event);\n\t\t}\n\n\t\tfunction handlePointerCancel(event: ReactPointerEvent<HTMLButtonElement>) {\n\t\t\tlensState.current.hovering = false;\n\t\t\tsyncLensSoon();\n\t\t\tonPointerCancel?.(event);\n\t\t}\n\n\t\t// Keyboard focus opens the lens centred on the disc (the hover\n\t\t// affordance for keyboard users). Pointer focus does not: the pointer\n\t\t// already drives it.\n\t\tfunction handleFocus(event: ReactFocusEvent<HTMLButtonElement>) {\n\t\t\tlet visible = false;\n\t\t\ttry {\n\t\t\t\tvisible = event.currentTarget.matches(\":focus-visible\");\n\t\t\t} catch {\n\t\t\t\tvisible = false;\n\t\t\t}\n\t\t\tlensState.current.focused = visible;\n\t\t\tsyncLens();\n\t\t\tonFocus?.(event);\n\t\t}\n\n\t\tfunction handleBlur(event: ReactFocusEvent<HTMLButtonElement>) {\n\t\t\tlensState.current.focused = false;\n\t\t\tsyncLensSoon();\n\t\t\tonBlur?.(event);\n\t\t}\n\n\t\t// Click → wave.\n\t\t/** Value asked for by the latest click until its wave settles, so a\n\t\t * click landing before the previous wave applied still toggles from the\n\t\t * right state (intent is never dropped). */\n\t\tconst intentRef = useRef<boolean | null>(null);\n\t\t/** Identifies the latest click; only its wave may clear the shared state. */\n\t\tconst tokenRef = useRef(0);\n\n\t\t/** Flip rate limit (page-wide through `flipWait`: at most 3 full-page flips\n\t\t * a second, photosensitivity): the value asked for by clicks that came too\n\t\t * soon, its timer, and the remover of the re-entry listeners. */\n\t\tconst flipRef = useRef({\n\t\t\tpending: null as boolean | null,\n\t\t\ttimer: undefined as ReturnType<typeof setTimeout> | undefined,\n\t\t\tdisarm: null as (() => void) | null,\n\t\t});\n\t\t/** The latest render's handlers, for long-lived listeners and timers. */\n\t\tconst actionsRef = useRef({\n\t\t\tflushPending: () => {},\n\t\t\tonReentryMove: (_event: PointerEvent) => {},\n\t\t});\n\n\t\tuseEffect(() => {\n\t\t\tconst f = flipRef.current;\n\t\t\treturn () => {\n\t\t\t\tclearTimeout(f.timer);\n\t\t\t\tf.timer = undefined;\n\t\t\t\tf.pending = null;\n\t\t\t\tf.disarm?.();\n\t\t\t\tf.disarm = null;\n\t\t\t};\n\t\t}, []);\n\n\t\t/** One more toggle asked for by the user, rate-limited without dropping intent. */\n\t\tfunction toggle() {\n\t\t\tconst f = flipRef.current;\n\t\t\tconst v = live.current;\n\t\t\tif (v.disabled) return;\n\t\t\tconst next = !(f.pending ?? intentRef.current ?? v.checked);\n\t\t\tconst wait = flipWait(window.performance.now());\n\t\t\tif (wait > 0) {\n\t\t\t\tf.pending = next;\n\t\t\t\tf.timer ??= setTimeout(() => actionsRef.current.flushPending(), wait);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tf.pending = null;\n\t\t\tstartWave(next);\n\t\t}\n\n\t\tfunction flushPending() {\n\t\t\tconst f = flipRef.current;\n\t\t\tf.timer = undefined;\n\t\t\t// Another switch may have flipped meanwhile: wait for the page-wide gap.\n\t\t\tconst wait = flipWait(window.performance.now());\n\t\t\tif (wait > 0 && f.pending !== null) {\n\t\t\t\tf.timer = setTimeout(() => actionsRef.current.flushPending(), wait);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst next = f.pending;\n\t\t\tf.pending = null;\n\t\t\tconst v = live.current;\n\t\t\t// An even number of early clicks asks for the current state: nothing to do.\n\t\t\tif (next === null || v.disabled || next === (intentRef.current ?? v.checked)) return;\n\t\t\tstartWave(next);\n\t\t}\n\n\t\tfunction onReentryMove(event: PointerEvent) {\n\t\t\tlensState.current.hovering = true;\n\t\t\ttrackPointer(event);\n\t\t\tsyncLens();\n\t\t}\n\n\t\tactionsRef.current = { flushPending, onReentryMove };\n\n\t\t/** Whether a viewport point falls on the button. */\n\t\tfunction overButton(x: number, y: number): boolean {\n\t\t\tconst r = buttonRef.current?.getBoundingClientRect();\n\t\t\treturn !!r && x >= r.left && x < r.right && y >= r.top && y < r.bottom;\n\t\t}\n\n\t\t/**\n\t\t * While a document-mode wave runs, the view transition hit-tests every\n\t\t * point as `<html>`, so the button never sees the pointer. Catch those\n\t\t * events on the document and route the ones over the button back to it.\n\t\t */\n\t\tfunction armReentry() {\n\t\t\tconst f = flipRef.current;\n\t\t\tif (f.disarm || live.current.mode !== \"document\") return;\n\t\t\tconst root = document.documentElement;\n\t\t\tconst onDown = (event: PointerEvent) => {\n\t\t\t\t// Keep focus on the switch instead of moving it to <body>.\n\t\t\t\tif (event.target === root && overButton(event.clientX, event.clientY)) {\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t}\n\t\t\t};\n\t\t\tconst onMove = (event: PointerEvent) => {\n\t\t\t\tif (event.target !== root || !overButton(event.clientX, event.clientY)) return;\n\t\t\t\tactionsRef.current.onReentryMove(event);\n\t\t\t};\n\t\t\tconst onDocClick = (event: MouseEvent) => {\n\t\t\t\t// Through the button, so the consumer's click handler runs too.\n\t\t\t\tif (event.target === root && overButton(event.clientX, event.clientY)) {\n\t\t\t\t\tbuttonRef.current?.click();\n\t\t\t\t}\n\t\t\t};\n\t\t\tdocument.addEventListener(\"pointerdown\", onDown, true);\n\t\t\tdocument.addEventListener(\"pointermove\", onMove, true);\n\t\t\tdocument.addEventListener(\"click\", onDocClick, true);\n\t\t\tf.disarm = () => {\n\t\t\t\tdocument.removeEventListener(\"pointerdown\", onDown, true);\n\t\t\t\tdocument.removeEventListener(\"pointermove\", onMove, true);\n\t\t\t\tdocument.removeEventListener(\"click\", onDocClick, true);\n\t\t\t};\n\t\t}\n\n\t\t// Auto-repeated Enter would click on every repeat: a full-page flash train.\n\t\tfunction handleKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {\n\t\t\tif (event.repeat && (event.key === \"Enter\" || event.key === \" \")) event.preventDefault();\n\t\t\tonKeyDown?.(event);\n\t\t}\n\n\t\tfunction isHovered(): boolean {\n\t\t\ttry {\n\t\t\t\treturn !!buttonRef.current?.matches(\":hover\");\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\tfunction startWave(next: boolean) {\n\t\t\tconst v = live.current;\n\t\t\tmarkFlip(window.performance.now());\n\t\t\tintentRef.current = next;\n\t\t\tconst id = ++tokenRef.current;\n\t\t\t// User activation: play before the view transition starts.\n\t\t\tplayCue(next ? \"toggle-on\" : \"toggle-off\");\n\n\t\t\tconst canvas = canvasRef.current;\n\t\t\tconst instant = v.reduced || isWaving();\n\t\t\tconst rect = canvas?.getBoundingClientRect();\n\t\t\tconst centre = centreCells() * v.cell;\n\t\t\t// The visible centre, robust to CSS scaling of the button.\n\t\t\tconst nominal = sizeCells() * v.cell;\n\t\t\tconst sx = rect && rect.width > 0 ? rect.width / nominal : 1;\n\t\t\tconst sy = rect && rect.height > 0 ? rect.height / nominal : 1;\n\t\t\tlet origin = { x: (rect?.left ?? 0) + centre * sx, y: (rect?.top ?? 0) + centre * sy };\n\t\t\tconst scopeEl = v.scope;\n\t\t\tif (v.mode === \"contained\" && scopeEl) {\n\t\t\t\tconst sr = scopeEl.getBoundingClientRect();\n\t\t\t\torigin = {\n\t\t\t\t\tx: origin.x - sr.left - scopeEl.clientLeft,\n\t\t\t\t\ty: origin.y - sr.top - scopeEl.clientTop,\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst oldInk = canvas ? getComputedStyle(canvas).color : \"\";\n\t\t\tconst halo = v.haloCells;\n\t\t\tconst cellPx = v.cell;\n\t\t\tconst front = (outer: number): SunFront => ({\n\t\t\t\tcx: centre,\n\t\t\t\tcy: centre,\n\t\t\t\tR: Math.max(0, outer - halo + 0.5) * cellPx,\n\t\t\t\tnextChecked: next,\n\t\t\t\toldInk,\n\t\t\t});\n\n\t\t\tsetWaving(!instant);\n\t\t\tif (!instant) armReentry();\n\t\t\t// Hold the old look on the canvas until the front reaches each cell;\n\t\t\t// the canvas renders live above the snapshots during a view transition.\n\t\t\tpainterRef.current?.setFront(instant ? null : front(0));\n\n\t\t\tvoid pixelWave({\n\t\t\t\torigin,\n\t\t\t\tmode: v.mode,\n\t\t\t\tscope: v.scope,\n\t\t\t\tlive: canvas ?? null,\n\t\t\t\tliveHdr: v.hdrCanvas,\n\t\t\t\thdr: v.hdr,\n\t\t\t\tfill: v.fill,\n\t\t\t\tcell: v.cell,\n\t\t\t\tdot: v.dot,\n\t\t\t\thaloCells: v.haloCells,\n\t\t\t\tduration: v.duration,\n\t\t\t\tpalette: v.palette,\n\t\t\t\treducedMotion: v.reduced,\n\t\t\t\tapply: () => {\n\t\t\t\t\t// Commit the DOM now: the view transition captures the new\n\t\t\t\t\t// state right after this callback returns. The consumer's\n\t\t\t\t\t// callback runs inside the flush too, so a theme kept in React\n\t\t\t\t\t// state lands in the same commit.\n\t\t\t\t\tflushSync(() => {\n\t\t\t\t\t\tif (!live.current.isControlled) setUncontrolledChecked(next);\n\t\t\t\t\t\tlive.current.onCheckedChange?.(next);\n\t\t\t\t\t});\n\t\t\t\t\treadInk();\n\t\t\t\t\t// Again once styles settle (a transition may still hold the old colour).\n\t\t\t\t\tscheduleInk();\n\t\t\t\t},\n\t\t\t\tonFront: (outer) => {\n\t\t\t\t\tif (id === tokenRef.current) painterRef.current?.setFront(front(outer));\n\t\t\t\t},\n\t\t\t}).then(() => {\n\t\t\t\tif (id !== tokenRef.current) return;\n\t\t\t\tintentRef.current = null;\n\t\t\t\tsetWaving(false);\n\t\t\t\tflipRef.current.disarm?.();\n\t\t\t\tflipRef.current.disarm = null;\n\t\t\t\tpainterRef.current?.setFront(null);\n\t\t\t\tlensState.current.hovering = isHovered();\n\t\t\t\tsyncLens();\n\t\t\t});\n\t\t}\n\n\t\tfunction handleClick(event: ReactMouseEvent<HTMLButtonElement>) {\n\t\t\ttoggle();\n\t\t\tonClick?.(event);\n\t\t}\n\n\t\tconst sunR = Math.max(1, Math.round(radius));\n\n\t\treturn (\n\t\t\t<button\n\t\t\t\t{...rest}\n\t\t\t\tref={composedRef}\n\t\t\t\ttype=\"button\"\n\t\t\t\trole=\"switch\"\n\t\t\t\taria-checked={checked ? \"true\" : \"false\"}\n\t\t\t\taria-label={label}\n\t\t\t\taria-busy={waving ? \"true\" : undefined}\n\t\t\t\tdata-state={checked ? \"day\" : \"night\"}\n\t\t\t\tdata-waving={waving ? \"\" : undefined}\n\t\t\t\tdata-hdr={hdrOn ? \"\" : undefined}\n\t\t\t\tdisabled={disabled}\n\t\t\t\tclassName={cn(\"ft-sun-switch\", className)}\n\t\t\t\tstyle={\n\t\t\t\t\t{\n\t\t\t\t\t\t...style,\n\t\t\t\t\t\t\"--ft-sun-r\": sunR,\n\t\t\t\t\t\t\"--ft-sun-cell\": `${cell}px`,\n\t\t\t\t\t} as CSSProperties\n\t\t\t\t}\n\t\t\t\tonClick={handleClick}\n\t\t\t\tonPointerEnter={handlePointerEnter}\n\t\t\t\tonPointerMove={handlePointerMove}\n\t\t\t\tonPointerDown={handlePointerDown}\n\t\t\t\tonPointerUp={handlePointerUp}\n\t\t\t\tonPointerLeave={handlePointerLeave}\n\t\t\t\tonPointerCancel={handlePointerCancel}\n\t\t\t\tonFocus={handleFocus}\n\t\t\t\tonBlur={handleBlur}\n\t\t\t\tonKeyDown={handleKeyDown}\n\t\t\t>\n\t\t\t\t<canvas\n\t\t\t\t\tref={canvasRef}\n\t\t\t\t\tclassName=\"ft-sun-switch-canvas\"\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\tstyle={{ color: ink }}\n\t\t\t\t/>\n\t\t\t\t{hdrCapable ? (\n\t\t\t\t\t<canvas ref={hdrCanvasRef} className=\"ft-sun-switch-hdr\" aria-hidden=\"true\" />\n\t\t\t\t) : null}\n\t\t\t</button>\n\t\t);\n\t}\n);\n\nSunSwitch.displayName = \"SunSwitch\";\n","type":"registry:ui","target":"@ui/fancy/components/sun-switch/SunSwitch.tsx"},{"path":"react/src/components/sun-switch/index.ts","content":"\"use client\";\n\nexport { SunSwitch } from \"./SunSwitch\";\nexport type { SunSwitchProps } from \"./SunSwitch\";\nexport { SUN_PALETTE, type SunPalette } from \"./sun-switch-core\";\nexport {\n\tpixelWave,\n\tisWaving,\n\tWAVE_CSS,\n\ttype PixelWaveOptions,\n\ttype PixelWaveMode,\n} from \"./pixel-wave-core\";\n","type":"registry:ui","target":"@ui/fancy/components/sun-switch/index.ts"},{"path":"react/src/components/sun-switch/pixel-wave-core.ts","content":"\"use client\";\n\n// pixel-wave-core.ts — framework-free \"pixel wave\" theme transition.\n// No framework imports, no module-scope window/document access, no build-time env.\n// Byte-identical copies live in the React and Vue packages: edit here, copy there.\n//\n// `pixelWave({ origin, apply })` calls `apply()` exactly once and reveals the new\n// state from `origin` through a grid-aligned staircase led by a dithered halo:\n// - document mode + View Transitions: `apply` runs inside `startViewTransition`,\n//   `::view-transition-new(root)` is clipped by the staircase each frame and the\n//   halo is painted on a fixed canvas that renders live above the snapshots;\n// - `contained` mode: a static copy of `scope` taken before `apply` (inert, hidden\n//   from assistive tech) keeps the old look outside the front, clipped by an\n//   even-odd staircase hole each frame; the halo is painted above it;\n// - no View Transitions (or no `path()` clip for the copy): `apply` runs\n//   synchronously and a canvas paints a sheet of the old colour with a staircase\n//   hole, the live element drawn onto it, plus the halo;\n// - reduced motion: `apply` only.\n// On HDR displays (WebGPU + `(dynamic-range: high)`) the halo squares are drawn\n// again above SDR white on a WebGPU canvas laid over the halo canvas.\n// A second call while a wave runs skips the running wave first (its own `apply`\n// still runs, once), then applies the new state instantly.\n\nimport {\n\tSUN_PALETTE,\n\tcoverRadiusCells,\n\teaseInOutCubic,\n\tgridOffset,\n\tpaintHalo,\n\tpaintSheet,\n\tparseInk,\n\tstaircaseHoleClip,\n\tstaircasePolygon,\n\tstaircaseRows,\n\ttype Ctx2D,\n\ttype SunPalette,\n} from \"./sun-switch-core\";\nimport { startSunHdr, sunHdrAvailable, type SunHdrLayer } from \"./sun-switch-hdr\";\n\nexport const WAVE_ATTR = \"data-fancy-sun-waving\";\nexport const WAVE_NAME = \"fancy-sun-wave\";\nexport const LIVE_NAME = \"fancy-sun-live\";\nexport const WAVE_HDR_NAME = \"fancy-sun-wave-hdr\";\nexport const LIVE_HDR_NAME = \"fancy-sun-live-hdr\";\nexport const WAVE_STYLE_ID = \"fancy-sun-wave-styles\";\nexport const HALO_ATTR = \"data-fancy-sun-halo\";\nexport const HALO_HDR_ATTR = \"data-fancy-sun-halo-hdr\";\nexport const SHEET_ATTR = \"data-fancy-sun-sheet\";\nexport const GHOST_ATTR = \"data-fancy-sun-ghost\";\n\n/** Largest backing store (device px) the wave canvas may use; some engines refuse\n * bigger canvases (no context at all). */\nexport const MAX_WAVE_CANVAS_PIXELS = 16_000_000;\n\n/** Backing-store scale of the halo / sheet canvas: an integer from 1 to 2 (the dots\n * are whole cells, drawn crisp with `image-rendering: pixelated`), dropping to 1 when\n * the canvas would exceed `MAX_WAVE_CANVAS_PIXELS`. */\nexport function waveCanvasDpr(devicePixelRatio: number, W: number, H: number): number {\n\tconst raw = devicePixelRatio > 0 ? devicePixelRatio : 1;\n\tconst dpr = Math.min(2, Math.max(1, Math.floor(raw)));\n\treturn W * H * dpr * dpr > MAX_WAVE_CANVAS_PIXELS ? 1 : dpr;\n}\n\n/** View-transition rules, scoped to `html[data-fancy-sun-waving]` so other view\n * transitions on the page keep their own animations. */\nexport const WAVE_CSS = `html[${WAVE_ATTR}]::view-transition-group(root),\nhtml[${WAVE_ATTR}]::view-transition-old(root),\nhtml[${WAVE_ATTR}]::view-transition-new(root),\nhtml[${WAVE_ATTR}]::view-transition-group(${WAVE_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-new(${WAVE_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-group(${LIVE_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-new(${LIVE_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-group(${WAVE_HDR_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-new(${WAVE_HDR_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-group(${LIVE_HDR_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-new(${LIVE_HDR_NAME}) {\n\tanimation: none;\n\tmix-blend-mode: normal;\n}\nhtml[${WAVE_ATTR}]::view-transition-old(${WAVE_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-old(${LIVE_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-old(${WAVE_HDR_NAME}),\nhtml[${WAVE_ATTR}]::view-transition-old(${LIVE_HDR_NAME}) {\n\tdisplay: none;\n}\nhtml[${WAVE_ATTR}]::view-transition-new(root) {\n\tclip-path: polygon(0 0, 0 0, 0 0);\n}\n`;\n\nexport type PixelWaveMode = \"document\" | \"contained\";\n\nexport interface PixelWaveOptions {\n\t/** Wave origin: viewport px (document) or scope-local px (contained). */\n\torigin: { x: number; y: number };\n\t/** Flips the state synchronously. Called exactly once; errors are reported, not thrown. */\n\tapply: () => void;\n\tmode?: PixelWaveMode;\n\t/** The element the wave covers in contained mode (positioned, overflow hidden). */\n\tscope?: HTMLElement | null;\n\t/** Element kept live during the wave (named `fancy-sun-live`, kept out of the sheet). */\n\tlive?: HTMLElement | null;\n\t/** A second live element, drawn above `live` (the switch's HDR canvas), named\n\t * `fancy-sun-live-hdr` during a view transition. */\n\tliveHdr?: HTMLElement | null;\n\t/** HDR displays: the halo also glows above SDR white on a WebGPU canvas. Default true. */\n\thdr?: boolean;\n\t/** Colour of the old-state sheet. Default: first opaque background from scope/body up. */\n\tfill?: string;\n\tcell?: number;\n\tdot?: number;\n\thaloCells?: number;\n\tduration?: number;\n\tpalette?: SunPalette;\n\tease?: (t: number) => number;\n\t/** Default: `prefers-reduced-motion` read at call time. */\n\treducedMotion?: boolean;\n\t/** Called every frame with the outer halo radius (cells) and progress t in [0, 1]. */\n\tonFront?: (R: number, t: number) => void;\n\t/** Clock in ms (tests). Default `performance.now()`. */\n\tnow?: () => number;\n}\n\nconst adopted = new WeakMap<Document, CSSStyleSheet>();\n\n/** Injects `WAVE_CSS` once per document: adopted style sheet when supported,\n * otherwise a `<style id=\"fancy-sun-wave-styles\">` in `<head>`. */\nexport function ensureWaveStyles(doc: Document): void {\n\tconst d = doc as Document & { adoptedStyleSheets?: CSSStyleSheet[] };\n\tconst Sheet = doc.defaultView?.CSSStyleSheet;\n\tif (Array.isArray(d.adoptedStyleSheets) && typeof Sheet === \"function\") {\n\t\tconst existing = adopted.get(doc);\n\t\tif (existing && d.adoptedStyleSheets.includes(existing)) return;\n\t\ttry {\n\t\t\tconst sheet = existing ?? new Sheet();\n\t\t\tif (!existing) sheet.replaceSync(WAVE_CSS);\n\t\t\td.adoptedStyleSheets = [...d.adoptedStyleSheets, sheet];\n\t\t\tadopted.set(doc, sheet);\n\t\t\treturn;\n\t\t} catch {\n\t\t\t// fall through to the <style> element\n\t\t}\n\t}\n\tif (doc.getElementById(WAVE_STYLE_ID)) return;\n\tconst el = doc.createElement(\"style\");\n\tel.id = WAVE_STYLE_ID;\n\tel.textContent = WAVE_CSS;\n\t(doc.head ?? doc.documentElement).append(el);\n}\n\n/** Whether `doc.startViewTransition` exists. A function, never a module constant. */\nexport function supportsViewTransition(doc: Document): boolean {\n\treturn typeof (doc as { startViewTransition?: unknown }).startViewTransition === \"function\";\n}\n\n/**\n * The colour `el` visibly sits on: computed backgrounds from `el` up to `<html>`,\n * translucent layers composited over the first opaque one (white if none).\n * A colour that cannot be parsed counts as opaque.\n */\nexport function firstOpaqueBackground(el: Element): string {\n\tconst win = el.ownerDocument.defaultView;\n\tif (!win) return \"#fff\";\n\tconst layers: { r: number; g: number; b: number; a: number }[] = [];\n\tlet base: string | null = null;\n\tfor (let n: Element | null = el; n; n = n.parentElement) {\n\t\tconst bg = win.getComputedStyle(n).backgroundColor;\n\t\tif (!bg) continue;\n\t\tconst p = parseInk(bg);\n\t\tif (!p) {\n\t\t\tif (bg === \"transparent\") continue;\n\t\t\tbase = bg;\n\t\t\tbreak;\n\t\t}\n\t\tif (p.a >= 1) {\n\t\t\tbase = bg;\n\t\t\tlayers.push(p);\n\t\t\tbreak;\n\t\t}\n\t\tif (p.a > 0) layers.push(p);\n\t}\n\tif (layers.length === 0) return base ?? \"#fff\";\n\tconst top = layers[0];\n\tif (layers.length === 1 && top && top.a >= 1) return base ?? \"#fff\";\n\t// Unparseable opaque base under translucent layers: keep the base.\n\tif (base && !parseInk(base)) return base;\n\tlet c = { r: 255, g: 255, b: 255 };\n\tfor (let i = layers.length - 1; i >= 0; i--) {\n\t\tconst l = layers[i];\n\t\tif (!l) continue;\n\t\tc = {\n\t\t\tr: l.r * l.a + c.r * (1 - l.a),\n\t\t\tg: l.g * l.a + c.g * (1 - l.a),\n\t\t\tb: l.b * l.a + c.b * (1 - l.a),\n\t\t};\n\t}\n\treturn `rgb(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)})`;\n}\n\n/** Minimum time between two theme flips: at most 3 full-page flips a second\n * (photosensitivity). Shared by every switch on the page. */\nexport const MIN_FLIP_MS = 334;\n\nlet lastFlipAt = -Infinity;\n\n/** Milliseconds before the next flip may start, page-wide (0 = now). */\nexport function flipWait(now: number): number {\n\treturn Math.max(0, lastFlipAt + MIN_FLIP_MS - now);\n}\n\n/** Records that a flip started at `now`. */\nexport function markFlip(now: number): void {\n\tlastFlipAt = now;\n}\n\n/** Forgets the last flip (tests). */\nexport function resetFlipGate(): void {\n\tlastFlipAt = -Infinity;\n}\n\ninterface ActiveWave {\n\tskip(): void;\n}\n\nlet active: ActiveWave | null = null;\n\n/** Whether a wave is running (waves are global: one at a time). */\nexport function isWaving(): boolean {\n\treturn active !== null;\n}\n\ninterface ViewTransitionLike {\n\tready: Promise<unknown>;\n\tfinished: Promise<unknown>;\n\tskipTransition(): void;\n}\n\nfunction report(error: unknown) {\n\tif (typeof console !== \"undefined\") console.error(error);\n}\n\n/**\n * Runs one wave. Always calls `apply` exactly once and never rejects; the promise\n * resolves once every canvas, attribute and view-transition name is cleaned up.\n */\nexport function pixelWave(o: PixelWaveOptions): Promise<void> {\n\tlet applied = false;\n\tconst runApply = () => {\n\t\tif (applied) return;\n\t\tapplied = true;\n\t\ttry {\n\t\t\to.apply();\n\t\t} catch (error) {\n\t\t\treport(error);\n\t\t}\n\t};\n\n\tif (active) {\n\t\tactive.skip();\n\t\trunApply();\n\t\treturn Promise.resolve();\n\t}\n\n\tconst doc = typeof document === \"undefined\" ? null : document;\n\tconst win = doc?.defaultView ?? null;\n\tif (!doc || !win || !doc.body) {\n\t\trunApply();\n\t\treturn Promise.resolve();\n\t}\n\tconst reduced =\n\t\to.reducedMotion ??\n\t\t(typeof win.matchMedia === \"function\" &&\n\t\t\twin.matchMedia(\"(prefers-reduced-motion: reduce)\").matches);\n\tif (reduced) {\n\t\trunApply();\n\t\treturn Promise.resolve();\n\t}\n\n\tconst html = doc.documentElement;\n\tconst cell = o.cell && o.cell > 0 ? o.cell : 5;\n\tconst dot = Math.min(o.dot && o.dot > 0 ? o.dot : 4, cell);\n\tconst halo = o.haloCells !== undefined && o.haloCells >= 0 ? o.haloCells : 9;\n\tconst duration = o.duration !== undefined && o.duration >= 0 ? o.duration : 1300;\n\tconst palette = o.palette ?? SUN_PALETTE;\n\tconst ease = o.ease ?? easeInOutCubic;\n\tconst now = o.now ?? (() => win.performance.now());\n\tconst scope = (o.mode ?? \"document\") === \"contained\" ? (o.scope ?? null) : null;\n\tconst contained = (o.mode ?? \"document\") === \"contained\";\n\n\tlet W: number;\n\tlet H: number;\n\tlet boxLeft = 0;\n\tlet boxTop = 0;\n\tif (scope) {\n\t\tconst r = scope.getBoundingClientRect();\n\t\tboxLeft = r.left + scope.clientLeft;\n\t\tboxTop = r.top + scope.clientTop;\n\t\tW = scope.clientWidth;\n\t\tH = scope.clientHeight;\n\t} else {\n\t\tW = win.innerWidth;\n\t\tH = win.innerHeight;\n\t}\n\tconst origin = { x: o.origin.x, y: o.origin.y };\n\tconst ox = gridOffset(origin.x - cell / 2, cell);\n\tconst oy = gridOffset(origin.y - cell / 2, cell);\n\tconst tx = (origin.x - ox) / cell;\n\tconst ty = (origin.y - oy) / cell;\n\tconst cover = coverRadiusCells(origin, W, H, cell, halo);\n\tconst dpr = waveCanvasDpr(win.devicePixelRatio, W, H);\n\n\tconst useVT = !contained && supportsViewTransition(doc) && typeof html.animate === \"function\";\n\tconst sheetColor = useVT ? \"\" : (o.fill ?? firstOpaqueBackground(scope ?? doc.body));\n\t// Contained: a copy of the scope's old content, taken before `apply`.\n\tconst ghost = scope && !useVT ? makeGhost(scope, W, H, sheetColor) : null;\n\n\tconst canvas = doc.createElement(\"canvas\");\n\tcanvas.setAttribute(HALO_ATTR, \"\");\n\tif (!useVT && !ghost) canvas.setAttribute(SHEET_ATTR, \"\");\n\tcanvas.setAttribute(\"aria-hidden\", \"true\");\n\tcanvas.width = Math.max(1, Math.round(W * dpr));\n\tcanvas.height = Math.max(1, Math.round(H * dpr));\n\tconst cs = canvas.style;\n\tcs.position = scope ? \"absolute\" : \"fixed\";\n\tcs.left = \"0\";\n\tcs.top = \"0\";\n\tcs.width = `${W}px`;\n\tcs.height = `${H}px`;\n\tcs.pointerEvents = \"none\";\n\tcs.zIndex = \"2147483647\";\n\tcs.margin = \"0\";\n\tcs.padding = \"0\";\n\tcs.border = \"0\";\n\tcs.imageRendering = \"pixelated\";\n\n\t// HDR: a WebGPU canvas over the halo canvas, same box; the halo squares are\n\t// drawn there again at `haloGlow(D) ×` their colour once the device is up.\n\tlet hdrCanvas: HTMLCanvasElement | null = null;\n\tlet hdrLayer: SunHdrLayer | null = null;\n\tif (o.hdr !== false && sunHdrAvailable()) {\n\t\thdrCanvas = doc.createElement(\"canvas\");\n\t\thdrCanvas.setAttribute(HALO_HDR_ATTR, \"\");\n\t\thdrCanvas.setAttribute(\"aria-hidden\", \"true\");\n\t\thdrCanvas.width = canvas.width;\n\t\thdrCanvas.height = canvas.height;\n\t\thdrCanvas.style.cssText = cs.cssText;\n\t}\n\n\t// Sheet path: the live element (a canvas) is drawn onto the sheet where the\n\t// sheet is opaque, so it shows on the old colour instead of in a hole.\n\tlet liveDraw: { el: HTMLCanvasElement; x: number; y: number; w: number; h: number } | null = null;\n\tif (!useVT && !ghost && o.live && o.live.tagName === \"CANVAS\") {\n\t\tconst lr = o.live.getBoundingClientRect();\n\t\tliveDraw = {\n\t\t\tel: o.live as HTMLCanvasElement,\n\t\t\tx: (lr.left - boxLeft) * dpr,\n\t\t\ty: (lr.top - boxTop) * dpr,\n\t\t\tw: lr.width * dpr,\n\t\t\th: lr.height * dpr,\n\t\t};\n\t}\n\n\tconst hasRaf = typeof win.requestAnimationFrame === \"function\";\n\tconst request = (cb: () => void): number =>\n\t\thasRaf ? win.requestAnimationFrame(cb) : (win.setTimeout(cb, 16) as unknown as number);\n\tconst cancelFrame = (id: number) => {\n\t\tif (!id) return;\n\t\tif (hasRaf) win.cancelAnimationFrame(id);\n\t\telse win.clearTimeout(id);\n\t};\n\n\tlet ctx: Ctx2D | null = null;\n\tlet raf = 0;\n\tlet t0: number | null = null;\n\tlet clipping = useVT;\n\tlet prevAnim: Animation | null = null;\n\tlet lastPoly = \"\";\n\t/** The live element's own inline transition name, restored on cleanup. */\n\tlet liveName = \"\";\n\tlet livePriority = \"\";\n\tlet liveHdrName = \"\";\n\tlet liveHdrPriority = \"\";\n\tlet vt: ViewTransitionLike | null = null;\n\tlet loopDone = false;\n\tlet vtDone = !useVT;\n\tlet done = false;\n\tlet resolve: () => void = () => {};\n\tconst promise = new Promise<void>((r) => {\n\t\tresolve = r;\n\t});\n\n\tconst wave: ActiveWave = {\n\t\tskip() {\n\t\t\trunApply();\n\t\t\tclipping = false;\n\t\t\tprevAnim?.cancel();\n\t\t\tprevAnim = null;\n\t\t\tif (vt) {\n\t\t\t\ttry {\n\t\t\t\t\tvt.skipTransition();\n\t\t\t\t} catch {\n\t\t\t\t\t// already finished\n\t\t\t\t}\n\t\t\t}\n\t\t\tcleanup();\n\t\t},\n\t};\n\n\tfunction cleanup() {\n\t\tif (done) return;\n\t\tdone = true;\n\t\tcancelFrame(raf);\n\t\traf = 0;\n\t\tcanvas.remove();\n\t\thdrCanvas?.remove();\n\t\thdrLayer?.destroy();\n\t\thdrLayer = null;\n\t\tghost?.remove();\n\t\tif (useVT) {\n\t\t\thtml.removeAttribute(WAVE_ATTR);\n\t\t\tif (o.live) {\n\t\t\t\tif (liveName) o.live.style.setProperty(\"view-transition-name\", liveName, livePriority);\n\t\t\t\telse o.live.style.removeProperty(\"view-transition-name\");\n\t\t\t}\n\t\t\tif (o.liveHdr) {\n\t\t\t\tif (liveHdrName)\n\t\t\t\t\to.liveHdr.style.setProperty(\"view-transition-name\", liveHdrName, liveHdrPriority);\n\t\t\t\telse o.liveHdr.style.removeProperty(\"view-transition-name\");\n\t\t\t}\n\t\t}\n\t\tif (active === wave) active = null;\n\t\tresolve();\n\t}\n\n\tfunction maybeFinish() {\n\t\tif (loopDone && vtDone) cleanup();\n\t}\n\n\tfunction frame() {\n\t\traf = 0;\n\t\tif (done) return;\n\t\tconst n = now();\n\t\tif (t0 === null) t0 = n;\n\t\tconst t = duration > 0 ? Math.min(1, Math.max(0, (n - t0) / duration)) : 1;\n\t\tconst R = ease(t) * cover;\n\t\tconst rows = staircaseRows(tx, ty, R - halo + 0.5, cell, ox, oy);\n\t\tif (ghost) ghost.style.setProperty(\"clip-path\", staircaseHoleClip(rows, W, H));\n\t\tif (ctx) {\n\t\t\tctx.clearRect(0, 0, canvas.width, canvas.height);\n\t\t\tif (!useVT && !ghost) {\n\t\t\t\tpaintSheet(ctx, sheetColor, W, H, dpr, rows, null);\n\t\t\t\tif (liveDraw) drawLive(ctx, liveDraw);\n\t\t\t}\n\t\t\tconst glow = hdrLayer?.sink ?? null;\n\t\t\tglow?.clear();\n\t\t\tpaintHalo(ctx, { cx: tx, cy: ty, R, halo, cell, dot, ox, oy, W, H, palette, dpr, glow });\n\t\t\thdrLayer?.render(canvas.width, canvas.height, Math.max(1, Math.round(dot * dpr)));\n\t\t}\n\t\tconst poly = clipping && vt ? staircasePolygon(rows) : \"\";\n\t\tif (clipping && vt && poly !== lastPoly) {\n\t\t\ttry {\n\t\t\t\tconst keyframes = [{ clipPath: poly }, { clipPath: poly }];\n\t\t\t\t// Update the running clip in place; create one only when needed.\n\t\t\t\tif (!(prevAnim && setKeyframes(prevAnim, keyframes))) {\n\t\t\t\t\tconst a = html.animate(keyframes, {\n\t\t\t\t\t\tduration: 1e6,\n\t\t\t\t\t\tfill: \"forwards\",\n\t\t\t\t\t\tpseudoElement: \"::view-transition-new(root)\",\n\t\t\t\t\t});\n\t\t\t\t\tprevAnim?.cancel();\n\t\t\t\t\tprevAnim = a;\n\t\t\t\t}\n\t\t\t\tlastPoly = poly;\n\t\t\t} catch {\n\t\t\t\t// Pseudo-element animation unsupported: flip now, keep the halo sweep.\n\t\t\t\tclipping = false;\n\t\t\t\tprevAnim?.cancel();\n\t\t\t\tprevAnim = null;\n\t\t\t\ttry {\n\t\t\t\t\tvt.skipTransition();\n\t\t\t\t} catch {\n\t\t\t\t\t// already finished\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (o.onFront) {\n\t\t\ttry {\n\t\t\t\to.onFront(R, t);\n\t\t\t} catch (error) {\n\t\t\t\treport(error);\n\t\t\t}\n\t\t}\n\t\tif (done) return;\n\t\tif (t < 1) {\n\t\t\traf = request(frame);\n\t\t} else {\n\t\t\tprevAnim?.finish();\n\t\t\tloopDone = true;\n\t\t\tmaybeFinish();\n\t\t}\n\t}\n\n\tactive = wave;\n\tif (ghost && scope) scope.append(ghost);\n\t(scope ?? doc.body).append(canvas);\n\tif (hdrCanvas) {\n\t\tcanvas.after(hdrCanvas);\n\t\tvoid startSunHdr(hdrCanvas).then((layer) => {\n\t\t\tif (done) layer?.destroy();\n\t\t\telse hdrLayer = layer;\n\t\t});\n\t}\n\ttry {\n\t\tctx =\n\t\t\ttypeof canvas.getContext === \"function\"\n\t\t\t\t? (canvas.getContext(\"2d\") as unknown as Ctx2D | null)\n\t\t\t\t: null;\n\t} catch {\n\t\tctx = null;\n\t}\n\n\tif (useVT) {\n\t\tensureWaveStyles(doc);\n\t\tif (o.live) {\n\t\t\tliveName = o.live.style.getPropertyValue(\"view-transition-name\");\n\t\t\tlivePriority = o.live.style.getPropertyPriority(\"view-transition-name\");\n\t\t\to.live.style.setProperty(\"view-transition-name\", LIVE_NAME);\n\t\t}\n\t\tif (o.liveHdr) {\n\t\t\tliveHdrName = o.liveHdr.style.getPropertyValue(\"view-transition-name\");\n\t\t\tliveHdrPriority = o.liveHdr.style.getPropertyPriority(\"view-transition-name\");\n\t\t\to.liveHdr.style.setProperty(\"view-transition-name\", LIVE_HDR_NAME);\n\t\t}\n\t\tcanvas.style.setProperty(\"view-transition-name\", WAVE_NAME);\n\t\thdrCanvas?.style.setProperty(\"view-transition-name\", WAVE_HDR_NAME);\n\t\thtml.setAttribute(WAVE_ATTR, \"\");\n\t\ttry {\n\t\t\tconst start = (doc as unknown as { startViewTransition(cb: () => void): ViewTransitionLike })\n\t\t\t\t.startViewTransition;\n\t\t\tvt = start.call(doc, runApply);\n\t\t} catch (error) {\n\t\t\treport(error);\n\t\t\tvt = null;\n\t\t}\n\t\tif (!vt) {\n\t\t\trunApply();\n\t\t\tcleanup();\n\t\t\treturn promise;\n\t\t}\n\t\tconst settleVT = () => {\n\t\t\tvtDone = true;\n\t\t\tmaybeFinish();\n\t\t};\n\t\tvt.finished.then(settleVT, settleVT);\n\t\tvt.ready.then(\n\t\t\t() => {\n\t\t\t\tif (!done) raf = request(frame);\n\t\t\t},\n\t\t\t() => {\n\t\t\t\tloopDone = true;\n\t\t\t\tmaybeFinish();\n\t\t\t}\n\t\t);\n\t} else {\n\t\trunApply();\n\t\t// First frame painted synchronously, in the same task as `apply`, so the\n\t\t// new state never shows uncovered for a frame.\n\t\tframe();\n\t}\n\treturn promise;\n}\n\n/** `effect.setKeyframes(keyframes)` on a running animation; false when unsupported. */\nfunction setKeyframes(anim: Animation, keyframes: Keyframe[]): boolean {\n\tconst effect = anim.effect as { setKeyframes?: (k: Keyframe[]) => void } | null | undefined;\n\tif (!effect || typeof effect.setKeyframes !== \"function\") return false;\n\ttry {\n\t\teffect.setKeyframes(keyframes);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/** Draws the live canvas onto the sheet, only where the sheet is opaque. */\nfunction drawLive(\n\tctx: Ctx2D,\n\td: { el: HTMLCanvasElement; x: number; y: number; w: number; h: number }\n): void {\n\tconst c = ctx as Ctx2D & {\n\t\tglobalCompositeOperation?: string;\n\t\tdrawImage?: (img: HTMLCanvasElement, x: number, y: number, w: number, h: number) => void;\n\t};\n\tif (typeof c.drawImage !== \"function\" || !(d.w > 0 && d.h > 0)) return;\n\ttry {\n\t\tc.globalCompositeOperation = \"source-atop\";\n\t\tc.drawImage(d.el, d.x, d.y, d.w, d.h);\n\t} catch {\n\t\t// a zero-size or tainted source: the sheet stays plain\n\t} finally {\n\t\tc.globalCompositeOperation = \"source-over\";\n\t}\n}\n\n/** Elements that would load, play or reconnect if copied into the page. */\nconst GHOST_DROP = \"iframe, object, embed, video, audio, script\";\n\n/**\n * A static copy of `scope` laid over its padding box: the old look kept outside\n * the front in contained mode. Inert and hidden from assistive tech, no ids or\n * form names (no duplicate ids, no radio-group interplay), canvases copied pixel\n * for pixel. Null when the engine cannot clip it with `path()`.\n */\nfunction makeGhost(\n\tscope: HTMLElement,\n\tW: number,\n\tH: number,\n\tbackground: string\n): HTMLElement | null {\n\tconst win = scope.ownerDocument.defaultView;\n\tconst css = win?.CSS as { supports?: (p: string, v: string) => boolean } | undefined;\n\ttry {\n\t\tif (!css?.supports?.(\"clip-path\", 'path(evenodd, \"M0 0H1V1H0Z\")')) return null;\n\t\tconst ghost = scope.cloneNode(true) as HTMLElement;\n\t\tfor (const el of Array.from(ghost.querySelectorAll(GHOST_DROP))) el.remove();\n\t\tfor (const el of [ghost, ...Array.from(ghost.querySelectorAll(\"[id], [name]\"))]) {\n\t\t\tel.removeAttribute(\"id\");\n\t\t\tel.removeAttribute(\"name\");\n\t\t}\n\t\tconst from = Array.from(scope.querySelectorAll(\"canvas\"));\n\t\tconst to = Array.from(ghost.querySelectorAll(\"canvas\"));\n\t\tfrom.forEach((src, i) => {\n\t\t\tconst dst = to[i];\n\t\t\tif (!dst) return;\n\t\t\ttry {\n\t\t\t\tdst.width = src.width;\n\t\t\t\tdst.height = src.height;\n\t\t\t\tdst.getContext(\"2d\")?.drawImage(src, 0, 0);\n\t\t\t} catch {\n\t\t\t\t// an unreadable canvas stays blank\n\t\t\t}\n\t\t});\n\t\tghost.setAttribute(GHOST_ATTR, \"\");\n\t\tghost.setAttribute(\"aria-hidden\", \"true\");\n\t\tghost.setAttribute(\"inert\", \"\");\n\t\tconst gs = ghost.style;\n\t\tgs.position = \"absolute\";\n\t\tgs.left = \"0\";\n\t\tgs.top = \"0\";\n\t\tgs.right = \"auto\";\n\t\tgs.bottom = \"auto\";\n\t\tgs.width = `${W}px`;\n\t\tgs.height = `${H}px`;\n\t\tgs.minWidth = \"0\";\n\t\tgs.minHeight = \"0\";\n\t\tgs.maxWidth = \"none\";\n\t\tgs.maxHeight = \"none\";\n\t\tgs.margin = \"0\";\n\t\tgs.borderStyle = \"none\";\n\t\tgs.boxSizing = \"border-box\";\n\t\tgs.transform = \"none\";\n\t\tgs.transition = \"none\";\n\t\tgs.overflow = \"hidden\";\n\t\tgs.pointerEvents = \"none\";\n\t\tgs.zIndex = \"2147483646\";\n\t\tif (background) gs.backgroundColor = background;\n\t\tgs.setProperty(\"clip-path\", staircaseHoleClip([], W, H));\n\t\treturn ghost;\n\t} catch {\n\t\treturn null;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/sun-switch/pixel-wave-core.ts"},{"path":"react/src/components/sun-switch/sun-switch-core.ts","content":"\"use client\";\n\n// sun-switch-core.ts — framework-free maths and Canvas 2D painter for SunSwitch.\n// No framework imports, no module-scope window/document access, no build-time env.\n// Byte-identical copies live in the React and Vue packages: edit here, copy there.\n//\n// Grid model: every dot sits at the top-left of its `cell`, is `dot` CSS px square,\n// and is drawn in device pixels with rounding (no `setTransform(dpr)`), so 4 px dots\n// stay crisp at fractional device pixel ratios.\n\n/** Four colours from the top of the sun to the bottom (halo: outer fringe → inner rim). */\nexport type SunPalette = readonly [string, string, string, string];\n\nexport const SUN_PALETTE: SunPalette = Object.freeze([\n\t\"#f7cf4a\",\n\t\"#f6a24e\",\n\t\"#ef7a5e\",\n\t\"#ea7d9c\",\n]) as SunPalette;\n\n/** Opacity of the horizon dots when drawn in ink (outside the lens). */\nexport const HORIZON_ALPHA = 0.16;\n/** Share of ink laid over the day sun at rest, so the lens (pure colour) reads on\n * the disc too. Mixing toward the ink also raises the disc's contrast with the page. */\nexport const DAY_REST_INK = 0.3;\n/** Half-widths of the reflection rows under the horizon line, as a share of the\n * disc's outer radius (radius + 0.5). */\nexport const REFLECTION_ROWS: readonly number[] = Object.freeze([0.85, 0.6, 0.35]);\n/** Exponent applied to the halo depth before the Bayer threshold. */\nexport const HALO_EXPONENT = 0.7;\n\nconst BAYER4_RAW = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];\n/** 4 × 4 ordered-dither thresholds, `(v + 0.5) / 16`, row-major (index = y * 4 + x). */\nexport const BAYER4: readonly number[] = Object.freeze(BAYER4_RAW.map((v) => (v + 0.5) / 16));\n\n/** Threshold of grid cell (x, y). Fixed in grid space; negative indices wrap. */\nexport function bayer4(x: number, y: number): number {\n\treturn BAYER4[(y & 3) * 4 + (x & 3)] ?? 0;\n}\n\nexport function easeInOutCubic(t: number): number {\n\tif (!(t > 0)) return 0;\n\tif (t >= 1) return 1;\n\treturn t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;\n}\n\n/** `clamp01(D) ** 0.7`: fraction of Bayer cells lit at halo depth D. */\nexport function haloDensity(D: number): number {\n\tif (!(D > 0)) return 0;\n\tif (D >= 1) return 1;\n\treturn D ** HALO_EXPONENT;\n}\n\n/** Palette index at halo depth D: 0 = outer fringe (first colour), 3 = inner rim. */\nexport function haloColorIndex(D: number): 0 | 1 | 2 | 3 {\n\tconst i = Math.floor(D * 4);\n\treturn (i <= 0 ? 0 : i >= 3 ? 3 : i) as 0 | 1 | 2 | 3;\n}\n\nexport interface ParsedColor {\n\tr: number;\n\tg: number;\n\tb: number;\n\ta: number;\n}\n\n/** Parses `rgb()` / `rgba()` (comma or space syntax), `#rgb[a]` / `#rrggbb[aa]` and\n * `transparent`. Returns null for anything else (named colours, `oklch()`, ...). */\nexport function parseInk(css: string): ParsedColor | null {\n\tconst s = css.trim().toLowerCase();\n\tif (s === \"transparent\") return { r: 0, g: 0, b: 0, a: 0 };\n\tif (s.startsWith(\"#\")) {\n\t\tconst hex = s.slice(1);\n\t\tif (!/^[0-9a-f]+$/.test(hex)) return null;\n\t\tif (hex.length === 3 || hex.length === 4) {\n\t\t\tconst n = [...hex].map((c) => parseInt(c + c, 16));\n\t\t\treturn { r: n[0] ?? 0, g: n[1] ?? 0, b: n[2] ?? 0, a: n[3] === undefined ? 1 : n[3] / 255 };\n\t\t}\n\t\tif (hex.length === 6 || hex.length === 8) {\n\t\t\tconst n = [0, 2, 4, 6].map((i) => parseInt(hex.slice(i, i + 2), 16));\n\t\t\tconst a = hex.length === 8 ? (n[3] ?? 255) / 255 : 1;\n\t\t\treturn { r: n[0] ?? 0, g: n[1] ?? 0, b: n[2] ?? 0, a };\n\t\t}\n\t\treturn null;\n\t}\n\tconst m = /^rgba?\\(([^)]*)\\)$/.exec(s);\n\tif (!m) return null;\n\tconst parts = (m[1] ?? \"\").split(/[\\s,/]+/).filter(Boolean);\n\tif (parts.length < 3) return null;\n\tconst num = (p: string | undefined, scale: number): number => {\n\t\tif (p === undefined) return NaN;\n\t\treturn p.endsWith(\"%\") ? (parseFloat(p) / 100) * scale : parseFloat(p);\n\t};\n\tconst r = num(parts[0], 255);\n\tconst g = num(parts[1], 255);\n\tconst b = num(parts[2], 255);\n\tconst a = parts[3] === undefined ? 1 : num(parts[3], 1);\n\tif ([r, g, b, a].some((v) => !Number.isFinite(v))) return null;\n\treturn { r, g, b, a: Math.min(1, Math.max(0, a)) };\n}\n\n// --- HDR glow ---------------------------------------------------------------\n\n/** Glow of the day sun at rest (× SDR white), on HDR displays. */\nexport const SUN_GLOW_DAY = 2.2;\n/** Glow of a sun cell under the lens. */\nexport const SUN_GLOW_LENS = 4.9;\n/** Glow of a horizon / reflection cell under the lens. */\nexport const HORIZON_GLOW_LENS = 1.6;\n\n/** Halo glow at depth D: the dense inner rim peaks, the sparse fringe stays near SDR. */\nexport function haloGlow(D: number): number {\n\treturn 1.5 + 3.4 * Math.min(1, Math.max(0, D));\n}\n\n/**\n * Receives every painted square that should glow on an HDR display: its\n * top-left corner in device px, its colour as `0xRRGGBB` (what the 2D canvas\n * shows) and its glow (× SDR white). The 2D painting is unchanged.\n */\nexport interface GlowSink {\n\tclear(): void;\n\tpush(px: number, py: number, rgb: number, glow: number): void;\n}\n\n/** What the painter hands its frames to: a sink and a render call. */\nexport interface SunGlowTarget {\n\treadonly sink: GlowSink;\n\trender(width: number, height: number, size: number): void;\n}\n\nconst RGB_CACHE = new Map<string, number>();\n\n/** `0xRRGGBB` of an opaque-enough CSS colour `parseInk` reads, else -1. Cached. */\nexport function rgbOf(css: string): number {\n\tconst hit = RGB_CACHE.get(css);\n\tif (hit !== undefined) return hit;\n\tconst c = parseInk(css);\n\tconst v =\n\t\tc && c.a > 0\n\t\t\t? (Math.round(Math.min(255, Math.max(0, c.r))) << 16) |\n\t\t\t\t(Math.round(Math.min(255, Math.max(0, c.g))) << 8) |\n\t\t\t\tMath.round(Math.min(255, Math.max(0, c.b)))\n\t\t\t: -1;\n\tif (RGB_CACHE.size > 64) RGB_CACHE.clear();\n\tRGB_CACHE.set(css, v);\n\treturn v;\n}\n\n/** `a` blended toward `b` by `t` (sRGB, like the 2D canvas does). */\nexport function mixRgb(a: number, b: number, t: number): number {\n\tconst ch = (s: number) => Math.round(((a >> s) & 255) * (1 - t) + ((b >> s) & 255) * t) & 255;\n\treturn (ch(16) << 16) | (ch(8) << 8) | ch(0);\n}\n\nexport interface SunCell {\n\tx: number;\n\ty: number;\n\t/** Palette index. */\n\tcolor: number;\n\tlayer: \"sun\" | \"horizon\";\n}\n\nexport interface SunRaster {\n\t/** Canvas side in cells: 2 × radius + 1. */\n\tsize: number;\n\t/** First row below the visible disc (== size when `horizon` is false). */\n\thorizonRow: number;\n\tcells: SunCell[];\n}\n\n/** Deterministic integer hash in [0, 1). Same bits in every JS engine. */\nexport function hash2(x: number, y: number): number {\n\tlet h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1);\n\th = Math.imul(h ^ (h >>> 15), 0x85ebca6b);\n\th ^= h >>> 13;\n\treturn (h >>> 0) / 4294967296;\n}\n\n/** Rasterises the sun disc (cut at 60 % when `horizon`), the dotted horizon line\n * and up to three dashed reflection rows under it. */\nexport function rasterSun(o: { radius: number; horizon: boolean }): SunRaster {\n\tconst R = Math.max(1, Math.round(o.radius));\n\tconst size = 2 * R + 1;\n\tconst hY = o.horizon ? Math.round(0.6 * size) : size;\n\tconst rr = R + 0.5;\n\tconst cells: SunCell[] = [];\n\tfor (let y = 0; y < hY; y++) {\n\t\tconst dy = y - R;\n\t\tconst half = Math.floor(Math.sqrt(Math.max(0, rr * rr - dy * dy)));\n\t\tconst color = Math.min(3, Math.floor((y * 4) / hY));\n\t\tfor (let x = R - half; x <= R + half; x++) cells.push({ x, y, color, layer: \"sun\" });\n\t}\n\tif (o.horizon && hY < size) {\n\t\tfor (let x = 0; x < size; x += 2) cells.push({ x, y: hY, color: 1, layer: \"horizon\" });\n\t\tfor (let k = 0; k < REFLECTION_ROWS.length; k++) {\n\t\t\tconst y = hY + 1 + k;\n\t\t\tif (y >= size) break;\n\t\t\tconst w = Math.round(rr * (REFLECTION_ROWS[k] ?? 0));\n\t\t\tfor (let x = R - w; x <= R + w; x++) {\n\t\t\t\tif ((x + y) % 4 === 3) continue;\n\t\t\t\tconst color = Math.min(3, 1 + Math.floor(hash2(y, x) * 3));\n\t\t\t\tcells.push({ x, y, color, layer: \"horizon\" });\n\t\t\t}\n\t\t}\n\t}\n\treturn { size, horizonRow: hY, cells };\n}\n\n/** `((origin % cell) + cell) % cell`: grid phase of a pixel coordinate. */\nexport function gridOffset(origin: number, cell: number): number {\n\treturn ((origin % cell) + cell) % cell;\n}\n\n/** Outer halo radius (cells) at which the clip covers every point of a W × H box. */\nexport function coverRadiusCells(\n\torigin: { x: number; y: number },\n\tW: number,\n\tH: number,\n\tcell: number,\n\thalo: number\n): number {\n\treturn (\n\t\tMath.hypot(Math.max(origin.x, W - origin.x), Math.max(origin.y, H - origin.y)) / cell + halo\n\t);\n}\n\n/** One row of the staircase clip, in px. Every coordinate is ≡ the grid offset (mod cell). */\nexport interface StairRow {\n\ty0: number;\n\ty1: number;\n\tx0: number;\n\tx1: number;\n}\n\n/**\n * Rows of the grid-aligned staircase disc of radius `innerRadius` (cells) centred\n * on (tx, ty) (grid units, usually integer + 0.5). A cell row is included when its\n * centre line meets the disc; its span is the chord widened to whole cells.\n * Empty when `innerRadius` is not > 0.\n */\nexport function staircaseRows(\n\ttx: number,\n\tty: number,\n\tinnerRadius: number,\n\tcell: number,\n\tox: number,\n\toy: number\n): StairRow[] {\n\tconst rows: StairRow[] = [];\n\tif (!(innerRadius > 0)) return rows;\n\tconst last = Math.ceil(ty + innerRadius);\n\tfor (let r = Math.floor(ty - innerRadius); r <= last; r++) {\n\t\tconst dy = r + 0.5 - ty;\n\t\tif (Math.abs(dy) > innerRadius) continue;\n\t\tconst h = Math.sqrt(innerRadius * innerRadius - dy * dy);\n\t\tconst y0 = r * cell + oy;\n\t\trows.push({\n\t\t\ty0,\n\t\t\ty1: y0 + cell,\n\t\t\tx0: Math.floor(tx - h) * cell + ox,\n\t\t\tx1: Math.ceil(tx + h) * cell + ox,\n\t\t});\n\t}\n\treturn rows;\n}\n\n/** Same membership rule as `staircaseRows`, for one cell (i, j). */\nexport function inStaircase(i: number, j: number, tx: number, ty: number, inner: number): boolean {\n\tif (!(inner > 0)) return false;\n\tconst dy = j + 0.5 - ty;\n\tif (Math.abs(dy) > inner) return false;\n\tconst h = Math.sqrt(inner * inner - dy * dy);\n\treturn i >= Math.floor(tx - h) && i < Math.ceil(tx + h);\n}\n\nexport const EMPTY_POLYGON = \"polygon(0 0, 0 0, 0 0)\";\n\n/** Outline vertices of the rows (right side down, left side up) as [x, y] pairs,\n * with consecutive duplicates removed. */\nfunction staircaseVertices(rows: readonly StairRow[]): [number, number][] {\n\tconst pts: number[] = [];\n\tfor (const r of rows) pts.push(r.x1, r.y0, r.x1, r.y1);\n\tfor (let i = rows.length - 1; i >= 0; i--) {\n\t\tconst r = rows[i];\n\t\tif (r) pts.push(r.x0, r.y1, r.x0, r.y0);\n\t}\n\tconst out: [number, number][] = [];\n\tfor (let i = 0; i < pts.length; i += 2) {\n\t\tif (i > 0 && pts[i] === pts[i - 2] && pts[i + 1] === pts[i - 1]) continue;\n\t\tout.push([pts[i] ?? 0, pts[i + 1] ?? 0]);\n\t}\n\treturn out;\n}\n\n/** CSS `polygon()` outlining the rows (right side down, left side up), with\n * consecutive duplicate vertices removed. `EMPTY_POLYGON` when there are no rows. */\nexport function staircasePolygon(rows: readonly StairRow[]): string {\n\tif (rows.length === 0) return EMPTY_POLYGON;\n\treturn `polygon(${staircaseVertices(rows)\n\t\t.map(([x, y]) => `${x}px ${y}px`)\n\t\t.join(\", \")})`;\n}\n\n/** CSS `clip-path` showing a W × H box except the staircase (an even-odd hole):\n * the old-state layer in contained mode. Just the box when there are no rows. */\nexport function staircaseHoleClip(rows: readonly StairRow[], W: number, H: number): string {\n\tlet d = `M0 0H${W}V${H}H0Z`;\n\tconst pts = rows.length > 0 ? staircaseVertices(rows) : [];\n\tif (pts.length > 0) d += `M${pts.map(([x, y]) => `${x} ${y}`).join(\"L\")}Z`;\n\treturn `path(evenodd, \"${d}\")`;\n}\n\n/** The slice of CanvasRenderingContext2D the cores use (a recording fake fits). */\nexport interface Ctx2D {\n\tfillStyle: string | CanvasGradient | CanvasPattern;\n\tglobalAlpha: number;\n\tfillRect(x: number, y: number, w: number, h: number): void;\n\tclearRect(x: number, y: number, w: number, h: number): void;\n\tsetTransform(a: number, b: number, c: number, d: number, e: number, f: number): void;\n\tbeginPath?(): void;\n\trect?(x: number, y: number, w: number, h: number): void;\n\tfill?(rule?: CanvasFillRule): void;\n}\n\nexport interface HaloParams {\n\t/** Wave origin in grid units of the halo canvas (usually integer + 0.5). */\n\tcx: number;\n\tcy: number;\n\t/** Outer halo radius in cells. */\n\tR: number;\n\t/** Halo thickness in cells. */\n\thalo: number;\n\tcell: number;\n\tdot: number;\n\t/** Grid phase in CSS px. */\n\tox: number;\n\toy: number;\n\t/** Canvas size in CSS px. */\n\tW: number;\n\tH: number;\n\tpalette: SunPalette;\n\t/** Device pixel ratio of the canvas backing store. Default 1. */\n\tdpr?: number;\n\t/** HDR: every halo square is also pushed here, glowing by depth ({@link haloGlow}). */\n\tglow?: GlowSink | null;\n}\n\n/**\n * Paints the dithered halo band (cells with depth D = (R − d) / halo in [0, 1] and\n * `bayer4 <= D^0.7`), iterating only the ring band of each row. Does not clear.\n * Returns the number of squares drawn.\n */\nexport function paintHalo(ctx: Ctx2D, p: HaloParams): number {\n\tconst { cx: tx, cy: ty, R, halo, cell, ox, oy } = p;\n\tif (!(R > 0) || !(halo > 0) || !(cell > 0)) return 0;\n\tconst dpr = p.dpr ?? 1;\n\tconst cols = Math.ceil(p.W / cell) + 1;\n\tconst rows = Math.ceil(p.H / cell) + 1;\n\tconst size = Math.max(1, Math.round(p.dot * dpr));\n\tconst Ri = R - halo;\n\tlet count = 0;\n\tlet lastColor = -1;\n\tctx.globalAlpha = 1;\n\tconst yEnd = Math.min(rows, Math.ceil(ty + R));\n\tfor (let y = Math.max(0, Math.floor(ty - R)); y < yEnd; y++) {\n\t\tconst dy = y + 0.5 - ty;\n\t\tconst hw = Math.sqrt(Math.max(0, R * R - dy * dy));\n\t\tconst iw = Ri > 0 && Math.abs(dy) < Ri ? Math.sqrt(Ri * Ri - dy * dy) : -1;\n\t\tconst skipA = iw > 1 ? Math.ceil(tx - iw + 1) : Infinity;\n\t\tconst skipB = iw > 1 ? Math.floor(tx + iw - 1) : -Infinity;\n\t\tconst x1 = Math.min(cols, Math.ceil(tx + hw));\n\t\tconst py = Math.round((y * cell + oy) * dpr);\n\t\tfor (let x = Math.max(0, Math.floor(tx - hw)); x < x1; x++) {\n\t\t\tif (x >= skipA && x < skipB) {\n\t\t\t\tx = skipB - 1;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst D = (R - Math.hypot(x + 0.5 - tx, dy)) / halo;\n\t\t\tif (D < 0 || D > 1 || bayer4(x, y) > haloDensity(D)) continue;\n\t\t\tconst ci = haloColorIndex(D);\n\t\t\tif (ci !== lastColor) {\n\t\t\t\tctx.fillStyle = p.palette[ci];\n\t\t\t\tlastColor = ci;\n\t\t\t}\n\t\t\tconst px = Math.round((x * cell + ox) * dpr);\n\t\t\tctx.fillRect(px, py, size, size);\n\t\t\tif (p.glow) p.glow.push(px, py, rgbOf(p.palette[ci]), haloGlow(D));\n\t\t\tcount++;\n\t\t}\n\t}\n\treturn count;\n}\n\n/** A rectangle in CSS px. */\nexport interface KeepRect {\n\tx: number;\n\ty: number;\n\tw: number;\n\th: number;\n}\n\n/**\n * Fills a W × H (CSS px) canvas with `color` except the staircase `hole`, then\n * clears `keep` (the live element, so it stays visible). Uses `fillRect` /\n * `clearRect` only. Does not clear first.\n */\nexport function paintSheet(\n\tctx: Ctx2D,\n\tcolor: string,\n\tW: number,\n\tH: number,\n\tdpr: number,\n\thole: readonly StairRow[] | null,\n\tkeep?: KeepRect | null\n): void {\n\tconst X = (v: number) => Math.round(Math.min(W, Math.max(0, v)) * dpr);\n\tconst Y = (v: number) => Math.round(Math.min(H, Math.max(0, v)) * dpr);\n\tconst band = (x0: number, y0: number, x1: number, y1: number) => {\n\t\tconst ax = X(x0);\n\t\tconst ay = Y(y0);\n\t\tconst w = X(x1) - ax;\n\t\tconst h = Y(y1) - ay;\n\t\tif (w > 0 && h > 0) ctx.fillRect(ax, ay, w, h);\n\t};\n\tctx.globalAlpha = 1;\n\tctx.fillStyle = color;\n\tif (!hole || hole.length === 0) {\n\t\tband(0, 0, W, H);\n\t} else {\n\t\tconst first = hole[0];\n\t\tconst last = hole[hole.length - 1];\n\t\tif (first) band(0, 0, W, first.y0);\n\t\tfor (const r of hole) {\n\t\t\tband(0, r.y0, r.x0, r.y1);\n\t\t\tband(r.x1, r.y0, W, r.y1);\n\t\t}\n\t\tif (last) band(0, last.y1, W, H);\n\t}\n\tif (keep && keep.w > 0 && keep.h > 0) {\n\t\tconst ax = X(keep.x);\n\t\tconst ay = Y(keep.y);\n\t\tconst w = X(keep.x + keep.w) - ax;\n\t\tconst h = Y(keep.y + keep.h) - ay;\n\t\tif (w > 0 && h > 0) ctx.clearRect(ax, ay, w, h);\n\t}\n}\n\nexport interface LensTarget {\n\tx: number;\n\ty: number;\n\ton: boolean;\n}\n\nexport interface Lens {\n\t/** Current centre (cells) and radius (cells). */\n\treadonly x: number;\n\treadonly y: number;\n\treadonly r: number;\n\t/** Target radius while open. */\n\tradius: number;\n\t/**\n\t * Advances to `nowMs` toward `target`. The first step after creation or\n\t * `resetClock()` counts as one 60 Hz frame; later steps clamp dt to 64 ms.\n\t * Returns true while still moving.\n\t */\n\tstep(nowMs: number, target: LensTarget, reduced: boolean): boolean;\n\t/** Forget the last timestamp (call when an idle loop restarts). */\n\tresetClock(): void;\n\t/** Whether cell (i, j) is under the lens. */\n\tcovers(i: number, j: number): boolean;\n}\n\n/** Soft circular lens: frame-rate independent position lerp, radius eased with\n * 140 ms (open) / 260 ms (close) time constants, snapping within 0.3 cell. */\nexport function createLens(o: {\n\tradius: number;\n\tlerp?: number;\n\tinMs?: number;\n\toutMs?: number;\n}): Lens {\n\tconst lerp = o.lerp ?? 0.35;\n\tconst inMs = o.inMs ?? 140;\n\tconst outMs = o.outMs ?? 260;\n\tlet x = 0;\n\tlet y = 0;\n\tlet r = 0;\n\tlet on = false;\n\tlet last: number | null = null;\n\tconst lens: Lens = {\n\t\tget x() {\n\t\t\treturn x;\n\t\t},\n\t\tget y() {\n\t\t\treturn y;\n\t\t},\n\t\tget r() {\n\t\t\treturn r;\n\t\t},\n\t\tradius: o.radius,\n\t\tresetClock() {\n\t\t\tlast = null;\n\t\t},\n\t\tstep(nowMs, target, reduced) {\n\t\t\tconst frame = 1000 / 60;\n\t\t\tconst dt = last === null ? frame : Math.min(64, Math.max(0, nowMs - last));\n\t\t\tlast = nowMs;\n\t\t\tif (target.on && !on && r === 0) {\n\t\t\t\tx = target.x;\n\t\t\t\ty = target.y;\n\t\t\t}\n\t\t\ton = target.on;\n\t\t\tconst g = on ? Math.max(0, lens.radius) : 0;\n\t\t\tif (reduced) {\n\t\t\t\tx = target.x;\n\t\t\t\ty = target.y;\n\t\t\t\tr = g;\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst k = 1 - Math.pow(1 - lerp, dt / frame);\n\t\t\tx += (target.x - x) * k;\n\t\t\ty += (target.y - y) * k;\n\t\t\tr += (g - r) * (1 - Math.exp((-3 * dt) / (on ? inMs : outMs)));\n\t\t\tif (Math.abs(g - r) < 0.3) r = g;\n\t\t\treturn r !== g || Math.abs(target.x - x) + Math.abs(target.y - y) > 0.05;\n\t\t},\n\t\tcovers(i, j) {\n\t\t\treturn r > 0.5 && Math.hypot(i + 0.5 - x, j + 0.5 - y) <= r;\n\t\t},\n\t};\n\treturn lens;\n}\n\nexport interface SunPainterOptions {\n\tradius: number;\n\tcell: number;\n\tdot: number;\n\tpalette: SunPalette;\n\tnightAlpha: number;\n\thorizon: boolean;\n\tlens: boolean;\n\tlensRadius: number;\n\tchecked: boolean;\n\treducedMotion: boolean;\n}\n\n/** The wave front as seen by the painter, in canvas-local CSS px. `R` is the CLIP\n * radius (outer halo radius − haloCells + 0.5, times cell), so the painter's\n * \"inside the front\" test is exactly the staircase membership of the clip. */\nexport interface SunFront {\n\tcx: number;\n\tcy: number;\n\tR: number;\n\tnextChecked: boolean;\n\toldInk: string;\n}\n\nexport interface SunPainter {\n\t/** Layout keys (radius / cell / dot / horizon) re-raster and resize. */\n\tsetOptions(next: Partial<SunPainterOptions>): void;\n\t/** Any CSS colour the canvas accepts (the computed `color` of the canvas). */\n\tsetInk(css: string): void;\n\t/** Pointer in canvas cells; `on` opens / closes the lens. */\n\tpointer(cellX: number, cellY: number, on: boolean): void;\n\tsetFront(front: SunFront | null): void;\n\t/** HDR: every painted frame is also handed to `target` (null detaches it). */\n\tsetGlowTarget(target: SunGlowTarget | null): void;\n\tresize(): void;\n\t/** Resumes after `sleep()`: paints once and restarts the loop if anything animates. */\n\twake(): void;\n\t/** Suspends the loop (out of view / hidden document). */\n\tsleep(): void;\n\tdestroy(): void;\n}\n\nfunction mergeOptions(\n\tbase: SunPainterOptions,\n\tnext: Partial<SunPainterOptions>\n): SunPainterOptions {\n\treturn {\n\t\tradius: next.radius ?? base.radius,\n\t\tcell: next.cell ?? base.cell,\n\t\tdot: next.dot ?? base.dot,\n\t\tpalette: next.palette ?? base.palette,\n\t\tnightAlpha: next.nightAlpha ?? base.nightAlpha,\n\t\thorizon: next.horizon ?? base.horizon,\n\t\tlens: next.lens ?? base.lens,\n\t\tlensRadius: next.lensRadius ?? base.lensRadius,\n\t\tchecked: next.checked ?? base.checked,\n\t\treducedMotion: next.reducedMotion ?? base.reducedMotion,\n\t};\n}\n\n/** Paints the sun raster for one frame. Exported for tests (a recording fake fits). */\nexport function paintSun(\n\tctx: Ctx2D,\n\traster: SunRaster,\n\to: {\n\t\tcell: number;\n\t\tdot: number;\n\t\tdpr: number;\n\t\tpalette: SunPalette;\n\t\tnightAlpha: number;\n\t\tchecked: boolean;\n\t\tink: string;\n\t\tlens: Lens | null;\n\t\tfront: SunFront | null;\n\t\t/** HDR: the day sun and the cells under the lens are also pushed here. */\n\t\tglow?: GlowSink | null;\n\t}\n): void {\n\tconst { cell, dpr } = o;\n\tconst size = Math.max(1, Math.round(Math.min(o.dot, cell) * dpr));\n\tconst f = o.front;\n\tconst ftx = f ? f.cx / cell : 0;\n\tconst fty = f ? f.cy / cell : 0;\n\tconst fin = f ? f.R / cell : 0;\n\tlet lastStyle = \"\";\n\tlet lastAlpha = -1;\n\t/** Day cells to tint at rest: x, y (device px), ink. */\n\tconst tint: (number | string)[] = [];\n\tfor (const c of raster.cells) {\n\t\tlet day = o.checked;\n\t\tlet ink = o.ink;\n\t\tif (f) {\n\t\t\tif (inStaircase(c.x, c.y, ftx, fty, fin)) {\n\t\t\t\tday = f.nextChecked;\n\t\t\t} else {\n\t\t\t\tday = !f.nextChecked;\n\t\t\t\tink = f.oldInk;\n\t\t\t}\n\t\t}\n\t\tlet style: string;\n\t\tlet alpha: number;\n\t\tlet mix = 0;\n\t\tconst lensed = !!o.lens && o.lens.covers(c.x, c.y);\n\t\tif (lensed || (c.layer === \"sun\" && day)) {\n\t\t\tstyle = o.palette[Math.min(3, Math.max(0, c.color)) as 0 | 1 | 2 | 3];\n\t\t\talpha = 1;\n\t\t\tif (!lensed) mix = DAY_REST_INK;\n\t\t} else {\n\t\t\tstyle = ink;\n\t\t\talpha = c.layer === \"sun\" ? o.nightAlpha : HORIZON_ALPHA;\n\t\t}\n\t\tif (alpha <= 0) continue;\n\t\tconst px = Math.round(c.x * cell * dpr);\n\t\tconst py = Math.round(c.y * cell * dpr);\n\t\tif (o.glow && alpha === 1) {\n\t\t\tconst rgb = rgbOf(style);\n\t\t\tif (lensed) {\n\t\t\t\to.glow.push(px, py, rgb, c.layer === \"sun\" ? SUN_GLOW_LENS : HORIZON_GLOW_LENS);\n\t\t\t} else if (mix > 0 && rgb >= 0) {\n\t\t\t\t// Day at rest: the colour the 2D canvas ends with after the ink tint.\n\t\t\t\tconst inkC = parseInk(ink);\n\t\t\t\tconst inkRgb = rgbOf(ink);\n\t\t\t\tconst t = inkC && inkRgb >= 0 ? mix * inkC.a : 0;\n\t\t\t\to.glow.push(px, py, t > 0 ? mixRgb(rgb, inkRgb, t) : rgb, SUN_GLOW_DAY);\n\t\t\t}\n\t\t}\n\t\tif (style !== lastStyle) {\n\t\t\tctx.fillStyle = style;\n\t\t\tlastStyle = style;\n\t\t}\n\t\tif (alpha !== lastAlpha) {\n\t\t\tctx.globalAlpha = alpha;\n\t\t\tlastAlpha = alpha;\n\t\t}\n\t\tctx.fillRect(px, py, size, size);\n\t\tif (mix > 0) tint.push(px, py, ink);\n\t}\n\t// Day at rest: tint the colour toward the cell's ink (one pass, cells never\n\t// overlap); the lens shows it pure.\n\tfor (let i = 0; i < tint.length; i += 3) {\n\t\tconst style = tint[i + 2] as string;\n\t\tif (style !== lastStyle) {\n\t\t\tctx.fillStyle = style;\n\t\t\tlastStyle = style;\n\t\t}\n\t\tif (lastAlpha !== DAY_REST_INK) {\n\t\t\tctx.globalAlpha = DAY_REST_INK;\n\t\t\tlastAlpha = DAY_REST_INK;\n\t\t}\n\t\tctx.fillRect(tint[i] as number, tint[i + 1] as number, size, size);\n\t}\n\tctx.globalAlpha = 1;\n}\n\n/**\n * Creates the painter for the switch canvas. Returns null when no 2D context is\n * available (jsdom, SSR). The rAF loop runs only while the lens moves or a wave\n * front is set; otherwise the canvas is painted once and the loop sleeps.\n */\nexport function createSunPainter(\n\tcanvas: HTMLCanvasElement,\n\topts: SunPainterOptions\n): SunPainter | null {\n\tconst maybe =\n\t\ttypeof canvas.getContext === \"function\"\n\t\t\t? (canvas.getContext(\"2d\") as unknown as Ctx2D | null)\n\t\t\t: null;\n\tif (!maybe) return null;\n\tconst ctx: Ctx2D = maybe;\n\n\tlet o = mergeOptions(opts, {});\n\tlet raster = rasterSun({ radius: o.radius, horizon: o.horizon });\n\tlet ink = \"rgb(0, 0, 0)\";\n\tlet dpr = 1;\n\tlet front: SunFront | null = null;\n\tlet glowTarget: SunGlowTarget | null = null;\n\tconst lens = createLens({ radius: o.lensRadius });\n\tconst target: LensTarget = { x: 0, y: 0, on: false };\n\tlet raf = 0;\n\tlet suspended = false;\n\tlet destroyed = false;\n\tlet dirty = false;\n\n\tconst g = globalThis as {\n\t\trequestAnimationFrame?: (cb: (t: number) => void) => number;\n\t\tcancelAnimationFrame?: (id: number) => void;\n\t\tdevicePixelRatio?: number;\n\t\tperformance?: { now(): number };\n\t};\n\tconst request = (cb: (t: number) => void): number =>\n\t\tg.requestAnimationFrame ? g.requestAnimationFrame(cb) : 0;\n\tconst cancel = (id: number) => {\n\t\tif (id && g.cancelAnimationFrame) g.cancelAnimationFrame(id);\n\t};\n\n\tfunction paint() {\n\t\tdirty = false;\n\t\tctx.clearRect(0, 0, canvas.width, canvas.height);\n\t\tconst hdr = glowTarget;\n\t\thdr?.sink.clear();\n\t\tpaintSun(ctx, raster, {\n\t\t\tcell: o.cell,\n\t\t\tdot: o.dot,\n\t\t\tdpr,\n\t\t\tpalette: o.palette,\n\t\t\tnightAlpha: o.nightAlpha,\n\t\t\tchecked: o.checked,\n\t\t\tink,\n\t\t\tlens: o.lens ? lens : null,\n\t\t\tfront,\n\t\t\tglow: hdr?.sink ?? null,\n\t\t});\n\t\thdr?.render(\n\t\t\tcanvas.width,\n\t\t\tcanvas.height,\n\t\t\tMath.max(1, Math.round(Math.min(o.dot, o.cell) * dpr))\n\t\t);\n\t}\n\n\tfunction frame(now: number) {\n\t\traf = 0;\n\t\tif (destroyed || suspended) return;\n\t\tconst moving = o.lens ? lens.step(now, target, o.reducedMotion) : false;\n\t\tpaint();\n\t\tif (moving || front) raf = request(frame);\n\t}\n\n\t/** Schedules one frame (which keeps looping while anything animates). */\n\tfunction kick() {\n\t\tif (destroyed) return;\n\t\tif (suspended) {\n\t\t\tdirty = true;\n\t\t\treturn;\n\t\t}\n\t\tif (raf) return;\n\t\tif (!g.requestAnimationFrame) {\n\t\t\tpaint();\n\t\t\treturn;\n\t\t}\n\t\tlens.resetClock();\n\t\traf = request(frame);\n\t}\n\n\tfunction resize() {\n\t\tif (destroyed) return;\n\t\tdpr = g.devicePixelRatio && g.devicePixelRatio > 0 ? g.devicePixelRatio : 1;\n\t\tconst css = raster.size * o.cell;\n\t\tconst w = Math.max(1, Math.round(css * dpr));\n\t\tif (canvas.width !== w) canvas.width = w;\n\t\tif (canvas.height !== w) canvas.height = w;\n\t\tif (suspended) dirty = true;\n\t\telse paint();\n\t}\n\n\tresize();\n\n\treturn {\n\t\tsetOptions(next) {\n\t\t\tif (destroyed) return;\n\t\t\tconst prev = o;\n\t\t\to = mergeOptions(o, next);\n\t\t\tlens.radius = o.lensRadius;\n\t\t\tif (o.radius !== prev.radius || o.horizon !== prev.horizon) {\n\t\t\t\traster = rasterSun({ radius: o.radius, horizon: o.horizon });\n\t\t\t}\n\t\t\tif (o.radius !== prev.radius || o.cell !== prev.cell || o.horizon !== prev.horizon) {\n\t\t\t\tresize();\n\t\t\t}\n\t\t\tkick();\n\t\t},\n\t\tsetInk(css) {\n\t\t\tif (destroyed || !css || css === ink) return;\n\t\t\tink = css;\n\t\t\tkick();\n\t\t},\n\t\tpointer(cellX, cellY, on) {\n\t\t\tif (destroyed) return;\n\t\t\ttarget.x = cellX;\n\t\t\ttarget.y = cellY;\n\t\t\ttarget.on = on && o.lens;\n\t\t\tkick();\n\t\t},\n\t\tsetFront(next) {\n\t\t\tif (destroyed) return;\n\t\t\tfront = next;\n\t\t\tkick();\n\t\t},\n\t\tsetGlowTarget(next) {\n\t\t\tif (destroyed) return;\n\t\t\tglowTarget = next;\n\t\t\tif (suspended) dirty = true;\n\t\t\telse paint();\n\t\t},\n\t\tresize,\n\t\twake() {\n\t\t\tif (destroyed) return;\n\t\t\tconst was = suspended;\n\t\t\tsuspended = false;\n\t\t\tif (was || dirty) {\n\t\t\t\tlens.resetClock();\n\t\t\t\tpaint();\n\t\t\t}\n\t\t\tif (!raf && (front || (o.lens && (target.on || lens.r > 0)))) {\n\t\t\t\tlens.resetClock();\n\t\t\t\traf = request(frame);\n\t\t\t}\n\t\t},\n\t\tsleep() {\n\t\t\tsuspended = true;\n\t\t\tcancel(raf);\n\t\t\traf = 0;\n\t\t},\n\t\tdestroy() {\n\t\t\tdestroyed = true;\n\t\t\tcancel(raf);\n\t\t\traf = 0;\n\t\t\tfront = null;\n\t\t},\n\t};\n}\n","type":"registry:ui","target":"@ui/fancy/components/sun-switch/sun-switch-core.ts"},{"path":"react/src/components/sun-switch/sun-switch-hdr.ts","content":"\"use client\";\n\n/**\n * SunSwitch HDR layer — framework-free, byte-identical in the Svelte, React\n * and Vue trees (see shared-cores.json).\n *\n * The sun and the wave halo are painted in 2D canvases, which stop at SDR\n * white. On HDR displays a WebGPU canvas is laid over each one\n * (`rgba16float`, `colorSpace: \"srgb\"`, `toneMapping: { mode: \"extended\" }`)\n * and the painted squares that carry a glow are drawn again there as hard\n * squares at `glow ×` their colour, alpha 1: one instanced quad per square,\n * no blur, no falloff term in the shader. The halo stays made of dithered\n * cells; nothing soft is added.\n *\n * Encoding: the canvas reads its values as extended (gamma-encoded) sRGB, so\n * each channel is sent as `encodeExt(srgbToLinear(c) × glow)`, computed on\n * the CPU when a square is pushed. A glow of exactly 1 reproduces the 2D dot.\n *\n * One GPUDevice per page, reference-counted across layers; each layer owns\n * its context, uniform, instance buffer and bind group. Nothing touches\n * `window` or `navigator` at import.\n */\n\nimport type { GlowSink } from \"./sun-switch-core\";\n\n/** SDR white ≈ 203 nits; 1000 nits / 203 ≈ 4.9. Every glow is clamped to it. */\nexport const SUN_HDR_PEAK = 4.9;\n\n/**\n * A lost device usually comes back (GPU process restarted, driver reset). The\n * component starts its layer again after `SUN_HDR_RESTART_MS × attempt`, up to\n * `SUN_HDR_RESTARTS` times per mount, before dropping the HDR canvas for good.\n */\nexport const SUN_HDR_RESTARTS = 3;\nexport const SUN_HDR_RESTART_MS = 600;\n\n/** Floats per instance: `x0, y0, 0, 0, r, g, b, 1`. */\nconst FPI = 8;\nconst START_CAPACITY = 1024;\nconst FORMAT = \"rgba16float\" as GPUTextureFormat;\n\nconst WGSL = /* wgsl */ `\nstruct Frame { size: vec2f, dot: f32, pad: f32 };\n@group(0) @binding(0) var<uniform> frame: Frame;\nstruct In { @location(0) cell: vec4f, @location(1) colour: vec4f };\nstruct Out { @builtin(position) pos: vec4f, @location(0) colour: vec4f };\n@vertex fn vs(@builtin(vertex_index) vi: u32, inst: In) -> Out {\n\tlet corners = array<vec2f, 6>(vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), vec2f(0, 1), vec2f(1, 0), vec2f(1, 1));\n\tlet p = inst.cell.xy + corners[vi] * frame.dot;\n\tvar o: Out;\n\to.pos = vec4f(p.x / frame.size.x * 2.0 - 1.0, 1.0 - p.y / frame.size.y * 2.0, 0.0, 1.0);\n\to.colour = inst.colour;\n\treturn o;\n}\n// a hard square at the pre-encoded colour; nothing else\n@fragment fn fs(o: Out) -> @location(0) vec4f { return vec4f(o.colour.rgb, 1.0); }\n`;\n\n// --- encoding ----------------------------------------------------------------\n\nconst LINEAR = new Float32Array(256);\nfor (let i = 0; i < 256; i++) {\n\tconst c = i / 255;\n\tLINEAR[i] = c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n}\n\n/** sRGB channel (0–255) → linear light. */\nexport function srgbToLinear(c8: number): number {\n\treturn LINEAR[Math.min(255, Math.max(0, Math.round(c8)))]!;\n}\n\n/** Linear light → extended-sRGB encoded value; keeps going above 1 for HDR. */\nexport function encodeExt(v: number): number {\n\treturn v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;\n}\n\n/** One channel (0–255) at `glow ×` SDR, encoded for the extended-sRGB canvas. */\nexport function encodeGlowChannel(c8: number, glow: number): number {\n\tconst g = Math.min(SUN_HDR_PEAK, Math.max(0, glow));\n\treturn encodeExt(srgbToLinear(c8) * g);\n}\n\n/**\n * Sync and SSR-safe: WebGPU is exposed and the display reports\n * `(dynamic-range: high)`. Decides whether an HDR canvas is created at all.\n */\nexport function sunHdrAvailable(): boolean {\n\ttry {\n\t\tif (typeof navigator === \"undefined\" || typeof window === \"undefined\") return false;\n\t\tif (!(navigator as Navigator & { gpu?: GPU }).gpu) return false;\n\t\treturn (\n\t\t\ttypeof window.matchMedia === \"function\" && window.matchMedia(\"(dynamic-range: high)\").matches\n\t\t);\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/** Next power of two ≥ n, at least the start capacity. */\nexport function hdrCapacityFor(n: number): number {\n\tlet c = START_CAPACITY;\n\twhile (c < n) c *= 2;\n\treturn c;\n}\n\n/**\n * The squares of one frame, encoded as they are pushed. Painters fill it\n * through {@link GlowSink}; the layer uploads it as is.\n */\nexport interface SunHdrSquares extends GlowSink {\n\t/** Squares pushed since the last `clear`. */\n\treadonly n: number;\n\t/** `FPI` floats per square; valid up to `n × 8`. */\n\treadonly data: Float32Array<ArrayBuffer>;\n}\n\nexport function createSunHdrSquares(): SunHdrSquares {\n\tlet data = new Float32Array(START_CAPACITY * FPI);\n\tlet n = 0;\n\treturn {\n\t\tget n() {\n\t\t\treturn n;\n\t\t},\n\t\tget data() {\n\t\t\treturn data;\n\t\t},\n\t\tclear() {\n\t\t\tn = 0;\n\t\t},\n\t\tpush(px, py, rgb, glow) {\n\t\t\tif (!(glow > 0) || rgb < 0) return;\n\t\t\tif ((n + 1) * FPI > data.length) {\n\t\t\t\tconst next = new Float32Array(hdrCapacityFor(n + 1) * FPI);\n\t\t\t\tnext.set(data);\n\t\t\t\tdata = next;\n\t\t\t}\n\t\t\tconst o = n * FPI;\n\t\t\tdata[o] = px;\n\t\t\tdata[o + 1] = py;\n\t\t\tdata[o + 2] = 0;\n\t\t\tdata[o + 3] = 0;\n\t\t\tdata[o + 4] = encodeGlowChannel((rgb >> 16) & 255, glow);\n\t\t\tdata[o + 5] = encodeGlowChannel((rgb >> 8) & 255, glow);\n\t\t\tdata[o + 6] = encodeGlowChannel(rgb & 255, glow);\n\t\t\tdata[o + 7] = 1;\n\t\t\tn++;\n\t\t},\n\t};\n}\n\nexport interface SunHdrLayer {\n\t/** Filled by the painter for the next `render`. */\n\treadonly sink: SunHdrSquares;\n\t/** Draws the squares in `sink` on a `width × height` backing, `size` device px each. */\n\trender(width: number, height: number, size: number): void;\n\tdestroy(): void;\n\t/** Resolves when the shared device is lost; the 2D picture carries on. */\n\treadonly lost: Promise<void>;\n\t/** Squares drawn by the last `render` (diagnostics, tests). */\n\treadonly count: number;\n}\n\n// --- one device per page -----------------------------------------------------\n\ninterface SharedDevice {\n\tdevice: Promise<GPUDevice | null>;\n\trefs: number;\n\tonLost: Set<() => void>;\n}\n\nlet shared: SharedDevice | null = null;\nconst PIPELINES = new WeakMap<GPUDevice, GPURenderPipeline>();\n\nfunction acquire(gpu: GPU): SharedDevice {\n\tif (!shared) {\n\t\tconst s: SharedDevice = { device: Promise.resolve(null), refs: 0, onLost: new Set() };\n\t\ts.device = (async () => {\n\t\t\tconst adapter = await gpu.requestAdapter();\n\t\t\tif (!adapter) return null;\n\t\t\tconst device = await adapter.requestDevice();\n\t\t\tvoid device.lost.then(() => {\n\t\t\t\tif (shared === s) shared = null;\n\t\t\t\tfor (const fn of [...s.onLost]) fn();\n\t\t\t\ts.onLost.clear();\n\t\t\t});\n\t\t\treturn device;\n\t\t})().catch(() => null);\n\t\tshared = s;\n\t}\n\tshared.refs++;\n\treturn shared;\n}\n\nfunction release(s: SharedDevice): void {\n\ts.refs--;\n\tif (s.refs > 0) return;\n\tif (shared === s) shared = null;\n\ts.onLost.clear();\n\tvoid s.device.then((d) => d?.destroy());\n}\n\nfunction pipelineFor(device: GPUDevice): GPURenderPipeline {\n\tlet p = PIPELINES.get(device);\n\tif (p) return p;\n\tconst module = device.createShaderModule({ code: WGSL });\n\tp = device.createRenderPipeline({\n\t\tlayout: \"auto\",\n\t\tvertex: {\n\t\t\tmodule,\n\t\t\tentryPoint: \"vs\",\n\t\t\tbuffers: [\n\t\t\t\t{\n\t\t\t\t\tarrayStride: FPI * 4,\n\t\t\t\t\tstepMode: \"instance\",\n\t\t\t\t\tattributes: [\n\t\t\t\t\t\t{ shaderLocation: 0, offset: 0, format: \"float32x4\" },\n\t\t\t\t\t\t{ shaderLocation: 1, offset: 16, format: \"float32x4\" },\n\t\t\t\t\t],\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t\tfragment: {\n\t\t\tmodule,\n\t\t\tentryPoint: \"fs\",\n\t\t\ttargets: [\n\t\t\t\t{\n\t\t\t\t\tformat: FORMAT,\n\t\t\t\t\tblend: {\n\t\t\t\t\t\tcolor: { srcFactor: \"one\", dstFactor: \"one\", operation: \"max\" },\n\t\t\t\t\t\talpha: { srcFactor: \"one\", dstFactor: \"one\", operation: \"max\" },\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t\tprimitive: { topology: \"triangle-list\" },\n\t});\n\tPIPELINES.set(device, p);\n\treturn p;\n}\n\n/**\n * Starts a layer on `canvas`. Resolves null when WebGPU, the adapter, the\n * context or extended tone mapping is missing; never throws.\n */\nexport async function startSunHdr(canvas: HTMLCanvasElement): Promise<SunHdrLayer | null> {\n\tif (!sunHdrAvailable()) return null;\n\tconst gpu = (navigator as Navigator & { gpu?: GPU }).gpu;\n\tif (!gpu) return null;\n\tconst s = acquire(gpu);\n\tlet held = true;\n\tconst drop = () => {\n\t\tif (held) {\n\t\t\theld = false;\n\t\t\trelease(s);\n\t\t}\n\t};\n\ttry {\n\t\tconst device = await s.device;\n\t\tif (!device) {\n\t\t\tdrop();\n\t\t\treturn null;\n\t\t}\n\t\tconst context = canvas.getContext(\"webgpu\") as GPUCanvasContext | null;\n\t\tif (!context) {\n\t\t\tdrop();\n\t\t\treturn null;\n\t\t}\n\t\tcontext.configure({\n\t\t\tdevice,\n\t\t\tformat: FORMAT,\n\t\t\talphaMode: \"premultiplied\",\n\t\t\tcolorSpace: \"srgb\",\n\t\t\ttoneMapping: { mode: \"extended\" },\n\t\t} as GPUCanvasConfiguration);\n\t\tlet extended = false;\n\t\ttry {\n\t\t\tconst applied = context.getConfiguration?.() as\n\t\t\t\t| (GPUCanvasConfiguration & { toneMapping?: { mode?: string } })\n\t\t\t\t| null;\n\t\t\textended = applied?.toneMapping?.mode === \"extended\";\n\t\t} catch {\n\t\t\textended = false;\n\t\t}\n\t\tif (!extended) {\n\t\t\ttry {\n\t\t\t\tcontext.unconfigure();\n\t\t\t} catch {\n\t\t\t\t// nothing to undo\n\t\t\t}\n\t\t\tdrop();\n\t\t\treturn null;\n\t\t}\n\n\t\tconst pipeline = pipelineFor(device);\n\t\tconst uniform = device.createBuffer({\n\t\t\tsize: 16,\n\t\t\tusage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,\n\t\t});\n\t\tconst uniformData = new Float32Array(4);\n\t\tconst bindGroup = device.createBindGroup({\n\t\t\tlayout: pipeline.getBindGroupLayout(0),\n\t\t\tentries: [{ binding: 0, resource: { buffer: uniform } }],\n\t\t});\n\t\tconst sink = createSunHdrSquares();\n\t\tlet capacity = START_CAPACITY;\n\t\tlet instances = device.createBuffer({\n\t\t\tsize: capacity * FPI * 4,\n\t\t\tusage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,\n\t\t});\n\t\tlet destroyed = false;\n\t\tlet count = 0;\n\n\t\tlet resolveLost: () => void = () => {};\n\t\tconst lost = new Promise<void>((r) => {\n\t\t\tresolveLost = r;\n\t\t});\n\t\tconst onLost = () => {\n\t\t\tdestroyed = true;\n\t\t\tresolveLost();\n\t\t};\n\t\ts.onLost.add(onLost);\n\n\t\tconst layer: SunHdrLayer = {\n\t\t\tsink,\n\t\t\tget lost() {\n\t\t\t\treturn lost;\n\t\t\t},\n\t\t\tget count() {\n\t\t\t\treturn count;\n\t\t\t},\n\t\t\trender(width, height, size) {\n\t\t\t\tif (destroyed || width <= 0 || height <= 0) return;\n\t\t\t\tif (canvas.width !== width) canvas.width = width;\n\t\t\t\tif (canvas.height !== height) canvas.height = height;\n\t\t\t\tconst k = sink.n;\n\t\t\t\tif (k > capacity) {\n\t\t\t\t\tcapacity = hdrCapacityFor(k);\n\t\t\t\t\tinstances.destroy();\n\t\t\t\t\tinstances = device.createBuffer({\n\t\t\t\t\t\tsize: capacity * FPI * 4,\n\t\t\t\t\t\tusage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tcount = k;\n\t\t\t\tuniformData[0] = width;\n\t\t\t\tuniformData[1] = height;\n\t\t\t\tuniformData[2] = size;\n\t\t\t\tuniformData[3] = 0;\n\t\t\t\ttry {\n\t\t\t\t\tdevice.queue.writeBuffer(uniform, 0, uniformData);\n\t\t\t\t\tif (k) device.queue.writeBuffer(instances, 0, sink.data, 0, k * FPI);\n\t\t\t\t\tconst encoder = device.createCommandEncoder();\n\t\t\t\t\tconst pass = encoder.beginRenderPass({\n\t\t\t\t\t\tcolorAttachments: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tview: context.getCurrentTexture().createView(),\n\t\t\t\t\t\t\t\tclearValue: { r: 0, g: 0, b: 0, a: 0 },\n\t\t\t\t\t\t\t\tloadOp: \"clear\",\n\t\t\t\t\t\t\t\tstoreOp: \"store\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t});\n\t\t\t\t\tif (k) {\n\t\t\t\t\t\tpass.setPipeline(pipeline);\n\t\t\t\t\t\tpass.setBindGroup(0, bindGroup);\n\t\t\t\t\t\tpass.setVertexBuffer(0, instances);\n\t\t\t\t\t\tpass.draw(6, k);\n\t\t\t\t\t}\n\t\t\t\t\tpass.end();\n\t\t\t\t\tdevice.queue.submit([encoder.finish()]);\n\t\t\t\t} catch {\n\t\t\t\t\t// a lost or reconfigured context: the next frame tries again\n\t\t\t\t}\n\t\t\t},\n\t\t\tdestroy() {\n\t\t\t\tif (destroyed && !held) return;\n\t\t\t\tdestroyed = true;\n\t\t\t\ts.onLost.delete(onLost);\n\t\t\t\ttry {\n\t\t\t\t\tcontext.unconfigure();\n\t\t\t\t} catch {\n\t\t\t\t\t// already gone\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tuniform.destroy();\n\t\t\t\t\tinstances.destroy();\n\t\t\t\t} catch {\n\t\t\t\t\t// device already lost\n\t\t\t\t}\n\t\t\t\tdrop();\n\t\t\t},\n\t\t};\n\t\treturn layer;\n\t} catch {\n\t\tdrop();\n\t\treturn null;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/sun-switch/sun-switch-hdr.ts"},{"path":"react/src/components/sun-switch/sun-switch.css","content":"/* Ported from the Svelte <style> block. Every rule is anchored on the\n   component's own root class, `.ft-sun-switch`, as the source names it. */\n\n.ft-sun-switch {\n\tappearance: none;\n\tbackground: none;\n\tborder: 0;\n\tpadding: 0;\n\tmargin: 0;\n\tdisplay: inline-block;\n\tposition: relative;\n\tline-height: 0;\n\tcolor: inherit;\n\tcursor: pointer;\n\ttouch-action: manipulation;\n\tborder-radius: 4px;\n\t-webkit-tap-highlight-color: transparent;\n}\n\n.ft-sun-switch:focus-visible {\n\toutline: 2px solid var(--ft-accent, currentColor);\n\toutline-offset: 2px;\n}\n\n.ft-sun-switch:disabled {\n\tcursor: not-allowed;\n\topacity: 0.5;\n}\n\n.ft-sun-switch .ft-sun-switch-canvas {\n\tdisplay: block;\n\twidth: calc((2 * var(--ft-sun-r) + 1) * var(--ft-sun-cell));\n\theight: calc((2 * var(--ft-sun-r) + 1) * var(--ft-sun-cell));\n\timage-rendering: pixelated;\n\tpointer-events: none;\n}\n\n/* HDR squares over the sun canvas: same box, transparent between squares. */\n.ft-sun-switch .ft-sun-switch-hdr {\n\tposition: absolute;\n\tinset: 0;\n\tdisplay: block;\n\twidth: 100%;\n\theight: 100%;\n\timage-rendering: pixelated;\n\tpointer-events: none;\n}\n","type":"registry:ui","target":"@ui/fancy/components/sun-switch/sun-switch.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/sun-switch?framework=react"}}