astralyx-ui 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "344 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "homepage": "https://ui.astralyx.dev",
5
5
  "items": [
6
6
  {
@@ -630,6 +630,7 @@
630
630
  "lib-motion",
631
631
  "lib-styles",
632
632
  "lib-utils",
633
+ "primitive-media-query",
633
634
  "select",
634
635
  "separator"
635
636
  ]
@@ -2944,7 +2945,7 @@
2944
2945
  "type": "registry:ui",
2945
2946
  "title": "Node Canvas",
2946
2947
  "description": "A pannable, zoomable canvas of draggable nodes and the edges between them — the substrate for an agent pipeline, a retrieval chain or a build DAG. Nodes are real DOM, so you put your own components inside them.",
2947
- "category": "Agents",
2948
+ "category": "Views",
2948
2949
  "dependencies": [],
2949
2950
  "registryDependencies": [
2950
2951
  "lib-styles",
@@ -13,6 +13,7 @@
13
13
  "lib-motion",
14
14
  "lib-styles",
15
15
  "lib-utils",
16
+ "primitive-media-query",
16
17
  "select",
17
18
  "separator"
18
19
  ],
@@ -20,7 +21,7 @@
20
21
  {
21
22
  "path": "components/ui/calendar.tsx",
22
23
  "type": "registry:ui",
23
- "content": "'use client'\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react'\nimport { ChevronLeft, ChevronRight } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Select } from '@/components/ui/select'\nimport { Separator } from '@/components/ui/separator'\nimport {\n addMonths,\n formatMonth,\n isAfter,\n isBefore,\n isInRange,\n isRangeEnd,\n isRangeStart,\n isSameDay,\n isSameMonth,\n monthWeeks,\n orderRange,\n setTime,\n startOfDay,\n startOfMonth,\n startOfWeek,\n withTimeOf,\n type DateRange,\n} from '@/lib/date'\nimport { enterFade } from '@/lib/motion'\nimport { focusRing, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A date picker grid, in single or range mode.\n *\n * No date library — the grid, weekday names, month labels and times all come\n * from `Intl` plus the helpers in `lib/date.ts`. Always six weeks per month, so\n * paging never changes the height and the layout around it never jumps.\n *\n * Range picking is drafted before it is committed: the first click sets an\n * anchor, the pointer paints a preview, and the second click (or the release of\n * a drag) confirms. Dragging and clicking are the same gesture here, which is\n * why the release is watched on the window rather than on a cell.\n */\nexport type CalendarMode = 'single' | 'range'\nexport type CalendarPreset = { label: string; range: DateRange }\nexport type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\ntype CalendarBaseProps = {\n /** Months shown side by side. */\n numberOfMonths?: number\n month?: Date\n defaultMonth?: Date\n onMonthChange?: (month: Date) => void\n weekStartsOn?: WeekStart\n locale?: string\n /** Bounds. Days outside them cannot be picked. */\n fromDate?: Date\n toDate?: Date\n disabled?: (date: Date) => boolean\n showOutsideDays?: boolean\n /** A static heading, or month and year dropdowns for fast navigation. */\n captionLayout?: 'label' | 'dropdown'\n showTime?: boolean\n /** Minute granularity in the time selects. */\n timeStep?: number\n renderDay?: (date: Date) => ReactNode\n footer?: ReactNode\n /** Accessible name for the previous-month button. */\n previousMonthLabel?: string\n /** Accessible name for the next-month button. */\n nextMonthLabel?: string\n /** Accessible name for the month dropdown, under `captionLayout=\"dropdown\"`. */\n monthLabel?: string\n /** Accessible name for the year dropdown. */\n yearLabel?: string\n /** Names the hour select, given the row's own label (\"Start\", \"End\"). */\n hourLabel?: (row: string) => string\n /** Names the minute select the same way. */\n minuteLabel?: (row: string) => string\n className?: string\n}\n\ntype CalendarSingleProps = CalendarBaseProps & {\n mode?: 'single'\n selected?: Date\n defaultSelected?: Date\n onSelect?: (date: Date | undefined) => void\n presets?: never\n}\n\ntype CalendarRangeProps = CalendarBaseProps & {\n mode: 'range'\n selected?: DateRange\n defaultSelected?: DateRange\n onSelect?: (range: DateRange | undefined) => void\n /** Quick ranges listed beside the grid. */\n presets?: CalendarPreset[]\n}\n\nexport type CalendarProps = CalendarSingleProps | CalendarRangeProps\n\nfunction Calendar(props: CalendarProps) {\n const {\n numberOfMonths = 1,\n month: monthProp,\n defaultMonth,\n onMonthChange,\n weekStartsOn = 1,\n locale = 'en-GB',\n fromDate,\n toDate,\n disabled,\n showOutsideDays = true,\n captionLayout = 'label',\n showTime = false,\n timeStep = 5,\n renderDay,\n footer,\n previousMonthLabel = 'Previous month',\n nextMonthLabel = 'Next month',\n monthLabel = 'Month',\n yearLabel = 'Year',\n hourLabel = (row) => `${row} hour`,\n minuteLabel = (row) => `${row} minute`,\n className,\n } = props\n\n const range = props.mode === 'range'\n\n /* ---------------------------------------------------------- selection */\n\n const controlled = props.selected !== undefined\n const [ownSelected, setOwnSelected] = useState<Date | DateRange | undefined>(\n props.defaultSelected,\n )\n const selected = controlled ? props.selected : ownSelected\n\n const single = range ? undefined : (selected as Date | undefined)\n const picked = range ? (selected as DateRange | undefined) : undefined\n\n // Read out of the union before the callback, so the dependency is the handler\n // itself rather than the whole props object.\n const onSelectProp = props.onSelect as\n | ((value: Date | DateRange | undefined) => void)\n | undefined\n\n const emit = useCallback(\n (next: Date | DateRange | undefined) => {\n if (!controlled) setOwnSelected(next)\n onSelectProp?.(next)\n },\n [controlled, onSelectProp],\n )\n\n /* -------------------------------------------------------------- month */\n\n const seed = useMemo(() => {\n if (defaultMonth) return startOfMonth(defaultMonth)\n const from = range ? picked?.from : single\n return startOfMonth(from ?? new Date())\n // Seeds the uncontrolled month once; later changes come from setMonth.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n const [ownMonth, setOwnMonth] = useState(seed)\n const month = monthProp ? startOfMonth(monthProp) : ownMonth\n\n const setMonth = (next: Date) => {\n const value = startOfMonth(next)\n if (!monthProp) setOwnMonth(value)\n onMonthChange?.(value)\n }\n\n /* ------------------------------------------------------- range draft */\n\n const [anchor, setAnchor] = useState<Date | null>(null)\n const [hover, setHover] = useState<Date | null>(null)\n const dragging = useRef(false)\n const dragFrom = useRef<Date | null>(null)\n\n const unavailable = useCallback(\n (date: Date) => {\n if (fromDate && isBefore(date, fromDate)) return true\n if (toDate && isAfter(date, toDate)) return true\n return disabled?.(date) ?? false\n },\n [disabled, fromDate, toDate],\n )\n\n const commit = useCallback(\n (a: Date, b: Date) => {\n const ordered = orderRange(a, b)\n emit({\n from: withTimeOf(ordered.from!, picked?.from),\n to: withTimeOf(ordered.to!, picked?.to),\n })\n },\n [emit, picked],\n )\n\n // A drag can end anywhere on the page, so the release is watched globally.\n useEffect(() => {\n if (!range) return\n\n const onUp = () => {\n if (!dragging.current) return\n dragging.current = false\n const from = dragFrom.current\n // Press and release on one day is a click: keep the anchor pending so the\n // next click closes the range.\n if (from && hover && !isSameDay(from, hover)) {\n commit(from, hover)\n setAnchor(null)\n }\n }\n\n window.addEventListener('pointerup', onUp)\n return () => window.removeEventListener('pointerup', onUp)\n }, [commit, hover, range])\n\n /** What the grid paints: the draft while picking, otherwise the selection. */\n const shown: DateRange | undefined = useMemo(() => {\n if (!range) return undefined\n if (anchor) return orderRange(anchor, hover ?? anchor)\n return picked\n }, [anchor, hover, range, picked])\n\n function onDayDown(date: Date) {\n if (unavailable(date)) return\n\n if (!range) {\n // Clicking the selected day clears it — the only way to undo a choice in\n // a picker with no clear button.\n const same = single && isSameDay(single, date)\n emit(same ? undefined : withTimeOf(date, single))\n return\n }\n\n if (anchor) {\n commit(anchor, date)\n setAnchor(null)\n dragging.current = false\n dragFrom.current = null\n return\n }\n\n setAnchor(date)\n setHover(date)\n dragging.current = true\n dragFrom.current = date\n }\n\n /* ------------------------------------------------------------ render */\n\n const months = Array.from({ length: Math.max(1, numberOfMonths) }, (_, i) =>\n addMonths(month, i),\n )\n\n const weekdays = useMemo(() => {\n const base = startOfWeek(new Date(), weekStartsOn)\n const format = new Intl.DateTimeFormat(locale, { weekday: 'short' })\n return Array.from({ length: 7 }, (_, i) =>\n format.format(new Date(base.getTime() + i * 86_400_000)),\n )\n }, [locale, weekStartsOn])\n\n return (\n <div\n data-slot=\"calendar\"\n data-mode={props.mode ?? 'single'}\n className={cn(enterFade, 'w-fit select-none', className)}\n onPointerLeave={() => {\n if (!dragging.current) setHover(null)\n }}\n >\n <div className=\"flex gap-5\">\n {range && props.presets?.length ? (\n <Presets\n presets={props.presets}\n onPick={(next) => {\n emit(next)\n if (next.from) setMonth(next.from)\n setAnchor(null)\n }}\n />\n ) : null}\n\n <div className=\"grid gap-3\">\n <Caption\n captionLayout={captionLayout}\n fromDate={fromDate}\n monthLabel={monthLabel}\n nextMonthLabel={nextMonthLabel}\n previousMonthLabel={previousMonthLabel}\n yearLabel={yearLabel}\n locale={locale}\n month={month}\n onMonthChange={setMonth}\n toDate={toDate}\n />\n\n <div className=\"flex gap-6\">\n {months.map((m) => (\n <Month\n key={m.toISOString()}\n locale={locale}\n mode={props.mode ?? 'single'}\n month={m}\n onDayDown={onDayDown}\n onDayEnter={(date) => range && setHover(date)}\n range={shown}\n renderDay={renderDay}\n selected={single}\n showCaption={months.length > 1}\n showOutsideDays={showOutsideDays}\n unavailable={unavailable}\n weekdays={weekdays}\n weekStartsOn={weekStartsOn}\n />\n ))}\n </div>\n\n {showTime && (\n <>\n <Separator />\n <TimeRow\n hourLabel={hourLabel}\n locale={locale}\n minuteLabel={minuteLabel}\n mode={props.mode ?? 'single'}\n onChange={emit}\n range={picked}\n single={single}\n step={timeStep}\n />\n </>\n )}\n\n {footer}\n </div>\n </div>\n </div>\n )\n}\n\n/* ------------------------------------------------------------- sub-parts */\n\nfunction Presets({\n presets,\n onPick,\n}: {\n presets: CalendarPreset[]\n onPick: (range: DateRange) => void\n}) {\n return (\n <div className=\"border-border flex w-36 shrink-0 flex-col gap-1 border-e pe-3\">\n {presets.map((preset) => (\n <Button\n key={preset.label}\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => onPick(preset.range)}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )\n}\n\nfunction Caption({\n month,\n onMonthChange,\n locale,\n captionLayout,\n fromDate,\n toDate,\n previousMonthLabel,\n nextMonthLabel,\n monthLabel,\n yearLabel,\n}: {\n month: Date\n onMonthChange: (month: Date) => void\n locale: string\n captionLayout: 'label' | 'dropdown'\n fromDate?: Date\n toDate?: Date\n previousMonthLabel: string\n nextMonthLabel: string\n monthLabel: string\n yearLabel: string\n}) {\n const monthNames = useMemo(() => {\n const format = new Intl.DateTimeFormat(locale, { month: 'long' })\n return Array.from({ length: 12 }, (_, i) => format.format(new Date(2024, i, 1)))\n }, [locale])\n\n const years = useMemo(() => {\n const now = month.getFullYear()\n const first = fromDate?.getFullYear() ?? now - 10\n const last = toDate?.getFullYear() ?? now + 10\n return Array.from({ length: Math.max(1, last - first + 1) }, (_, i) => first + i)\n }, [month, fromDate, toDate])\n\n return (\n <div className=\"flex items-center justify-between gap-2\">\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={previousMonthLabel}\n disabled={fromDate ? isBefore(addMonths(month, -1), startOfMonth(fromDate)) : false}\n onClick={() => onMonthChange(addMonths(month, -1))}\n >\n <ChevronLeft />\n </Button>\n\n {captionLayout === 'dropdown' ? (\n <div className=\"flex flex-1 gap-1.5\">\n <Select\n size=\"sm\"\n aria-label={monthLabel}\n value={String(month.getMonth())}\n onValueChange={(v) =>\n onMonthChange(new Date(month.getFullYear(), Number(v), 1))\n }\n options={monthNames.map((name, i) => ({ value: String(i), label: name }))}\n />\n <Select\n size=\"sm\"\n aria-label={yearLabel}\n value={String(month.getFullYear())}\n onValueChange={(v) =>\n onMonthChange(new Date(Number(v), month.getMonth(), 1))\n }\n options={years.map((year) => ({\n value: String(year),\n label: String(year),\n }))}\n />\n </div>\n ) : (\n <div aria-live=\"polite\" className=\"flex-1 text-center text-sm font-medium\">\n {formatMonth(month, locale)}\n </div>\n )}\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={nextMonthLabel}\n disabled={toDate ? isAfter(addMonths(month, 1), startOfMonth(toDate)) : false}\n onClick={() => onMonthChange(addMonths(month, 1))}\n >\n <ChevronRight />\n </Button>\n </div>\n )\n}\n\nfunction Month({\n month,\n weekStartsOn,\n weekdays,\n locale,\n mode,\n selected,\n range,\n unavailable,\n onDayDown,\n onDayEnter,\n showOutsideDays,\n showCaption,\n renderDay,\n}: {\n month: Date\n weekStartsOn: WeekStart\n weekdays: string[]\n locale: string\n mode: CalendarMode\n selected?: Date\n range?: DateRange\n unavailable: (date: Date) => boolean\n onDayDown: (date: Date) => void\n onDayEnter: (date: Date) => void\n showOutsideDays: boolean\n showCaption: boolean\n renderDay?: (date: Date) => ReactNode\n}) {\n const weeks = useMemo(\n () => monthWeeks(month, weekStartsOn),\n [month, weekStartsOn],\n )\n const today = startOfDay(new Date())\n\n return (\n <div>\n {showCaption && (\n <div className=\"mb-2 text-center text-sm font-medium\">\n {formatMonth(month, locale)}\n </div>\n )}\n\n <div role=\"grid\" className=\"grid grid-cols-7\">\n {weekdays.map((day) => (\n <div\n key={day}\n role=\"columnheader\"\n aria-label={day}\n className={cn(\n 'text-muted-foreground grid place-items-center text-[11px] font-medium',\n renderDay ? 'h-9 w-12' : 'size-9',\n )}\n >\n <span aria-hidden=\"true\">{day.slice(0, 2)}</span>\n </div>\n ))}\n\n {weeks.flat().map((date) => {\n const outside = !isSameMonth(date, month)\n if (outside && !showOutsideDays) {\n return (\n <div\n key={date.toISOString()}\n className={renderDay ? 'h-14 w-12' : 'size-9'}\n />\n )\n }\n\n const off = unavailable(date)\n const start = isRangeStart(date, range)\n const end = isRangeEnd(date, range)\n const middle = isInRange(date, range) && !start && !end\n const isSelected =\n mode === 'range' ? start || end : Boolean(selected && isSameDay(date, selected))\n\n return (\n <button\n key={date.toISOString()}\n type=\"button\"\n role=\"gridcell\"\n disabled={off}\n aria-selected={isSelected || middle}\n aria-current={isSameDay(date, today) ? 'date' : undefined}\n onPointerDown={() => onDayDown(date)}\n onPointerEnter={() => onDayEnter(date)}\n className={cn(\n 'relative grid text-sm tabular-nums',\n renderDay ? 'h-14 w-12 place-items-start p-1.5' : 'size-9 place-items-center',\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n 'disabled:pointer-events-none disabled:opacity-30',\n outside && 'text-muted-foreground/50',\n // The band has to be square where it continues and rounded only\n // at the ends, or a range reads as separate pills.\n middle && 'bg-accent text-accent-foreground rounded-none',\n (start || end) && 'bg-primary text-primary-foreground font-medium',\n start && !end && 'rounded-s-lg rounded-e-none',\n end && !start && 'rounded-e-lg rounded-s-none',\n start && end && radius.control,\n mode === 'single' && isSelected && cn(radius.control, 'bg-primary text-primary-foreground font-medium'),\n !isSelected && !middle && cn(radius.control, 'hover:bg-accent hover:text-accent-foreground'),\n !isSelected && !middle && isSameDay(date, today) && 'border-border border font-medium',\n )}\n >\n {renderDay ? renderDay(date) : date.getDate()}\n </button>\n )\n })}\n </div>\n </div>\n )\n}\n\nfunction TimeRow({\n mode,\n single,\n range,\n step,\n locale,\n onChange,\n hourLabel,\n minuteLabel,\n}: {\n mode: CalendarMode\n single?: Date\n range?: DateRange\n step: number\n locale: string\n onChange: (value: Date | DateRange | undefined) => void\n hourLabel: (row: string) => string\n minuteLabel: (row: string) => string\n}) {\n const hours = Array.from({ length: 24 }, (_, i) => i)\n const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step)\n const pad = (n: number) => String(n).padStart(2, '0')\n\n function field(date: Date | undefined, apply: (next: Date) => void, label: string) {\n // Times are meaningless without a day, so the selects wait for one.\n const base = date ?? startOfDay(new Date())\n\n return (\n <div className=\"flex items-center gap-1.5\">\n <span className=\"text-muted-foreground w-10 text-xs\">{label}</span>\n <Select\n size=\"sm\"\n aria-label={hourLabel(label)}\n disabled={!date}\n value={String(base.getHours())}\n onValueChange={(v) => apply(setTime(base, Number(v), base.getMinutes()))}\n className=\"w-20\"\n options={hours.map((h) => ({ value: String(h), label: pad(h) }))}\n />\n <Select\n size=\"sm\"\n aria-label={minuteLabel(label)}\n disabled={!date}\n value={String(Math.round(base.getMinutes() / step) * step % 60)}\n onValueChange={(v) => apply(setTime(base, base.getHours(), Number(v)))}\n className=\"w-20\"\n options={minutes.map((m) => ({ value: String(m), label: pad(m) }))}\n />\n </div>\n )\n }\n\n if (mode === 'single') {\n return (\n <div className=\"flex flex-col gap-2\" data-locale={locale}>\n {field(single, (next) => onChange(next), 'Time')}\n </div>\n )\n }\n\n return (\n <div className=\"flex flex-col gap-2\">\n {field(range?.from, (next) => onChange({ ...range, from: next }), 'From')}\n {field(range?.to, (next) => onChange({ ...range, to: next }), 'To')}\n </div>\n )\n}\n\nexport { Calendar }\n"
24
+ "content": "'use client'\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react'\nimport { ChevronLeft, ChevronRight } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Select } from '@/components/ui/select'\nimport { Separator } from '@/components/ui/separator'\nimport {\n addMonths,\n formatMonth,\n isAfter,\n isBefore,\n isInRange,\n isRangeEnd,\n isRangeStart,\n isSameDay,\n isSameMonth,\n monthWeeks,\n orderRange,\n setTime,\n startOfDay,\n startOfMonth,\n startOfWeek,\n withTimeOf,\n type DateRange,\n} from '@/lib/date'\nimport { enterFade } from '@/lib/motion'\nimport { focusRing, radius } from '@/lib/styles'\nimport { useBreakpoint } from '@/components/primitives/media-query'\nimport { cn } from '@/lib/utils'\n\n/**\n * A date picker grid, in single or range mode.\n *\n * No date library — the grid, weekday names, month labels and times all come\n * from `Intl` plus the helpers in `lib/date.ts`. Always six weeks per month, so\n * paging never changes the height and the layout around it never jumps.\n *\n * Range picking is drafted before it is committed: the first click sets an\n * anchor, the pointer paints a preview, and the second click (or the release of\n * a drag) confirms. Dragging and clicking are the same gesture here, which is\n * why the release is watched on the window rather than on a cell.\n */\nexport type CalendarMode = 'single' | 'range'\nexport type CalendarPreset = { label: string; range: DateRange }\nexport type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\ntype CalendarBaseProps = {\n /** Months shown side by side. */\n numberOfMonths?: number\n month?: Date\n defaultMonth?: Date\n onMonthChange?: (month: Date) => void\n weekStartsOn?: WeekStart\n locale?: string\n /** Bounds. Days outside them cannot be picked. */\n fromDate?: Date\n toDate?: Date\n disabled?: (date: Date) => boolean\n showOutsideDays?: boolean\n /** A static heading, or month and year dropdowns for fast navigation. */\n captionLayout?: 'label' | 'dropdown'\n showTime?: boolean\n /** Minute granularity in the time selects. */\n timeStep?: number\n renderDay?: (date: Date) => ReactNode\n footer?: ReactNode\n /** Accessible name for the previous-month button. */\n previousMonthLabel?: string\n /** Accessible name for the next-month button. */\n nextMonthLabel?: string\n /** Accessible name for the month dropdown, under `captionLayout=\"dropdown\"`. */\n monthLabel?: string\n /** Accessible name for the year dropdown. */\n yearLabel?: string\n /** Names the hour select, given the row's own label (\"Start\", \"End\"). */\n hourLabel?: (row: string) => string\n /** Names the minute select the same way. */\n minuteLabel?: (row: string) => string\n className?: string\n}\n\ntype CalendarSingleProps = CalendarBaseProps & {\n mode?: 'single'\n selected?: Date\n defaultSelected?: Date\n onSelect?: (date: Date | undefined) => void\n presets?: never\n}\n\ntype CalendarRangeProps = CalendarBaseProps & {\n mode: 'range'\n selected?: DateRange\n defaultSelected?: DateRange\n onSelect?: (range: DateRange | undefined) => void\n /** Quick ranges listed beside the grid. */\n presets?: CalendarPreset[]\n}\n\nexport type CalendarProps = CalendarSingleProps | CalendarRangeProps\n\nfunction Calendar(props: CalendarProps) {\n const {\n numberOfMonths = 1,\n month: monthProp,\n defaultMonth,\n onMonthChange,\n weekStartsOn = 1,\n locale = 'en-GB',\n fromDate,\n toDate,\n disabled,\n showOutsideDays = true,\n captionLayout = 'label',\n showTime = false,\n timeStep = 5,\n renderDay,\n footer,\n previousMonthLabel = 'Previous month',\n nextMonthLabel = 'Next month',\n monthLabel = 'Month',\n yearLabel = 'Year',\n hourLabel = (row) => `${row} hour`,\n minuteLabel = (row) => `${row} minute`,\n className,\n } = props\n\n const range = props.mode === 'range'\n\n /* ---------------------------------------------------------- selection */\n\n const controlled = props.selected !== undefined\n const [ownSelected, setOwnSelected] = useState<Date | DateRange | undefined>(\n props.defaultSelected,\n )\n const selected = controlled ? props.selected : ownSelected\n\n const single = range ? undefined : (selected as Date | undefined)\n const picked = range ? (selected as DateRange | undefined) : undefined\n\n // Read out of the union before the callback, so the dependency is the handler\n // itself rather than the whole props object.\n const onSelectProp = props.onSelect as\n | ((value: Date | DateRange | undefined) => void)\n | undefined\n\n const emit = useCallback(\n (next: Date | DateRange | undefined) => {\n if (!controlled) setOwnSelected(next)\n onSelectProp?.(next)\n },\n [controlled, onSelectProp],\n )\n\n /* -------------------------------------------------------------- month */\n\n const seed = useMemo(() => {\n if (defaultMonth) return startOfMonth(defaultMonth)\n const from = range ? picked?.from : single\n return startOfMonth(from ?? new Date())\n // Seeds the uncontrolled month once; later changes come from setMonth.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n const [ownMonth, setOwnMonth] = useState(seed)\n const month = monthProp ? startOfMonth(monthProp) : ownMonth\n\n const setMonth = (next: Date) => {\n const value = startOfMonth(next)\n if (!monthProp) setOwnMonth(value)\n onMonthChange?.(value)\n }\n\n /* ------------------------------------------------------- range draft */\n\n const [anchor, setAnchor] = useState<Date | null>(null)\n const [hover, setHover] = useState<Date | null>(null)\n const dragging = useRef(false)\n const dragFrom = useRef<Date | null>(null)\n\n const unavailable = useCallback(\n (date: Date) => {\n if (fromDate && isBefore(date, fromDate)) return true\n if (toDate && isAfter(date, toDate)) return true\n return disabled?.(date) ?? false\n },\n [disabled, fromDate, toDate],\n )\n\n const commit = useCallback(\n (a: Date, b: Date) => {\n const ordered = orderRange(a, b)\n emit({\n from: withTimeOf(ordered.from!, picked?.from),\n to: withTimeOf(ordered.to!, picked?.to),\n })\n },\n [emit, picked],\n )\n\n // A drag can end anywhere on the page, so the release is watched globally.\n useEffect(() => {\n if (!range) return\n\n const onUp = () => {\n if (!dragging.current) return\n dragging.current = false\n const from = dragFrom.current\n // Press and release on one day is a click: keep the anchor pending so the\n // next click closes the range.\n if (from && hover && !isSameDay(from, hover)) {\n commit(from, hover)\n setAnchor(null)\n }\n }\n\n window.addEventListener('pointerup', onUp)\n return () => window.removeEventListener('pointerup', onUp)\n }, [commit, hover, range])\n\n /** What the grid paints: the draft while picking, otherwise the selection. */\n const shown: DateRange | undefined = useMemo(() => {\n if (!range) return undefined\n if (anchor) return orderRange(anchor, hover ?? anchor)\n return picked\n }, [anchor, hover, range, picked])\n\n function onDayDown(date: Date) {\n if (unavailable(date)) return\n\n if (!range) {\n // Clicking the selected day clears it — the only way to undo a choice in\n // a picker with no clear button.\n const same = single && isSameDay(single, date)\n emit(same ? undefined : withTimeOf(date, single))\n return\n }\n\n if (anchor) {\n commit(anchor, date)\n setAnchor(null)\n dragging.current = false\n dragFrom.current = null\n return\n }\n\n setAnchor(date)\n setHover(date)\n dragging.current = true\n dragFrom.current = date\n }\n\n /* ------------------------------------------------------------ render */\n\n /*\n * One month on a phone, whatever was asked for.\n *\n * A range picker defaults to two, and two months of seven columns each do not\n * fit 390px: they did not overflow, they compressed, and the day numbers ran\n * into each other until the grid was a smear. Native pickers show one month\n * and a way to page through it, so that is what this does.\n *\n * `useBreakpoint` reads during the first render rather than after it, so the\n * narrow layout is the one that paints — no flash of two crushed months.\n */\n const wide = useBreakpoint('sm')\n const months = Array.from({ length: wide ? Math.max(1, numberOfMonths) : 1 }, (_, i) =>\n addMonths(month, i),\n )\n\n const weekdays = useMemo(() => {\n const base = startOfWeek(new Date(), weekStartsOn)\n const format = new Intl.DateTimeFormat(locale, { weekday: 'short' })\n return Array.from({ length: 7 }, (_, i) =>\n format.format(new Date(base.getTime() + i * 86_400_000)),\n )\n }, [locale, weekStartsOn])\n\n return (\n <div\n data-slot=\"calendar\"\n data-mode={props.mode ?? 'single'}\n className={cn(enterFade, 'w-fit select-none', className)}\n onPointerLeave={() => {\n if (!dragging.current) setHover(null)\n }}\n >\n <div className=\"flex flex-col gap-4 sm:flex-row sm:gap-5\">\n {range && props.presets?.length ? (\n <Presets\n presets={props.presets}\n onPick={(next) => {\n emit(next)\n if (next.from) setMonth(next.from)\n setAnchor(null)\n }}\n />\n ) : null}\n\n <div className=\"grid gap-3\">\n <Caption\n captionLayout={captionLayout}\n fromDate={fromDate}\n monthLabel={monthLabel}\n nextMonthLabel={nextMonthLabel}\n previousMonthLabel={previousMonthLabel}\n yearLabel={yearLabel}\n locale={locale}\n month={month}\n onMonthChange={setMonth}\n toDate={toDate}\n />\n\n <div className=\"flex flex-col gap-6 sm:flex-row\">\n {months.map((m) => (\n <Month\n key={m.toISOString()}\n locale={locale}\n mode={props.mode ?? 'single'}\n month={m}\n onDayDown={onDayDown}\n onDayEnter={(date) => range && setHover(date)}\n range={shown}\n renderDay={renderDay}\n selected={single}\n showCaption={months.length > 1}\n showOutsideDays={showOutsideDays}\n unavailable={unavailable}\n weekdays={weekdays}\n weekStartsOn={weekStartsOn}\n />\n ))}\n </div>\n\n {showTime && (\n <>\n <Separator />\n <TimeRow\n hourLabel={hourLabel}\n locale={locale}\n minuteLabel={minuteLabel}\n mode={props.mode ?? 'single'}\n onChange={emit}\n range={picked}\n single={single}\n step={timeStep}\n />\n </>\n )}\n\n {footer}\n </div>\n </div>\n </div>\n )\n}\n\n/* ------------------------------------------------------------- sub-parts */\n\nfunction Presets({\n presets,\n onPick,\n}: {\n presets: CalendarPreset[]\n onPick: (range: DateRange) => void\n}) {\n return (\n <div\n className={cn(\n 'border-border flex shrink-0 gap-1',\n // Beside the grid when there is room, above it when there is not. At\n // 390px a fixed 9rem column left the calendar 250px to live in.\n 'flex-row flex-wrap border-b pb-3',\n 'sm:w-36 sm:flex-col sm:flex-nowrap sm:border-e sm:border-b-0 sm:pe-3 sm:pb-0',\n )}\n >\n {presets.map((preset) => (\n <Button\n key={preset.label}\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => onPick(preset.range)}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )\n}\n\nfunction Caption({\n month,\n onMonthChange,\n locale,\n captionLayout,\n fromDate,\n toDate,\n previousMonthLabel,\n nextMonthLabel,\n monthLabel,\n yearLabel,\n}: {\n month: Date\n onMonthChange: (month: Date) => void\n locale: string\n captionLayout: 'label' | 'dropdown'\n fromDate?: Date\n toDate?: Date\n previousMonthLabel: string\n nextMonthLabel: string\n monthLabel: string\n yearLabel: string\n}) {\n const monthNames = useMemo(() => {\n const format = new Intl.DateTimeFormat(locale, { month: 'long' })\n return Array.from({ length: 12 }, (_, i) => format.format(new Date(2024, i, 1)))\n }, [locale])\n\n const years = useMemo(() => {\n const now = month.getFullYear()\n const first = fromDate?.getFullYear() ?? now - 10\n const last = toDate?.getFullYear() ?? now + 10\n return Array.from({ length: Math.max(1, last - first + 1) }, (_, i) => first + i)\n }, [month, fromDate, toDate])\n\n return (\n <div className=\"flex items-center justify-between gap-2\">\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={previousMonthLabel}\n disabled={fromDate ? isBefore(addMonths(month, -1), startOfMonth(fromDate)) : false}\n onClick={() => onMonthChange(addMonths(month, -1))}\n >\n <ChevronLeft />\n </Button>\n\n {captionLayout === 'dropdown' ? (\n <div className=\"flex flex-1 gap-1.5\">\n <Select\n size=\"sm\"\n aria-label={monthLabel}\n value={String(month.getMonth())}\n onValueChange={(v) =>\n onMonthChange(new Date(month.getFullYear(), Number(v), 1))\n }\n options={monthNames.map((name, i) => ({ value: String(i), label: name }))}\n />\n <Select\n size=\"sm\"\n aria-label={yearLabel}\n value={String(month.getFullYear())}\n onValueChange={(v) =>\n onMonthChange(new Date(Number(v), month.getMonth(), 1))\n }\n options={years.map((year) => ({\n value: String(year),\n label: String(year),\n }))}\n />\n </div>\n ) : (\n <div aria-live=\"polite\" className=\"flex-1 text-center text-sm font-medium\">\n {formatMonth(month, locale)}\n </div>\n )}\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={nextMonthLabel}\n disabled={toDate ? isAfter(addMonths(month, 1), startOfMonth(toDate)) : false}\n onClick={() => onMonthChange(addMonths(month, 1))}\n >\n <ChevronRight />\n </Button>\n </div>\n )\n}\n\nfunction Month({\n month,\n weekStartsOn,\n weekdays,\n locale,\n mode,\n selected,\n range,\n unavailable,\n onDayDown,\n onDayEnter,\n showOutsideDays,\n showCaption,\n renderDay,\n}: {\n month: Date\n weekStartsOn: WeekStart\n weekdays: string[]\n locale: string\n mode: CalendarMode\n selected?: Date\n range?: DateRange\n unavailable: (date: Date) => boolean\n onDayDown: (date: Date) => void\n onDayEnter: (date: Date) => void\n showOutsideDays: boolean\n showCaption: boolean\n renderDay?: (date: Date) => ReactNode\n}) {\n const weeks = useMemo(\n () => monthWeeks(month, weekStartsOn),\n [month, weekStartsOn],\n )\n const today = startOfDay(new Date())\n\n return (\n <div>\n {showCaption && (\n <div className=\"mb-2 text-center text-sm font-medium\">\n {formatMonth(month, locale)}\n </div>\n )}\n\n <div role=\"grid\" className=\"grid grid-cols-7\">\n {weekdays.map((day) => (\n <div\n key={day}\n role=\"columnheader\"\n aria-label={day}\n className={cn(\n 'text-muted-foreground grid place-items-center text-[11px] font-medium',\n renderDay ? 'h-9 w-12' : 'size-9',\n )}\n >\n <span aria-hidden=\"true\">{day.slice(0, 2)}</span>\n </div>\n ))}\n\n {weeks.flat().map((date) => {\n const outside = !isSameMonth(date, month)\n if (outside && !showOutsideDays) {\n return (\n <div\n key={date.toISOString()}\n className={renderDay ? 'h-14 w-12' : 'size-9'}\n />\n )\n }\n\n const off = unavailable(date)\n const start = isRangeStart(date, range)\n const end = isRangeEnd(date, range)\n const middle = isInRange(date, range) && !start && !end\n const isSelected =\n mode === 'range' ? start || end : Boolean(selected && isSameDay(date, selected))\n\n return (\n <button\n key={date.toISOString()}\n type=\"button\"\n role=\"gridcell\"\n disabled={off}\n aria-selected={isSelected || middle}\n aria-current={isSameDay(date, today) ? 'date' : undefined}\n onPointerDown={() => onDayDown(date)}\n onPointerEnter={() => onDayEnter(date)}\n className={cn(\n 'relative grid text-sm tabular-nums',\n renderDay ? 'h-14 w-12 place-items-start p-1.5' : 'size-9 place-items-center',\n focusRing,\n 'transition-colors duration-150 ease-out motion-reduce:transition-none',\n 'disabled:pointer-events-none disabled:opacity-30',\n outside && 'text-muted-foreground/50',\n // The band has to be square where it continues and rounded only\n // at the ends, or a range reads as separate pills.\n middle && 'bg-accent text-accent-foreground rounded-none',\n (start || end) && 'bg-primary text-primary-foreground font-medium',\n start && !end && 'rounded-s-lg rounded-e-none',\n end && !start && 'rounded-e-lg rounded-s-none',\n start && end && radius.control,\n mode === 'single' && isSelected && cn(radius.control, 'bg-primary text-primary-foreground font-medium'),\n !isSelected && !middle && cn(radius.control, 'hover:bg-accent hover:text-accent-foreground'),\n !isSelected && !middle && isSameDay(date, today) && 'border-border border font-medium',\n )}\n >\n {renderDay ? renderDay(date) : date.getDate()}\n </button>\n )\n })}\n </div>\n </div>\n )\n}\n\nfunction TimeRow({\n mode,\n single,\n range,\n step,\n locale,\n onChange,\n hourLabel,\n minuteLabel,\n}: {\n mode: CalendarMode\n single?: Date\n range?: DateRange\n step: number\n locale: string\n onChange: (value: Date | DateRange | undefined) => void\n hourLabel: (row: string) => string\n minuteLabel: (row: string) => string\n}) {\n const hours = Array.from({ length: 24 }, (_, i) => i)\n const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step)\n const pad = (n: number) => String(n).padStart(2, '0')\n\n function field(date: Date | undefined, apply: (next: Date) => void, label: string) {\n // Times are meaningless without a day, so the selects wait for one.\n const base = date ?? startOfDay(new Date())\n\n return (\n <div className=\"flex items-center gap-1.5\">\n <span className=\"text-muted-foreground w-10 text-xs\">{label}</span>\n <Select\n size=\"sm\"\n aria-label={hourLabel(label)}\n disabled={!date}\n value={String(base.getHours())}\n onValueChange={(v) => apply(setTime(base, Number(v), base.getMinutes()))}\n className=\"w-20\"\n options={hours.map((h) => ({ value: String(h), label: pad(h) }))}\n />\n <Select\n size=\"sm\"\n aria-label={minuteLabel(label)}\n disabled={!date}\n value={String(Math.round(base.getMinutes() / step) * step % 60)}\n onValueChange={(v) => apply(setTime(base, base.getHours(), Number(v)))}\n className=\"w-20\"\n options={minutes.map((m) => ({ value: String(m), label: pad(m) }))}\n />\n </div>\n )\n }\n\n if (mode === 'single') {\n return (\n <div className=\"flex flex-col gap-2\" data-locale={locale}>\n {field(single, (next) => onChange(next), 'Time')}\n </div>\n )\n }\n\n return (\n <div className=\"flex flex-col gap-2\">\n {field(range?.from, (next) => onChange({ ...range, from: next }), 'From')}\n {field(range?.to, (next) => onChange({ ...range, to: next }), 'To')}\n </div>\n )\n}\n\nexport { Calendar }\n"
24
25
  }
25
26
  ]
26
27
  }
@@ -24,7 +24,7 @@
24
24
  {
25
25
  "path": "components/ui/composer.tsx",
26
26
  "type": "registry:ui",
27
- "content": "'use client'\n\nimport {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Maximize2, Minimize2, RotateCcw } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardBody, CardHeader } from '@/components/ui/card'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { ColorPicker } from '@/components/ui/color-picker'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { NumberInput } from '@/components/ui/number-input'\nimport { Select } from '@/components/ui/select'\nimport { Switch } from '@/components/ui/switch'\nimport type { Language } from '@/lib/highlighter'\nimport { cn } from '@/lib/utils'\n\n/**\n * A live playground: drive a set of props and watch generated source follow.\n *\n * Assembled entirely from the kit's own controls, which is the point rather\n * than a flourish — this is the densest form on the site, so anything awkward\n * about Select, Switch or NumberInput surfaces here before it reaches anyone\n * else's form.\n *\n * It owns no knowledge of the documentation registry. `controls` describes the\n * inputs, `render` draws the result and `code` writes the snippet; a composer\n * works the same in a README page, a design review or a prop explorer.\n *\n * State can be controlled. The uncontrolled default is what a docs page wants,\n * but a page that needs two composers in sync — a light and a dark preview of\n * one configuration — has to be able to lift it.\n */\nexport type ComposerValue = string | boolean | number\n\nexport type ComposerControl =\n | {\n type: 'select'\n prop: string\n label: string\n options: readonly string[]\n default: string\n }\n | { type: 'boolean'; prop: string; label: string; default: boolean }\n | {\n type: 'text'\n prop: string\n label: string\n default: string\n placeholder?: string\n }\n | {\n type: 'number'\n prop: string\n label: string\n default: number\n min?: number\n max?: number\n step?: number\n }\n | { type: 'color'; prop: string; label: string; default: string }\n\nexport type ComposerState = Record<string, ComposerValue>\n\nfunction initialState(controls: ComposerControl[]): ComposerState {\n return Object.fromEntries(controls.map((control) => [control.prop, control.default]))\n}\n\ntype ComposerProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n controls: ComposerControl[]\n /** Draws the live result. */\n render: (state: ComposerState) => ReactNode\n /** Source for the current state, shown beneath the preview. */\n code?: (state: ComposerState) => string\n language?: Language\n /** Centre the preview in a taller box, for anything with real height. */\n tall?: boolean\n panelLabel?: ReactNode\n state?: ComposerState\n onStateChange?: (state: ComposerState) => void\n resetLabel?: ReactNode\n /**\n * Offer a button that gives the playground the whole screen.\n *\n * Worth turning off for a composer driving something small, where the room is\n * not the constraint and the button is just another thing in the header.\n */\n fullscreen?: boolean\n fullscreenLabel?: string\n exitFullscreenLabel?: string\n}\n\nfunction Composer({\n controls,\n render,\n code,\n language = 'tsx',\n tall = false,\n panelLabel = 'Props',\n state: stateProp,\n onStateChange,\n resetLabel = 'Reset',\n fullscreen = true,\n fullscreenLabel = 'Expand to full screen',\n exitFullscreenLabel = 'Exit full screen',\n className,\n ...props\n}: ComposerProps) {\n const controlled = stateProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(() => initialState(controls))\n const state = controlled ? stateProp : uncontrolled\n\n const dirty = controls.some((control) => state[control.prop] !== control.default)\n // Two composers on one page is the ordinary docs case, so field ids are\n // namespaced per instance rather than derived from the prop name alone.\n const fieldScope = useId()\n\n function set(next: ComposerState) {\n if (!controlled) setUncontrolled(next)\n onStateChange?.(next)\n }\n\n const shellRef = useRef<HTMLDivElement>(null)\n const { expanded, toggle } = useExpand(shellRef)\n\n return (\n <Card\n ref={shellRef}\n data-slot=\"composer\"\n data-expanded={expanded || undefined}\n className={cn(\n 'overflow-hidden',\n // Filling the screen is the same shape either way: the browser sizes a\n // fullscreen element itself, and these are what the fallback needs.\n expanded && 'fixed inset-0 z-50 rounded-none',\n className,\n )}\n {...props}\n >\n {/* Panel beside the preview on wide screens, beneath it on narrow ones —\n a 280px control column leaves nothing for the preview on a phone. */}\n <div\n className={cn(\n 'grid lg:grid-cols-[minmax(0,1fr)_280px]',\n // `min-h-0` or the preview refuses to shrink and pushes the code\n // block off the bottom of the screen instead of scrolling.\n expanded && 'min-h-0 flex-1',\n )}\n >\n <CardBody\n className={cn(\n 'flex items-center justify-center',\n expanded ? 'min-h-0 overflow-auto' : tall ? 'min-h-80' : 'min-h-48',\n )}\n >\n {render(state)}\n </CardBody>\n\n <div\n className={cn(\n 'border-border flex flex-col border-t lg:border-t-0 lg:border-s',\n expanded && 'min-h-0',\n )}\n >\n {/* A real CardHeader, not a label with a rule under it: the panel\n gets the same header band as every other card in the kit, and the\n band's own border replaces the Separator. */}\n <CardHeader className=\"flex-row items-center justify-between gap-2\">\n <span className=\"text-muted-foreground text-[11px] font-medium tracking-wide uppercase\">\n {panelLabel}\n </span>\n <span className=\"-me-2 flex items-center gap-1\">\n {dirty && (\n <Button\n variant=\"ghost\"\n size=\"xs\"\n onClick={() => set(initialState(controls))}\n >\n <RotateCcw />\n {resetLabel}\n </Button>\n )}\n {fullscreen && (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={expanded ? exitFullscreenLabel : fullscreenLabel}\n onClick={toggle}\n >\n {expanded ? <Minimize2 /> : <Maximize2 />}\n </Button>\n )}\n </span>\n </CardHeader>\n\n <div\n className={cn(\n 'bg-secondary/40 flex flex-1 flex-col gap-3.5 p-4.5',\n expanded && 'overflow-y-auto',\n )}\n >\n {controls.map((control) => (\n <ComposerField\n key={control.prop}\n scope={fieldScope}\n control={control}\n value={state[control.prop]}\n onChange={(value) => set({ ...state, [control.prop]: value })}\n />\n ))}\n\n {controls.length === 0 && (\n <p className=\"text-muted-foreground text-xs\">\n No props to configure.\n </p>\n )}\n </div>\n </div>\n </div>\n\n {code && (\n <div\n className={cn(\n 'border-border border-t p-3',\n // Capped rather than free, so the source never crowds out the\n // preview the screen was given over to.\n expanded && 'max-h-[40vh] shrink-0 overflow-auto',\n )}\n >\n <CodeBlock code={code(state)} language={language} />\n </div>\n )}\n </Card>\n )\n}\n\n/**\n * Fill the screen, by whichever route the browser allows.\n *\n * The Fullscreen API is the one that means it — the page chrome goes too, and\n * Escape is handled for us. It is not everywhere: iPhone Safari exposes no\n * element fullscreen at all, and a request can be refused outright. So a\n * refusal falls back to covering the viewport instead, which is the same thing\n * minus the browser's own furniture, and never leaves a button that does\n * nothing.\n *\n * Fullscreen can also be left without asking us — Escape, or the browser's own\n * control — so the state is read back from `fullscreenchange` rather than\n * assumed from the click that started it.\n */\nfunction useExpand(ref: React.RefObject<HTMLElement | null>) {\n const [native, setNative] = useState(false)\n const [overlay, setOverlay] = useState(false)\n const expanded = native || overlay\n\n useEffect(() => {\n const onChange = () => setNative(document.fullscreenElement === ref.current)\n document.addEventListener('fullscreenchange', onChange)\n return () => document.removeEventListener('fullscreenchange', onChange)\n }, [ref])\n\n // Only the fallback needs these: in real fullscreen the browser already owns\n // Escape, and the page behind is not being scrolled past.\n useEffect(() => {\n if (!overlay) return\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') setOverlay(false)\n }\n window.addEventListener('keydown', onKeyDown)\n\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n\n return () => {\n window.removeEventListener('keydown', onKeyDown)\n document.body.style.overflow = previous\n }\n }, [overlay])\n\n const toggle = useCallback(() => {\n if (document.fullscreenElement) {\n void document.exitFullscreen()\n return\n }\n if (overlay) {\n setOverlay(false)\n return\n }\n\n const element = ref.current\n if (element?.requestFullscreen && document.fullscreenEnabled) {\n // A rejected request resolves nothing and throws asynchronously, which is\n // why the fallback is chained rather than decided up front.\n element.requestFullscreen().catch(() => setOverlay(true))\n return\n }\n setOverlay(true)\n }, [overlay, ref])\n\n return { expanded, toggle }\n}\n\n/** One row of the control panel. */\nfunction ComposerField({\n control,\n value,\n onChange,\n scope,\n}: {\n control: ComposerControl\n value: ComposerValue\n onChange: (value: ComposerValue) => void\n scope: string\n resetLabel?: ReactNode\n}) {\n const id = `${scope}-${control.prop}`\n\n // A switch carries its own label, so it needs no Label above it.\n if (control.type === 'boolean') {\n return (\n <Switch\n id={id}\n size=\"sm\"\n checked={Boolean(value)}\n onChange={(event) => onChange(event.target.checked)}\n label={<span className=\"font-mono text-xs\">{control.label}</span>}\n labelPosition=\"start\"\n containerClassName=\"justify-between w-full\"\n />\n )\n }\n\n return (\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor={id} className=\"font-mono text-xs\">\n {control.label}\n </Label>\n\n {control.type === 'select' && (\n <Select\n size=\"sm\"\n value={String(value)}\n onValueChange={onChange}\n options={control.options.map((option) => ({\n value: option,\n label: option,\n }))}\n />\n )}\n\n {control.type === 'text' && (\n <Input\n id={id}\n size=\"sm\"\n value={String(value)}\n placeholder={control.placeholder}\n onChange={(event) => onChange(event.target.value)}\n />\n )}\n\n {control.type === 'number' && (\n <NumberInput\n id={id}\n size=\"sm\"\n value={Number(value)}\n min={control.min}\n max={control.max}\n step={control.step}\n onValueChange={(next) => onChange(next ?? 0)}\n />\n )}\n\n {control.type === 'color' && (\n <ColorPicker\n size=\"sm\"\n clearable\n value={String(value)}\n onValueChange={onChange}\n />\n )}\n </div>\n )\n}\n\nexport { Composer, initialState as composerInitialState }\nexport type { ComposerProps }\n"
27
+ "content": "'use client'\n\nimport {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Maximize2, Minimize2, RotateCcw } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardBody, CardHeader } from '@/components/ui/card'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { ColorPicker } from '@/components/ui/color-picker'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { NumberInput } from '@/components/ui/number-input'\nimport { Select } from '@/components/ui/select'\nimport { Switch } from '@/components/ui/switch'\nimport type { Language } from '@/lib/highlighter'\nimport { cn } from '@/lib/utils'\n\n/**\n * A live playground: drive a set of props and watch generated source follow.\n *\n * Assembled entirely from the kit's own controls, which is the point rather\n * than a flourish — this is the densest form on the site, so anything awkward\n * about Select, Switch or NumberInput surfaces here before it reaches anyone\n * else's form.\n *\n * It owns no knowledge of the documentation registry. `controls` describes the\n * inputs, `render` draws the result and `code` writes the snippet; a composer\n * works the same in a README page, a design review or a prop explorer.\n *\n * State can be controlled. The uncontrolled default is what a docs page wants,\n * but a page that needs two composers in sync — a light and a dark preview of\n * one configuration — has to be able to lift it.\n */\nexport type ComposerValue = string | boolean | number\n\nexport type ComposerControl =\n | {\n type: 'select'\n prop: string\n label: string\n options: readonly string[]\n default: string\n }\n | { type: 'boolean'; prop: string; label: string; default: boolean }\n | {\n type: 'text'\n prop: string\n label: string\n default: string\n placeholder?: string\n }\n | {\n type: 'number'\n prop: string\n label: string\n default: number\n min?: number\n max?: number\n step?: number\n }\n | { type: 'color'; prop: string; label: string; default: string }\n\nexport type ComposerState = Record<string, ComposerValue>\n\nfunction initialState(controls: ComposerControl[]): ComposerState {\n return Object.fromEntries(controls.map((control) => [control.prop, control.default]))\n}\n\ntype ComposerProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n controls: ComposerControl[]\n /** Draws the live result. */\n render: (state: ComposerState) => ReactNode\n /** Source for the current state, shown beneath the preview. */\n code?: (state: ComposerState) => string\n language?: Language\n /** Centre the preview in a taller box, for anything with real height. */\n tall?: boolean\n panelLabel?: ReactNode\n state?: ComposerState\n onStateChange?: (state: ComposerState) => void\n resetLabel?: ReactNode\n /**\n * Offer a button that gives the playground the whole screen.\n *\n * Worth turning off for a composer driving something small, where the room is\n * not the constraint and the button is just another thing in the header.\n */\n fullscreen?: boolean\n fullscreenLabel?: string\n exitFullscreenLabel?: string\n}\n\nfunction Composer({\n controls,\n render,\n code,\n language = 'tsx',\n tall = false,\n panelLabel = 'Props',\n state: stateProp,\n onStateChange,\n resetLabel = 'Reset',\n fullscreen = true,\n fullscreenLabel = 'Expand to full screen',\n exitFullscreenLabel = 'Exit full screen',\n className,\n ...props\n}: ComposerProps) {\n const controlled = stateProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(() => initialState(controls))\n const state = controlled ? stateProp : uncontrolled\n\n const dirty = controls.some((control) => state[control.prop] !== control.default)\n // Two composers on one page is the ordinary docs case, so field ids are\n // namespaced per instance rather than derived from the prop name alone.\n const fieldScope = useId()\n\n function set(next: ComposerState) {\n if (!controlled) setUncontrolled(next)\n onStateChange?.(next)\n }\n\n const shellRef = useRef<HTMLDivElement>(null)\n const { expanded, toggle } = useExpand(shellRef)\n\n return (\n <Card\n ref={shellRef}\n data-slot=\"composer\"\n data-expanded={expanded || undefined}\n className={cn(\n 'overflow-hidden',\n // Filling the screen is the same shape either way: the browser sizes a\n // fullscreen element itself, and these are what the fallback needs.\n expanded && 'fixed inset-0 z-50 rounded-none',\n className,\n )}\n {...props}\n >\n {/* Panel beside the preview on wide screens, beneath it on narrow ones —\n a 280px control column leaves nothing for the preview on a phone. */}\n <div\n className={cn(\n 'grid lg:grid-cols-[minmax(0,1fr)_280px]',\n // `min-h-0` or the preview refuses to shrink and pushes the code\n // block off the bottom of the screen instead of scrolling.\n expanded && 'min-h-0 flex-1',\n )}\n >\n <CardBody\n className={cn(\n // `safe center` centres the preview when it fits and falls back to\n // `start` when it does not. Plain `center` centres the overflow\n // too, which puts the left edge of a wide component outside the\n // scroll container where nothing can reach it.\n 'flex items-center justify-center-safe overflow-x-auto',\n expanded ? 'min-h-0 overflow-y-auto' : tall ? 'min-h-80' : 'min-h-48',\n )}\n >\n {render(state)}\n </CardBody>\n\n <div\n className={cn(\n 'border-border flex flex-col border-t lg:border-t-0 lg:border-s',\n expanded && 'min-h-0',\n )}\n >\n {/* A real CardHeader, not a label with a rule under it: the panel\n gets the same header band as every other card in the kit, and the\n band's own border replaces the Separator. */}\n <CardHeader className=\"flex-row items-center justify-between gap-2\">\n <span className=\"text-muted-foreground text-[11px] font-medium tracking-wide uppercase\">\n {panelLabel}\n </span>\n <span className=\"-me-2 flex items-center gap-1\">\n {dirty && (\n <Button\n variant=\"ghost\"\n size=\"xs\"\n onClick={() => set(initialState(controls))}\n >\n <RotateCcw />\n {resetLabel}\n </Button>\n )}\n {fullscreen && (\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={expanded ? exitFullscreenLabel : fullscreenLabel}\n onClick={toggle}\n >\n {expanded ? <Minimize2 /> : <Maximize2 />}\n </Button>\n )}\n </span>\n </CardHeader>\n\n <div\n className={cn(\n 'bg-secondary/40 flex flex-1 flex-col gap-3.5 p-4.5',\n expanded && 'overflow-y-auto',\n )}\n >\n {controls.map((control) => (\n <ComposerField\n key={control.prop}\n scope={fieldScope}\n control={control}\n value={state[control.prop]}\n onChange={(value) => set({ ...state, [control.prop]: value })}\n />\n ))}\n\n {controls.length === 0 && (\n <p className=\"text-muted-foreground text-xs\">\n No props to configure.\n </p>\n )}\n </div>\n </div>\n </div>\n\n {code && (\n <div\n className={cn(\n 'border-border border-t p-3',\n // Capped rather than free, so the source never crowds out the\n // preview the screen was given over to.\n expanded && 'max-h-[40vh] shrink-0 overflow-auto',\n )}\n >\n <CodeBlock code={code(state)} language={language} />\n </div>\n )}\n </Card>\n )\n}\n\n/**\n * Fill the screen, by whichever route the browser allows.\n *\n * The Fullscreen API is the one that means it — the page chrome goes too, and\n * Escape is handled for us. It is not everywhere: iPhone Safari exposes no\n * element fullscreen at all, and a request can be refused outright. So a\n * refusal falls back to covering the viewport instead, which is the same thing\n * minus the browser's own furniture, and never leaves a button that does\n * nothing.\n *\n * Fullscreen can also be left without asking us — Escape, or the browser's own\n * control — so the state is read back from `fullscreenchange` rather than\n * assumed from the click that started it.\n */\nfunction useExpand(ref: React.RefObject<HTMLElement | null>) {\n const [native, setNative] = useState(false)\n const [overlay, setOverlay] = useState(false)\n const expanded = native || overlay\n\n useEffect(() => {\n const onChange = () => setNative(document.fullscreenElement === ref.current)\n document.addEventListener('fullscreenchange', onChange)\n return () => document.removeEventListener('fullscreenchange', onChange)\n }, [ref])\n\n // Only the fallback needs these: in real fullscreen the browser already owns\n // Escape, and the page behind is not being scrolled past.\n useEffect(() => {\n if (!overlay) return\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') setOverlay(false)\n }\n window.addEventListener('keydown', onKeyDown)\n\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n\n return () => {\n window.removeEventListener('keydown', onKeyDown)\n document.body.style.overflow = previous\n }\n }, [overlay])\n\n const toggle = useCallback(() => {\n if (document.fullscreenElement) {\n void document.exitFullscreen()\n return\n }\n if (overlay) {\n setOverlay(false)\n return\n }\n\n const element = ref.current\n if (element?.requestFullscreen && document.fullscreenEnabled) {\n // A rejected request resolves nothing and throws asynchronously, which is\n // why the fallback is chained rather than decided up front.\n element.requestFullscreen().catch(() => setOverlay(true))\n return\n }\n setOverlay(true)\n }, [overlay, ref])\n\n return { expanded, toggle }\n}\n\n/** One row of the control panel. */\nfunction ComposerField({\n control,\n value,\n onChange,\n scope,\n}: {\n control: ComposerControl\n value: ComposerValue\n onChange: (value: ComposerValue) => void\n scope: string\n resetLabel?: ReactNode\n}) {\n const id = `${scope}-${control.prop}`\n\n // A switch carries its own label, so it needs no Label above it.\n if (control.type === 'boolean') {\n return (\n <Switch\n id={id}\n size=\"sm\"\n checked={Boolean(value)}\n onChange={(event) => onChange(event.target.checked)}\n label={<span className=\"font-mono text-xs\">{control.label}</span>}\n labelPosition=\"start\"\n containerClassName=\"justify-between w-full\"\n />\n )\n }\n\n return (\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor={id} className=\"font-mono text-xs\">\n {control.label}\n </Label>\n\n {control.type === 'select' && (\n <Select\n size=\"sm\"\n value={String(value)}\n onValueChange={onChange}\n options={control.options.map((option) => ({\n value: option,\n label: option,\n }))}\n />\n )}\n\n {control.type === 'text' && (\n <Input\n id={id}\n size=\"sm\"\n value={String(value)}\n placeholder={control.placeholder}\n onChange={(event) => onChange(event.target.value)}\n />\n )}\n\n {control.type === 'number' && (\n <NumberInput\n id={id}\n size=\"sm\"\n value={Number(value)}\n min={control.min}\n max={control.max}\n step={control.step}\n onValueChange={(next) => onChange(next ?? 0)}\n />\n )}\n\n {control.type === 'color' && (\n <ColorPicker\n size=\"sm\"\n clearable\n value={String(value)}\n onValueChange={onChange}\n />\n )}\n </div>\n )\n}\n\nexport { Composer, initialState as composerInitialState }\nexport type { ComposerProps }\n"
28
28
  }
29
29
  ]
30
30
  }
@@ -3,7 +3,7 @@
3
3
  "type": "registry:ui",
4
4
  "title": "Node Canvas",
5
5
  "description": "A pannable, zoomable canvas of draggable nodes and the edges between them — the substrate for an agent pipeline, a retrieval chain or a build DAG. Nodes are real DOM, so you put your own components inside them.",
6
- "category": "Agents",
6
+ "category": "Views",
7
7
  "dependencies": [],
8
8
  "registryDependencies": [
9
9
  "lib-styles",
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "path": "styles/astralyx.css",
14
14
  "type": "registry:theme",
15
- "content": "@import 'tailwindcss';\n@import 'tw-animate-css';\n\n@custom-variant dark (&:is(.dark *));\n\n:root {\n /* Tells the platform which way to render native controls, form widgets and\n default scrollbars. Without it a dark page still gets light checkboxes. */\n color-scheme: light;\n\n --radius: 1.25rem;\n\n /*\n * Control corner radii, tuned per control height rather than derived from\n * --radius: the shared scale's fixed offsets do not track height, so a step\n * that looks right on h-12 is a different proportion entirely on h-7.\n *\n * Each is half its control's height — the roundest a squircle can be before\n * the corners meet. Under `corner-shape: squircle` these render as iOS-style\n * continuous corners, which is the point; they are deliberately NOT pills.\n *\n * xs has no entry: that one size is a true `rounded-full` pill.\n */\n /*\n * Group radii. Concentric with the cards inside: a container's radius should\n * be the inner radius plus the padding between them, or the gap between the\n * two curves visibly narrows at the corners.\n *\n * card radius 30px (--radius-3xl) + the group's own padding\n */\n --radius-group-sm: 42px; /* 30 + 12 */\n --radius-group-md: 48px; /* 30 + 18 */\n --radius-group-lg: 54px; /* 30 + 24 */\n\n /* Small square indicators — a checkbox is too small for the control scale,\n where any step would round it into a circle. */\n --radius-check-xs: 2px; /* 8px box — diff squares, status dots */\n --radius-check-sm: 5px; /* 16px box */\n --radius-check-md: 6px; /* 20px box */\n --radius-check-lg: 8px; /* 24px box */\n\n --radius-control-sm: 16px; /* h-8 / 32px */\n --radius-control-md: 18px; /* h-9 / 36px */\n --radius-control-lg: 20px; /* h-10 / 40px */\n --radius-control-xl: 24px; /* h-12 / 48px */\n\n /*\n * How far a tinted control shifts its fill and text on hover. Negative\n * darkens, which is right on a light background; `.dark` flips the sign.\n * Consumed by `tintStyle()` through relative colour syntax.\n */\n --tint-shift: -0.08;\n /* How far a custom tint moves to become readable *text* on the page surface.\n The named colour sets ship a `-soft-foreground` for this; an arbitrary tint\n has to derive one, and the raw colour as text measures ~3.5:1. Larger than\n --tint-shift, which is only a hover nudge. */\n --tint-text-shift: -0.26;\n\n /*\n * Which way a pressed control moves. Hover already steps in the right\n * direction per theme; press continues in that same direction, so mixing\n * toward black on light and white on dark keeps one rule working for all\n * eight colour sets and for `tint`.\n */\n --press-shade: black;\n\n --background: oklch(1 0 0);\n --foreground: oklch(0.145 0 0);\n --card: oklch(1 0 0);\n --card-foreground: oklch(0.145 0 0);\n --popover: oklch(1 0 0);\n --popover-foreground: oklch(0.145 0 0);\n --primary: oklch(0.205 0 0);\n --primary-foreground: oklch(0.985 0 0);\n --primary-hover: oklch(0.32 0 0);\n --secondary: oklch(0.97 0 0);\n /* Recessed trays. Group sits above CardHeader so a card inside a group still\n reads as the raised element. */\n --card-header: oklch(0.988 0 0);\n --group: oklch(0.97 0 0);\n\n /* The sidebar is deliberately theme-independent: black ground, light ink, in\n both themes. Declared only here — repeating them under `.dark` is what\n would make the rail flip. */\n --sidebar: oklch(0.08 0 0);\n --sidebar-foreground: oklch(0.985 0 0);\n --secondary-foreground: oklch(0.205 0 0);\n --secondary-hover: oklch(0.93 0 0);\n --secondary-foreground-hover: oklch(0.08 0 0);\n --muted: oklch(0.97 0 0);\n /* Set by the worst surface it lands on, not the best. At 0.556 this cleared\n AA on --card (4.73:1) but missed on --secondary (4.34:1), which is where\n muted text actually sits most often — menu rows, tab triggers, field\n hints. 0.544 gives 4.56 on secondary and 4.98 on card. */\n --muted-foreground: oklch(0.544 0 0);\n --accent: oklch(0.97 0 0);\n --accent-foreground: oklch(0.205 0 0);\n --destructive: oklch(0.577 0.245 27.325);\n --destructive-foreground: oklch(0.985 0 0);\n --destructive-hover: oklch(0.51 0.235 27.325);\n --destructive-soft: oklch(0.95 0.04 27.325);\n --destructive-soft-foreground: oklch(0.47 0.2 27.325);\n --destructive-soft-hover: oklch(0.91 0.055 27.325);\n --destructive-soft-foreground-hover: oklch(0.4 0.21 27.325);\n --border: oklch(0.922 0 0);\n /* Border while a field has focus — more contrast, not literally darker: in\n dark mode \"darker\" would mean invisible, so it brightens instead. */\n --border-active: oklch(0.62 0 0);\n --input: oklch(0.922 0 0);\n --ring: oklch(0.708 0 0);\n\n /* Color sets: solid pair (--x / --x-foreground) plus a tinted pair. */\n --blue: oklch(0.55 0.19 258);\n --blue-foreground: oklch(0.985 0 0);\n --blue-soft: oklch(0.95 0.04 258);\n --blue-soft-foreground: oklch(0.45 0.16 258);\n --blue-hover: oklch(0.48 0.19 258);\n --blue-soft-hover: oklch(0.91 0.055 258);\n --blue-soft-foreground-hover: oklch(0.38 0.17 258);\n\n --violet: oklch(0.55 0.23 295);\n --violet-foreground: oklch(0.985 0 0);\n --violet-soft: oklch(0.95 0.045 295);\n --violet-soft-foreground: oklch(0.45 0.19 295);\n --violet-hover: oklch(0.48 0.22 295);\n --violet-soft-hover: oklch(0.91 0.06 295);\n --violet-soft-foreground-hover: oklch(0.38 0.2 295);\n\n /* 3.27:1 before — the worst of the set. */\n --cyan: oklch(0.53 0.12 210);\n --cyan-foreground: oklch(0.985 0 0);\n --cyan-soft: oklch(0.95 0.04 210);\n --cyan-soft-foreground: oklch(0.44 0.1 210);\n --cyan-hover: oklch(0.55 0.12 210);\n --cyan-soft-hover: oklch(0.91 0.05 210);\n --cyan-soft-foreground-hover: oklch(0.37 0.11 210);\n\n /* Darkened from 0.58 so white foreground text clears AA on a solid fill\n (3.84:1 before, 4.61 now). Amber solves the same problem the other way,\n with a dark foreground — it is too light to carry white at any usable\n saturation. */\n --green: oklch(0.535 0.15 150);\n --green-foreground: oklch(0.985 0 0);\n --green-soft: oklch(0.95 0.05 150);\n --green-soft-foreground: oklch(0.42 0.12 150);\n --green-hover: oklch(0.51 0.14 150);\n --green-soft-hover: oklch(0.91 0.065 150);\n --green-soft-foreground-hover: oklch(0.35 0.13 150);\n\n --amber: oklch(0.78 0.15 75);\n --amber-foreground: oklch(0.25 0.05 75);\n --amber-soft: oklch(0.95 0.06 80);\n --amber-soft-foreground: oklch(0.45 0.11 70);\n --amber-hover: oklch(0.72 0.16 75);\n --amber-soft-hover: oklch(0.91 0.08 80);\n --amber-soft-foreground-hover: oklch(0.38 0.12 70);\n\n /* 4.22:1 before. */\n --rose: oklch(0.575 0.21 15);\n --rose-foreground: oklch(0.985 0 0);\n --rose-soft: oklch(0.95 0.04 15);\n --rose-soft-foreground: oklch(0.47 0.19 15);\n --rose-hover: oklch(0.53 0.2 15);\n --rose-soft-hover: oklch(0.91 0.055 15);\n --rose-soft-foreground-hover: oklch(0.4 0.2 15);\n\n --chart-1: oklch(0.646 0.222 41.116);\n --chart-2: oklch(0.6 0.118 184.704);\n --chart-3: oklch(0.398 0.07 227.392);\n --chart-4: oklch(0.828 0.189 84.429);\n --chart-5: oklch(0.769 0.188 70.08);\n}\n\n.dark {\n color-scheme: dark;\n\n --background: oklch(0.08 0 0);\n --foreground: oklch(0.985 0 0);\n --card: oklch(0.13 0 0);\n --card-foreground: oklch(0.985 0 0);\n --popover: oklch(0.13 0 0);\n --popover-foreground: oklch(0.985 0 0);\n --primary: oklch(0.922 0 0);\n --primary-foreground: oklch(0.13 0 0);\n --primary-hover: oklch(0.82 0 0);\n --tint-shift: 0.08;\n --tint-text-shift: 0.18;\n --press-shade: white;\n --secondary: oklch(0.18 0 0);\n --card-header: oklch(0.19 0 0);\n --group: oklch(0.23 0 0);\n --secondary-foreground: oklch(0.985 0 0);\n --secondary-hover: oklch(0.24 0 0);\n --secondary-foreground-hover: oklch(1 0 0);\n --muted: oklch(0.18 0 0);\n --muted-foreground: oklch(0.708 0 0);\n --accent: oklch(0.2 0 0);\n --accent-foreground: oklch(0.985 0 0);\n --destructive: oklch(0.704 0.191 22.216);\n --destructive-foreground: oklch(0.16 0.02 22.216);\n --destructive-hover: oklch(0.77 0.17 22.216);\n --destructive-soft: oklch(0.704 0.191 22.216 / 18%);\n --destructive-soft-foreground: oklch(0.85 0.1 22.216);\n --destructive-soft-hover: oklch(0.704 0.191 22.216 / 30%);\n --destructive-soft-foreground-hover: oklch(0.92 0.08 22.216);\n --border: oklch(1 0 0 / 12%);\n --border-active: oklch(1 0 0 / 40%);\n --input: oklch(1 0 0 / 15%);\n --ring: oklch(0.556 0 0);\n\n --blue: oklch(0.62 0.19 258);\n --blue-foreground: oklch(0.16 0.02 258);\n --blue-soft: oklch(0.62 0.19 258 / 18%);\n --blue-soft-foreground: oklch(0.82 0.11 258);\n --blue-hover: oklch(0.7 0.17 258);\n --blue-soft-hover: oklch(0.62 0.19 258 / 30%);\n --blue-soft-foreground-hover: oklch(0.9 0.09 258);\n\n --violet: oklch(0.65 0.22 295);\n --violet-foreground: oklch(0.16 0.02 295);\n --violet-soft: oklch(0.65 0.22 295 / 18%);\n --violet-soft-foreground: oklch(0.84 0.11 295);\n --violet-hover: oklch(0.72 0.2 295);\n --violet-soft-hover: oklch(0.65 0.22 295 / 30%);\n --violet-soft-foreground-hover: oklch(0.91 0.09 295);\n\n --cyan: oklch(0.72 0.11 210);\n --cyan-foreground: oklch(0.16 0.02 210);\n --cyan-soft: oklch(0.72 0.11 210 / 18%);\n --cyan-soft-foreground: oklch(0.85 0.08 210);\n --cyan-hover: oklch(0.79 0.1 210);\n --cyan-soft-hover: oklch(0.72 0.11 210 / 30%);\n --cyan-soft-foreground-hover: oklch(0.92 0.06 210);\n\n --green: oklch(0.68 0.15 150);\n --green-foreground: oklch(0.16 0.02 150);\n --green-soft: oklch(0.68 0.15 150 / 18%);\n --green-soft-foreground: oklch(0.84 0.11 150);\n --green-hover: oklch(0.75 0.14 150);\n --green-soft-hover: oklch(0.68 0.15 150 / 30%);\n --green-soft-foreground-hover: oklch(0.91 0.09 150);\n\n --amber: oklch(0.8 0.15 75);\n --amber-foreground: oklch(0.2 0.04 75);\n --amber-soft: oklch(0.8 0.15 75 / 18%);\n --amber-soft-foreground: oklch(0.88 0.1 80);\n --amber-hover: oklch(0.86 0.14 75);\n --amber-soft-hover: oklch(0.8 0.15 75 / 30%);\n --amber-soft-foreground-hover: oklch(0.94 0.08 80);\n\n --rose: oklch(0.68 0.2 15);\n --rose-foreground: oklch(0.16 0.02 15);\n --rose-soft: oklch(0.68 0.2 15 / 18%);\n --rose-soft-foreground: oklch(0.85 0.1 15);\n --rose-hover: oklch(0.75 0.18 15);\n --rose-soft-hover: oklch(0.68 0.2 15 / 30%);\n --rose-soft-foreground-hover: oklch(0.92 0.08 15);\n\n --chart-1: oklch(0.488 0.243 264.376);\n --chart-2: oklch(0.696 0.17 162.48);\n --chart-3: oklch(0.769 0.188 70.08);\n --chart-4: oklch(0.627 0.265 303.9);\n --chart-5: oklch(0.645 0.246 16.439);\n}\n\n\n@theme inline {\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-card: var(--card);\n --color-card-foreground: var(--card-foreground);\n --color-popover: var(--popover);\n --color-popover-foreground: var(--popover-foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-destructive: var(--destructive);\n --color-destructive-foreground: var(--destructive-foreground);\n --color-border: var(--border);\n --color-input: var(--input);\n --color-ring: var(--ring);\n\n --color-blue: var(--blue);\n --color-blue-foreground: var(--blue-foreground);\n --color-blue-soft: var(--blue-soft);\n --color-blue-soft-foreground: var(--blue-soft-foreground);\n --color-violet: var(--violet);\n --color-violet-foreground: var(--violet-foreground);\n --color-violet-soft: var(--violet-soft);\n --color-violet-soft-foreground: var(--violet-soft-foreground);\n --color-cyan: var(--cyan);\n --color-cyan-foreground: var(--cyan-foreground);\n --color-cyan-soft: var(--cyan-soft);\n --color-cyan-soft-foreground: var(--cyan-soft-foreground);\n --color-green: var(--green);\n --color-green-foreground: var(--green-foreground);\n --color-green-soft: var(--green-soft);\n --color-green-soft-foreground: var(--green-soft-foreground);\n --color-amber: var(--amber);\n --color-amber-foreground: var(--amber-foreground);\n --color-amber-soft: var(--amber-soft);\n --color-amber-soft-foreground: var(--amber-soft-foreground);\n --color-rose: var(--rose);\n --color-rose-foreground: var(--rose-foreground);\n --color-rose-soft: var(--rose-soft);\n --color-rose-soft-foreground: var(--rose-soft-foreground);\n\n --color-chart-1: var(--chart-1);\n --color-chart-2: var(--chart-2);\n --color-chart-3: var(--chart-3);\n --color-chart-4: var(--chart-4);\n --color-chart-5: var(--chart-5);\n\n --radius-xs: calc(var(--radius) - 8px);\n --radius-sm: calc(var(--radius) - 6px);\n --radius-md: calc(var(--radius) - 4px);\n --radius-lg: calc(var(--radius) - 2px);\n --radius-xl: var(--radius);\n --radius-2xl: calc(var(--radius) + 4px);\n --radius-3xl: calc(var(--radius) + 10px);\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n }\n\n /*\n * iOS-style continuous corners. `corner-shape: squircle` reshapes whatever\n * `border-radius` already says, so every rounded thing in the kit — and the\n * focus ring, which follows the same shape — becomes a squircle with no per\n * component work. Browsers without it keep circular corners, which is why the\n * radius scale still has to look right on its own.\n *\n * This MUST stay inside `@layer base`. Unlayered declarations outrank every\n * layered one, so as a top-level rule it silently beat the\n * `[corner-shape:round]` utility that xs controls use to opt back out into a\n * true pill.\n */\n @supports (corner-shape: squircle) {\n *,\n *::before,\n *::after {\n corner-shape: squircle;\n }\n\n /*\n * Fully rounded means a real circle or pill, never a squircle.\n *\n * A superellipse at 50% radius is a rounded rectangle, so without this a\n * spinner renders as a cube, progress bars get square ends, and every dot\n * turns into a tiny box. Higher specificity than the `*` rule above, so it\n * wins without !important.\n */\n .rounded-full {\n corner-shape: round;\n }\n }\n\n body {\n @apply bg-background text-foreground;\n font-synthesis: none;\n text-rendering: optimizeLegibility;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n}\n\n/* Shiki dual-theme output: both palettes ship in the markup as CSS variables,\n so switching themes needs no re-highlight. See src/lib/highlighter.ts. */\n.shiki,\n.shiki span {\n color: var(--shiki-light);\n background-color: var(--shiki-light-bg);\n}\n\n.dark .shiki,\n.dark .shiki span {\n color: var(--shiki-dark);\n background-color: var(--shiki-dark-bg);\n}\n\n/* Line numbers render in a pseudo-element gutter; the editable overlay in\n CodeBlock reserves the same 3.5ch on its left edge to stay aligned. */\n.code-block--numbers .shiki code {\n counter-reset: code-line;\n}\n\n.code-block--numbers .shiki .line {\n counter-increment: code-line;\n}\n\n.code-block--numbers .shiki .line::before {\n content: counter(code-line);\n display: inline-block;\n width: 2ch;\n margin-left: -3.5ch;\n margin-right: 1.5ch;\n text-align: right;\n color: var(--muted-foreground);\n opacity: 0.5;\n}\n\n.shiki .line[data-highlighted] {\n display: inline-block;\n width: 100%;\n /* Marker bar drawn as a gradient rather than an inset box-shadow, which the\n kit avoids; a border would shift the code by 2px. */\n background-color: var(--accent);\n background-image: linear-gradient(\n to right,\n var(--ring) 0 2px,\n transparent 2px\n );\n}\n\n/*\n * Range input.\n *\n * The input still does all the work — pointer mapping, arrow keys, Home/End,\n * touch dragging, form submission — but it paints none of it. Track, fill and\n * thumb are real elements beside it, drawn by the Slider component.\n *\n * That split exists for one reason: a native thumb's position is the browser's,\n * derived from `value`, and there is no property on it to transition. Nothing\n * can make it trail the pointer. An element positioned from a *registered*\n * custom property can be interpolated like any other, so the visible thumb is\n * ours and the invisible one underneath is only a hit target.\n *\n * The two must agree on geometry or the thumb sits beside the pointer: a range\n * input insets the thumb's travel by half its own width at each end, so the\n * pseudo-elements keep their size — and lose their borders, which would\n * otherwise grow the box past `--slider-thumb` and shift the mapping by 2px.\n */\n@property --slider-progress {\n syntax: '<number>';\n inherits: true;\n initial-value: 0;\n}\n\n.slider {\n appearance: none;\n background: transparent;\n cursor: pointer;\n margin: 0;\n}\n\n.slider::-webkit-slider-runnable-track {\n height: var(--slider-track);\n background: transparent;\n}\n\n.slider::-webkit-slider-thumb {\n appearance: none;\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border: 0;\n background: transparent;\n /* Centre the hit target on the track rather than on the input box. */\n margin-top: calc((var(--slider-track) - var(--slider-thumb)) / 2);\n}\n\n.slider::-moz-range-track {\n height: var(--slider-track);\n background: transparent;\n}\n\n.slider::-moz-range-thumb {\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border: 0;\n background: transparent;\n}\n\n.slider:disabled {\n cursor: not-allowed;\n}\n\n/*\n * The follow.\n *\n * `--slider-progress` is registered as a `<number>` above, which is what makes\n * it interpolable — an unregistered custom property animates discretely, and\n * the thumb would jump exactly as it did when the browser owned it. Everything\n * that reads it (the fill's width, the thumb's offset) inherits the animated\n * value, so one transition drives the whole control and the parts cannot drift\n * out of step.\n *\n * 130ms: long enough to read as the thumb following the pointer rather than\n * being nailed to it, short enough that a click on the track still feels like\n * it landed. Past about 200ms it stops reading as weight and starts reading as\n * lag.\n */\n.slider-shell {\n transition: --slider-progress 130ms ease-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .slider-shell {\n transition: none;\n }\n}\n\n/*\n * Hide the native resize grip. It is drawn by the browser in the bottom-right\n * corner and cannot be styled, so it never matches the kit — and it collides\n * with the rounded corner. Dragging the edge still resizes where `resize` is\n * not `none`; only the icon goes.\n */\n[data-slot='textarea']::-webkit-resizer {\n display: none;\n}\n\n/*\n * Native <dialog> backdrop. It is a pseudo-element in the top layer, so it\n * cannot be reached from a class — these rules are the only way to style it.\n */\ndialog::backdrop {\n background-color: color-mix(in oklab, black, transparent 55%);\n animation: dialog-backdrop-in 150ms ease-out;\n}\n\ndialog[data-slot='sheet']::backdrop {\n background-color: color-mix(in oklab, black, transparent 60%);\n}\n\n@keyframes dialog-backdrop-in {\n from {\n opacity: 0;\n }\n}\n\n@keyframes dialog-in {\n from {\n opacity: 0;\n }\n}\n\n@keyframes sheet-in-right {\n from {\n translate: 100% 0;\n }\n}\n\n@keyframes sheet-in-left {\n from {\n translate: -100% 0;\n }\n}\n\n@keyframes sheet-in-top {\n from {\n translate: 0 -100%;\n }\n}\n\n@keyframes sheet-in-bottom {\n from {\n translate: 0 100%;\n }\n}\n\ndialog[open] {\n animation: dialog-in 150ms ease-out;\n}\n\ndialog[open][data-side='right'] {\n animation: sheet-in-right 200ms ease-out;\n}\n\ndialog[open][data-side='left'] {\n animation: sheet-in-left 200ms ease-out;\n}\n\ndialog[open][data-side='top'] {\n animation: sheet-in-top 200ms ease-out;\n}\n\ndialog[open][data-side='bottom'] {\n animation: sheet-in-bottom 200ms ease-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n dialog[open],\n dialog[open][data-side],\n dialog::backdrop {\n animation: none;\n }\n}\n\n/* Indeterminate progress: a bar that sweeps rather than fills. */\n@keyframes progress-indeterminate {\n 0% {\n translate: -100% 0;\n }\n 100% {\n translate: 350% 0;\n }\n}\n\n/* Toasts arrive from the edge they are anchored to. */\n@keyframes toast-in {\n from {\n opacity: 0;\n translate: 0 0.5rem;\n }\n}\n\n/*\n * Custom scrollbars, everywhere.\n *\n * Applied globally rather than to one component: every overflowing surface in\n * the kit — a menu, a code block, a table wrapper, the sidebar, the page — gets\n * the same bar, so nothing drops back to the platform default.\n *\n * The two mechanisms are kept apart on purpose. Chromium ignores every\n * `::-webkit-scrollbar` rule the moment `scrollbar-color` is set to anything but\n * `auto`, so declaring both means the shaped version silently never applies\n * there. The `@supports` query hands Chromium and Safari the pseudo-elements —\n * which can do the rounded, inset thumb — and gives Firefox the standard\n * properties, which are all it understands.\n *\n * No JavaScript overlay anywhere, so native momentum, keyboard scrolling and\n * accessibility stay intact.\n */\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track,\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n background-color: var(--border);\n border-radius: 999px;\n corner-shape: round;\n /* Transparent border plus content-box clipping insets the thumb, so it reads\n as a 4px pill rather than filling the whole gutter. */\n border: 3px solid transparent;\n background-clip: content-box;\n transition: background-color 150ms ease-out;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background-color: var(--border-active);\n}\n\n@supports not selector(::-webkit-scrollbar) {\n :root {\n /* Inherited, so this reaches every scrollable descendant. */\n scrollbar-color: var(--border) transparent;\n }\n\n * {\n /* Not inherited — it has to be set on each scroll container. */\n scrollbar-width: thin;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *::-webkit-scrollbar-thumb {\n transition: none;\n }\n}\n\n/*\n * Astralyx logo blink. Ported from the Lottie timeline on astralyx.dev: two\n * quick blinks, then a longer one where the left eye only closes to 22% — the\n * wink is in the original, not an accident.\n *\n * `transform-box: view-box` resolves transform-origin in viewBox units, so each\n * eyelid pivots on its own hinge rather than the SVG's top-left corner.\n */\n.ax-eyelid-l,\n.ax-eyelid-r {\n transform-box: view-box;\n}\n\n.ax-eyelid-r {\n transform-origin: 25.52px 31.14px;\n animation: ax-blink-r 3s steps(1, end) infinite;\n}\n\n.ax-eyelid-l {\n transform-origin: 9.94px 30.24px;\n animation: ax-blink-l 3s steps(1, end) infinite;\n}\n\n@keyframes ax-blink-r {\n 0%, 32.22% { transform: scaleY(0); }\n 34.44% { transform: scaleY(1); }\n 37.78%, 41.11% { transform: scaleY(0); }\n 42.78% { transform: scaleY(1); }\n 45.56%, 66.67% { transform: scaleY(0); }\n 70%, 78.89% { transform: scaleY(1); }\n 83.33%, 100% { transform: scaleY(0); }\n}\n\n@keyframes ax-blink-l {\n 0%, 32.22% { transform: scaleY(0); }\n 34.44% { transform: scaleY(1); }\n 37.78%, 41.11% { transform: scaleY(0); }\n 42.78% { transform: scaleY(1); }\n 45.56%, 66.67% { transform: scaleY(0); }\n 70%, 78.89% { transform: scaleY(0.22); }\n 83.33%, 100% { transform: scaleY(0); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ax-eyelid-l,\n .ax-eyelid-r {\n animation: none;\n transform: scaleY(0);\n }\n}\n\n/*\n * -------------------------------------------------------------- motion ----\n *\n * The keyframes the kit's own components reference by name. Everything else —\n * entrances, hovers, presses, value transitions — is a utility class composed\n * in `lib/motion.ts`, so this section only holds what a class cannot express:\n * a loop, or a two-ended sweep.\n *\n * Each is paired with a `prefers-reduced-motion` rule at the point of use\n * rather than here, because a few of them (the sweep below) still need to leave\n * something visible when the motion is taken away.\n */\n\n/*\n * Skeleton sweep. A band of light crossing a placeholder, left to right.\n *\n * Transform rather than `background-position`: the band is a pseudo-element\n * that is already the size of its parent, so moving it is a composited step\n * with no repaint, while animating a gradient's position repaints the whole box\n * every frame — on a screen of forty loading rows that is the difference\n * between free and not.\n */\n@keyframes ax-sweep {\n from {\n transform: translateX(-100%);\n }\n to {\n transform: translateX(100%);\n }\n}\n\n/*\n * The plainest entrance in the kit: opacity, and nothing else.\n *\n * Deliberately not `tw-animate-css`'s `animate-in fade-in-0`, which is built on\n * one shared `enter` keyframe that sets `transform` as well — identity in this\n * case, but a *computed* transform all the same, and a computed transform makes\n * the element a containing block for every `position: fixed` descendant while\n * it runs. This is worn by component roots, and a component root is exactly the\n * thing a menu inside it is positioned against.\n */\n@keyframes ax-fade-in {\n from {\n opacity: 0;\n }\n}\n\n/*\n * A bar arriving out of its own leading edge.\n *\n * A clip rather than a `scaleX`, and both rather than an animated `width`.\n * Width reflows the row on every frame; `scaleX` is free but squashes whatever\n * the bar contains — and these are worn by segmented tracks that carry their\n * own percentage labels, which would spend 600ms as illegible smears. A clip\n * moves no geometry at all and is composited the same way.\n *\n * Both ends are written out. `clip-path: none` is not interpolable with a basic\n * shape, so a keyframe with only a `from` would animate discretely — a jump at\n * the halfway mark instead of a wipe.\n */\n@keyframes ax-grow-x {\n from {\n clip-path: inset(0 100% 0 0);\n }\n to {\n clip-path: inset(0 0 0 0);\n }\n}\n\n/* The same, for a column standing on a baseline. */\n@keyframes ax-grow-y {\n from {\n clip-path: inset(100% 0 0 0);\n }\n to {\n clip-path: inset(0 0 0 0);\n }\n}\n\n/*\n * A live indicator: one ring expanding out of a dot and fading as it goes.\n *\n * Drawn as a pseudo-element so the dot itself never moves — a status light that\n * changes size is harder to read at a glance, and it would reflow anything\n * measured against it.\n */\n@keyframes ax-ping {\n 0% {\n transform: scale(1);\n opacity: 0.45;\n }\n 75%,\n 100% {\n transform: scale(2.25);\n opacity: 0;\n }\n}\n\n/*\n * A value arriving in place of another: the old one leaves upward, the new one\n * comes up from below. Used where a figure is *replaced* rather than counted\n * to — a ticker's last trade, a rank, a status word.\n */\n@keyframes ax-roll-in {\n from {\n transform: translateY(0.4em);\n opacity: 0;\n }\n}\n\n/* The pending-reply dots. Opacity only, so it sits inside the motion rule. */\n@keyframes message-dot {\n 0%, 60%, 100% { opacity: 0.3; }\n 30% { opacity: 1; }\n}\n\n/*\n * Interactive things get the hand.\n *\n * Tailwind v4 stopped forcing `cursor: pointer` on buttons and let the UA\n * default (`default`) stand, which is defensible for a native `<button>` in a\n * document and wrong for an application: every Button in the kit, and every\n * hand-written `<button>` inside a component, pointed at nothing. Restored\n * here rather than in `controlBase`, because most of the offenders are the\n * bare buttons inside components — a row's star, a menu's chevron, a chip's\n * dismiss — that never went near the control tokens.\n *\n * Inside `@layer base` on purpose. An unlayered rule beats every layered one\n * whatever its specificity, so this would quietly outrank `cursor-grab` on a\n * drag handle and `cursor-not-allowed` on a disabled control. From `base`, any\n * utility still wins.\n *\n * The roles are here too: plenty of these controls are a `div` with a role,\n * and a listbox option that does not point is the same bug wearing a different\n * element.\n */\n@layer base {\n :where(\n button,\n summary,\n [role='button'],\n [role='menuitem'],\n [role='menuitemcheckbox'],\n [role='menuitemradio'],\n [role='option'],\n [role='tab'],\n [role='switch'],\n [role='checkbox'],\n [role='radio']\n ):not(:disabled, [aria-disabled='true'], [data-disabled='true']) {\n cursor: pointer;\n }\n}\n\n/*\n * Contain visually hidden elements.\n *\n * Tailwind's `sr-only` is `position: absolute`. An absolutely positioned box\n * whose ancestors are all `static` resolves against the *initial containing\n * block* — the document — and it contributes to the document's scrollable\n * overflow. So a page full of switches, checkboxes and radios (each of which\n * hides its real input this way) becomes scrollable far past its own content,\n * and because it is the document scrolling rather than the main region, the\n * header and the sidebar slide away with it. The symptom is a screen of blank\n * space under the last section.\n *\n * Giving the direct parent a containing block costs nothing and closes the\n * whole class of bug rather than the three components that happen to show it\n * today. `:where()` keeps the specificity at zero, so any component that sets\n * its own `position` still wins.\n */\n:where(*:has(> .sr-only)) {\n position: relative;\n}\n\n/*\n * The documentation rail's rows.\n *\n * One class rather than the utility list inline, and the only place in this\n * repo where that trade is worth making. The rail carries 366 links and is\n * rendered into all 358 exported pages: at roughly 1.2 kB of repeated class\n * names per row it was 466 kB of every page and about two thirds of the whole\n * artefact. The markup is identical, and `aria-current` was already the thing\n * marking the active row, so nothing needed a second attribute to say it twice.\n */\n@layer components {\n .rail-link {\n @apply flex shrink-0 items-center justify-between gap-2 rounded-lg px-3 py-1.5;\n @apply text-left text-sm whitespace-nowrap;\n @apply text-muted-foreground hover:text-foreground hover:bg-accent/50;\n @apply outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px];\n @apply transition-colors duration-150 ease-out motion-reduce:transition-none;\n }\n\n .rail-link[aria-current='page'] {\n @apply bg-accent text-accent-foreground font-medium;\n }\n}\n"
15
+ "content": "@import 'tailwindcss';\n@import 'tw-animate-css';\n\n@custom-variant dark (&:is(.dark *));\n\n:root {\n /* Tells the platform which way to render native controls, form widgets and\n default scrollbars. Without it a dark page still gets light checkboxes. */\n color-scheme: light;\n\n --radius: 1.25rem;\n\n /*\n * Control corner radii, tuned per control height rather than derived from\n * --radius: the shared scale's fixed offsets do not track height, so a step\n * that looks right on h-12 is a different proportion entirely on h-7.\n *\n * Each is half its control's height — the roundest a squircle can be before\n * the corners meet. Under `corner-shape: squircle` these render as iOS-style\n * continuous corners, which is the point; they are deliberately NOT pills.\n *\n * xs has no entry: that one size is a true `rounded-full` pill.\n */\n /*\n * Group radii. Concentric with the cards inside: a container's radius should\n * be the inner radius plus the padding between them, or the gap between the\n * two curves visibly narrows at the corners.\n *\n * card radius 30px (--radius-3xl) + the group's own padding\n */\n --radius-group-sm: 42px; /* 30 + 12 */\n --radius-group-md: 48px; /* 30 + 18 */\n --radius-group-lg: 54px; /* 30 + 24 */\n\n /* Small square indicators — a checkbox is too small for the control scale,\n where any step would round it into a circle. */\n --radius-check-xs: 2px; /* 8px box — diff squares, status dots */\n --radius-check-sm: 5px; /* 16px box */\n --radius-check-md: 6px; /* 20px box */\n --radius-check-lg: 8px; /* 24px box */\n\n --radius-control-sm: 16px; /* h-8 / 32px */\n --radius-control-md: 18px; /* h-9 / 36px */\n --radius-control-lg: 20px; /* h-10 / 40px */\n --radius-control-xl: 24px; /* h-12 / 48px */\n\n /*\n * How far a tinted control shifts its fill and text on hover. Negative\n * darkens, which is right on a light background; `.dark` flips the sign.\n * Consumed by `tintStyle()` through relative colour syntax.\n */\n --tint-shift: -0.08;\n /* How far a custom tint moves to become readable *text* on the page surface.\n The named colour sets ship a `-soft-foreground` for this; an arbitrary tint\n has to derive one, and the raw colour as text measures ~3.5:1. Larger than\n --tint-shift, which is only a hover nudge. */\n --tint-text-shift: -0.26;\n\n /*\n * Which way a pressed control moves. Hover already steps in the right\n * direction per theme; press continues in that same direction, so mixing\n * toward black on light and white on dark keeps one rule working for all\n * eight colour sets and for `tint`.\n */\n --press-shade: black;\n\n --background: oklch(1 0 0);\n --foreground: oklch(0.145 0 0);\n --card: oklch(1 0 0);\n --card-foreground: oklch(0.145 0 0);\n --popover: oklch(1 0 0);\n --popover-foreground: oklch(0.145 0 0);\n --primary: oklch(0.205 0 0);\n --primary-foreground: oklch(0.985 0 0);\n --primary-hover: oklch(0.32 0 0);\n --secondary: oklch(0.97 0 0);\n /* Recessed trays. Group sits above CardHeader so a card inside a group still\n reads as the raised element. */\n --card-header: oklch(0.988 0 0);\n --group: oklch(0.97 0 0);\n\n /* The sidebar is deliberately theme-independent: black ground, light ink, in\n both themes. Declared only here — repeating them under `.dark` is what\n would make the rail flip. */\n --sidebar: oklch(0.08 0 0);\n --sidebar-foreground: oklch(0.985 0 0);\n --secondary-foreground: oklch(0.205 0 0);\n --secondary-hover: oklch(0.93 0 0);\n --secondary-foreground-hover: oklch(0.08 0 0);\n --muted: oklch(0.97 0 0);\n /* Set by the worst surface it lands on, not the best. At 0.556 this cleared\n AA on --card (4.73:1) but missed on --secondary (4.34:1), which is where\n muted text actually sits most often — menu rows, tab triggers, field\n hints. 0.544 gives 4.56 on secondary and 4.98 on card. */\n --muted-foreground: oklch(0.544 0 0);\n --accent: oklch(0.97 0 0);\n --accent-foreground: oklch(0.205 0 0);\n --destructive: oklch(0.577 0.245 27.325);\n --destructive-foreground: oklch(0.985 0 0);\n --destructive-hover: oklch(0.51 0.235 27.325);\n --destructive-soft: oklch(0.95 0.04 27.325);\n --destructive-soft-foreground: oklch(0.47 0.2 27.325);\n --destructive-soft-hover: oklch(0.91 0.055 27.325);\n --destructive-soft-foreground-hover: oklch(0.4 0.21 27.325);\n --border: oklch(0.922 0 0);\n /* Border while a field has focus — more contrast, not literally darker: in\n dark mode \"darker\" would mean invisible, so it brightens instead. */\n --border-active: oklch(0.62 0 0);\n --input: oklch(0.922 0 0);\n --ring: oklch(0.708 0 0);\n\n /* Color sets: solid pair (--x / --x-foreground) plus a tinted pair. */\n --blue: oklch(0.55 0.19 258);\n --blue-foreground: oklch(0.985 0 0);\n --blue-soft: oklch(0.95 0.04 258);\n --blue-soft-foreground: oklch(0.45 0.16 258);\n --blue-hover: oklch(0.48 0.19 258);\n --blue-soft-hover: oklch(0.91 0.055 258);\n --blue-soft-foreground-hover: oklch(0.38 0.17 258);\n\n --violet: oklch(0.55 0.23 295);\n --violet-foreground: oklch(0.985 0 0);\n --violet-soft: oklch(0.95 0.045 295);\n --violet-soft-foreground: oklch(0.45 0.19 295);\n --violet-hover: oklch(0.48 0.22 295);\n --violet-soft-hover: oklch(0.91 0.06 295);\n --violet-soft-foreground-hover: oklch(0.38 0.2 295);\n\n /* 3.27:1 before — the worst of the set. */\n --cyan: oklch(0.53 0.12 210);\n --cyan-foreground: oklch(0.985 0 0);\n --cyan-soft: oklch(0.95 0.04 210);\n --cyan-soft-foreground: oklch(0.44 0.1 210);\n --cyan-hover: oklch(0.55 0.12 210);\n --cyan-soft-hover: oklch(0.91 0.05 210);\n --cyan-soft-foreground-hover: oklch(0.37 0.11 210);\n\n /* Darkened from 0.58 so white foreground text clears AA on a solid fill\n (3.84:1 before, 4.61 now). Amber solves the same problem the other way,\n with a dark foreground — it is too light to carry white at any usable\n saturation. */\n --green: oklch(0.535 0.15 150);\n --green-foreground: oklch(0.985 0 0);\n --green-soft: oklch(0.95 0.05 150);\n --green-soft-foreground: oklch(0.42 0.12 150);\n --green-hover: oklch(0.51 0.14 150);\n --green-soft-hover: oklch(0.91 0.065 150);\n --green-soft-foreground-hover: oklch(0.35 0.13 150);\n\n --amber: oklch(0.78 0.15 75);\n --amber-foreground: oklch(0.25 0.05 75);\n --amber-soft: oklch(0.95 0.06 80);\n --amber-soft-foreground: oklch(0.45 0.11 70);\n --amber-hover: oklch(0.72 0.16 75);\n --amber-soft-hover: oklch(0.91 0.08 80);\n --amber-soft-foreground-hover: oklch(0.38 0.12 70);\n\n /* 4.22:1 before. */\n --rose: oklch(0.575 0.21 15);\n --rose-foreground: oklch(0.985 0 0);\n --rose-soft: oklch(0.95 0.04 15);\n --rose-soft-foreground: oklch(0.47 0.19 15);\n --rose-hover: oklch(0.53 0.2 15);\n --rose-soft-hover: oklch(0.91 0.055 15);\n --rose-soft-foreground-hover: oklch(0.4 0.2 15);\n\n --chart-1: oklch(0.646 0.222 41.116);\n --chart-2: oklch(0.6 0.118 184.704);\n --chart-3: oklch(0.398 0.07 227.392);\n --chart-4: oklch(0.828 0.189 84.429);\n --chart-5: oklch(0.769 0.188 70.08);\n}\n\n/*\n * The same scale, for browsers without `corner-shape`.\n *\n * The control radii above are each half their control's height — the roundest a\n * squircle goes before the corners meet. A *circular* corner at half the height\n * is not a squircle, it is a pill: on iOS Safari, which has no `corner-shape`\n * yet, every input, button and select came out fully rounded, and the group\n * radii — 42px to 54px, sized to stay concentric with a 30px card — turned\n * containers into lozenges.\n *\n * So where the property is missing the scale steps down to something a circular\n * corner can express. The concentric rule is kept, just recomputed against the\n * smaller card: a container's radius is still the inner radius plus the padding\n * between them, or the gap between the two curves narrows at the corners.\n *\n * `--radius-xs` through `--radius-3xl` are `calc()` off `--radius`, so they\n * follow on their own. The check radii do not move — at 2px to 8px on a 8px to\n * 24px box there is no visible difference between the two corner shapes.\n */\n@supports not (corner-shape: squircle) {\n :root {\n --radius: 0.875rem; /* 14px, so --radius-3xl lands on 24px */\n\n --radius-control-sm: 10px; /* h-8 / 32px */\n --radius-control-md: 11px; /* h-9 / 36px */\n --radius-control-lg: 12px; /* h-10 / 40px */\n --radius-control-xl: 14px; /* h-12 / 48px */\n\n --radius-group-sm: 36px; /* 24 + 12 */\n --radius-group-md: 42px; /* 24 + 18 */\n --radius-group-lg: 48px; /* 24 + 24 */\n }\n}\n\n\n.dark {\n color-scheme: dark;\n\n --background: oklch(0.08 0 0);\n --foreground: oklch(0.985 0 0);\n --card: oklch(0.13 0 0);\n --card-foreground: oklch(0.985 0 0);\n --popover: oklch(0.13 0 0);\n --popover-foreground: oklch(0.985 0 0);\n --primary: oklch(0.922 0 0);\n --primary-foreground: oklch(0.13 0 0);\n --primary-hover: oklch(0.82 0 0);\n --tint-shift: 0.08;\n --tint-text-shift: 0.18;\n --press-shade: white;\n --secondary: oklch(0.18 0 0);\n --card-header: oklch(0.19 0 0);\n --group: oklch(0.23 0 0);\n --secondary-foreground: oklch(0.985 0 0);\n --secondary-hover: oklch(0.24 0 0);\n --secondary-foreground-hover: oklch(1 0 0);\n --muted: oklch(0.18 0 0);\n --muted-foreground: oklch(0.708 0 0);\n --accent: oklch(0.2 0 0);\n --accent-foreground: oklch(0.985 0 0);\n --destructive: oklch(0.704 0.191 22.216);\n --destructive-foreground: oklch(0.16 0.02 22.216);\n --destructive-hover: oklch(0.77 0.17 22.216);\n --destructive-soft: oklch(0.704 0.191 22.216 / 18%);\n --destructive-soft-foreground: oklch(0.85 0.1 22.216);\n --destructive-soft-hover: oklch(0.704 0.191 22.216 / 30%);\n --destructive-soft-foreground-hover: oklch(0.92 0.08 22.216);\n --border: oklch(1 0 0 / 12%);\n --border-active: oklch(1 0 0 / 40%);\n --input: oklch(1 0 0 / 15%);\n --ring: oklch(0.556 0 0);\n\n --blue: oklch(0.62 0.19 258);\n --blue-foreground: oklch(0.16 0.02 258);\n --blue-soft: oklch(0.62 0.19 258 / 18%);\n --blue-soft-foreground: oklch(0.82 0.11 258);\n --blue-hover: oklch(0.7 0.17 258);\n --blue-soft-hover: oklch(0.62 0.19 258 / 30%);\n --blue-soft-foreground-hover: oklch(0.9 0.09 258);\n\n --violet: oklch(0.65 0.22 295);\n --violet-foreground: oklch(0.16 0.02 295);\n --violet-soft: oklch(0.65 0.22 295 / 18%);\n --violet-soft-foreground: oklch(0.84 0.11 295);\n --violet-hover: oklch(0.72 0.2 295);\n --violet-soft-hover: oklch(0.65 0.22 295 / 30%);\n --violet-soft-foreground-hover: oklch(0.91 0.09 295);\n\n --cyan: oklch(0.72 0.11 210);\n --cyan-foreground: oklch(0.16 0.02 210);\n --cyan-soft: oklch(0.72 0.11 210 / 18%);\n --cyan-soft-foreground: oklch(0.85 0.08 210);\n --cyan-hover: oklch(0.79 0.1 210);\n --cyan-soft-hover: oklch(0.72 0.11 210 / 30%);\n --cyan-soft-foreground-hover: oklch(0.92 0.06 210);\n\n --green: oklch(0.68 0.15 150);\n --green-foreground: oklch(0.16 0.02 150);\n --green-soft: oklch(0.68 0.15 150 / 18%);\n --green-soft-foreground: oklch(0.84 0.11 150);\n --green-hover: oklch(0.75 0.14 150);\n --green-soft-hover: oklch(0.68 0.15 150 / 30%);\n --green-soft-foreground-hover: oklch(0.91 0.09 150);\n\n --amber: oklch(0.8 0.15 75);\n --amber-foreground: oklch(0.2 0.04 75);\n --amber-soft: oklch(0.8 0.15 75 / 18%);\n --amber-soft-foreground: oklch(0.88 0.1 80);\n --amber-hover: oklch(0.86 0.14 75);\n --amber-soft-hover: oklch(0.8 0.15 75 / 30%);\n --amber-soft-foreground-hover: oklch(0.94 0.08 80);\n\n --rose: oklch(0.68 0.2 15);\n --rose-foreground: oklch(0.16 0.02 15);\n --rose-soft: oklch(0.68 0.2 15 / 18%);\n --rose-soft-foreground: oklch(0.85 0.1 15);\n --rose-hover: oklch(0.75 0.18 15);\n --rose-soft-hover: oklch(0.68 0.2 15 / 30%);\n --rose-soft-foreground-hover: oklch(0.92 0.08 15);\n\n --chart-1: oklch(0.488 0.243 264.376);\n --chart-2: oklch(0.696 0.17 162.48);\n --chart-3: oklch(0.769 0.188 70.08);\n --chart-4: oklch(0.627 0.265 303.9);\n --chart-5: oklch(0.645 0.246 16.439);\n}\n\n\n@theme inline {\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-card: var(--card);\n --color-card-foreground: var(--card-foreground);\n --color-popover: var(--popover);\n --color-popover-foreground: var(--popover-foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-destructive: var(--destructive);\n --color-destructive-foreground: var(--destructive-foreground);\n --color-border: var(--border);\n --color-input: var(--input);\n --color-ring: var(--ring);\n\n --color-blue: var(--blue);\n --color-blue-foreground: var(--blue-foreground);\n --color-blue-soft: var(--blue-soft);\n --color-blue-soft-foreground: var(--blue-soft-foreground);\n --color-violet: var(--violet);\n --color-violet-foreground: var(--violet-foreground);\n --color-violet-soft: var(--violet-soft);\n --color-violet-soft-foreground: var(--violet-soft-foreground);\n --color-cyan: var(--cyan);\n --color-cyan-foreground: var(--cyan-foreground);\n --color-cyan-soft: var(--cyan-soft);\n --color-cyan-soft-foreground: var(--cyan-soft-foreground);\n --color-green: var(--green);\n --color-green-foreground: var(--green-foreground);\n --color-green-soft: var(--green-soft);\n --color-green-soft-foreground: var(--green-soft-foreground);\n --color-amber: var(--amber);\n --color-amber-foreground: var(--amber-foreground);\n --color-amber-soft: var(--amber-soft);\n --color-amber-soft-foreground: var(--amber-soft-foreground);\n --color-rose: var(--rose);\n --color-rose-foreground: var(--rose-foreground);\n --color-rose-soft: var(--rose-soft);\n --color-rose-soft-foreground: var(--rose-soft-foreground);\n\n --color-chart-1: var(--chart-1);\n --color-chart-2: var(--chart-2);\n --color-chart-3: var(--chart-3);\n --color-chart-4: var(--chart-4);\n --color-chart-5: var(--chart-5);\n\n --radius-xs: calc(var(--radius) - 8px);\n --radius-sm: calc(var(--radius) - 6px);\n --radius-md: calc(var(--radius) - 4px);\n --radius-lg: calc(var(--radius) - 2px);\n --radius-xl: var(--radius);\n --radius-2xl: calc(var(--radius) + 4px);\n --radius-3xl: calc(var(--radius) + 10px);\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n }\n\n /*\n * iOS-style continuous corners. `corner-shape: squircle` reshapes whatever\n * `border-radius` already says, so every rounded thing in the kit — and the\n * focus ring, which follows the same shape — becomes a squircle with no per\n * component work. Browsers without it keep circular corners, which is why the\n * radius scale still has to look right on its own.\n *\n * This MUST stay inside `@layer base`. Unlayered declarations outrank every\n * layered one, so as a top-level rule it silently beat the\n * `[corner-shape:round]` utility that xs controls use to opt back out into a\n * true pill.\n */\n @supports (corner-shape: squircle) {\n *,\n *::before,\n *::after {\n corner-shape: squircle;\n }\n\n /*\n * Fully rounded means a real circle or pill, never a squircle.\n *\n * A superellipse at 50% radius is a rounded rectangle, so without this a\n * spinner renders as a cube, progress bars get square ends, and every dot\n * turns into a tiny box. Higher specificity than the `*` rule above, so it\n * wins without !important.\n */\n .rounded-full {\n corner-shape: round;\n }\n }\n\n body {\n @apply bg-background text-foreground;\n font-synthesis: none;\n text-rendering: optimizeLegibility;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n}\n\n/* Shiki dual-theme output: both palettes ship in the markup as CSS variables,\n so switching themes needs no re-highlight. See src/lib/highlighter.ts. */\n.shiki,\n.shiki span {\n color: var(--shiki-light);\n background-color: var(--shiki-light-bg);\n}\n\n.dark .shiki,\n.dark .shiki span {\n color: var(--shiki-dark);\n background-color: var(--shiki-dark-bg);\n}\n\n/* Line numbers render in a pseudo-element gutter; the editable overlay in\n CodeBlock reserves the same 3.5ch on its left edge to stay aligned. */\n.code-block--numbers .shiki code {\n counter-reset: code-line;\n}\n\n.code-block--numbers .shiki .line {\n counter-increment: code-line;\n}\n\n.code-block--numbers .shiki .line::before {\n content: counter(code-line);\n display: inline-block;\n width: 2ch;\n margin-left: -3.5ch;\n margin-right: 1.5ch;\n text-align: right;\n color: var(--muted-foreground);\n opacity: 0.5;\n}\n\n.shiki .line[data-highlighted] {\n display: inline-block;\n width: 100%;\n /* Marker bar drawn as a gradient rather than an inset box-shadow, which the\n kit avoids; a border would shift the code by 2px. */\n background-color: var(--accent);\n background-image: linear-gradient(\n to right,\n var(--ring) 0 2px,\n transparent 2px\n );\n}\n\n/*\n * Range input.\n *\n * The input still does all the work — pointer mapping, arrow keys, Home/End,\n * touch dragging, form submission — but it paints none of it. Track, fill and\n * thumb are real elements beside it, drawn by the Slider component.\n *\n * That split exists for one reason: a native thumb's position is the browser's,\n * derived from `value`, and there is no property on it to transition. Nothing\n * can make it trail the pointer. An element positioned from a *registered*\n * custom property can be interpolated like any other, so the visible thumb is\n * ours and the invisible one underneath is only a hit target.\n *\n * The two must agree on geometry or the thumb sits beside the pointer: a range\n * input insets the thumb's travel by half its own width at each end, so the\n * pseudo-elements keep their size — and lose their borders, which would\n * otherwise grow the box past `--slider-thumb` and shift the mapping by 2px.\n */\n@property --slider-progress {\n syntax: '<number>';\n inherits: true;\n initial-value: 0;\n}\n\n.slider {\n appearance: none;\n background: transparent;\n cursor: pointer;\n margin: 0;\n}\n\n.slider::-webkit-slider-runnable-track {\n height: var(--slider-track);\n background: transparent;\n}\n\n.slider::-webkit-slider-thumb {\n appearance: none;\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border: 0;\n background: transparent;\n /* Centre the hit target on the track rather than on the input box. */\n margin-top: calc((var(--slider-track) - var(--slider-thumb)) / 2);\n}\n\n.slider::-moz-range-track {\n height: var(--slider-track);\n background: transparent;\n}\n\n.slider::-moz-range-thumb {\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border: 0;\n background: transparent;\n}\n\n.slider:disabled {\n cursor: not-allowed;\n}\n\n/*\n * The follow.\n *\n * `--slider-progress` is registered as a `<number>` above, which is what makes\n * it interpolable — an unregistered custom property animates discretely, and\n * the thumb would jump exactly as it did when the browser owned it. Everything\n * that reads it (the fill's width, the thumb's offset) inherits the animated\n * value, so one transition drives the whole control and the parts cannot drift\n * out of step.\n *\n * 130ms: long enough to read as the thumb following the pointer rather than\n * being nailed to it, short enough that a click on the track still feels like\n * it landed. Past about 200ms it stops reading as weight and starts reading as\n * lag.\n */\n.slider-shell {\n transition: --slider-progress 130ms ease-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .slider-shell {\n transition: none;\n }\n}\n\n/*\n * Hide the native resize grip. It is drawn by the browser in the bottom-right\n * corner and cannot be styled, so it never matches the kit — and it collides\n * with the rounded corner. Dragging the edge still resizes where `resize` is\n * not `none`; only the icon goes.\n */\n[data-slot='textarea']::-webkit-resizer {\n display: none;\n}\n\n/*\n * Native <dialog> backdrop. It is a pseudo-element in the top layer, so it\n * cannot be reached from a class — these rules are the only way to style it.\n */\ndialog::backdrop {\n background-color: color-mix(in oklab, black, transparent 55%);\n animation: dialog-backdrop-in 150ms ease-out;\n}\n\ndialog[data-slot='sheet']::backdrop {\n background-color: color-mix(in oklab, black, transparent 60%);\n}\n\n@keyframes dialog-backdrop-in {\n from {\n opacity: 0;\n }\n}\n\n@keyframes dialog-in {\n from {\n opacity: 0;\n }\n}\n\n@keyframes sheet-in-right {\n from {\n translate: 100% 0;\n }\n}\n\n@keyframes sheet-in-left {\n from {\n translate: -100% 0;\n }\n}\n\n@keyframes sheet-in-top {\n from {\n translate: 0 -100%;\n }\n}\n\n@keyframes sheet-in-bottom {\n from {\n translate: 0 100%;\n }\n}\n\ndialog[open] {\n animation: dialog-in 150ms ease-out;\n}\n\ndialog[open][data-side='right'] {\n animation: sheet-in-right 200ms ease-out;\n}\n\ndialog[open][data-side='left'] {\n animation: sheet-in-left 200ms ease-out;\n}\n\ndialog[open][data-side='top'] {\n animation: sheet-in-top 200ms ease-out;\n}\n\ndialog[open][data-side='bottom'] {\n animation: sheet-in-bottom 200ms ease-out;\n}\n\n@media (prefers-reduced-motion: reduce) {\n dialog[open],\n dialog[open][data-side],\n dialog::backdrop {\n animation: none;\n }\n}\n\n/* Indeterminate progress: a bar that sweeps rather than fills. */\n@keyframes progress-indeterminate {\n 0% {\n translate: -100% 0;\n }\n 100% {\n translate: 350% 0;\n }\n}\n\n/* Toasts arrive from the edge they are anchored to. */\n@keyframes toast-in {\n from {\n opacity: 0;\n translate: 0 0.5rem;\n }\n}\n\n/*\n * Custom scrollbars, everywhere.\n *\n * Applied globally rather than to one component: every overflowing surface in\n * the kit — a menu, a code block, a table wrapper, the sidebar, the page — gets\n * the same bar, so nothing drops back to the platform default.\n *\n * The two mechanisms are kept apart on purpose. Chromium ignores every\n * `::-webkit-scrollbar` rule the moment `scrollbar-color` is set to anything but\n * `auto`, so declaring both means the shaped version silently never applies\n * there. The `@supports` query hands Chromium and Safari the pseudo-elements —\n * which can do the rounded, inset thumb — and gives Firefox the standard\n * properties, which are all it understands.\n *\n * No JavaScript overlay anywhere, so native momentum, keyboard scrolling and\n * accessibility stay intact.\n */\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track,\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n background-color: var(--border);\n border-radius: 999px;\n corner-shape: round;\n /* Transparent border plus content-box clipping insets the thumb, so it reads\n as a 4px pill rather than filling the whole gutter. */\n border: 3px solid transparent;\n background-clip: content-box;\n transition: background-color 150ms ease-out;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background-color: var(--border-active);\n}\n\n@supports not selector(::-webkit-scrollbar) {\n :root {\n /* Inherited, so this reaches every scrollable descendant. */\n scrollbar-color: var(--border) transparent;\n }\n\n * {\n /* Not inherited — it has to be set on each scroll container. */\n scrollbar-width: thin;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *::-webkit-scrollbar-thumb {\n transition: none;\n }\n}\n\n/*\n * Astralyx logo blink. Ported from the Lottie timeline on astralyx.dev: two\n * quick blinks, then a longer one where the left eye only closes to 22% — the\n * wink is in the original, not an accident.\n *\n * `transform-box: view-box` resolves transform-origin in viewBox units, so each\n * eyelid pivots on its own hinge rather than the SVG's top-left corner.\n */\n.ax-eyelid-l,\n.ax-eyelid-r {\n transform-box: view-box;\n}\n\n.ax-eyelid-r {\n transform-origin: 25.52px 31.14px;\n animation: ax-blink-r 3s steps(1, end) infinite;\n}\n\n.ax-eyelid-l {\n transform-origin: 9.94px 30.24px;\n animation: ax-blink-l 3s steps(1, end) infinite;\n}\n\n@keyframes ax-blink-r {\n 0%, 32.22% { transform: scaleY(0); }\n 34.44% { transform: scaleY(1); }\n 37.78%, 41.11% { transform: scaleY(0); }\n 42.78% { transform: scaleY(1); }\n 45.56%, 66.67% { transform: scaleY(0); }\n 70%, 78.89% { transform: scaleY(1); }\n 83.33%, 100% { transform: scaleY(0); }\n}\n\n@keyframes ax-blink-l {\n 0%, 32.22% { transform: scaleY(0); }\n 34.44% { transform: scaleY(1); }\n 37.78%, 41.11% { transform: scaleY(0); }\n 42.78% { transform: scaleY(1); }\n 45.56%, 66.67% { transform: scaleY(0); }\n 70%, 78.89% { transform: scaleY(0.22); }\n 83.33%, 100% { transform: scaleY(0); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ax-eyelid-l,\n .ax-eyelid-r {\n animation: none;\n transform: scaleY(0);\n }\n}\n\n/*\n * -------------------------------------------------------------- motion ----\n *\n * The keyframes the kit's own components reference by name. Everything else —\n * entrances, hovers, presses, value transitions — is a utility class composed\n * in `lib/motion.ts`, so this section only holds what a class cannot express:\n * a loop, or a two-ended sweep.\n *\n * Each is paired with a `prefers-reduced-motion` rule at the point of use\n * rather than here, because a few of them (the sweep below) still need to leave\n * something visible when the motion is taken away.\n */\n\n/*\n * Skeleton sweep. A band of light crossing a placeholder, left to right.\n *\n * Transform rather than `background-position`: the band is a pseudo-element\n * that is already the size of its parent, so moving it is a composited step\n * with no repaint, while animating a gradient's position repaints the whole box\n * every frame — on a screen of forty loading rows that is the difference\n * between free and not.\n */\n@keyframes ax-sweep {\n from {\n transform: translateX(-100%);\n }\n to {\n transform: translateX(100%);\n }\n}\n\n/*\n * The plainest entrance in the kit: opacity, and nothing else.\n *\n * Deliberately not `tw-animate-css`'s `animate-in fade-in-0`, which is built on\n * one shared `enter` keyframe that sets `transform` as well — identity in this\n * case, but a *computed* transform all the same, and a computed transform makes\n * the element a containing block for every `position: fixed` descendant while\n * it runs. This is worn by component roots, and a component root is exactly the\n * thing a menu inside it is positioned against.\n */\n@keyframes ax-fade-in {\n from {\n opacity: 0;\n }\n}\n\n/*\n * A bar arriving out of its own leading edge.\n *\n * A clip rather than a `scaleX`, and both rather than an animated `width`.\n * Width reflows the row on every frame; `scaleX` is free but squashes whatever\n * the bar contains — and these are worn by segmented tracks that carry their\n * own percentage labels, which would spend 600ms as illegible smears. A clip\n * moves no geometry at all and is composited the same way.\n *\n * Both ends are written out. `clip-path: none` is not interpolable with a basic\n * shape, so a keyframe with only a `from` would animate discretely — a jump at\n * the halfway mark instead of a wipe.\n */\n@keyframes ax-grow-x {\n from {\n clip-path: inset(0 100% 0 0);\n }\n to {\n clip-path: inset(0 0 0 0);\n }\n}\n\n/* The same, for a column standing on a baseline. */\n@keyframes ax-grow-y {\n from {\n clip-path: inset(100% 0 0 0);\n }\n to {\n clip-path: inset(0 0 0 0);\n }\n}\n\n/*\n * A live indicator: one ring expanding out of a dot and fading as it goes.\n *\n * Drawn as a pseudo-element so the dot itself never moves — a status light that\n * changes size is harder to read at a glance, and it would reflow anything\n * measured against it.\n */\n@keyframes ax-ping {\n 0% {\n transform: scale(1);\n opacity: 0.45;\n }\n 75%,\n 100% {\n transform: scale(2.25);\n opacity: 0;\n }\n}\n\n/*\n * A value arriving in place of another: the old one leaves upward, the new one\n * comes up from below. Used where a figure is *replaced* rather than counted\n * to — a ticker's last trade, a rank, a status word.\n */\n@keyframes ax-roll-in {\n from {\n transform: translateY(0.4em);\n opacity: 0;\n }\n}\n\n/* The pending-reply dots. Opacity only, so it sits inside the motion rule. */\n@keyframes message-dot {\n 0%, 60%, 100% { opacity: 0.3; }\n 30% { opacity: 1; }\n}\n\n/*\n * Interactive things get the hand.\n *\n * Tailwind v4 stopped forcing `cursor: pointer` on buttons and let the UA\n * default (`default`) stand, which is defensible for a native `<button>` in a\n * document and wrong for an application: every Button in the kit, and every\n * hand-written `<button>` inside a component, pointed at nothing. Restored\n * here rather than in `controlBase`, because most of the offenders are the\n * bare buttons inside components — a row's star, a menu's chevron, a chip's\n * dismiss — that never went near the control tokens.\n *\n * Inside `@layer base` on purpose. An unlayered rule beats every layered one\n * whatever its specificity, so this would quietly outrank `cursor-grab` on a\n * drag handle and `cursor-not-allowed` on a disabled control. From `base`, any\n * utility still wins.\n *\n * The roles are here too: plenty of these controls are a `div` with a role,\n * and a listbox option that does not point is the same bug wearing a different\n * element.\n */\n@layer base {\n :where(\n button,\n summary,\n [role='button'],\n [role='menuitem'],\n [role='menuitemcheckbox'],\n [role='menuitemradio'],\n [role='option'],\n [role='tab'],\n [role='switch'],\n [role='checkbox'],\n [role='radio']\n ):not(:disabled, [aria-disabled='true'], [data-disabled='true']) {\n cursor: pointer;\n }\n}\n\n/*\n * Contain visually hidden elements.\n *\n * Tailwind's `sr-only` is `position: absolute`. An absolutely positioned box\n * whose ancestors are all `static` resolves against the *initial containing\n * block* — the document — and it contributes to the document's scrollable\n * overflow. So a page full of switches, checkboxes and radios (each of which\n * hides its real input this way) becomes scrollable far past its own content,\n * and because it is the document scrolling rather than the main region, the\n * header and the sidebar slide away with it. The symptom is a screen of blank\n * space under the last section.\n *\n * Giving the direct parent a containing block costs nothing and closes the\n * whole class of bug rather than the three components that happen to show it\n * today. `:where()` keeps the specificity at zero, so any component that sets\n * its own `position` still wins.\n */\n:where(*:has(> .sr-only)) {\n position: relative;\n}\n\n/*\n * The documentation rail's rows.\n *\n * One class rather than the utility list inline, and the only place in this\n * repo where that trade is worth making. The rail carries 366 links and is\n * rendered into all 358 exported pages: at roughly 1.2 kB of repeated class\n * names per row it was 466 kB of every page and about two thirds of the whole\n * artefact. The markup is identical, and `aria-current` was already the thing\n * marking the active row, so nothing needed a second attribute to say it twice.\n */\n@layer components {\n .rail-link {\n @apply flex shrink-0 items-center justify-between gap-2 rounded-lg px-3 py-1.5;\n @apply text-left text-sm whitespace-nowrap;\n @apply text-muted-foreground hover:text-foreground hover:bg-accent/50;\n @apply outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px];\n @apply transition-colors duration-150 ease-out motion-reduce:transition-none;\n }\n\n .rail-link[aria-current='page'] {\n @apply bg-accent text-accent-foreground font-medium;\n }\n}\n"
16
16
  }
17
17
  ]
18
18
  }