astralyx-ui 0.7.2 → 0.7.5
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 +1 -1
- package/registry/index.json +1 -1
- package/registry/items/audio-player.json +1 -1
- package/registry/items/card.json +1 -1
- package/registry/items/image-cropper.json +1 -1
- package/registry/items/message.json +1 -1
- package/registry/items/org-chart.json +1 -1
- package/registry/items/primitive-router.json +1 -1
- package/registry/items/sandbox-policy.json +1 -1
- package/registry/items/treemap.json +1 -1
package/package.json
CHANGED
package/registry/index.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"path": "components/ui/audio-player.tsx",
|
|
18
18
|
"type": "registry:ui",
|
|
19
|
-
"content": "import {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An audio element with a scrubber, built on the real `<audio>` element.\n *\n * The element does the work — buffering, codecs, media keys, the OS lock\n * screen, Bluetooth controls — and this draws the surface over it. A player\n * built on `AudioContext` for the sake of a nicer waveform loses all of that,\n * and the waveform is the least important part.\n *\n * **State is read from the element, not mirrored beside it.** `timeupdate`,\n * `play` and `pause` all fire whether the change came from this UI, a media\n * key, or the OS — so mirroring into React state means the two disagree the\n * moment anything else touches playback.\n *\n * **The scrubber is an `<input type=\"range\">`.** Keyboard seeking, page-up\n * jumps, screen-reader announcement and touch drag are all native. It is\n * scrubbing *while dragging* that needs care: `dragging` suspends the\n * `timeupdate` handler, or every frame of playback yanks the thumb back out of\n * your hand.\n *\n * `peaks` draws a waveform behind the track when you have one. Computing it\n * here would mean decoding the whole file in the main thread before the first\n * frame — the caller either has it precomputed or does not want it.\n */\ntype AudioPlayerProps = Omit<ComponentProps<'div'>, 'title'> & {\n src: string\n title?: ReactNode\n artist?: ReactNode\n /** Precomputed amplitudes, 0–1. Drawn behind the scrubber. */\n peaks?: number[]\n /** Start muted — required for anything that also autoplays. */\n defaultMuted?: boolean\n /** Seconds a skip button moves. */\n skipBy?: number\n /** Hide the skip buttons for a short clip. */\n showSkip?: boolean\n /** Cover art. */\n artwork?: ReactNode\n onEnded?: () => void\n playLabel?: string\n pauseLabel?: string\n muteLabel?: string\n unmuteLabel?: string\n seekLabel?: string\n backLabel?: string\n forwardLabel?: string\n /** Formats the clock. Defaults to `m:ss`, and `h:mm:ss` past an hour. */\n formatTime?: (seconds: number) => string\n}\n\nfunction defaultTime(seconds: number) {\n if (!Number.isFinite(seconds)) return '0:00'\n const whole = Math.floor(seconds)\n const h = Math.floor(whole / 3600)\n const m = Math.floor((whole % 3600) / 60)\n const s = whole % 60\n const pad = (value: number) => String(value).padStart(2, '0')\n return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`\n}\n\nfunction AudioPlayer({\n src,\n title,\n artist,\n peaks,\n defaultMuted = false,\n skipBy = 15,\n showSkip = true,\n artwork,\n onEnded,\n playLabel = 'Play',\n pauseLabel = 'Pause',\n muteLabel = 'Mute',\n unmuteLabel = 'Unmute',\n seekLabel = 'Seek',\n backLabel = 'Back',\n forwardLabel = 'Forward',\n formatTime = defaultTime,\n className,\n ...props\n}: AudioPlayerProps) {\n const ref = useRef<HTMLAudioElement>(null)\n const id = useId()\n\n const [playing, setPlaying] = useState(false)\n const [muted, setMuted] = useState(defaultMuted)\n const [time, setTime] = useState(0)\n const [duration, setDuration] = useState(0)\n const [dragging, setDragging] = useState(false)\n\n // Every listener reads back off the element, so a media key or the OS lock\n // screen keeps this UI correct without going through it.\n useEffect(() => {\n const audio = ref.current\n if (!audio) return\n\n const onTime = () => {\n // Suspended while scrubbing, or playback yanks the thumb out of your hand.\n if (!dragging) setTime(audio.currentTime)\n }\n const onMeta = () => setDuration(audio.duration)\n const onPlay = () => setPlaying(true)\n const onPause = () => setPlaying(false)\n const onVolume = () => setMuted(audio.muted)\n const onEnd = () => {\n setPlaying(false)\n onEnded?.()\n }\n\n audio.addEventListener('timeupdate', onTime)\n audio.addEventListener('loadedmetadata', onMeta)\n audio.addEventListener('durationchange', onMeta)\n audio.addEventListener('play', onPlay)\n audio.addEventListener('pause', onPause)\n audio.addEventListener('volumechange', onVolume)\n audio.addEventListener('ended', onEnd)\n\n return () => {\n audio.removeEventListener('timeupdate', onTime)\n audio.removeEventListener('loadedmetadata', onMeta)\n audio.removeEventListener('durationchange', onMeta)\n audio.removeEventListener('play', onPlay)\n audio.removeEventListener('pause', onPause)\n audio.removeEventListener('volumechange', onVolume)\n audio.removeEventListener('ended', onEnd)\n }\n }, [dragging, onEnded])\n\n const seek = useCallback((seconds: number) => {\n const audio = ref.current\n if (!audio) return\n audio.currentTime = Math.min(Math.max(0, seconds), audio.duration || 0)\n }, [])\n\n const progress = duration > 0 ? time / duration : 0\n\n return (\n <div\n data-slot=\"audio-player\"\n data-playing={playing || undefined}\n className={cn(surface, radius.surface, 'flex items-center gap-4 p-4', className)}\n {...props}\n >\n {/* The real element. Hidden, never removed — it is what actually plays. */}\n <audio ref={ref} src={src} muted={defaultMuted} preload=\"metadata\" className=\"sr-only\" />\n\n {artwork && (\n <div className={cn('size-14 shrink-0 overflow-hidden', radius.control)}>{artwork}</div>\n )}\n\n <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n {(title || artist) && (\n <div className=\"min-w-0\">\n {title && <p className=\"truncate text-sm font-medium\">{title}</p>}\n {artist && <p className=\"text-muted-foreground truncate text-xs\">{artist}</p>}\n </div>\n )}\n\n <div className=\"flex items-center gap-2\">\n <Button\n variant=\"secondary\"\n size=\"icon-sm\"\n aria-label={playing ? pauseLabel : playLabel}\n className=\"shrink-0\"\n onClick={() => {\n const audio = ref.current\n if (!audio) return\n if (audio.paused) void audio.play()\n else audio.pause()\n }}\n >\n {playing ? <Pause /> : <Play />}\n </Button>\n\n {showSkip && (\n <>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`${backLabel} ${skipBy}s`}\n className=\"shrink-0\"\n onClick={() => seek(time - skipBy)}\n >\n <SkipBack />\n </Button>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`${forwardLabel} ${skipBy}s`}\n className=\"shrink-0\"\n onClick={() => seek(time + skipBy)}\n >\n <SkipForward />\n </Button>\n </>\n )}\n\n <span className=\"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums\">\n {formatTime(time)}\n </span>\n\n <div className=\"relative min-w-0 flex-1\">\n {/* Behind the track, never in front: the range input has to stay\n the thing you actually grab. */}\n {peaks && peaks.length > 0 && (\n <div aria-hidden=\"true\" className=\"absolute inset-0 flex items-center gap-px\">\n {peaks.map((peak, index) => (\n <span\n key={index}\n className={cn(\n 'flex-1 rounded-full',\n index / peaks.length <= progress ? 'bg-foreground/70' : 'bg-muted-foreground/25',\n )}\n style={{ height: `${Math.max(8, peak * 100)}%` }}\n />\n ))}\n </div>\n )}\n\n <input\n id={id}\n type=\"range\"\n min={0}\n max={duration || 0}\n step={0.01}\n value={time}\n aria-label={seekLabel}\n aria-valuetext={`${formatTime(time)} of ${formatTime(duration)}`}\n onPointerDown={() => setDragging(true)}\n onPointerUp={() => setDragging(false)}\n onKeyDown={() => setDragging(true)}\n onKeyUp={() => setDragging(false)}\n onChange={(event) => {\n const next = Number(event.target.value)\n setTime(next)\n seek(next)\n }}\n className={cn(\n 'relative h-6 w-full cursor-pointer appearance-none bg-transparent',\n focusRing,\n radius.control,\n // Track and thumb have to be styled per engine; there is no\n // cross-browser shorthand for either.\n '[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full',\n peaks?.length\n ? '[&::-webkit-slider-runnable-track]:bg-transparent'\n : '[&::-webkit-slider-runnable-track]:bg-muted',\n '[&::-webkit-slider-thumb]:mt-[-4px] [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none',\n '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground',\n '[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted',\n '[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-foreground',\n )}\n />\n </div>\n\n <span className=\"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums\">\n {formatTime(duration)}\n </span>\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={muted ? unmuteLabel : muteLabel}\n className=\"shrink-0\"\n onClick={() => {\n const audio = ref.current\n if (audio) audio.muted = !audio.muted\n }}\n >\n {muted ? <VolumeX /> : <Volume2 />}\n </Button>\n </div>\n </div>\n </div>\n )\n}\n\nexport { AudioPlayer, defaultTime as formatAudioTime }\nexport type { AudioPlayerProps }\n"
|
|
19
|
+
"content": "import {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An audio element with a scrubber, built on the real `<audio>` element.\n *\n * The element does the work — buffering, codecs, media keys, the OS lock\n * screen, Bluetooth controls — and this draws the surface over it. A player\n * built on `AudioContext` for the sake of a nicer waveform loses all of that,\n * and the waveform is the least important part.\n *\n * **State is read from the element, not mirrored beside it.** `timeupdate`,\n * `play` and `pause` all fire whether the change came from this UI, a media\n * key, or the OS — so mirroring into React state means the two disagree the\n * moment anything else touches playback.\n *\n * **The scrubber is an `<input type=\"range\">`.** Keyboard seeking, page-up\n * jumps, screen-reader announcement and touch drag are all native. It is\n * scrubbing *while dragging* that needs care: `dragging` suspends the\n * `timeupdate` handler, or every frame of playback yanks the thumb back out of\n * your hand.\n *\n * `peaks` draws a waveform behind the track when you have one. Computing it\n * here would mean decoding the whole file in the main thread before the first\n * frame — the caller either has it precomputed or does not want it.\n */\n// Omitted because the DOM declares it too, and in an intersection the DOM\n// signature wins — which left the prop below unusable and the generated docs\n// advertising the browser's handler instead of ours.\ntype AudioPlayerProps = Omit<ComponentProps<'div'>, 'title' | 'onEnded'> & {\n src: string\n title?: ReactNode\n artist?: ReactNode\n /** Precomputed amplitudes, 0–1. Drawn behind the scrubber. */\n peaks?: number[]\n /** Start muted — required for anything that also autoplays. */\n defaultMuted?: boolean\n /** Seconds a skip button moves. */\n skipBy?: number\n /** Hide the skip buttons for a short clip. */\n showSkip?: boolean\n /** Cover art. */\n artwork?: ReactNode\n onEnded?: () => void\n playLabel?: string\n pauseLabel?: string\n muteLabel?: string\n unmuteLabel?: string\n seekLabel?: string\n backLabel?: string\n forwardLabel?: string\n /** Formats the clock. Defaults to `m:ss`, and `h:mm:ss` past an hour. */\n formatTime?: (seconds: number) => string\n}\n\nfunction defaultTime(seconds: number) {\n if (!Number.isFinite(seconds)) return '0:00'\n const whole = Math.floor(seconds)\n const h = Math.floor(whole / 3600)\n const m = Math.floor((whole % 3600) / 60)\n const s = whole % 60\n const pad = (value: number) => String(value).padStart(2, '0')\n return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`\n}\n\nfunction AudioPlayer({\n src,\n title,\n artist,\n peaks,\n defaultMuted = false,\n skipBy = 15,\n showSkip = true,\n artwork,\n onEnded,\n playLabel = 'Play',\n pauseLabel = 'Pause',\n muteLabel = 'Mute',\n unmuteLabel = 'Unmute',\n seekLabel = 'Seek',\n backLabel = 'Back',\n forwardLabel = 'Forward',\n formatTime = defaultTime,\n className,\n ...props\n}: AudioPlayerProps) {\n const ref = useRef<HTMLAudioElement>(null)\n const id = useId()\n\n const [playing, setPlaying] = useState(false)\n const [muted, setMuted] = useState(defaultMuted)\n const [time, setTime] = useState(0)\n const [duration, setDuration] = useState(0)\n const [dragging, setDragging] = useState(false)\n\n // Every listener reads back off the element, so a media key or the OS lock\n // screen keeps this UI correct without going through it.\n useEffect(() => {\n const audio = ref.current\n if (!audio) return\n\n const onTime = () => {\n // Suspended while scrubbing, or playback yanks the thumb out of your hand.\n if (!dragging) setTime(audio.currentTime)\n }\n const onMeta = () => setDuration(audio.duration)\n const onPlay = () => setPlaying(true)\n const onPause = () => setPlaying(false)\n const onVolume = () => setMuted(audio.muted)\n const onEnd = () => {\n setPlaying(false)\n onEnded?.()\n }\n\n audio.addEventListener('timeupdate', onTime)\n audio.addEventListener('loadedmetadata', onMeta)\n audio.addEventListener('durationchange', onMeta)\n audio.addEventListener('play', onPlay)\n audio.addEventListener('pause', onPause)\n audio.addEventListener('volumechange', onVolume)\n audio.addEventListener('ended', onEnd)\n\n return () => {\n audio.removeEventListener('timeupdate', onTime)\n audio.removeEventListener('loadedmetadata', onMeta)\n audio.removeEventListener('durationchange', onMeta)\n audio.removeEventListener('play', onPlay)\n audio.removeEventListener('pause', onPause)\n audio.removeEventListener('volumechange', onVolume)\n audio.removeEventListener('ended', onEnd)\n }\n }, [dragging, onEnded])\n\n const seek = useCallback((seconds: number) => {\n const audio = ref.current\n if (!audio) return\n audio.currentTime = Math.min(Math.max(0, seconds), audio.duration || 0)\n }, [])\n\n const progress = duration > 0 ? time / duration : 0\n\n return (\n <div\n data-slot=\"audio-player\"\n data-playing={playing || undefined}\n className={cn(surface, radius.surface, 'flex items-center gap-4 p-4', className)}\n {...props}\n >\n {/* The real element. Hidden, never removed — it is what actually plays. */}\n <audio ref={ref} src={src} muted={defaultMuted} preload=\"metadata\" className=\"sr-only\" />\n\n {artwork && (\n <div className={cn('size-14 shrink-0 overflow-hidden', radius.control)}>{artwork}</div>\n )}\n\n <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n {(title || artist) && (\n <div className=\"min-w-0\">\n {title && <p className=\"truncate text-sm font-medium\">{title}</p>}\n {artist && <p className=\"text-muted-foreground truncate text-xs\">{artist}</p>}\n </div>\n )}\n\n <div className=\"flex items-center gap-2\">\n <Button\n variant=\"secondary\"\n size=\"icon-sm\"\n aria-label={playing ? pauseLabel : playLabel}\n className=\"shrink-0\"\n onClick={() => {\n const audio = ref.current\n if (!audio) return\n if (audio.paused) void audio.play()\n else audio.pause()\n }}\n >\n {playing ? <Pause /> : <Play />}\n </Button>\n\n {showSkip && (\n <>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`${backLabel} ${skipBy}s`}\n className=\"shrink-0\"\n onClick={() => seek(time - skipBy)}\n >\n <SkipBack />\n </Button>\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={`${forwardLabel} ${skipBy}s`}\n className=\"shrink-0\"\n onClick={() => seek(time + skipBy)}\n >\n <SkipForward />\n </Button>\n </>\n )}\n\n <span className=\"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums\">\n {formatTime(time)}\n </span>\n\n <div className=\"relative min-w-0 flex-1\">\n {/* Behind the track, never in front: the range input has to stay\n the thing you actually grab. */}\n {peaks && peaks.length > 0 && (\n <div aria-hidden=\"true\" className=\"absolute inset-0 flex items-center gap-px\">\n {peaks.map((peak, index) => (\n <span\n key={index}\n className={cn(\n 'flex-1 rounded-full',\n index / peaks.length <= progress ? 'bg-foreground/70' : 'bg-muted-foreground/25',\n )}\n style={{ height: `${Math.max(8, peak * 100)}%` }}\n />\n ))}\n </div>\n )}\n\n <input\n id={id}\n type=\"range\"\n min={0}\n max={duration || 0}\n step={0.01}\n value={time}\n aria-label={seekLabel}\n aria-valuetext={`${formatTime(time)} of ${formatTime(duration)}`}\n onPointerDown={() => setDragging(true)}\n onPointerUp={() => setDragging(false)}\n onKeyDown={() => setDragging(true)}\n onKeyUp={() => setDragging(false)}\n onChange={(event) => {\n const next = Number(event.target.value)\n setTime(next)\n seek(next)\n }}\n className={cn(\n 'relative h-6 w-full cursor-pointer appearance-none bg-transparent',\n focusRing,\n radius.control,\n // Track and thumb have to be styled per engine; there is no\n // cross-browser shorthand for either.\n '[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full',\n peaks?.length\n ? '[&::-webkit-slider-runnable-track]:bg-transparent'\n : '[&::-webkit-slider-runnable-track]:bg-muted',\n '[&::-webkit-slider-thumb]:mt-[-4px] [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none',\n '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground',\n '[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted',\n '[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-foreground',\n )}\n />\n </div>\n\n <span className=\"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums\">\n {formatTime(duration)}\n </span>\n\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label={muted ? unmuteLabel : muteLabel}\n className=\"shrink-0\"\n onClick={() => {\n const audio = ref.current\n if (audio) audio.muted = !audio.muted\n }}\n >\n {muted ? <VolumeX /> : <Volume2 />}\n </Button>\n </div>\n </div>\n </div>\n )\n}\n\nexport { AudioPlayer, defaultTime as formatAudioTime }\nexport type { AudioPlayerProps }\n"
|
|
20
20
|
}
|
|
21
21
|
]
|
|
22
22
|
}
|
package/registry/items/card.json
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
{
|
|
16
16
|
"path": "components/ui/card.tsx",
|
|
17
17
|
"type": "registry:ui",
|
|
18
|
-
"content": "import { createContext, use, type ComponentProps } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cardPadding, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A surface that groups related content, split into three optional sections.\n *\n * Header and footer draw their own dividers; a Card holding only a CardBody is\n * just a padded box, with no stray rules. The Card's `size` reaches its sections\n * through context, so `<Card size=\"lg\">` re-pads all of them and a section can\n * still override its own.\n */\ntype CardSize = keyof typeof cardPadding\n\nconst CardContext = createContext<CardSize>('default')\n\n// `overflow-hidden` is what lets a tinted header sit flush in the rounded\n// corner instead of poking a square edge out of it.\nconst cardVariants = cva([surface, 'flex flex-col overflow-hidden'].join(' '), {\n variants: {\n variant: {\n default: '',\n /** Filled, no border — for a card sitting on the page background. */\n secondary: 'bg-secondary border-transparent',\n /** Outline only, no fill. */\n ghost: 'bg-transparent',\n },\n },\n defaultVariants: { variant: 'default' },\n})\n\ntype CardProps = ComponentProps<'div'> &\n VariantProps<typeof cardVariants> & { size?: CardSize }\n\nfunction Card({\n className,\n variant,\n size = 'default',\n ...props\n}: CardProps) {\n return (\n <CardContext value={size}>\n <div\n data-slot=\"card\"\n data-size={size}\n className={cn(cardVariants({ variant }), radius.panel, className)}\n {...props}\n />\n </CardContext>\n )\n}\n\nfunction useCardPadding(override?: CardSize) {\n const inherited = use(CardContext)\n return cardPadding[override ?? inherited]\n}\n\nfunction CardHeader({\n className,\n size,\n ...props\n}: ComponentProps<'div'> & { size?: CardSize }) {\n return (\n <div\n data-slot=\"card-header\"\n className={cn(\n // A shade darker than the body, so the header reads as a distinct band\n // without needing a heavier rule under it.\n 'border-border bg-[var(--card-header)] flex
|
|
18
|
+
"content": "import { createContext, use, type ComponentProps, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cardPadding, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A surface that groups related content, split into three optional sections.\n *\n * Header and footer draw their own dividers; a Card holding only a CardBody is\n * just a padded box, with no stray rules. The Card's `size` reaches its sections\n * through context, so `<Card size=\"lg\">` re-pads all of them and a section can\n * still override its own.\n */\ntype CardSize = keyof typeof cardPadding\n\nconst CardContext = createContext<CardSize>('default')\n\n// `overflow-hidden` is what lets a tinted header sit flush in the rounded\n// corner instead of poking a square edge out of it.\nconst cardVariants = cva([surface, 'flex flex-col overflow-hidden'].join(' '), {\n variants: {\n variant: {\n default: '',\n /** Filled, no border — for a card sitting on the page background. */\n secondary: 'bg-secondary border-transparent',\n /** Outline only, no fill. */\n ghost: 'bg-transparent',\n },\n },\n defaultVariants: { variant: 'default' },\n})\n\ntype CardProps = ComponentProps<'div'> &\n VariantProps<typeof cardVariants> & { size?: CardSize }\n\nfunction Card({\n className,\n variant,\n size = 'default',\n ...props\n}: CardProps) {\n return (\n <CardContext value={size}>\n <div\n data-slot=\"card\"\n data-size={size}\n className={cn(cardVariants({ variant }), radius.panel, className)}\n {...props}\n />\n </CardContext>\n )\n}\n\nfunction useCardPadding(override?: CardSize) {\n const inherited = use(CardContext)\n return cardPadding[override ?? inherited]\n}\n\n/**\n * A header takes a title and a description. Anything else — a menu, a filter,\n * a badge — goes in `action`.\n *\n * The slot exists because the alternative was a habit: a caller who needs a\n * control beside the title reaches for `className=\"flex-row justify-between\"`,\n * which turns the header's own column into a row and lands the description\n * beside the title rather than under it. Every one of them then re-stacks the\n * pair in a wrapper div by hand. This owns that layout instead, so the title\n * column keeps its stacking and the control keeps its size.\n *\n * The control never shrinks and the text column always can, so a long title\n * truncates rather than squeezing a select into illegibility.\n */\nfunction CardHeader({\n className,\n size,\n action,\n children,\n ...props\n}: ComponentProps<'div'> & { size?: CardSize; action?: ReactNode }) {\n return (\n <div\n data-slot=\"card-header\"\n className={cn(\n // A shade darker than the body, so the header reads as a distinct band\n // without needing a heavier rule under it.\n 'border-border bg-[var(--card-header)] flex gap-1 border-b',\n action ? 'flex-row items-center justify-between gap-3' : 'flex-col',\n useCardPadding(size),\n className,\n )}\n {...props}\n >\n {action ? <div className=\"flex min-w-0 flex-col gap-1\">{children}</div> : children}\n {action && (\n <div className=\"flex shrink-0 items-center gap-1.5\">{action}</div>\n )}\n </div>\n )\n}\n\n/**\n * The heading level is a prop because the right one depends on where the card\n * sits: a card directly under the page's `h1` should be an `h2`, and one inside\n * a section that already has an `h2` should be an `h3`. Hard-coding it means\n * every page that nests differently skips a level.\n */\nfunction CardTitle({\n className,\n as: Comp = 'h3',\n ...props\n}: ComponentProps<'h3'> & { as?: 'h2' | 'h3' | 'h4' | 'div' }) {\n return (\n <Comp\n data-slot=\"card-title\"\n className={cn('text-sm leading-none font-semibold', className)}\n {...props}\n />\n )\n}\n\nfunction CardDescription({ className, ...props }: ComponentProps<'p'>) {\n return (\n <p\n data-slot=\"card-description\"\n className={cn('text-muted-foreground text-xs', className)}\n {...props}\n />\n )\n}\n\nfunction CardBody({\n className,\n size,\n ...props\n}: ComponentProps<'div'> & { size?: CardSize }) {\n return (\n <div\n data-slot=\"card-body\"\n className={cn('flex-1', useCardPadding(size), className)}\n {...props}\n />\n )\n}\n\nfunction CardFooter({\n className,\n size,\n ...props\n}: ComponentProps<'div'> & { size?: CardSize }) {\n return (\n <div\n data-slot=\"card-footer\"\n className={cn(\n 'border-border flex items-center gap-2 border-t',\n useCardPadding(size),\n className,\n )}\n {...props}\n />\n )\n}\n\nexport {\n Card,\n CardBody,\n CardDescription,\n CardFooter,\n CardHeader,\n CardTitle,\n cardVariants,\n}\nexport type { CardProps, CardSize }\n"
|
|
19
19
|
}
|
|
20
20
|
]
|
|
21
21
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
{
|
|
15
15
|
"path": "components/ui/image-cropper.tsx",
|
|
16
16
|
"type": "registry:ui",
|
|
17
|
-
"content": "import {\n useCallback,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { Button } from '@/components/ui/button'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The step between picking an image and saving it.\n *\n * A kit with a dropzone, a file input and an upload list still cannot set an\n * avatar, because every avatar upload needs a square and every photo is a\n * rectangle. Without this the crop happens on the server, or not at all and\n * the image is squashed by CSS.\n *\n * **The crop rectangle is stored in natural image coordinates**, not in\n * displayed pixels. The display size depends on the container, which changes\n * with the viewport; a crop expressed in screen pixels silently means something\n * different after a resize, and produces a different output on a phone than on\n * a desktop.\n *\n * **The output is produced with `canvas.toBlob`, not `toDataURL`.** A data URL\n * is base64, which is a third larger, has to be built as one string in memory,\n * and then usually gets converted back to a blob to upload anyway. `toBlob`\n * hands you something you can put straight in a `FormData`.\n *\n * **A cross-origin image taints the canvas** and makes `toBlob` throw a\n * `SecurityError`. `crossOrigin=\"anonymous\"` is set on the image so a server\n * that sends permissive CORS headers works; one that does not will fail at\n * export, and `onError` reports it rather than leaving a dead button.\n */\nexport type CropRect = { x: number; y: number; width: number; height: number }\n\ntype ImageCropperProps = Omit<ComponentProps<'div'>, 'onChange'> & {\n src: string\n /** Width divided by height. Omit to crop freely. */\n aspect?: number\n /** Controlled crop, in natural image pixels. */\n value?: CropRect\n defaultValue?: CropRect\n onChange?: (crop: CropRect) => void\n /** The cropped image. Fires on demand, not on every drag. */\n onCrop?: (blob: Blob, crop: CropRect) => void\n onError?: (error: Error) => void\n /** Output type and quality for the exported blob. */\n outputType?: string\n outputQuality?: number\n /** Longest side of the export. The crop is scaled to fit. */\n maxOutput?: number\n /** Round mask, for avatars. Cosmetic — the export is still a rectangle. */\n round?: boolean\n cropLabel?: string\n resetLabel?: string\n /** Hide the built-in buttons to drive it from your own footer. */\n actions?: boolean\n alt?: string\n}\n\ntype Handle = 'nw' | 'ne' | 'sw' | 'se' | 'move'\n\nconst HANDLES: { id: Handle; className: string; label: string }[] = [\n { id: 'nw', className: 'start-0 top-0 cursor-nwse-resize', label: 'Top left' },\n { id: 'ne', className: 'end-0 top-0 cursor-nesw-resize', label: 'Top right' },\n { id: 'sw', className: 'start-0 bottom-0 cursor-nesw-resize', label: 'Bottom left' },\n { id: 'se', className: 'end-0 bottom-0 cursor-nwse-resize', label: 'Bottom right' },\n]\n\nfunction ImageCropper({\n src,\n aspect,\n value,\n defaultValue,\n onChange,\n onCrop,\n onError,\n outputType = 'image/png',\n outputQuality = 0.92,\n maxOutput,\n round = false,\n cropLabel = 'Crop',\n resetLabel = 'Reset',\n actions = true,\n alt = 'Image to crop',\n className,\n ...props\n}: ImageCropperProps) {\n const imageRef = useRef<HTMLImageElement>(null)\n const boxRef = useRef<HTMLDivElement>(null)\n /**\n * Measurements carry the `src` they belong to, rather than being reset by an\n * effect when `src` changes.\n *\n * The effect version has a race that only shows up in a browser: the reset\n * runs *after* the image has already loaded (a remount, or a cached image\n * that resolves before effects flush), clearing the measured size — and\n * because the image is already complete, `load` never fires again, so the\n * crop rectangle never comes back. Deriving staleness from the src makes the\n * stale state unrepresentable instead.\n */\n const [measured, setMeasured] = useState<{ src: string; width: number; height: number } | null>(null)\n const [internal, setInternal] = useState<{ src: string; rect: CropRect } | null>(\n defaultValue ? { src, rect: defaultValue } : null,\n )\n const drag = useRef<{ handle: Handle; startX: number; startY: number; from: CropRect } | null>(null)\n\n const natural =\n measured?.src === src ? { width: measured.width, height: measured.height } : { width: 0, height: 0 }\n const crop = value ?? (internal?.src === src ? internal.rect : null)\n\n /** Largest rectangle of the requested aspect that fits the image, centred. */\n const initial = useCallback(\n (width: number, height: number): CropRect => {\n if (!aspect) {\n const inset = Math.min(width, height) * 0.1\n return { x: inset, y: inset, width: width - inset * 2, height: height - inset * 2 }\n }\n const byWidth = width / aspect <= height\n const w = byWidth ? width : height * aspect\n const h = byWidth ? width / aspect : height\n return { x: (width - w) / 2, y: (height - h) / 2, width: w, height: h }\n },\n [aspect],\n )\n\n const commit = (next: CropRect) => {\n if (value === undefined) setInternal({ src, rect: next })\n onChange?.(next)\n }\n\n /** Measure, and seed a crop if there is not one for this image yet. */\n const measure = (image: HTMLImageElement) => {\n if (!image.naturalWidth) return\n if (measured?.src === src && measured.width === image.naturalWidth) return\n\n setMeasured({ src, width: image.naturalWidth, height: image.naturalHeight })\n if (value === undefined && internal?.src !== src) {\n const seeded = initial(image.naturalWidth, image.naturalHeight)\n setInternal({ src, rect: seeded })\n onChange?.(seeded)\n }\n }\n\n /** Natural pixels per displayed pixel — the whole coordinate story. */\n const scale = () => {\n const image = imageRef.current\n if (!image || !image.clientWidth) return 1\n return natural.width / image.clientWidth\n }\n\n function onPointerDown(event: ReactPointerEvent, handle: Handle) {\n if (!crop) return\n event.preventDefault()\n event.stopPropagation()\n ;(event.currentTarget as Element).setPointerCapture?.(event.pointerId)\n drag.current = { handle, startX: event.clientX, startY: event.clientY, from: { ...crop } }\n }\n\n function onPointerMove(event: ReactPointerEvent) {\n const state = drag.current\n if (!state || !crop) return\n\n const factor = scale()\n const dx = (event.clientX - state.startX) * factor\n const dy = (event.clientY - state.startY) * factor\n const { from, handle } = state\n const limit = { width: natural.width, height: natural.height }\n\n if (handle === 'move') {\n commit({\n ...from,\n x: Math.min(Math.max(0, from.x + dx), limit.width - from.width),\n y: Math.min(Math.max(0, from.y + dy), limit.height - from.height),\n })\n return\n }\n\n const west = handle === 'nw' || handle === 'sw'\n const north = handle === 'nw' || handle === 'ne'\n const right = west ? from.x + from.width : limit.width\n const bottom = north ? from.y + from.height : limit.height\n\n let width = Math.max(24, west ? from.width - dx : from.width + dx)\n let height = Math.max(24, north ? from.height - dy : from.height + dy)\n\n // With a locked aspect the two axes cannot be resolved independently, so\n // width wins and height follows it.\n if (aspect) height = width / aspect\n\n width = Math.min(width, west ? right : limit.width - from.x)\n height = Math.min(height, north ? bottom : limit.height - from.y)\n if (aspect) width = Math.min(width, height * aspect)\n\n commit({\n x: west ? right - width : from.x,\n y: north ? bottom - height : from.y,\n width,\n height,\n })\n }\n\n const endDrag = () => {\n drag.current = null\n }\n\n /** Draw the crop to a canvas and hand back a blob. */\n const exportCrop = () => {\n const image = imageRef.current\n if (!image || !crop) return\n\n const ratio = maxOutput ? Math.min(1, maxOutput / Math.max(crop.width, crop.height)) : 1\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(crop.width * ratio)\n canvas.height = Math.round(crop.height * ratio)\n\n const context = canvas.getContext('2d')\n if (!context) return\n context.drawImage(\n image,\n crop.x, crop.y, crop.width, crop.height,\n 0, 0, canvas.width, canvas.height,\n )\n\n try {\n canvas.toBlob(\n (blob) => {\n if (blob) onCrop?.(blob, crop)\n else onError?.(new Error('The canvas produced no image.'))\n },\n outputType,\n outputQuality,\n )\n } catch (error) {\n // A cross-origin image without CORS headers taints the canvas; this is\n // the only place it surfaces, so it must not be swallowed.\n onError?.(error instanceof Error ? error : new Error(String(error)))\n }\n }\n\n const percent = (part: number, whole: number) => (whole ? `${(part / whole) * 100}%` : '0%')\n\n return (\n <div\n data-slot=\"image-cropper\"\n className={cn('flex flex-col gap-3', className)}\n {...props}\n >\n <div\n ref={boxRef}\n className={cn('relative touch-none overflow-hidden select-none', surface, radius.surface)}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerLeave={endDrag}\n >\n <img\n ref={(element) => {\n imageRef.current = element\n // A cached image can already be complete before any load event\n // would fire, so it is measured here as well as on load.\n if (element?.complete) measure(element)\n }}\n src={src}\n alt={alt}\n // Required for `toBlob` to work on an image from another origin.\n crossOrigin=\"anonymous\"\n onLoad={(event) => measure(event.currentTarget)}\n draggable={false}\n className=\"block max-h-[420px] w-full object-contain\"\n />\n\n {crop && natural.width > 0 && (\n <>\n {/* One element, four shadows: darkening the outside without four\n positioned overlays that have to stay in sync. */}\n <div\n aria-hidden=\"true\"\n className={cn(\n 'pointer-events-none absolute shadow-[0_0_0_9999px_rgba(0,0,0,0.55)]',\n round && 'rounded-full',\n )}\n style={{\n left: percent(crop.x, natural.width),\n top: percent(crop.y, natural.height),\n width: percent(crop.width, natural.width),\n height: percent(crop.height, natural.height),\n }}\n />\n\n <div\n className={cn(\n 'absolute cursor-move border-2 border-white/90',\n round && 'rounded-full',\n )}\n style={{\n left: percent(crop.x, natural.width),\n top: percent(crop.y, natural.height),\n width: percent(crop.width, natural.width),\n height: percent(crop.height, natural.height),\n }}\n onPointerDown={(event) => onPointerDown(event, 'move')}\n >\n {HANDLES.map((handle) => (\n <span\n key={handle.id}\n role=\"slider\"\n tabIndex={0}\n aria-label={`${handle.label} corner`}\n aria-valuenow={Math.round(crop.width)}\n aria-valuemin={24}\n aria-valuemax={natural.width}\n className={cn(\n 'absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-black/20 bg-white',\n 'ltr:translate-x-[-50%] rtl:translate-x-[50%]',\n focusRing,\n handle.className,\n )}\n style={{\n insetInlineStart: handle.id.includes('w') ? 0 : undefined,\n insetInlineEnd: handle.id.includes('e') ? 0 : undefined,\n top: handle.id.startsWith('n') ? 0 : undefined,\n bottom: handle.id.startsWith('s') ? 0 : undefined,\n transform: 'translate(-50%, -50%)',\n }}\n onPointerDown={(event) => onPointerDown(event, handle.id)}\n onKeyDown={(event) => {\n // Keyboard resize, because a drag handle that only responds\n // to a pointer is unusable without one.\n const step = event.shiftKey ? 20 : 4\n const delta =\n event.key === 'ArrowRight' || event.key === 'ArrowDown'\n ? step\n : event.key === 'ArrowLeft' || event.key === 'ArrowUp'\n ? -step\n : 0\n if (!delta) return\n event.preventDefault()\n const width = Math.max(24, Math.min(crop.width + delta, natural.width - crop.x))\n commit({\n ...crop,\n width,\n height: aspect ? width / aspect : crop.height,\n })\n }}\n />\n ))}\n </div>\n </>\n )}\n </div>\n\n {actions && (\n <div className=\"flex items-center gap-2\">\n <Button size=\"sm\" onClick={exportCrop} disabled={!crop}>\n {cropLabel}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => commit(initial(natural.width, natural.height))}\n disabled={!natural.width}\n >\n {resetLabel}\n </Button>\n {crop && (\n <span className=\"text-muted-foreground ms-auto font-mono text-xs tabular-nums\">\n {Math.round(crop.width)}×{Math.round(crop.height)}\n </span>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport { ImageCropper }\nexport type { ImageCropperProps }\n"
|
|
17
|
+
"content": "import {\n useCallback,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { Button } from '@/components/ui/button'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The step between picking an image and saving it.\n *\n * A kit with a dropzone, a file input and an upload list still cannot set an\n * avatar, because every avatar upload needs a square and every photo is a\n * rectangle. Without this the crop happens on the server, or not at all and\n * the image is squashed by CSS.\n *\n * **The crop rectangle is stored in natural image coordinates**, not in\n * displayed pixels. The display size depends on the container, which changes\n * with the viewport; a crop expressed in screen pixels silently means something\n * different after a resize, and produces a different output on a phone than on\n * a desktop.\n *\n * **The output is produced with `canvas.toBlob`, not `toDataURL`.** A data URL\n * is base64, which is a third larger, has to be built as one string in memory,\n * and then usually gets converted back to a blob to upload anyway. `toBlob`\n * hands you something you can put straight in a `FormData`.\n *\n * **A cross-origin image taints the canvas** and makes `toBlob` throw a\n * `SecurityError`. `crossOrigin=\"anonymous\"` is set on the image so a server\n * that sends permissive CORS headers works; one that does not will fail at\n * export, and `onError` reports it rather than leaving a dead button.\n */\nexport type CropRect = { x: number; y: number; width: number; height: number }\n\n// Omitted because the DOM declares it too, and in an intersection the DOM\n// signature wins — which left the prop below unusable and the generated docs\n// advertising the browser's handler instead of ours.\ntype ImageCropperProps = Omit<ComponentProps<'div'>, 'onChange' | 'onError'> & {\n src: string\n /** Width divided by height. Omit to crop freely. */\n aspect?: number\n /** Controlled crop, in natural image pixels. */\n value?: CropRect\n defaultValue?: CropRect\n onChange?: (crop: CropRect) => void\n /** The cropped image. Fires on demand, not on every drag. */\n onCrop?: (blob: Blob, crop: CropRect) => void\n onError?: (error: Error) => void\n /** Output type and quality for the exported blob. */\n outputType?: string\n outputQuality?: number\n /** Longest side of the export. The crop is scaled to fit. */\n maxOutput?: number\n /** Round mask, for avatars. Cosmetic — the export is still a rectangle. */\n round?: boolean\n cropLabel?: string\n resetLabel?: string\n /** Hide the built-in buttons to drive it from your own footer. */\n actions?: boolean\n alt?: string\n}\n\ntype Handle = 'nw' | 'ne' | 'sw' | 'se' | 'move'\n\nconst HANDLES: { id: Handle; className: string; label: string }[] = [\n { id: 'nw', className: 'start-0 top-0 cursor-nwse-resize', label: 'Top left' },\n { id: 'ne', className: 'end-0 top-0 cursor-nesw-resize', label: 'Top right' },\n { id: 'sw', className: 'start-0 bottom-0 cursor-nesw-resize', label: 'Bottom left' },\n { id: 'se', className: 'end-0 bottom-0 cursor-nwse-resize', label: 'Bottom right' },\n]\n\nfunction ImageCropper({\n src,\n aspect,\n value,\n defaultValue,\n onChange,\n onCrop,\n onError,\n outputType = 'image/png',\n outputQuality = 0.92,\n maxOutput,\n round = false,\n cropLabel = 'Crop',\n resetLabel = 'Reset',\n actions = true,\n alt = 'Image to crop',\n className,\n ...props\n}: ImageCropperProps) {\n const imageRef = useRef<HTMLImageElement>(null)\n const boxRef = useRef<HTMLDivElement>(null)\n /**\n * Measurements carry the `src` they belong to, rather than being reset by an\n * effect when `src` changes.\n *\n * The effect version has a race that only shows up in a browser: the reset\n * runs *after* the image has already loaded (a remount, or a cached image\n * that resolves before effects flush), clearing the measured size — and\n * because the image is already complete, `load` never fires again, so the\n * crop rectangle never comes back. Deriving staleness from the src makes the\n * stale state unrepresentable instead.\n */\n const [measured, setMeasured] = useState<{ src: string; width: number; height: number } | null>(null)\n const [internal, setInternal] = useState<{ src: string; rect: CropRect } | null>(\n defaultValue ? { src, rect: defaultValue } : null,\n )\n const drag = useRef<{ handle: Handle; startX: number; startY: number; from: CropRect } | null>(null)\n\n const natural =\n measured?.src === src ? { width: measured.width, height: measured.height } : { width: 0, height: 0 }\n const crop = value ?? (internal?.src === src ? internal.rect : null)\n\n /** Largest rectangle of the requested aspect that fits the image, centred. */\n const initial = useCallback(\n (width: number, height: number): CropRect => {\n if (!aspect) {\n const inset = Math.min(width, height) * 0.1\n return { x: inset, y: inset, width: width - inset * 2, height: height - inset * 2 }\n }\n const byWidth = width / aspect <= height\n const w = byWidth ? width : height * aspect\n const h = byWidth ? width / aspect : height\n return { x: (width - w) / 2, y: (height - h) / 2, width: w, height: h }\n },\n [aspect],\n )\n\n const commit = (next: CropRect) => {\n if (value === undefined) setInternal({ src, rect: next })\n onChange?.(next)\n }\n\n /** Measure, and seed a crop if there is not one for this image yet. */\n const measure = (image: HTMLImageElement) => {\n if (!image.naturalWidth) return\n if (measured?.src === src && measured.width === image.naturalWidth) return\n\n setMeasured({ src, width: image.naturalWidth, height: image.naturalHeight })\n if (value === undefined && internal?.src !== src) {\n const seeded = initial(image.naturalWidth, image.naturalHeight)\n setInternal({ src, rect: seeded })\n onChange?.(seeded)\n }\n }\n\n /** Natural pixels per displayed pixel — the whole coordinate story. */\n const scale = () => {\n const image = imageRef.current\n if (!image || !image.clientWidth) return 1\n return natural.width / image.clientWidth\n }\n\n function onPointerDown(event: ReactPointerEvent, handle: Handle) {\n if (!crop) return\n event.preventDefault()\n event.stopPropagation()\n ;(event.currentTarget as Element).setPointerCapture?.(event.pointerId)\n drag.current = { handle, startX: event.clientX, startY: event.clientY, from: { ...crop } }\n }\n\n function onPointerMove(event: ReactPointerEvent) {\n const state = drag.current\n if (!state || !crop) return\n\n const factor = scale()\n const dx = (event.clientX - state.startX) * factor\n const dy = (event.clientY - state.startY) * factor\n const { from, handle } = state\n const limit = { width: natural.width, height: natural.height }\n\n if (handle === 'move') {\n commit({\n ...from,\n x: Math.min(Math.max(0, from.x + dx), limit.width - from.width),\n y: Math.min(Math.max(0, from.y + dy), limit.height - from.height),\n })\n return\n }\n\n const west = handle === 'nw' || handle === 'sw'\n const north = handle === 'nw' || handle === 'ne'\n const right = west ? from.x + from.width : limit.width\n const bottom = north ? from.y + from.height : limit.height\n\n let width = Math.max(24, west ? from.width - dx : from.width + dx)\n let height = Math.max(24, north ? from.height - dy : from.height + dy)\n\n // With a locked aspect the two axes cannot be resolved independently, so\n // width wins and height follows it.\n if (aspect) height = width / aspect\n\n width = Math.min(width, west ? right : limit.width - from.x)\n height = Math.min(height, north ? bottom : limit.height - from.y)\n if (aspect) width = Math.min(width, height * aspect)\n\n commit({\n x: west ? right - width : from.x,\n y: north ? bottom - height : from.y,\n width,\n height,\n })\n }\n\n const endDrag = () => {\n drag.current = null\n }\n\n /** Draw the crop to a canvas and hand back a blob. */\n const exportCrop = () => {\n const image = imageRef.current\n if (!image || !crop) return\n\n const ratio = maxOutput ? Math.min(1, maxOutput / Math.max(crop.width, crop.height)) : 1\n const canvas = document.createElement('canvas')\n canvas.width = Math.round(crop.width * ratio)\n canvas.height = Math.round(crop.height * ratio)\n\n const context = canvas.getContext('2d')\n if (!context) return\n context.drawImage(\n image,\n crop.x, crop.y, crop.width, crop.height,\n 0, 0, canvas.width, canvas.height,\n )\n\n try {\n canvas.toBlob(\n (blob) => {\n if (blob) onCrop?.(blob, crop)\n else onError?.(new Error('The canvas produced no image.'))\n },\n outputType,\n outputQuality,\n )\n } catch (error) {\n // A cross-origin image without CORS headers taints the canvas; this is\n // the only place it surfaces, so it must not be swallowed.\n onError?.(error instanceof Error ? error : new Error(String(error)))\n }\n }\n\n const percent = (part: number, whole: number) => (whole ? `${(part / whole) * 100}%` : '0%')\n\n return (\n <div\n data-slot=\"image-cropper\"\n className={cn('flex flex-col gap-3', className)}\n {...props}\n >\n <div\n ref={boxRef}\n className={cn('relative touch-none overflow-hidden select-none', surface, radius.surface)}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerLeave={endDrag}\n >\n <img\n ref={(element) => {\n imageRef.current = element\n // A cached image can already be complete before any load event\n // would fire, so it is measured here as well as on load.\n if (element?.complete) measure(element)\n }}\n src={src}\n alt={alt}\n // Required for `toBlob` to work on an image from another origin.\n crossOrigin=\"anonymous\"\n onLoad={(event) => measure(event.currentTarget)}\n draggable={false}\n className=\"block max-h-[420px] w-full object-contain\"\n />\n\n {crop && natural.width > 0 && (\n <>\n {/* One element, four shadows: darkening the outside without four\n positioned overlays that have to stay in sync. */}\n <div\n aria-hidden=\"true\"\n className={cn(\n 'pointer-events-none absolute shadow-[0_0_0_9999px_rgba(0,0,0,0.55)]',\n round && 'rounded-full',\n )}\n style={{\n left: percent(crop.x, natural.width),\n top: percent(crop.y, natural.height),\n width: percent(crop.width, natural.width),\n height: percent(crop.height, natural.height),\n }}\n />\n\n <div\n className={cn(\n 'absolute cursor-move border-2 border-white/90',\n round && 'rounded-full',\n )}\n style={{\n left: percent(crop.x, natural.width),\n top: percent(crop.y, natural.height),\n width: percent(crop.width, natural.width),\n height: percent(crop.height, natural.height),\n }}\n onPointerDown={(event) => onPointerDown(event, 'move')}\n >\n {HANDLES.map((handle) => (\n <span\n key={handle.id}\n role=\"slider\"\n tabIndex={0}\n aria-label={`${handle.label} corner`}\n aria-valuenow={Math.round(crop.width)}\n aria-valuemin={24}\n aria-valuemax={natural.width}\n className={cn(\n 'absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-black/20 bg-white',\n 'ltr:translate-x-[-50%] rtl:translate-x-[50%]',\n focusRing,\n handle.className,\n )}\n style={{\n insetInlineStart: handle.id.includes('w') ? 0 : undefined,\n insetInlineEnd: handle.id.includes('e') ? 0 : undefined,\n top: handle.id.startsWith('n') ? 0 : undefined,\n bottom: handle.id.startsWith('s') ? 0 : undefined,\n transform: 'translate(-50%, -50%)',\n }}\n onPointerDown={(event) => onPointerDown(event, handle.id)}\n onKeyDown={(event) => {\n // Keyboard resize, because a drag handle that only responds\n // to a pointer is unusable without one.\n const step = event.shiftKey ? 20 : 4\n const delta =\n event.key === 'ArrowRight' || event.key === 'ArrowDown'\n ? step\n : event.key === 'ArrowLeft' || event.key === 'ArrowUp'\n ? -step\n : 0\n if (!delta) return\n event.preventDefault()\n const width = Math.max(24, Math.min(crop.width + delta, natural.width - crop.x))\n commit({\n ...crop,\n width,\n height: aspect ? width / aspect : crop.height,\n })\n }}\n />\n ))}\n </div>\n </>\n )}\n </div>\n\n {actions && (\n <div className=\"flex items-center gap-2\">\n <Button size=\"sm\" onClick={exportCrop} disabled={!crop}>\n {cropLabel}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => commit(initial(natural.width, natural.height))}\n disabled={!natural.width}\n >\n {resetLabel}\n </Button>\n {crop && (\n <span className=\"text-muted-foreground ms-auto font-mono text-xs tabular-nums\">\n {Math.round(crop.width)}×{Math.round(crop.height)}\n </span>\n )}\n </div>\n )}\n </div>\n )\n}\n\nexport { ImageCropper }\nexport type { ImageCropperProps }\n"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
20
20
|
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
{
|
|
20
20
|
"path": "components/ui/message.tsx",
|
|
21
21
|
"type": "registry:ui",
|
|
22
|
-
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp, User } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Button } from '@/components/ui/button'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { radius } from '@/lib/styles'\nimport { useClipboard } from '@/lib/use-clipboard'\nimport { cn } from '@/lib/utils'\n\n/**\n * One turn in a conversation.\n *\n * The two roles are shaped differently on purpose: a user message is a bubble\n * pinned to the trailing edge, an assistant message is full-width prose. Giving\n * both the same bubble makes a long answer unreadable, and makes it harder to\n * tell at a glance who said what.\n */\ntype MessageProps = ComponentProps<'div'> & {\n role: 'user' | 'assistant'\n /** Author name, used for the avatar's initials. */\n name?: string\n avatar?: ReactNode\n /** Action row under an assistant message. */\n actions?: boolean\n onCopy?: () => void\n onRetry?: () => void\n onVote?: (vote: 'up' | 'down') => void\n /** Raw text used by the copy button. Falls back to nothing. */\n copyText?: string\n copyLabel?: string\n /** Replaces `copyLabel` in the tooltip once copied. */\n copiedLabel?: string\n retryLabel?: string\n upvoteLabel?: string\n downvoteLabel?: string\n}\n\nfunction Message({\n className,\n role,\n name,\n avatar,\n actions = role === 'assistant',\n onCopy,\n onRetry,\n onVote,\n copyText,\n copyLabel = 'Copy',\n copiedLabel = 'Copied',\n retryLabel = 'Retry',\n upvoteLabel = 'Good response',\n downvoteLabel = 'Bad response',\n children,\n ...props\n}: MessageProps) {\n const { copy: writeClipboard, copied } = useClipboard()\n const user = role === 'user'\n\n function copy() {\n if (copyText) void writeClipboard(copyText)\n onCopy?.()\n }\n\n return (\n <div\n data-slot=\"message\"\n data-role={role}\n className={cn(\n 'flex w-full gap-3',\n user ? 'justify-end' : 'justify-start',\n className,\n )}\n {...props}\n >\n {!user && (avatar ?? <Avatar size=\"sm\" name={name ?? 'Assistant'} />)}\n\n <div className={cn('min-w-0 space-y-2', user ? 'max-w-[80%]' : 'flex-1')}>\n <div\n className={cn(\n 'text-sm leading-relaxed',\n user\n ? cn('bg-secondary text-secondary-foreground px-4 py-2.5', radius.panel)\n : 'text-foreground',\n )}\n >\n {children}\n </div>\n\n {actions && (\n <div className=\"flex items-center gap-0.5\">\n <Tooltip content={copied ? copiedLabel : copyLabel}>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={copyLabel} onClick={copy}>\n {copied ? <Check /> : <Copy />}\n </Button>\n </Tooltip>\n {onRetry && (\n <Tooltip content=\"Try again\">\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={retryLabel} onClick={onRetry}>\n <RefreshCw />\n </Button>\n </Tooltip>\n )}\n {onVote && (\n <>\n <Tooltip content={upvoteLabel}>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={upvoteLabel} onClick={() => onVote('up')}>\n <ThumbsUp />\n </Button>\n </Tooltip>\n <Tooltip content={downvoteLabel}>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={downvoteLabel} onClick={() => onVote('down')}>\n <ThumbsDown />\n </Button>\n </Tooltip>\n </>\n )}\n </div>\n )}\n </div>\n\n {user && (avatar ?? <Avatar size=\"sm\" name={name ?? 'You'} fallback={<User className=\"size-3.5\" />} />)}\n </div>\n )\n}\n\n/**\n * The three-dot pulse shown while a reply is being generated.\n *\n * `role=\"status\"` with a screen-reader label, because a purely visual animation\n * tells a screen-reader user nothing about why the interface has gone quiet.\n */\nfunction MessagePending({\n className,\n label = 'Generating a response',\n ...props\n}: ComponentProps<'div'> & { label?: string }) {\n return (\n <div\n role=\"status\"\n data-slot=\"message-pending\"\n className={cn('flex items-center gap-3', className)}\n {...props}\n >\n <Avatar size=\"sm\" name=\"Assistant\" />\n <span className=\"flex gap-1\" aria-hidden=\"true\">\n {[0, 1, 2].map((index) => (\n <span\n key={index}\n className=\"bg-muted-foreground/60 size-1.5 rounded-full motion-safe:animate-[message-dot_1.2s_ease-in-out_infinite]\"\n style={{ animationDelay: `${index * 0.16}s` }}\n />\n ))}\n </span>\n <span className=\"sr-only\">{label}</span>\n </div>\n )\n}\n\nexport { Message, MessagePending }\nexport type { MessageProps }\n"
|
|
22
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp, User } from 'lucide-react'\nimport { Avatar } from '@/components/ui/avatar'\nimport { Button } from '@/components/ui/button'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport { radius } from '@/lib/styles'\nimport { useClipboard } from '@/lib/use-clipboard'\nimport { cn } from '@/lib/utils'\n\n/**\n * One turn in a conversation.\n *\n * The two roles are shaped differently on purpose: a user message is a bubble\n * pinned to the trailing edge, an assistant message is full-width prose. Giving\n * both the same bubble makes a long answer unreadable, and makes it harder to\n * tell at a glance who said what.\n */\n// Omitted because the DOM declares it too, and in an intersection the DOM\n// signature wins — which left the prop below unusable and the generated docs\n// advertising the browser's handler instead of ours.\ntype MessageProps = Omit<ComponentProps<'div'>, 'onCopy'> & {\n role: 'user' | 'assistant'\n /** Author name, used for the avatar's initials. */\n name?: string\n avatar?: ReactNode\n /** Action row under an assistant message. */\n actions?: boolean\n onCopy?: () => void\n onRetry?: () => void\n onVote?: (vote: 'up' | 'down') => void\n /** Raw text used by the copy button. Falls back to nothing. */\n copyText?: string\n copyLabel?: string\n /** Replaces `copyLabel` in the tooltip once copied. */\n copiedLabel?: string\n retryLabel?: string\n upvoteLabel?: string\n downvoteLabel?: string\n}\n\nfunction Message({\n className,\n role,\n name,\n avatar,\n actions = role === 'assistant',\n onCopy,\n onRetry,\n onVote,\n copyText,\n copyLabel = 'Copy',\n copiedLabel = 'Copied',\n retryLabel = 'Retry',\n upvoteLabel = 'Good response',\n downvoteLabel = 'Bad response',\n children,\n ...props\n}: MessageProps) {\n const { copy: writeClipboard, copied } = useClipboard()\n const user = role === 'user'\n\n function copy() {\n if (copyText) void writeClipboard(copyText)\n onCopy?.()\n }\n\n return (\n <div\n data-slot=\"message\"\n data-role={role}\n className={cn(\n 'flex w-full gap-3',\n user ? 'justify-end' : 'justify-start',\n className,\n )}\n {...props}\n >\n {!user && (avatar ?? <Avatar size=\"sm\" name={name ?? 'Assistant'} />)}\n\n <div className={cn('min-w-0 space-y-2', user ? 'max-w-[80%]' : 'flex-1')}>\n <div\n className={cn(\n 'text-sm leading-relaxed',\n user\n ? cn('bg-secondary text-secondary-foreground px-4 py-2.5', radius.panel)\n : 'text-foreground',\n )}\n >\n {children}\n </div>\n\n {actions && (\n <div className=\"flex items-center gap-0.5\">\n <Tooltip content={copied ? copiedLabel : copyLabel}>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={copyLabel} onClick={copy}>\n {copied ? <Check /> : <Copy />}\n </Button>\n </Tooltip>\n {onRetry && (\n <Tooltip content=\"Try again\">\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={retryLabel} onClick={onRetry}>\n <RefreshCw />\n </Button>\n </Tooltip>\n )}\n {onVote && (\n <>\n <Tooltip content={upvoteLabel}>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={upvoteLabel} onClick={() => onVote('up')}>\n <ThumbsUp />\n </Button>\n </Tooltip>\n <Tooltip content={downvoteLabel}>\n <Button variant=\"ghost\" size=\"icon-xs\" aria-label={downvoteLabel} onClick={() => onVote('down')}>\n <ThumbsDown />\n </Button>\n </Tooltip>\n </>\n )}\n </div>\n )}\n </div>\n\n {user && (avatar ?? <Avatar size=\"sm\" name={name ?? 'You'} fallback={<User className=\"size-3.5\" />} />)}\n </div>\n )\n}\n\n/**\n * The three-dot pulse shown while a reply is being generated.\n *\n * `role=\"status\"` with a screen-reader label, because a purely visual animation\n * tells a screen-reader user nothing about why the interface has gone quiet.\n */\nfunction MessagePending({\n className,\n label = 'Generating a response',\n ...props\n}: ComponentProps<'div'> & { label?: string }) {\n return (\n <div\n role=\"status\"\n data-slot=\"message-pending\"\n className={cn('flex items-center gap-3', className)}\n {...props}\n >\n <Avatar size=\"sm\" name=\"Assistant\" />\n <span className=\"flex gap-1\" aria-hidden=\"true\">\n {[0, 1, 2].map((index) => (\n <span\n key={index}\n className=\"bg-muted-foreground/60 size-1.5 rounded-full motion-safe:animate-[message-dot_1.2s_ease-in-out_infinite]\"\n style={{ animationDelay: `${index * 0.16}s` }}\n />\n ))}\n </span>\n <span className=\"sr-only\">{label}</span>\n </div>\n )\n}\n\nexport { Message, MessagePending }\nexport type { MessageProps }\n"
|
|
23
23
|
}
|
|
24
24
|
]
|
|
25
25
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
{
|
|
16
16
|
"path": "components/ui/org-chart.tsx",
|
|
17
17
|
"type": "registry:ui",
|
|
18
|
-
"content": "import { useId, useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, ChevronRight } from 'lucide-react'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A reporting hierarchy, drawn top-down with connectors.\n *\n * **Why not `Tree`.** A tree is an indented list — it is the right shape for a\n * filesystem, where depth is all that matters and breadth is unbounded. An org\n * chart is read for *spans*: how many people report to this person, and are\n * these two peers. Siblings side by side answer both at a glance; siblings\n * stacked vertically answer neither, and an org of forty people becomes forty\n * indented rows you have to count.\n *\n * **It builds the tree from flat `{ id, managerId }` rows**, because that is\n * how every HR system, directory and database actually stores it. Requiring\n * pre-nested input pushes the same recursion into every caller.\n *\n * **Multiple roots and orphans are rendered, not dropped.** Real directories\n * have a vacant manager slot, a contractor with no manager, a recent transfer\n * pointing at a deleted record. Silently omitting those rows makes the chart\n * quietly wrong; they are shown as additional roots so the gap is visible. A\n * cycle — A reports to B reports to A — is detected and reported rather than\n * recursed into.\n *\n * Nodes are `<li>` inside nested `<ul>`s: the semantics are already a tree, so\n * the connectors are drawn with borders on top of correct markup rather than\n * replacing it.\n */\nexport type OrgNode = {\n id: string\n name: ReactNode\n title?: ReactNode\n avatar?: ReactNode\n managerId?: string | null\n meta?: ReactNode\n}\n\ntype Built = OrgNode & { children: Built[] }\n\ntype OrgChartProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n nodes: OrgNode[]\n onSelect?: (node: OrgNode) => void\n selectedId?: string\n /** Depth open on first render. `Infinity` expands everything. */\n defaultDepth?: number\n /** Lay a node's children out in a column past this many. */\n stackAfter?: number\n onError?: (error: Error) => void\n emptyLabel?: string\n label?: string\n}\n\nfunction build(nodes: OrgNode[]): { roots: Built[]; error: Error | null } {\n const byId = new Map<string, Built>(nodes.map((node) => [node.id, { ...node, children: [] }]))\n const roots: Built[] = []\n\n for (const node of byId.values()) {\n const parent = node.managerId ? byId.get(node.managerId) : undefined\n // An orphan — no manager, or a manager who is not in the data — becomes a\n // root, so the row stays visible instead of vanishing.\n if (parent && parent.id !== node.id) parent.children.push(node)\n else roots.push(node)\n }\n\n // Reachability check: anything not reached from a root is in a cycle.\n const seen = new Set<string>()\n const walk = (node: Built) => {\n if (seen.has(node.id)) return\n seen.add(node.id)\n node.children.forEach(walk)\n }\n roots.forEach(walk)\n\n const error =\n seen.size < byId.size\n ? new Error(`${byId.size - seen.size} node(s) are in a reporting cycle and are not shown.`)\n : null\n\n return { roots, error }\n}\n\nfunction Node({\n node,\n depth,\n open,\n onToggle,\n onSelect,\n selectedId,\n stackAfter,\n position,\n}: {\n node: Built\n depth: number\n open: Set<string>\n onToggle: (id: string) => void\n onSelect?: (node: OrgNode) => void\n selectedId?: string\n stackAfter: number\n /** Where this node sits in its parent's row, for the connector geometry. */\n position?: { index: number; count: number; row: boolean }\n}) {\n const expanded = open.has(node.id)\n const hasChildren = node.children.length > 0\n const stacked = node.children.length > stackAfter\n\n return (\n <li className=\"relative flex flex-col items-center\">\n {/*\n Each child draws its own half of the horizontal rule, from its own\n centre outward.\n\n One rule spanning the row cannot be positioned correctly: it would have\n to start at the first child's centre and end at the last child's, and\n with variable-width names those centres are not at any percentage of the\n row. (Sizing every child equally would fix the geometry by making the\n layout worse.) Half-segments anchored to each child's own box meet in\n the gaps regardless of how wide any name is, so the rule lands on the\n stubs at every width.\n\n The 0.5rem is half the row's `gap-4`, so adjacent halves meet.\n */}\n {position?.row && position.count > 1 && (\n <>\n {position.index > 0 && (\n <span\n aria-hidden=\"true\"\n className=\"bg-border absolute top-0 -start-2 h-px w-[calc(50%+0.5rem)]\"\n />\n )}\n {position.index < position.count - 1 && (\n <span\n aria-hidden=\"true\"\n className=\"bg-border absolute top-0 -end-2 h-px w-[calc(50%+0.5rem)]\"\n />\n )}\n </>\n )}\n\n {/* The stub joining this node up to that rule. */}\n {depth > 0 && <span aria-hidden=\"true\" className=\"bg-border h-4 w-px shrink-0\" />}\n\n <div\n className={cn(\n 'relative flex items-center gap-2 px-3 py-2',\n surface,\n radius.surface,\n selectedId === node.id && 'ring-2 ring-[var(--primary)]',\n )}\n >\n {node.avatar && <span className=\"shrink-0\">{node.avatar}</span>}\n <span className=\"min-w-0\">\n <button\n type=\"button\"\n disabled={!onSelect}\n onClick={() => onSelect?.(node)}\n className={cn(\n 'block max-w-40 truncate text-start text-sm font-medium',\n onSelect ? 'cursor-pointer hover:underline' : 'cursor-default',\n radius.xs,\n focusRing,\n )}\n >\n {node.name}\n </button>\n {node.title && (\n <span className=\"text-muted-foreground block max-w-40 truncate text-xs\">{node.title}</span>\n )}\n {node.meta}\n </span>\n\n {hasChildren && (\n <button\n type=\"button\"\n aria-expanded={expanded}\n aria-label={expanded ? 'Collapse reports' : 'Expand reports'}\n onClick={() => onToggle(node.id)}\n className={cn('text-muted-foreground ms-1 shrink-0', radius.xs, focusRing)}\n >\n {expanded ? <ChevronDown className=\"size-3.5\" /> : <ChevronRight className=\"size-3.5 rtl:rotate-180\" />}\n <span className=\"sr-only\">{node.children.length} reports</span>\n </button>\n )}\n </div>\n\n {hasChildren && expanded && (\n <>\n <span aria-hidden=\"true\" className=\"bg-border h-4 w-px shrink-0\" />\n <ul\n className={cn(\n 'relative flex list-none',\n stacked ? 'flex-col items-start gap-2 ps-6' : 'flex-row items-start gap-4',\n )}\n >\n {/* Stacked children get a vertical spine instead; a very long\n horizontal rule reads worse than a column. */}\n {stacked && (\n <span aria-hidden=\"true\" className=\"bg-border absolute top-0 bottom-4 start-2 w-px\" />\n )}\n\n {node.children.map((child, index) => (\n <Node\n key={child.id}\n node={child}\n depth={depth + 1}\n open={open}\n onToggle={onToggle}\n onSelect={onSelect}\n selectedId={selectedId}\n stackAfter={stackAfter}\n position={{ index, count: node.children.length, row: !stacked }}\n />\n ))}\n </ul>\n </>\n )}\n </li>\n )\n}\n\nfunction OrgChart({\n nodes,\n onSelect,\n selectedId,\n defaultDepth = 2,\n stackAfter = 4,\n onError,\n emptyLabel = 'No people.',\n label = 'Organisation chart',\n className,\n ...props\n}: OrgChartProps) {\n const titleId = useId()\n const { roots, error } = useMemo(() => build(nodes), [nodes])\n\n const [open, setOpen] = useState<Set<string>>(() => {\n const initial = new Set<string>()\n const walk = (node: Built, depth: number) => {\n if (depth < defaultDepth) initial.add(node.id)\n node.children.forEach((child) => walk(child, depth + 1))\n }\n build(nodes).roots.forEach((root) => walk(root, 0))\n return initial\n })\n\n if (error) onError?.(error)\n\n if (roots.length === 0) {\n return (\n <div className={cn('text-muted-foreground p-4 text-xs', className)} {...props}>\n {emptyLabel}\n </div>\n )\n }\n\n const toggle = (id: string) => {\n const next = new Set(open)\n if (next.has(id)) next.delete(id)\n else next.add(id)\n setOpen(next)\n }\n\n return (\n <div\n data-slot=\"org-chart\"\n className={cn('w-full overflow-x-auto', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <p id={titleId} className=\"sr-only\">\n {label}\n </p>\n\n <ul className=\"flex list-none justify-center gap-8 p-2\">\n {roots.map((root) => (\n <Node\n key={root.id}\n node={root}\n depth={0}\n open={open}\n onToggle={toggle}\n onSelect={onSelect}\n selectedId={selectedId}\n stackAfter={stackAfter}\n />\n ))}\n </ul>\n\n {error && (\n <p role=\"status\" className=\"text-[var(--destructive)] px-2 text-[11px]\">\n {error.message}\n </p>\n )}\n </div>\n )\n}\n\nexport { OrgChart }\nexport type { OrgChartProps }\n"
|
|
18
|
+
"content": "import { useId, useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { ChevronDown, ChevronRight } from 'lucide-react'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A reporting hierarchy, drawn top-down with connectors.\n *\n * **Why not `Tree`.** A tree is an indented list — it is the right shape for a\n * filesystem, where depth is all that matters and breadth is unbounded. An org\n * chart is read for *spans*: how many people report to this person, and are\n * these two peers. Siblings side by side answer both at a glance; siblings\n * stacked vertically answer neither, and an org of forty people becomes forty\n * indented rows you have to count.\n *\n * **It builds the tree from flat `{ id, managerId }` rows**, because that is\n * how every HR system, directory and database actually stores it. Requiring\n * pre-nested input pushes the same recursion into every caller.\n *\n * **Multiple roots and orphans are rendered, not dropped.** Real directories\n * have a vacant manager slot, a contractor with no manager, a recent transfer\n * pointing at a deleted record. Silently omitting those rows makes the chart\n * quietly wrong; they are shown as additional roots so the gap is visible. A\n * cycle — A reports to B reports to A — is detected and reported rather than\n * recursed into.\n *\n * Nodes are `<li>` inside nested `<ul>`s: the semantics are already a tree, so\n * the connectors are drawn with borders on top of correct markup rather than\n * replacing it.\n */\nexport type OrgNode = {\n id: string\n name: ReactNode\n title?: ReactNode\n avatar?: ReactNode\n managerId?: string | null\n meta?: ReactNode\n}\n\ntype Built = OrgNode & { children: Built[] }\n\n// Omitted because the DOM declares it too, and in an intersection the DOM\n// signature wins — which left the prop below unusable and the generated docs\n// advertising the browser's handler instead of ours.\ntype OrgChartProps = Omit<ComponentProps<'div'>, 'onSelect' | 'onError'> & {\n nodes: OrgNode[]\n onSelect?: (node: OrgNode) => void\n selectedId?: string\n /** Depth open on first render. `Infinity` expands everything. */\n defaultDepth?: number\n /** Lay a node's children out in a column past this many. */\n stackAfter?: number\n onError?: (error: Error) => void\n emptyLabel?: string\n label?: string\n}\n\nfunction build(nodes: OrgNode[]): { roots: Built[]; error: Error | null } {\n const byId = new Map<string, Built>(nodes.map((node) => [node.id, { ...node, children: [] }]))\n const roots: Built[] = []\n\n for (const node of byId.values()) {\n const parent = node.managerId ? byId.get(node.managerId) : undefined\n // An orphan — no manager, or a manager who is not in the data — becomes a\n // root, so the row stays visible instead of vanishing.\n if (parent && parent.id !== node.id) parent.children.push(node)\n else roots.push(node)\n }\n\n // Reachability check: anything not reached from a root is in a cycle.\n const seen = new Set<string>()\n const walk = (node: Built) => {\n if (seen.has(node.id)) return\n seen.add(node.id)\n node.children.forEach(walk)\n }\n roots.forEach(walk)\n\n const error =\n seen.size < byId.size\n ? new Error(`${byId.size - seen.size} node(s) are in a reporting cycle and are not shown.`)\n : null\n\n return { roots, error }\n}\n\nfunction Node({\n node,\n depth,\n open,\n onToggle,\n onSelect,\n selectedId,\n stackAfter,\n position,\n}: {\n node: Built\n depth: number\n open: Set<string>\n onToggle: (id: string) => void\n onSelect?: (node: OrgNode) => void\n selectedId?: string\n stackAfter: number\n /** Where this node sits in its parent's row, for the connector geometry. */\n position?: { index: number; count: number; row: boolean }\n}) {\n const expanded = open.has(node.id)\n const hasChildren = node.children.length > 0\n const stacked = node.children.length > stackAfter\n\n return (\n <li className=\"relative flex flex-col items-center\">\n {/*\n Each child draws its own half of the horizontal rule, from its own\n centre outward.\n\n One rule spanning the row cannot be positioned correctly: it would have\n to start at the first child's centre and end at the last child's, and\n with variable-width names those centres are not at any percentage of the\n row. (Sizing every child equally would fix the geometry by making the\n layout worse.) Half-segments anchored to each child's own box meet in\n the gaps regardless of how wide any name is, so the rule lands on the\n stubs at every width.\n\n The 0.5rem is half the row's `gap-4`, so adjacent halves meet.\n */}\n {position?.row && position.count > 1 && (\n <>\n {position.index > 0 && (\n <span\n aria-hidden=\"true\"\n className=\"bg-border absolute top-0 -start-2 h-px w-[calc(50%+0.5rem)]\"\n />\n )}\n {position.index < position.count - 1 && (\n <span\n aria-hidden=\"true\"\n className=\"bg-border absolute top-0 -end-2 h-px w-[calc(50%+0.5rem)]\"\n />\n )}\n </>\n )}\n\n {/* The stub joining this node up to that rule. */}\n {depth > 0 && <span aria-hidden=\"true\" className=\"bg-border h-4 w-px shrink-0\" />}\n\n <div\n className={cn(\n 'relative flex items-center gap-2 px-3 py-2',\n surface,\n radius.surface,\n selectedId === node.id && 'ring-2 ring-[var(--primary)]',\n )}\n >\n {node.avatar && <span className=\"shrink-0\">{node.avatar}</span>}\n <span className=\"min-w-0\">\n <button\n type=\"button\"\n disabled={!onSelect}\n onClick={() => onSelect?.(node)}\n className={cn(\n 'block max-w-40 truncate text-start text-sm font-medium',\n onSelect ? 'cursor-pointer hover:underline' : 'cursor-default',\n radius.xs,\n focusRing,\n )}\n >\n {node.name}\n </button>\n {node.title && (\n <span className=\"text-muted-foreground block max-w-40 truncate text-xs\">{node.title}</span>\n )}\n {node.meta}\n </span>\n\n {hasChildren && (\n <button\n type=\"button\"\n aria-expanded={expanded}\n aria-label={expanded ? 'Collapse reports' : 'Expand reports'}\n onClick={() => onToggle(node.id)}\n className={cn('text-muted-foreground ms-1 shrink-0', radius.xs, focusRing)}\n >\n {expanded ? <ChevronDown className=\"size-3.5\" /> : <ChevronRight className=\"size-3.5 rtl:rotate-180\" />}\n <span className=\"sr-only\">{node.children.length} reports</span>\n </button>\n )}\n </div>\n\n {hasChildren && expanded && (\n <>\n <span aria-hidden=\"true\" className=\"bg-border h-4 w-px shrink-0\" />\n <ul\n className={cn(\n 'relative flex list-none',\n stacked ? 'flex-col items-start gap-2 ps-6' : 'flex-row items-start gap-4',\n )}\n >\n {/* Stacked children get a vertical spine instead; a very long\n horizontal rule reads worse than a column. */}\n {stacked && (\n <span aria-hidden=\"true\" className=\"bg-border absolute top-0 bottom-4 start-2 w-px\" />\n )}\n\n {node.children.map((child, index) => (\n <Node\n key={child.id}\n node={child}\n depth={depth + 1}\n open={open}\n onToggle={onToggle}\n onSelect={onSelect}\n selectedId={selectedId}\n stackAfter={stackAfter}\n position={{ index, count: node.children.length, row: !stacked }}\n />\n ))}\n </ul>\n </>\n )}\n </li>\n )\n}\n\nfunction OrgChart({\n nodes,\n onSelect,\n selectedId,\n defaultDepth = 2,\n stackAfter = 4,\n onError,\n emptyLabel = 'No people.',\n label = 'Organisation chart',\n className,\n ...props\n}: OrgChartProps) {\n const titleId = useId()\n const { roots, error } = useMemo(() => build(nodes), [nodes])\n\n const [open, setOpen] = useState<Set<string>>(() => {\n const initial = new Set<string>()\n const walk = (node: Built, depth: number) => {\n if (depth < defaultDepth) initial.add(node.id)\n node.children.forEach((child) => walk(child, depth + 1))\n }\n build(nodes).roots.forEach((root) => walk(root, 0))\n return initial\n })\n\n if (error) onError?.(error)\n\n if (roots.length === 0) {\n return (\n <div className={cn('text-muted-foreground p-4 text-xs', className)} {...props}>\n {emptyLabel}\n </div>\n )\n }\n\n const toggle = (id: string) => {\n const next = new Set(open)\n if (next.has(id)) next.delete(id)\n else next.add(id)\n setOpen(next)\n }\n\n return (\n <div\n data-slot=\"org-chart\"\n className={cn('w-full overflow-x-auto', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <p id={titleId} className=\"sr-only\">\n {label}\n </p>\n\n <ul className=\"flex list-none justify-center gap-8 p-2\">\n {roots.map((root) => (\n <Node\n key={root.id}\n node={root}\n depth={0}\n open={open}\n onToggle={toggle}\n onSelect={onSelect}\n selectedId={selectedId}\n stackAfter={stackAfter}\n />\n ))}\n </ul>\n\n {error && (\n <p role=\"status\" className=\"text-[var(--destructive)] px-2 text-[11px]\">\n {error.message}\n </p>\n )}\n </div>\n )\n}\n\nexport { OrgChart }\nexport type { OrgChartProps }\n"
|
|
19
19
|
}
|
|
20
20
|
]
|
|
21
21
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
{
|
|
9
9
|
"path": "components/primitives/router.tsx",
|
|
10
10
|
"type": "registry:primitive",
|
|
11
|
-
"content": "import {\n createContext,\n use,\n useCallback,\n useEffect,\n useMemo,\n useState,\n type ComponentProps,\n type MouseEvent,\n type ReactNode,\n} from 'react'\n\ntype NavigateOptions = { replace?: boolean }\n\ntype RouterValue = {\n path: string\n navigate: (to: string, options?: NavigateOptions) => void\n}\n\nconst RouterContext = createContext<RouterValue | null>(null)\n\n/**\n * A minimal History API router — enough for a static, data-less site.\n *\n * Deliberately hand-rolled to keep the kit dependency-free. The surface\n * (`Link`, `useRoute`, `useLocation`, `navigate`) mirrors React Router's, so\n * swapping in the real thing later is an import change.\n */\nfunction Router({ children }: { children: ReactNode }) {\n const [path, setPath] = useState(() => window.location.pathname)\n\n useEffect(() => {\n const onPopState = () => setPath(window.location.pathname)\n\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const navigate = useCallback((to: string, options: NavigateOptions = {}) => {\n
|
|
11
|
+
"content": "import {\n createContext,\n use,\n useCallback,\n useEffect,\n useMemo,\n useState,\n type ComponentProps,\n type MouseEvent,\n type ReactNode,\n} from 'react'\n\ntype NavigateOptions = { replace?: boolean }\n\ntype RouterValue = {\n path: string\n navigate: (to: string, options?: NavigateOptions) => void\n}\n\nconst RouterContext = createContext<RouterValue | null>(null)\n\n/**\n * A trailing slash is the host's, not ours.\n *\n * A static host that answers `/components` out of `components/index.html`\n * first redirects to `/components/`, so the path the app boots with is not the\n * path any route was written against. `matchRoute` never noticed — it drops\n * empty segments — but a plain `path === '/components'` did, which is why a\n * refresh on an index page landed on Not found while the prerendered HTML\n * behind it was correct.\n *\n * Normalised once, here, so a route can be spelled one way and every\n * comparison downstream agrees. The address bar is left as the host wrote it:\n * rewriting it would only be undone by the next reload.\n */\nfunction normalize(path: string) {\n return path.length > 1 ? path.replace(/\\/+$/, '') || '/' : path\n}\n\n/**\n * A minimal History API router — enough for a static, data-less site.\n *\n * Deliberately hand-rolled to keep the kit dependency-free. The surface\n * (`Link`, `useRoute`, `useLocation`, `navigate`) mirrors React Router's, so\n * swapping in the real thing later is an import change.\n */\nfunction Router({ children }: { children: ReactNode }) {\n const [path, setPath] = useState(() => normalize(window.location.pathname))\n\n useEffect(() => {\n const onPopState = () => setPath(normalize(window.location.pathname))\n\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const navigate = useCallback((to: string, options: NavigateOptions = {}) => {\n const next = normalize(to)\n if (next === normalize(window.location.pathname)) return\n\n window.history[options.replace ? 'replaceState' : 'pushState']({}, '', to)\n setPath(next)\n }, [])\n\n const value = useMemo(() => ({ path, navigate }), [path, navigate])\n\n return <RouterContext value={value}>{children}</RouterContext>\n}\n\nfunction useRouter() {\n const value = use(RouterContext)\n\n if (!value) throw new Error('useRouter must be used inside <Router>')\n\n return value\n}\n\nfunction useLocation() {\n return useRouter().path\n}\n\nfunction useNavigate() {\n return useRouter().navigate\n}\n\n/**\n * Match the current path against a pattern with `:param` segments.\n * Returns the params on a match, or `null`.\n */\nfunction matchRoute(pattern: string, path: string) {\n const patternParts = pattern.split('/').filter(Boolean)\n const pathParts = path.split('/').filter(Boolean)\n\n if (patternParts.length !== pathParts.length) return null\n\n const params: Record<string, string> = {}\n\n for (const [index, part] of patternParts.entries()) {\n const value = pathParts[index]\n\n if (part.startsWith(':')) {\n params[part.slice(1)] = decodeURIComponent(value)\n continue\n }\n\n if (part !== value) return null\n }\n\n return params\n}\n\nfunction useRoute(pattern: string) {\n const path = useLocation()\n\n return useMemo(() => matchRoute(pattern, path), [pattern, path])\n}\n\ntype LinkProps = ComponentProps<'a'> & {\n to: string\n replace?: boolean\n}\n\n/** An `<a>` that navigates in-app, while staying a real, openable link. */\nfunction Link({ to, replace, onClick, target, ...props }: LinkProps) {\n const navigate = useNavigate()\n\n function handleClick(event: MouseEvent<HTMLAnchorElement>) {\n onClick?.(event)\n\n // Leave modified clicks and non-self targets to the browser.\n if (event.defaultPrevented) return\n if (event.button !== 0) return\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return\n if (target && target !== '_self') return\n\n event.preventDefault()\n navigate(to, { replace })\n }\n\n return <a href={to} target={target} onClick={handleClick} {...props} />\n}\n\nexport { Link, Router, matchRoute, useLocation, useNavigate, useRoute }\nexport type { LinkProps }\n"
|
|
12
12
|
}
|
|
13
13
|
]
|
|
14
14
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
{
|
|
18
18
|
"path": "components/ui/sandbox-policy.tsx",
|
|
19
19
|
"type": "registry:ui",
|
|
20
|
-
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Ban, FolderTree, Globe, ShieldCheck, Terminal, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Switch } from '@/components/ui/switch'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The permissions an agent actually runs under.\n *\n * Filesystem, network and process execution, each with an explicit mode and the\n * paths or hosts it applies to. This is the screen someone is asked to approve\n * before an agent is let near a real machine, so it is built to make the\n * dangerous configuration look dangerous.\n *\n * **A wide-open scope is called out, not rendered as an ordinary row.** An\n * allow-list of `/` or `*` is functionally \"no sandbox\", and a UI that shows it\n * in the same grey mono as `/tmp` is technically accurate and practically\n * useless. `unrestricted` flags those rows.\n *\n * **Deny wins, and says so.** Every real sandbox evaluates deny before allow;\n * showing the two lists side by side without that ordering invites someone to\n * assume an allow entry re-opens something a deny closed.\n */\nexport type SandboxMode = 'none' | 'allowlist' | 'full'\n\nexport type SandboxScope = {\n id: string\n kind: 'filesystem' | 'network' | 'exec'\n mode: SandboxMode\n /** Paths, hosts or binaries permitted. Ignored when mode is not allowlist. */\n allow?: string[]\n /** Always refused, whatever `allow` says. */\n deny?: string[]\n label?: ReactNode\n description?: ReactNode\n /** Toggle the whole scope. Omit for a read-only policy. */\n enabled?: boolean\n}\n\nconst KIND = {\n filesystem: { label: 'Filesystem', icon: FolderTree },\n network: { label: 'Network', icon: Globe },\n exec: { label: 'Process execution', icon: Terminal },\n} as const\n\nconst MODE: Record<SandboxMode, { label: string; color: 'green' | 'amber' | 'destructive' }> = {\n none: { label: 'Blocked', color: 'green' },\n allowlist: { label: 'Allow-list', color: 'amber' },\n full: { label: 'Unrestricted', color: 'destructive' },\n}\n\n/** `/`, `*`, `**` and bare `~` are \"everything\" wearing an allow-list costume. */\nconst WIDE_OPEN = new Set(['/', '*', '**', '~', '0.0.0.0/0', '*.*'])\n\ntype SandboxPolicyProps = Omit<ComponentProps<'div'>, 'children'> & {\n scopes: SandboxScope[]\n onToggle?: (id: string, enabled: boolean) => void\n modeLabels?: Partial<Record<SandboxMode, string>>\n denyLabel?: string\n allowLabel?: string\n unrestrictedLabel?: string\n emptyLabel?: string\n label?: string\n}\n\nfunction SandboxPolicy({\n scopes,\n onToggle,\n modeLabels,\n denyLabel = 'Always denied',\n allowLabel = 'Permitted',\n unrestrictedLabel = 'This grants access to everything.',\n emptyLabel = 'No sandbox configured — the agent runs with the host’s own permissions.',\n label = 'Sandbox policy',\n className,\n ...props\n}: SandboxPolicyProps) {\n if (scopes.length === 0) {\n return (\n <div className={cn(surface, radius.surface, 'border-destructive p-4', className)} {...props}>\n <p className=\"flex items-start gap-2 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-px size-3.5 shrink-0\" aria-hidden=\"true\" />\n {emptyLabel}\n </p>\n </div>\n )\n }\n\n return (\n <ul\n data-slot=\"sandbox-policy\"\n aria-label={label}\n className={cn(surface, radius.surface, 'divide-border list-none divide-y overflow-hidden', className)}\n {...(props as ComponentProps<'ul'>)}\n >\n {scopes.map((scope) => {\n const kind = KIND[scope.kind]\n const Icon = kind.icon\n const mode = MODE[scope.mode]\n const wideOpen =\n scope.mode === 'full' || (scope.allow ?? []).some((entry) => WIDE_OPEN.has(entry.trim()))\n\n return (\n <li key={scope.id} className=\"flex flex-col gap-3 px-4 py-3.5\">\n <div className=\"flex items-start gap-3\">\n <Icon className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <p className=\"text-sm font-medium\">{scope.label ?? kind.label}</p>\n <Badge size=\"sm\" color={mode.color}>\n {modeLabels?.[scope.mode] ?? mode.label}\n </Badge>\n </div>\n {scope.description && (\n <p className=\"text-muted-foreground mt-1 text-xs leading-relaxed\">\n {scope.description}\n </p>\n )}\n </div>\n\n {scope.enabled !== undefined && onToggle && (\n <Switch\n size=\"sm\"\n className=\"mt-0.5 shrink-0\"\n checked={scope.enabled}\n aria-label={`Enable ${scope.label ?? kind.label}`}\n onChange={(event) => onToggle(scope.id, event.target.checked)}\n />\n )}\n </div>\n\n {wideOpen && (\n <p className=\"flex items-start gap-2 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-px size-3.5 shrink-0\" aria-hidden=\"true\" />\n {unrestrictedLabel}\n </p>\n )}\n\n {/* Deny first, because that is the order a sandbox evaluates in. */}\n {scope.deny && scope.deny.length > 0 && (\n <div className=\"space-y-1.5\">\n <p className=\"text-muted-foreground/70 flex items-center gap-1.5 text-[11px] tracking-wide uppercase\">\n <Ban className=\"size-3\" aria-hidden=\"true\" />\n {denyLabel}\n </p>\n <div className=\"flex flex-wrap gap-1.5\">\n {scope.deny.map((entry) => (\n <code\n key={entry}\n className={cn(\n 'bg-[var(--destructive-soft)] px-2 py-0.5 font-mono text-[11px] text-[var(--destructive-soft-foreground)]',\n radius.xs,\n )}\n >\n {entry}\n </code>\n ))}\n </div>\n </div>\n )}\n\n {scope.mode === 'allowlist' && scope.allow && scope.allow.length > 0 && (\n <div className=\"space-y-1.5\">\n <p className=\"text-muted-foreground/70 flex items-center gap-1.5 text-[11px] tracking-wide uppercase\">\n <ShieldCheck className=\"size-3\" aria-hidden=\"true\" />\n {allowLabel}\n </p>\n <div className=\"flex flex-wrap gap-1.5\">\n {scope.allow.map((entry) => (\n <code\n key={entry}\n className={cn(\n 'bg-secondary text-secondary-foreground px-2 py-0.5 font-mono text-[11px]',\n radius.xs,\n )}\n >\n {entry}\n </code>\n ))}\n </div>\n </div>\n )}\n </li>\n )\n })}\n </ul>\n )\n}\n\nexport { SandboxPolicy }\nexport type { SandboxPolicyProps }\n"
|
|
20
|
+
"content": "import type { ComponentProps, ReactNode } from 'react'\nimport { Ban, FolderTree, Globe, ShieldCheck, Terminal, TriangleAlert } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Switch } from '@/components/ui/switch'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The permissions an agent actually runs under.\n *\n * Filesystem, network and process execution, each with an explicit mode and the\n * paths or hosts it applies to. This is the screen someone is asked to approve\n * before an agent is let near a real machine, so it is built to make the\n * dangerous configuration look dangerous.\n *\n * **A wide-open scope is called out, not rendered as an ordinary row.** An\n * allow-list of `/` or `*` is functionally \"no sandbox\", and a UI that shows it\n * in the same grey mono as `/tmp` is technically accurate and practically\n * useless. `unrestricted` flags those rows.\n *\n * **Deny wins, and says so.** Every real sandbox evaluates deny before allow;\n * showing the two lists side by side without that ordering invites someone to\n * assume an allow entry re-opens something a deny closed.\n */\nexport type SandboxMode = 'none' | 'allowlist' | 'full'\n\nexport type SandboxScope = {\n id: string\n kind: 'filesystem' | 'network' | 'exec'\n mode: SandboxMode\n /** Paths, hosts or binaries permitted. Ignored when mode is not allowlist. */\n allow?: string[]\n /** Always refused, whatever `allow` says. */\n deny?: string[]\n label?: ReactNode\n description?: ReactNode\n /** Toggle the whole scope. Omit for a read-only policy. */\n enabled?: boolean\n}\n\nconst KIND = {\n filesystem: { label: 'Filesystem', icon: FolderTree },\n network: { label: 'Network', icon: Globe },\n exec: { label: 'Process execution', icon: Terminal },\n} as const\n\nconst MODE: Record<SandboxMode, { label: string; color: 'green' | 'amber' | 'destructive' }> = {\n none: { label: 'Blocked', color: 'green' },\n allowlist: { label: 'Allow-list', color: 'amber' },\n full: { label: 'Unrestricted', color: 'destructive' },\n}\n\n/** `/`, `*`, `**` and bare `~` are \"everything\" wearing an allow-list costume. */\nconst WIDE_OPEN = new Set(['/', '*', '**', '~', '0.0.0.0/0', '*.*'])\n\n// `onToggle` is also a DOM event on every element, and in an intersection the\n// DOM signature wins — so the prop below was unusable and the generated docs\n// advertised the browser's handler instead of ours.\ntype SandboxPolicyProps = Omit<ComponentProps<'div'>, 'children' | 'onToggle'> & {\n scopes: SandboxScope[]\n onToggle?: (id: string, enabled: boolean) => void\n modeLabels?: Partial<Record<SandboxMode, string>>\n denyLabel?: string\n allowLabel?: string\n unrestrictedLabel?: string\n emptyLabel?: string\n label?: string\n}\n\nfunction SandboxPolicy({\n scopes,\n onToggle,\n modeLabels,\n denyLabel = 'Always denied',\n allowLabel = 'Permitted',\n unrestrictedLabel = 'This grants access to everything.',\n emptyLabel = 'No sandbox configured — the agent runs with the host’s own permissions.',\n label = 'Sandbox policy',\n className,\n ...props\n}: SandboxPolicyProps) {\n if (scopes.length === 0) {\n return (\n <div className={cn(surface, radius.surface, 'border-destructive p-4', className)} {...props}>\n <p className=\"flex items-start gap-2 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-px size-3.5 shrink-0\" aria-hidden=\"true\" />\n {emptyLabel}\n </p>\n </div>\n )\n }\n\n return (\n <ul\n data-slot=\"sandbox-policy\"\n aria-label={label}\n className={cn(surface, radius.surface, 'divide-border list-none divide-y overflow-hidden', className)}\n {...(props as ComponentProps<'ul'>)}\n >\n {scopes.map((scope) => {\n const kind = KIND[scope.kind]\n const Icon = kind.icon\n const mode = MODE[scope.mode]\n const wideOpen =\n scope.mode === 'full' || (scope.allow ?? []).some((entry) => WIDE_OPEN.has(entry.trim()))\n\n return (\n <li key={scope.id} className=\"flex flex-col gap-3 px-4 py-3.5\">\n <div className=\"flex items-start gap-3\">\n <Icon className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />\n\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <p className=\"text-sm font-medium\">{scope.label ?? kind.label}</p>\n <Badge size=\"sm\" color={mode.color}>\n {modeLabels?.[scope.mode] ?? mode.label}\n </Badge>\n </div>\n {scope.description && (\n <p className=\"text-muted-foreground mt-1 text-xs leading-relaxed\">\n {scope.description}\n </p>\n )}\n </div>\n\n {scope.enabled !== undefined && onToggle && (\n <Switch\n size=\"sm\"\n className=\"mt-0.5 shrink-0\"\n checked={scope.enabled}\n aria-label={`Enable ${scope.label ?? kind.label}`}\n onChange={(event) => onToggle(scope.id, event.target.checked)}\n />\n )}\n </div>\n\n {wideOpen && (\n <p className=\"flex items-start gap-2 text-xs text-[var(--destructive-soft-foreground)]\">\n <TriangleAlert className=\"mt-px size-3.5 shrink-0\" aria-hidden=\"true\" />\n {unrestrictedLabel}\n </p>\n )}\n\n {/* Deny first, because that is the order a sandbox evaluates in. */}\n {scope.deny && scope.deny.length > 0 && (\n <div className=\"space-y-1.5\">\n <p className=\"text-muted-foreground/70 flex items-center gap-1.5 text-[11px] tracking-wide uppercase\">\n <Ban className=\"size-3\" aria-hidden=\"true\" />\n {denyLabel}\n </p>\n <div className=\"flex flex-wrap gap-1.5\">\n {scope.deny.map((entry) => (\n <code\n key={entry}\n className={cn(\n 'bg-[var(--destructive-soft)] px-2 py-0.5 font-mono text-[11px] text-[var(--destructive-soft-foreground)]',\n radius.xs,\n )}\n >\n {entry}\n </code>\n ))}\n </div>\n </div>\n )}\n\n {scope.mode === 'allowlist' && scope.allow && scope.allow.length > 0 && (\n <div className=\"space-y-1.5\">\n <p className=\"text-muted-foreground/70 flex items-center gap-1.5 text-[11px] tracking-wide uppercase\">\n <ShieldCheck className=\"size-3\" aria-hidden=\"true\" />\n {allowLabel}\n </p>\n <div className=\"flex flex-wrap gap-1.5\">\n {scope.allow.map((entry) => (\n <code\n key={entry}\n className={cn(\n 'bg-secondary text-secondary-foreground px-2 py-0.5 font-mono text-[11px]',\n radius.xs,\n )}\n >\n {entry}\n </code>\n ))}\n </div>\n </div>\n )}\n </li>\n )\n })}\n </ul>\n )\n}\n\nexport { SandboxPolicy }\nexport type { SandboxPolicyProps }\n"
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"path": "components/ui/treemap.tsx",
|
|
15
15
|
"type": "registry:ui",
|
|
16
|
-
"content": "import { useId, useMemo, useState, type ComponentProps } from 'react'\nimport { dataPalette } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Part-to-whole as nested rectangles, sized by area.\n *\n * **Squarified, not sliced.** The naive layout cuts the space into strips, which\n * produces slivers — rectangles hundreds of times longer than they are wide.\n * Slivers cannot be labelled, are almost impossible to compare, and are hard to\n * hit with a pointer. This uses the squarify algorithm (Bruls, Huizing, van\n * Wijk): it grows a row while the worst aspect ratio in that row improves, and\n * closes it as soon as adding the next item would make it worse. The result is\n * tiles close to square at every size.\n *\n * **Area encodes the value, and area is read poorly.** People judge area far\n * less accurately than length, so a treemap is the right choice for \"what is\n * big, roughly, and what is inside what\" and the wrong one for \"is A bigger\n * than B\" when A and B are close — for that, use a bar chart. The honest use is\n * disk usage, bundle composition, spend by category: hierarchies where the\n * nesting matters and precision does not.\n *\n * **Labels are drawn only where they fit.** A label wider or taller than its\n * tile is not truncated to an ellipsis that tells you nothing; it is dropped,\n * and the tile keeps its tooltip. Everything about the value stays reachable\n * through the title and the legend.\n */\nexport type TreemapNode = {\n id: string\n label: string\n /** Leaf magnitude. Ignored when `children` is present — parents sum. */\n value?: number\n children?: TreemapNode[]\n color?: string\n}\n\ntype Tile = {\n node: TreemapNode\n x: number\n y: number\n width: number\n height: number\n depth: number\n value: number\n}\n\ntype TreemapProps = Omit<ComponentProps<'figure'>, 'height'> & {\n nodes: TreemapNode[]\n height?: number\n /** Levels to draw. 1 flattens the hierarchy to its top level. */\n depth?: number\n valueFormat?: (value: number) => string\n onSelect?: (node: TreemapNode) => void\n /** Gap between tiles, in pixels. */\n gap?: number\n emptyLabel?: string\n label?: string\n}\n\nconst DEFAULT_FORMAT: (value: number) => string = (value: number) =>\n value >= 1_000_000\n ? `${(value / 1_000_000).toFixed(1)}M`\n : value >= 1000\n ? `${(value / 1000).toFixed(1)}k`\n : String(Math.round(value))\n\nconst total = (node: TreemapNode): number =>\n node.children?.length ? node.children.reduce((sum, child) => sum + total(child), 0) : (node.value ?? 0)\n\n/**\n * Squarify: lay `items` into `rect`, keeping tiles as square as possible.\n *\n * The worst aspect ratio in a row is the objective; a row is closed as soon as\n * adding the next item would make that worse.\n */\nfunction squarify(\n items: { node: TreemapNode; value: number }[],\n rect: { x: number; y: number; width: number; height: number },\n out: Tile[],\n depth: number,\n) {\n if (items.length === 0 || rect.width <= 0 || rect.height <= 0) return\n\n const sum = items.reduce((acc, item) => acc + item.value, 0)\n if (sum <= 0) return\n\n const area = rect.width * rect.height\n const scaled = items.map((item) => ({ ...item, area: (item.value / sum) * area }))\n\n let cursor = { ...rect }\n let index = 0\n\n while (index < scaled.length) {\n const vertical = cursor.width >= cursor.height\n const side = vertical ? cursor.height : cursor.width\n\n const row: typeof scaled = []\n let rowArea = 0\n let best = Infinity\n\n while (index < scaled.length) {\n const candidate = scaled[index]\n const nextArea = rowArea + candidate.area\n const thickness = nextArea / side\n\n // Worst aspect ratio if this item joins the row.\n const worst = Math.max(\n ...[...row, candidate].map((item) => {\n const length = item.area / thickness\n return Math.max(thickness / length, length / thickness)\n }),\n )\n\n if (row.length > 0 && worst > best) break\n row.push(candidate)\n rowArea = nextArea\n best = worst\n index++\n }\n\n const thickness = rowArea / side\n let offset = vertical ? cursor.y : cursor.x\n\n for (const item of row) {\n const length = item.area / thickness\n out.push({\n node: item.node,\n value: item.value,\n depth,\n x: vertical ? cursor.x : offset,\n y: vertical ? offset : cursor.y,\n width: vertical ? thickness : length,\n height: vertical ? length : thickness,\n })\n offset += length\n }\n\n cursor = vertical\n ? { x: cursor.x + thickness, y: cursor.y, width: cursor.width - thickness, height: cursor.height }\n : { x: cursor.x, y: cursor.y + thickness, width: cursor.width, height: cursor.height - thickness }\n }\n}\n\nfunction Treemap({\n nodes,\n height = 320,\n depth = 2,\n valueFormat = DEFAULT_FORMAT,\n onSelect,\n gap = 2,\n emptyLabel = 'Nothing to show.',\n label = 'Treemap',\n className,\n ...props\n}: TreemapProps) {\n const titleId = useId()\n const [hovered, setHovered] = useState<string | null>(null)\n\n const tiles = useMemo(() => {\n const out: Tile[] = []\n // Percentage space, so the layout is resolution independent.\n const top = nodes\n .map((node) => ({ node, value: total(node) }))\n .filter((item) => item.value > 0)\n .sort((a, b) => b.value - a.value)\n\n squarify(top, { x: 0, y: 0, width: 100, height: 100 }, out, 0)\n\n if (depth > 1) {\n // Children are laid out inside their parent's rectangle, recursively.\n const parents = out.filter((tile) => tile.node.children?.length)\n for (const parent of parents) {\n const inner = (parent.node.children ?? [])\n .map((node) => ({ node, value: total(node) }))\n .filter((item) => item.value > 0)\n .sort((a, b) => b.value - a.value)\n squarify(\n inner,\n // Inset leaves the parent's own edge visible as a frame.\n { x: parent.x, y: parent.y + 3, width: parent.width, height: Math.max(0, parent.height - 3) },\n out,\n 1,\n )\n }\n }\n\n return out\n }, [nodes, depth])\n\n if (tiles.length === 0) {\n return (\n <figure className={cn('text-muted-foreground p-4 text-xs', className)} {...props}>\n {emptyLabel}\n </figure>\n )\n }\n\n const colourFor = (tile: Tile, index: number) =>\n tile.node.color ?? dataPalette[index % dataPalette.length].fill\n\n return (\n <figure\n data-slot=\"treemap\"\n className={cn('flex flex-col gap-2', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <figcaption id={titleId} className=\"sr-only\">\n {label}\n </figcaption>\n\n {/* Positioned elements, not SVG: text inside a stretched viewBox would be\n sheared, and these tiles must hold readable labels. */}\n <div className=\"relative w-full overflow-hidden\" style={{ height }}>\n {tiles.map((tile, index) => {\n const leaf = tile.depth > 0 || !tile.node.children?.length\n const fits = tile.width > 12 && tile.height > 9\n\n return (\n <button\n key={`${tile.node.id}-${tile.depth}`}\n type=\"button\"\n disabled={!onSelect}\n onClick={() => onSelect?.(tile.node)}\n onMouseEnter={() => setHovered(tile.node.id)}\n onMouseLeave={() => setHovered(null)}\n title={`${tile.node.label}: ${valueFormat(tile.value)}`}\n className={cn(\n 'absolute overflow-hidden text-start transition-opacity',\n onSelect ? 'cursor-pointer' : 'cursor-default',\n leaf ? 'rounded-[3px]' : 'rounded-[4px] ring-1 ring-inset ring-white/25',\n )}\n style={{\n insetInlineStart: `${tile.x}%`,\n top: `${tile.y}%`,\n width: `calc(${tile.width}% - ${gap}px)`,\n height: `calc(${tile.height}% - ${gap}px)`,\n background: colourFor(tile, index),\n opacity: hovered === null || hovered === tile.node.id ? (leaf ? 0.85 : 0.35) : 0.3,\n }}\n >\n {/* Dropped rather than truncated: \"Node_m…\" tells you nothing. */}\n {fits && (\n <span className=\"pointer-events-none block p-1.5 leading-tight text-white\">\n <span className=\"block truncate text-[11px] font-medium\">{tile.node.label}</span>\n <span className=\"block truncate text-[10px] tabular-nums opacity-80\">\n {valueFormat(tile.value)}\n </span>\n </span>\n )}\n </button>\n )\n })}\n </div>\n </figure>\n )\n}\n\nexport { Treemap }\nexport type { TreemapProps }\n"
|
|
16
|
+
"content": "import { useId, useMemo, useState, type ComponentProps } from 'react'\nimport { dataPalette } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Part-to-whole as nested rectangles, sized by area.\n *\n * **Squarified, not sliced.** The naive layout cuts the space into strips, which\n * produces slivers — rectangles hundreds of times longer than they are wide.\n * Slivers cannot be labelled, are almost impossible to compare, and are hard to\n * hit with a pointer. This uses the squarify algorithm (Bruls, Huizing, van\n * Wijk): it grows a row while the worst aspect ratio in that row improves, and\n * closes it as soon as adding the next item would make it worse. The result is\n * tiles close to square at every size.\n *\n * **Area encodes the value, and area is read poorly.** People judge area far\n * less accurately than length, so a treemap is the right choice for \"what is\n * big, roughly, and what is inside what\" and the wrong one for \"is A bigger\n * than B\" when A and B are close — for that, use a bar chart. The honest use is\n * disk usage, bundle composition, spend by category: hierarchies where the\n * nesting matters and precision does not.\n *\n * **Labels are drawn only where they fit.** A label wider or taller than its\n * tile is not truncated to an ellipsis that tells you nothing; it is dropped,\n * and the tile keeps its tooltip. Everything about the value stays reachable\n * through the title and the legend.\n */\nexport type TreemapNode = {\n id: string\n label: string\n /** Leaf magnitude. Ignored when `children` is present — parents sum. */\n value?: number\n children?: TreemapNode[]\n color?: string\n}\n\ntype Tile = {\n node: TreemapNode\n x: number\n y: number\n width: number\n height: number\n depth: number\n value: number\n}\n\n// Omitted because the DOM declares it too, and in an intersection the DOM\n// signature wins — which left the prop below unusable and the generated docs\n// advertising the browser's handler instead of ours.\ntype TreemapProps = Omit<ComponentProps<'figure'>, 'height' | 'onSelect'> & {\n nodes: TreemapNode[]\n height?: number\n /** Levels to draw. 1 flattens the hierarchy to its top level. */\n depth?: number\n valueFormat?: (value: number) => string\n onSelect?: (node: TreemapNode) => void\n /** Gap between tiles, in pixels. */\n gap?: number\n emptyLabel?: string\n label?: string\n}\n\nconst DEFAULT_FORMAT: (value: number) => string = (value: number) =>\n value >= 1_000_000\n ? `${(value / 1_000_000).toFixed(1)}M`\n : value >= 1000\n ? `${(value / 1000).toFixed(1)}k`\n : String(Math.round(value))\n\nconst total = (node: TreemapNode): number =>\n node.children?.length ? node.children.reduce((sum, child) => sum + total(child), 0) : (node.value ?? 0)\n\n/**\n * Squarify: lay `items` into `rect`, keeping tiles as square as possible.\n *\n * The worst aspect ratio in a row is the objective; a row is closed as soon as\n * adding the next item would make that worse.\n */\nfunction squarify(\n items: { node: TreemapNode; value: number }[],\n rect: { x: number; y: number; width: number; height: number },\n out: Tile[],\n depth: number,\n) {\n if (items.length === 0 || rect.width <= 0 || rect.height <= 0) return\n\n const sum = items.reduce((acc, item) => acc + item.value, 0)\n if (sum <= 0) return\n\n const area = rect.width * rect.height\n const scaled = items.map((item) => ({ ...item, area: (item.value / sum) * area }))\n\n let cursor = { ...rect }\n let index = 0\n\n while (index < scaled.length) {\n const vertical = cursor.width >= cursor.height\n const side = vertical ? cursor.height : cursor.width\n\n const row: typeof scaled = []\n let rowArea = 0\n let best = Infinity\n\n while (index < scaled.length) {\n const candidate = scaled[index]\n const nextArea = rowArea + candidate.area\n const thickness = nextArea / side\n\n // Worst aspect ratio if this item joins the row.\n const worst = Math.max(\n ...[...row, candidate].map((item) => {\n const length = item.area / thickness\n return Math.max(thickness / length, length / thickness)\n }),\n )\n\n if (row.length > 0 && worst > best) break\n row.push(candidate)\n rowArea = nextArea\n best = worst\n index++\n }\n\n const thickness = rowArea / side\n let offset = vertical ? cursor.y : cursor.x\n\n for (const item of row) {\n const length = item.area / thickness\n out.push({\n node: item.node,\n value: item.value,\n depth,\n x: vertical ? cursor.x : offset,\n y: vertical ? offset : cursor.y,\n width: vertical ? thickness : length,\n height: vertical ? length : thickness,\n })\n offset += length\n }\n\n cursor = vertical\n ? { x: cursor.x + thickness, y: cursor.y, width: cursor.width - thickness, height: cursor.height }\n : { x: cursor.x, y: cursor.y + thickness, width: cursor.width, height: cursor.height - thickness }\n }\n}\n\nfunction Treemap({\n nodes,\n height = 320,\n depth = 2,\n valueFormat = DEFAULT_FORMAT,\n onSelect,\n gap = 2,\n emptyLabel = 'Nothing to show.',\n label = 'Treemap',\n className,\n ...props\n}: TreemapProps) {\n const titleId = useId()\n const [hovered, setHovered] = useState<string | null>(null)\n\n const tiles = useMemo(() => {\n const out: Tile[] = []\n // Percentage space, so the layout is resolution independent.\n const top = nodes\n .map((node) => ({ node, value: total(node) }))\n .filter((item) => item.value > 0)\n .sort((a, b) => b.value - a.value)\n\n squarify(top, { x: 0, y: 0, width: 100, height: 100 }, out, 0)\n\n if (depth > 1) {\n // Children are laid out inside their parent's rectangle, recursively.\n const parents = out.filter((tile) => tile.node.children?.length)\n for (const parent of parents) {\n const inner = (parent.node.children ?? [])\n .map((node) => ({ node, value: total(node) }))\n .filter((item) => item.value > 0)\n .sort((a, b) => b.value - a.value)\n squarify(\n inner,\n // Inset leaves the parent's own edge visible as a frame.\n { x: parent.x, y: parent.y + 3, width: parent.width, height: Math.max(0, parent.height - 3) },\n out,\n 1,\n )\n }\n }\n\n return out\n }, [nodes, depth])\n\n if (tiles.length === 0) {\n return (\n <figure className={cn('text-muted-foreground p-4 text-xs', className)} {...props}>\n {emptyLabel}\n </figure>\n )\n }\n\n const colourFor = (tile: Tile, index: number) =>\n tile.node.color ?? dataPalette[index % dataPalette.length].fill\n\n return (\n <figure\n data-slot=\"treemap\"\n className={cn('flex flex-col gap-2', className)}\n aria-labelledby={titleId}\n {...props}\n >\n <figcaption id={titleId} className=\"sr-only\">\n {label}\n </figcaption>\n\n {/* Positioned elements, not SVG: text inside a stretched viewBox would be\n sheared, and these tiles must hold readable labels. */}\n <div className=\"relative w-full overflow-hidden\" style={{ height }}>\n {tiles.map((tile, index) => {\n const leaf = tile.depth > 0 || !tile.node.children?.length\n const fits = tile.width > 12 && tile.height > 9\n\n return (\n <button\n key={`${tile.node.id}-${tile.depth}`}\n type=\"button\"\n disabled={!onSelect}\n onClick={() => onSelect?.(tile.node)}\n onMouseEnter={() => setHovered(tile.node.id)}\n onMouseLeave={() => setHovered(null)}\n title={`${tile.node.label}: ${valueFormat(tile.value)}`}\n className={cn(\n 'absolute overflow-hidden text-start transition-opacity',\n onSelect ? 'cursor-pointer' : 'cursor-default',\n leaf ? 'rounded-[3px]' : 'rounded-[4px] ring-1 ring-inset ring-white/25',\n )}\n style={{\n insetInlineStart: `${tile.x}%`,\n top: `${tile.y}%`,\n width: `calc(${tile.width}% - ${gap}px)`,\n height: `calc(${tile.height}% - ${gap}px)`,\n background: colourFor(tile, index),\n opacity: hovered === null || hovered === tile.node.id ? (leaf ? 0.85 : 0.35) : 0.3,\n }}\n >\n {/* Dropped rather than truncated: \"Node_m…\" tells you nothing. */}\n {fits && (\n <span className=\"pointer-events-none block p-1.5 leading-tight text-white\">\n <span className=\"block truncate text-[11px] font-medium\">{tile.node.label}</span>\n <span className=\"block truncate text-[10px] tabular-nums opacity-80\">\n {valueFormat(tile.value)}\n </span>\n </span>\n )}\n </button>\n )\n })}\n </div>\n </figure>\n )\n}\n\nexport { Treemap }\nexport type { TreemapProps }\n"
|
|
17
17
|
}
|
|
18
18
|
]
|
|
19
19
|
}
|