@hachej/boring-ui-kit 0.4.8 → 0.4.9

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.
@@ -4,13 +4,13 @@
4
4
  "title": "Shared chat",
5
5
  "description": "Optional multi-party host log presentation over PiChat's generic composition seams. No agent runtime or speaking policy.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.8",
7
+ "@hachej/boring-ui-kit@^0.4.9",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
14
14
  "avatar",
15
15
  "badge",
16
16
  "button",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  {
42
42
  "path": "registry/shared-chat/messages.tsx",
43
- "content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport { channelAvatarStates } from '@hachej/boring-ui-kit/shared-chat';\nimport type { Card as Request, ChannelMessage, SharedChatController, Member, SharedChatState } from '@hachej/boring-ui-kit/shared-chat';\nimport { BotIcon, CircleIcon, ReplyIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { relativeTime } from '../pi-chat/history';\nimport { useChatText } from './text';\n\n// Text shares one treatment across headings and muted bubbles; only AA-qualified default palette entries colour it.\nconst agentTones = [\n { token: 'chart-1', icon: 'text-chart-1', text: 'text-foreground' },\n { token: 'chart-2', icon: 'text-chart-2', text: 'text-foreground dark:text-chart-2' },\n { token: 'chart-3', icon: 'text-chart-3', text: 'text-chart-3' },\n { token: 'chart-4', icon: 'text-chart-4', text: 'text-foreground' },\n { token: 'chart-5', icon: 'text-chart-5', text: 'text-foreground' },\n];\nexport function agentTone(member: Pick<Member, 'id' | 'kind'>) {\n if (member.kind !== 'agent') return undefined;\n let hash = 0;\n for (const character of member.id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) >>> 0;\n return agentTones[hash % agentTones.length];\n}\n\nfunction usePhone() {\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.('(max-width: 640px)').matches === true);\n useEffect(() => { const media = globalThis.matchMedia?.('(max-width: 640px)'); if (!media) return; const change = () => setPhone(media.matches); media.addEventListener('change', change); return () => media.removeEventListener('change', change); }, []);\n return phone;\n}\nconst latestCard = (shared: SharedChatState, id: string) => shared.messages.findLast(message => message.author === id && message.card)?.card;\nconst memberOf = (shared: SharedChatState, id: string): Member => shared.members.find(member => member.id === id) ?? { id, name: id, kind: 'person' };\nexport function sharedQuote(shared: SharedChatState, id: string | undefined) {\n const message = shared.messages.find(message => message.id === id);\n return message ? { message, author: memberOf(shared, message.author) } : undefined;\n}\n\nexport function MemberAvatar({ member, card, interactive = true, size = 'default', compact = false }: { readonly member: Member; readonly card?: Request | undefined; readonly interactive?: boolean; readonly size?: ComponentProps<typeof Avatar>['size']; readonly compact?: boolean }) {\n const { labels } = useChatText();\n const phone = usePhone();\n const [open, setOpen] = useState(false);\n useEffect(() => {\n if (!open) return;\n const close = () => setOpen(false);\n document.addEventListener('scroll', close, true);\n return () => document.removeEventListener('scroll', close, true);\n }, [open]);\n const state = member.kind === 'agent' && card?.state && Object.hasOwn(channelAvatarStates, card.state) ? channelAvatarStates[card.state] ?? 'neutral' : 'neutral';\n const tone = state === 'blocked' ? 'text-destructive' : state === 'waiting' ? 'text-muted-foreground' : 'text-primary';\n const agent = agentTone(member);\n const avatar = <Avatar size={compact ? 'sm' : size} data-agent-color={agent?.token} data-testid=\"member-avatar\" role=\"img\" aria-label={member.kind === 'agent' ? `${member.name}, ${labels.sharedAgent}` : member.name} data-state={state}>\n {member.avatarUrl && <AvatarImage src={member.avatarUrl} alt={member.name} />}\n <AvatarFallback>{member.kind === 'agent' ? <BotIcon data-lucide=\"bot\" className={`${compact ? 'size-3' : 'size-4'} ${agent?.icon ?? ''}`} aria-hidden=\"true\" /> : member.name.replace(/[()]/g, '').split(/\\s+/).slice(0, 2).map(word => word[0]).join('').toUpperCase()}</AvatarFallback>\n {state !== 'neutral' && <span data-testid=\"avatar-indicator\" data-state={state} className={`absolute right-0 bottom-0 ${tone}`}>\n <CircleIcon className=\"size-2 fill-current\" aria-hidden=\"true\" />\n </span>}\n </Avatar>;\n if (member.kind !== 'agent' || !card || !interactive) return avatar;\n const trigger = <span role=\"button\" tabIndex={0} className=\"inline-flex shrink-0\" data-testid={`agent-avatar-${member.id}`} data-state={state} aria-label={`${member.name}: ${card.line}`}>{avatar}</span>;\n return phone\n ? <Popover open={open} onOpenChange={setOpen}><PopoverTrigger nativeButton={false} render={trigger} /><PopoverContent data-testid=\"agent-state-popover\" side=\"bottom\" align=\"start\" className=\"max-w-[calc(100vw-1rem)]\">{card.line}</PopoverContent></Popover>\n : <Tooltip><TooltipTrigger render={trigger} /><TooltipContent>{card.line}</TooltipContent></Tooltip>;\n}\nfunction MemberBadge({ member }: { readonly member: Member }) {\n const { labels } = useChatText();\n return member.kind === 'admin' ? <Badge data-testid=\"author-badge\" variant=\"secondary\">{labels.sharedAdmin}</Badge> : null;\n}\nfunction Dot({ value }: { readonly value: SharedChatState['status'] | Member['presence'] }) {\n return <CircleIcon aria-hidden=\"true\" className={`size-2 fill-current ${value === 'live' || value === 'here' ? 'text-primary' : value === 'offline' ? 'text-destructive' : 'text-muted-foreground'}`} />;\n}\n\n/** Mounted in the existing bar. The source owns membership and visibility, including the admin. */\nexport function SharedMembers({ controller }: { readonly controller: SharedChatController }) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const phone = usePhone();\n const shared = state.shared;\n if (!shared) return null;\n const name = shared.status === 'live' ? labels.live : shared.status === 'reconnecting' ? labels.reconnecting : labels.sharedOffline;\n const content = <ul data-testid=\"member-list\" className=\"m-0 flex min-w-0 list-none flex-col gap-3 p-4\">\n {shared.members.map(member => <li key={member.id} data-member-id={member.id} className=\"flex min-w-0 items-center gap-2\">\n <MemberAvatar member={member} card={latestCard(shared, member.id)} /><span className=\"min-w-0 flex-1 truncate\">{member.name}</span>\n <Badge variant=\"secondary\">{member.role ?? member.kind}</Badge>\n <span data-testid=\"presence\" data-presence={member.presence ?? 'away'} role=\"img\" aria-label={member.presence === 'here' ? labels.sharedHere : labels.sharedAway}><Dot value={member.presence} /></span>\n </li>)}\n </ul>;\n const stack = <AvatarGroup>{shared.members.slice(0, phone ? 1 : 3).map(member => <MemberAvatar key={member.id} member={member} card={latestCard(shared, member.id)} interactive={false} />)}<AvatarGroupCount>{shared.members.length}</AvatarGroupCount></AvatarGroup>;\n const trigger = <Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open}>{stack}</Button>;\n return <div className=\"flex shrink-0 items-center gap-2\">\n <span data-testid=\"shared-connection-announcement\" className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">{name}</span>\n {shared.status !== 'live' && <Tooltip><TooltipTrigger render={<span tabIndex={0} data-testid=\"shared-status\" data-status={shared.status} role=\"img\" aria-label={name}><Dot value={shared.status} /></span>} /><TooltipContent>{name}</TooltipContent></Tooltip>}\n {phone ? <><Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open} onClick={() => setOpen(true)}>{stack}</Button>\n {open && <Drawer open onOpenChange={setOpen} showSwipeHandle><DrawerContent className=\"pi-chat max-h-[80dvh]\"><div className=\"flex shrink-0 items-center justify-between p-4\"><DrawerTitle>{labels.sharedMembers}</DrawerTitle><Button variant=\"ghost\" size=\"icon\" data-testid=\"shared-members-close\" aria-label={labels.close} onClick={() => setOpen(false)}><XIcon aria-hidden=\"true\" /></Button></div><div className=\"min-h-0 overflow-y-auto\">{content}</div></DrawerContent></Drawer>}</>\n : <Popover open={open} onOpenChange={setOpen}><PopoverTrigger render={trigger} /><PopoverContent role=\"dialog\" aria-label={labels.sharedMembers} align=\"end\" className=\"max-w-[calc(100vw-1rem)]\">{content}</PopoverContent></Popover>}\n </div>;\n}\n\nexport function RequestCard({ card, action }: { readonly card: Request; readonly action: (card: Request) => Promise<void> }) {\n const { labels } = useChatText();\n const [busy, setBusy] = useState(false), [error, setError] = useState<string>();\n return <Card data-testid=\"request-card\">\n <CardHeader><CardTitle>{card.title}</CardTitle></CardHeader>\n <CardContent><p className=\"m-0\">{card.line}</p>{card.since && <time dateTime={card.since} className=\"text-muted-foreground\">{labels.sharedSince(relativeTime(card.since, Date.now(), labels))}</time>}{error && <p role=\"alert\">{error}</p>}</CardContent>\n {card.action && <CardFooter><Button disabled={busy} onClick={() => { setBusy(true); setError(undefined); void action(card).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)).finally(() => setBusy(false)); }}>{card.action.label}</Button></CardFooter>}\n </Card>;\n}\n\n/** Both the composer chip and the timeline quote use this one parent reference. No thread or routing policy. */\nexport function ReplyQuote({ targetId, quote, onRemove, onReveal }: { readonly targetId: string; readonly quote: ReturnType<typeof sharedQuote>; readonly onRemove?: () => void; readonly onReveal?: ((messageId: string) => void) | undefined }) {\n const { labels } = useChatText();\n const text = quote?.message.removed ? labels.sharedRemoved : quote?.message.card?.title ?? quote?.message.text?.split(/\\r?\\n/)[0] ?? labels.sharedEarlierMessage;\n return <div data-testid={onRemove ? 'composer-reply' : 'message-quote'} className=\"flex w-full min-w-0 items-center gap-1\">\n <Button variant=\"ghost\" className=\"min-w-0 max-w-full justify-start\" onClick={event => {\n const chat = event.currentTarget.closest('[data-boring=pi-chat]');\n const parent = [...(chat?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === targetId);\n if (parent) parent.scrollIntoView({ block: 'center', behavior: 'instant' }); else onReveal?.(targetId);\n }}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n {onRemove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-reply-remove\" aria-label={labels.sharedCancelReply} onClick={onRemove}><XIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedCancelReply}</TooltipContent></Tooltip>}\n </div>;\n}\n\n/** Participant metadata surrounds the existing bubbles, Markdown, cards and tool trace. */\nexport function SharedMessage({ shared, children, remove, canRemove, openFile, onReply, onReveal }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n readonly remove?: ((message: ChannelMessage) => Promise<void>) | undefined;\n readonly canRemove?: ((message: ChannelMessage) => boolean) | undefined;\n readonly openFile?: ((path: string) => void) | undefined;\n readonly onReply?: ((messageId: string) => void) | undefined;\n readonly onReveal?: ((messageId: string) => void) | undefined;\n}) {\n const { labels } = useChatText();\n const [error, setError] = useState<string>();\n const [actionsOpen, setActionsOpen] = useState(false);\n const phone = usePhone();\n const element = useRef<HTMLDivElement>(null);\n const press = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n const start = useRef<{ x: number; y: number } | undefined>(undefined);\n const cancelPress = () => { clearTimeout(press.current); start.current = undefined; };\n useEffect(() => cancelPress, []);\n useEffect(() => {\n if (!actionsOpen) return;\n const outside = (event: PointerEvent) => { if (event.target instanceof Node && !element.current?.contains(event.target)) setActionsOpen(false); };\n document.addEventListener('pointerdown', outside);\n return () => document.removeEventListener('pointerdown', outside);\n }, [actionsOpen]);\n const { message, author, first, own, lastItem } = shared;\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n const actionable = !message.removed && (replyable || removable);\n const heading = first && !own && <div data-testid=\"message-author-row\" className=\"ml-8 flex min-w-0 items-center gap-2\">\n <div className=\"flex min-w-0 items-center gap-2\">\n <span data-testid=\"message-author\" data-agent-color={agentTone(author)?.token} className={`min-w-0 truncate text-sm ${agentTone(author)?.text ?? 'text-muted-foreground'}`}>{author.name}</span>\n {message.via && <span data-testid=\"message-via\" className={`min-w-0 truncate text-muted-foreground${own ? '' : ' text-sm'}`}>{labels.sharedVia(message.via)}</span>}\n {!(message.via && author.kind === 'agent') && <MemberBadge member={author} />}\n </div>\n </div>;\n const body = <>\n {message.replyTo && <ReplyQuote targetId={message.replyTo} quote={shared.quote} onReveal={onReveal} />}\n {message.removed ? <p className=\"text-muted-foreground\">{labels.sharedRemoved}</p> : children}\n </>;\n return <div ref={element} data-shared-message={message.id} className=\"group/shared-message relative flex min-w-0 flex-col gap-1\"\n tabIndex={phone && actionable ? 0 : undefined} aria-keyshortcuts={phone && actionable ? 'Shift+F10' : undefined}\n onKeyDown={event => {\n if (!phone || !actionable) return;\n if (event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey) { event.preventDefault(); setActionsOpen(true); }\n if (event.key === 'Escape') { setActionsOpen(false); element.current?.focus(); }\n }}\n onPointerDown={event => {\n if (!phone || !actionable || event.pointerType !== 'touch' || event.target instanceof Element && event.target.closest('button, a, input, textarea, [role=button], [role=link]')) return;\n cancelPress(); start.current = { x: event.clientX, y: event.clientY };\n press.current = setTimeout(() => setActionsOpen(true), 400);\n }}\n onPointerMove={event => { if (start.current && (Math.abs(event.clientX - start.current.x) > 8 || Math.abs(event.clientY - start.current.y) > 8)) cancelPress(); }}\n onPointerUp={cancelPress} onPointerCancel={cancelPress}\n onContextMenu={event => { if (phone && actionable && event.target instanceof Element && !event.target.closest('button, a, input, textarea, [role=button], [role=link]')) { event.preventDefault(); setActionsOpen(true); } }}>\n {heading}\n <div className=\"flex min-w-0 items-end gap-2\">\n {!own && <div data-testid=\"message-avatar\" className=\"flex w-6 shrink-0 items-end\">{lastItem && <MemberAvatar member={author} card={message.via ? undefined : shared.latestCard} size=\"sm\" />}</div>}\n <div data-testid=\"shared-message-content\" className=\"flex min-w-0 flex-1 flex-col gap-1\">\n {body}\n {!message.removed && message.attachments?.map(file => <Button key={file.path} variant=\"outline\" disabled={!openFile} onClick={() => openFile?.(file.path)} className=\"max-w-full justify-start\"><span className=\"truncate\">{file.title ?? file.path}</span></Button>)}\n {error && <p role=\"alert\">{error}</p>}\n </div></div>\n {actionable && <div data-testid=\"shared-message-actions\" hidden={phone && !actionsOpen} className={`absolute right-0 top-0 ${phone && !actionsOpen ? 'hidden' : 'flex'} items-center gap-1${phone ? '' : ' opacity-0 group-hover/shared-message:opacity-100 group-focus-within/shared-message:opacity-100'}`}>\n {replyable && onReply && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"shared-reply\" aria-label={labels.sharedReply} onClick={() => { setActionsOpen(false); onReply(message.id); }}><ReplyIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedReply}</TooltipContent></Tooltip>}\n {removable && remove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} aria-label={labels.delete} onClick={() => { void remove(message).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)); }}><Trash2Icon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.delete}</TooltipContent></Tooltip>}\n </div>}\n </div>;\n}\n\nexport interface SharedItem {\n readonly key: string; readonly message: ChannelMessage; readonly author: Member; readonly own: boolean; readonly first: boolean; readonly lastItem: boolean; readonly separator: boolean;\n readonly showSpinner: boolean; readonly latestCard: Request | undefined; readonly quote: ReturnType<typeof sharedQuote>;\n}\nexport function sharedItems(shared: SharedChatState): SharedItem[] {\n const working = new Map(shared.messages.filter(message => message.streaming).map(message => [message.author, message.id]));\n const groups: ChannelMessage[][] = [];\n for (const message of shared.messages) {\n const group = groups.at(-1), previous = group?.at(-1);\n const elapsed = previous ? Date.parse(message.at) - Date.parse(previous.at) : NaN;\n if (group && previous?.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000) group.push(message);\n else groups.push([message]);\n }\n return groups.flatMap((group, groupIndex) => {\n const lastItem = group.at(-1);\n return group.map((message, index): SharedItem => {\n const previous = index > 0 ? group[index - 1] : groups[groupIndex - 1]?.at(-1);\n return { key: `shared:${message.id}`, message, author: memberOf(shared, message.author), own: message.author === shared.me.id,\n first: index === 0, lastItem: message === lastItem,\n separator: !previous || Date.parse(message.at) - Date.parse(previous.at) > 15 * 60_000,\n showSpinner: working.get(message.author) === message.id, latestCard: latestCard(shared, message.author), quote: sharedQuote(shared, message.replyTo) };\n });\n });\n}\n",
43
+ "content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport { channelAvatarStates } from '@hachej/boring-ui-kit/shared-chat';\nimport type { Card as Request, ChannelMessage, SharedChatController, Member, SharedChatState } from '@hachej/boring-ui-kit/shared-chat';\nimport { BotIcon, CircleIcon, ReplyIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { relativeTime } from '../pi-chat/history';\nimport { useChatText } from './text';\n\n// Text shares one treatment across headings and muted bubbles; only AA-qualified default palette entries colour it.\nconst agentTones = [\n { token: 'chart-1', icon: 'text-chart-1', text: 'text-foreground' },\n { token: 'chart-2', icon: 'text-chart-2', text: 'text-foreground dark:text-chart-2' },\n { token: 'chart-3', icon: 'text-chart-3', text: 'text-chart-3' },\n { token: 'chart-4', icon: 'text-chart-4', text: 'text-foreground' },\n { token: 'chart-5', icon: 'text-chart-5', text: 'text-foreground' },\n];\nexport function agentTone(member: Pick<Member, 'id' | 'kind'>) {\n if (member.kind !== 'agent') return undefined;\n let hash = 0;\n for (const character of member.id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) >>> 0;\n return agentTones[hash % agentTones.length];\n}\n\nfunction usePhone() {\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.('(max-width: 640px)').matches === true);\n useEffect(() => { const media = globalThis.matchMedia?.('(max-width: 640px)'); if (!media) return; const change = () => setPhone(media.matches); media.addEventListener('change', change); return () => media.removeEventListener('change', change); }, []);\n return phone;\n}\nconst latestCard = (shared: SharedChatState, id: string) => shared.messages.findLast(message => message.author === id && message.card)?.card;\nconst memberOf = (shared: SharedChatState, id: string): Member => shared.members.find(member => member.id === id) ?? { id, name: id, kind: 'person' };\nexport function sharedQuote(shared: SharedChatState, id: string | undefined) {\n const message = shared.messages.find(message => message.id === id);\n return message ? { message, author: memberOf(shared, message.author) } : undefined;\n}\n\n/** Only https, `data:image/`, `blob:` and same-origin relative addresses load; anything else falls back to the initials. */\nexport function safeAvatarUrl(url: string | undefined): string | undefined {\n if (!url) return undefined;\n return /^(?:https:\\/\\/|data:image\\/|blob:)/i.test(url) || /^\\/(?![/\\\\])/.test(url) ? url : undefined;\n}\n\nexport function MemberAvatar({ member, card, interactive = true, size = 'default', compact = false }: { readonly member: Member; readonly card?: Request | undefined; readonly interactive?: boolean; readonly size?: ComponentProps<typeof Avatar>['size']; readonly compact?: boolean }) {\n const { labels } = useChatText();\n const phone = usePhone();\n const [open, setOpen] = useState(false);\n useEffect(() => {\n if (!open) return;\n const close = () => setOpen(false);\n document.addEventListener('scroll', close, true);\n return () => document.removeEventListener('scroll', close, true);\n }, [open]);\n const state = member.kind === 'agent' && card?.state && Object.hasOwn(channelAvatarStates, card.state) ? channelAvatarStates[card.state] ?? 'neutral' : 'neutral';\n const tone = state === 'blocked' ? 'text-destructive' : state === 'waiting' ? 'text-muted-foreground' : 'text-primary';\n const agent = agentTone(member);\n const avatarSrc = safeAvatarUrl(member.avatarUrl);\n const avatar = <Avatar size={compact ? 'sm' : size} data-agent-color={agent?.token} data-testid=\"member-avatar\" role=\"img\" aria-label={member.kind === 'agent' ? `${member.name}, ${labels.sharedAgent}` : member.name} data-state={state}>\n {avatarSrc && <AvatarImage src={avatarSrc} alt={member.name} />}\n <AvatarFallback>{member.kind === 'agent' ? <BotIcon data-lucide=\"bot\" className={`${compact ? 'size-3' : 'size-4'} ${agent?.icon ?? ''}`} aria-hidden=\"true\" /> : member.name.replace(/[()]/g, '').split(/\\s+/).slice(0, 2).map(word => word[0]).join('').toUpperCase()}</AvatarFallback>\n {state !== 'neutral' && <span data-testid=\"avatar-indicator\" data-state={state} className={`absolute right-0 bottom-0 ${tone}`}>\n <CircleIcon className=\"size-2 fill-current\" aria-hidden=\"true\" />\n </span>}\n </Avatar>;\n if (member.kind !== 'agent' || !card || !interactive) return avatar;\n const trigger = <span role=\"button\" tabIndex={0} className=\"inline-flex shrink-0\" data-testid={`agent-avatar-${member.id}`} data-state={state} aria-label={`${member.name}: ${card.line}`}>{avatar}</span>;\n return phone\n ? <Popover open={open} onOpenChange={setOpen}><PopoverTrigger nativeButton={false} render={trigger} /><PopoverContent data-testid=\"agent-state-popover\" side=\"bottom\" align=\"start\" className=\"max-w-[calc(100vw-1rem)]\">{card.line}</PopoverContent></Popover>\n : <Tooltip><TooltipTrigger render={trigger} /><TooltipContent>{card.line}</TooltipContent></Tooltip>;\n}\nfunction MemberBadge({ member }: { readonly member: Member }) {\n const { labels } = useChatText();\n return member.kind === 'admin' ? <Badge data-testid=\"author-badge\" variant=\"secondary\">{labels.sharedAdmin}</Badge> : null;\n}\nfunction Dot({ value }: { readonly value: SharedChatState['status'] | Member['presence'] }) {\n return <CircleIcon aria-hidden=\"true\" className={`size-2 fill-current ${value === 'live' || value === 'here' ? 'text-primary' : value === 'offline' ? 'text-destructive' : 'text-muted-foreground'}`} />;\n}\n\n/** Mounted in the existing bar. The source owns membership and visibility, including the admin. */\nexport function SharedMembers({ controller }: { readonly controller: SharedChatController }) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const phone = usePhone();\n const shared = state.shared;\n if (!shared) return null;\n const name = shared.status === 'live' ? labels.live : shared.status === 'reconnecting' ? labels.reconnecting : labels.sharedOffline;\n const content = <ul data-testid=\"member-list\" className=\"m-0 flex min-w-0 list-none flex-col gap-3 p-4\">\n {shared.members.map(member => <li key={member.id} data-member-id={member.id} className=\"flex min-w-0 items-center gap-2\">\n <MemberAvatar member={member} card={latestCard(shared, member.id)} /><span className=\"min-w-0 flex-1 truncate\">{member.name}</span>\n <Badge variant=\"secondary\">{member.role ?? member.kind}</Badge>\n <span data-testid=\"presence\" data-presence={member.presence ?? 'away'} role=\"img\" aria-label={member.presence === 'here' ? labels.sharedHere : labels.sharedAway}><Dot value={member.presence} /></span>\n </li>)}\n </ul>;\n const stack = <AvatarGroup>{shared.members.slice(0, phone ? 1 : 3).map(member => <MemberAvatar key={member.id} member={member} card={latestCard(shared, member.id)} interactive={false} />)}<AvatarGroupCount>{shared.members.length}</AvatarGroupCount></AvatarGroup>;\n const trigger = <Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open}>{stack}</Button>;\n return <div className=\"flex shrink-0 items-center gap-2\">\n <span data-testid=\"shared-connection-announcement\" className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">{name}</span>\n {shared.status !== 'live' && <Tooltip><TooltipTrigger render={<span tabIndex={0} data-testid=\"shared-status\" data-status={shared.status} role=\"img\" aria-label={name}><Dot value={shared.status} /></span>} /><TooltipContent>{name}</TooltipContent></Tooltip>}\n {phone ? <><Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open} onClick={() => setOpen(true)}>{stack}</Button>\n {open && <Drawer open onOpenChange={setOpen} showSwipeHandle><DrawerContent className=\"pi-chat max-h-[80dvh]\"><div className=\"flex shrink-0 items-center justify-between p-4\"><DrawerTitle>{labels.sharedMembers}</DrawerTitle><Button variant=\"ghost\" size=\"icon\" data-testid=\"shared-members-close\" aria-label={labels.close} onClick={() => setOpen(false)}><XIcon aria-hidden=\"true\" /></Button></div><div className=\"min-h-0 overflow-y-auto\">{content}</div></DrawerContent></Drawer>}</>\n : <Popover open={open} onOpenChange={setOpen}><PopoverTrigger render={trigger} /><PopoverContent role=\"dialog\" aria-label={labels.sharedMembers} align=\"end\" className=\"max-w-[calc(100vw-1rem)]\">{content}</PopoverContent></Popover>}\n </div>;\n}\n\nexport function RequestCard({ card, action }: { readonly card: Request; readonly action: (card: Request) => Promise<void> }) {\n const { labels } = useChatText();\n const [busy, setBusy] = useState(false), [error, setError] = useState<string>();\n return <Card data-testid=\"request-card\">\n <CardHeader><CardTitle>{card.title}</CardTitle></CardHeader>\n <CardContent><p className=\"m-0\">{card.line}</p>{card.since && <time dateTime={card.since} className=\"text-muted-foreground\">{labels.sharedSince(relativeTime(card.since, Date.now(), labels))}</time>}{error && <p role=\"alert\">{error}</p>}</CardContent>\n {card.action && <CardFooter><Button disabled={busy} onClick={() => { setBusy(true); setError(undefined); void action(card).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)).finally(() => setBusy(false)); }}>{card.action.label}</Button></CardFooter>}\n </Card>;\n}\n\n/** Both the composer chip and the timeline quote use this one parent reference. No thread or routing policy. */\nexport function ReplyQuote({ targetId, quote, onRemove, onReveal }: { readonly targetId: string; readonly quote: ReturnType<typeof sharedQuote>; readonly onRemove?: () => void; readonly onReveal?: ((messageId: string) => void) | undefined }) {\n const { labels } = useChatText();\n const text = quote?.message.removed ? labels.sharedRemoved : quote?.message.card?.title ?? quote?.message.text?.split(/\\r?\\n/)[0] ?? labels.sharedEarlierMessage;\n return <div data-testid={onRemove ? 'composer-reply' : 'message-quote'} className=\"flex w-full min-w-0 items-center gap-1\">\n <Button variant=\"ghost\" className=\"min-w-0 max-w-full justify-start\" onClick={event => {\n const chat = event.currentTarget.closest('[data-boring=pi-chat]');\n const parent = [...(chat?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === targetId);\n if (parent) parent.scrollIntoView({ block: 'center', behavior: 'instant' }); else onReveal?.(targetId);\n }}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n {onRemove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-reply-remove\" aria-label={labels.sharedCancelReply} onClick={onRemove}><XIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedCancelReply}</TooltipContent></Tooltip>}\n </div>;\n}\n\n/** Participant metadata surrounds the existing bubbles, Markdown, cards and tool trace. */\nexport function SharedMessage({ shared, children, remove, canRemove, openFile, onReply, onReveal }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n readonly remove?: ((message: ChannelMessage) => Promise<void>) | undefined;\n readonly canRemove?: ((message: ChannelMessage) => boolean) | undefined;\n readonly openFile?: ((path: string) => void) | undefined;\n readonly onReply?: ((messageId: string) => void) | undefined;\n readonly onReveal?: ((messageId: string) => void) | undefined;\n}) {\n const { labels } = useChatText();\n const [error, setError] = useState<string>();\n const [actionsOpen, setActionsOpen] = useState(false);\n const phone = usePhone();\n const element = useRef<HTMLDivElement>(null);\n const press = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n const start = useRef<{ x: number; y: number } | undefined>(undefined);\n const cancelPress = () => { clearTimeout(press.current); start.current = undefined; };\n useEffect(() => cancelPress, []);\n useEffect(() => {\n if (!actionsOpen) return;\n const outside = (event: PointerEvent) => { if (event.target instanceof Node && !element.current?.contains(event.target)) setActionsOpen(false); };\n document.addEventListener('pointerdown', outside);\n return () => document.removeEventListener('pointerdown', outside);\n }, [actionsOpen]);\n const { message, author, first, own, lastItem } = shared;\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n const actionable = !message.removed && (replyable || removable);\n const heading = first && !own && <div data-testid=\"message-author-row\" className=\"ml-8 flex min-w-0 items-center gap-2\">\n <div className=\"flex min-w-0 items-center gap-2\">\n <span data-testid=\"message-author\" data-agent-color={agentTone(author)?.token} className={`min-w-0 truncate text-sm ${agentTone(author)?.text ?? 'text-muted-foreground'}`}>{author.name}</span>\n {message.via && <span data-testid=\"message-via\" className={`min-w-0 truncate text-muted-foreground${own ? '' : ' text-sm'}`}>{labels.sharedVia(message.via)}</span>}\n {!(message.via && author.kind === 'agent') && <MemberBadge member={author} />}\n </div>\n </div>;\n const body = <>\n {message.replyTo && <ReplyQuote targetId={message.replyTo} quote={shared.quote} onReveal={onReveal} />}\n {message.removed ? <p className=\"text-muted-foreground\">{labels.sharedRemoved}</p> : children}\n </>;\n return <div ref={element} data-shared-message={message.id} className=\"group/shared-message relative flex min-w-0 flex-col gap-1\"\n tabIndex={phone && actionable ? 0 : undefined} aria-keyshortcuts={phone && actionable ? 'Shift+F10' : undefined}\n onKeyDown={event => {\n if (!phone || !actionable) return;\n if (event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey) { event.preventDefault(); setActionsOpen(true); }\n if (event.key === 'Escape') { setActionsOpen(false); element.current?.focus(); }\n }}\n onPointerDown={event => {\n if (!phone || !actionable || event.pointerType !== 'touch' || event.target instanceof Element && event.target.closest('button, a, input, textarea, [role=button], [role=link]')) return;\n cancelPress(); start.current = { x: event.clientX, y: event.clientY };\n press.current = setTimeout(() => setActionsOpen(true), 400);\n }}\n onPointerMove={event => { if (start.current && (Math.abs(event.clientX - start.current.x) > 8 || Math.abs(event.clientY - start.current.y) > 8)) cancelPress(); }}\n onPointerUp={cancelPress} onPointerCancel={cancelPress}\n onContextMenu={event => { if (phone && actionable && event.target instanceof Element && !event.target.closest('button, a, input, textarea, [role=button], [role=link]')) { event.preventDefault(); setActionsOpen(true); } }}>\n {heading}\n <div className=\"flex min-w-0 items-end gap-2\">\n {!own && <div data-testid=\"message-avatar\" className=\"flex w-6 shrink-0 items-end\">{lastItem && <MemberAvatar member={author} card={message.via ? undefined : shared.latestCard} size=\"sm\" />}</div>}\n <div data-testid=\"shared-message-content\" className=\"flex min-w-0 flex-1 flex-col gap-1\">\n {body}\n {!message.removed && message.attachments?.map(file => <Button key={file.path} variant=\"outline\" disabled={!openFile} onClick={() => openFile?.(file.path)} className=\"max-w-full justify-start\"><span className=\"truncate\">{file.title ?? file.path}</span></Button>)}\n {error && <p role=\"alert\">{error}</p>}\n </div></div>\n {actionable && <div data-testid=\"shared-message-actions\" hidden={phone && !actionsOpen} className={`absolute right-0 top-0 ${phone && !actionsOpen ? 'hidden' : 'flex'} items-center gap-1${phone ? '' : ' opacity-0 group-hover/shared-message:opacity-100 group-focus-within/shared-message:opacity-100'}`}>\n {replyable && onReply && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"shared-reply\" aria-label={labels.sharedReply} onClick={() => { setActionsOpen(false); onReply(message.id); }}><ReplyIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedReply}</TooltipContent></Tooltip>}\n {removable && remove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} aria-label={labels.delete} onClick={() => { void remove(message).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)); }}><Trash2Icon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.delete}</TooltipContent></Tooltip>}\n </div>}\n </div>;\n}\n\nexport interface SharedItem {\n readonly key: string; readonly message: ChannelMessage; readonly author: Member; readonly own: boolean; readonly first: boolean; readonly lastItem: boolean; readonly separator: boolean;\n readonly showSpinner: boolean; readonly latestCard: Request | undefined; readonly quote: ReturnType<typeof sharedQuote>;\n}\nexport function sharedItems(shared: SharedChatState): SharedItem[] {\n const working = new Map(shared.messages.filter(message => message.streaming).map(message => [message.author, message.id]));\n const groups: ChannelMessage[][] = [];\n for (const message of shared.messages) {\n const group = groups.at(-1), previous = group?.at(-1);\n const elapsed = previous ? Date.parse(message.at) - Date.parse(previous.at) : NaN;\n if (group && previous?.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000) group.push(message);\n else groups.push([message]);\n }\n return groups.flatMap((group, groupIndex) => {\n const lastItem = group.at(-1);\n return group.map((message, index): SharedItem => {\n const previous = index > 0 ? group[index - 1] : groups[groupIndex - 1]?.at(-1);\n return { key: `shared:${message.id}`, message, author: memberOf(shared, message.author), own: message.author === shared.me.id,\n first: index === 0, lastItem: message === lastItem,\n separator: !previous || Date.parse(message.at) - Date.parse(previous.at) > 15 * 60_000,\n showSpinner: working.get(message.author) === message.id, latestCard: latestCard(shared, message.author), quote: sharedQuote(shared, message.replyTo) };\n });\n });\n}\n",
44
44
  "type": "registry:component",
45
45
  "target": "components/shared-chat/messages.tsx"
46
46
  },
@@ -4,8 +4,8 @@
4
4
  "title": "Consumer task-list viewer",
5
5
  "description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.8",
8
- "@hachej/boring-files@^0.4.8",
7
+ "@hachej/boring-ui-kit@^0.4.9",
8
+ "@hachej/boring-files@^0.4.9",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "zod@^4.6.5"
@@ -4,12 +4,12 @@
4
4
  "title": "Version history rows",
5
5
  "description": "Shared view-only version rows and menu metadata with relative time, provenance and Current. No file reads, publication or restoration.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.8",
7
+ "@hachej/boring-files@^0.4.9",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/button.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/button.json",
13
13
  "button"
14
14
  ],
15
15
  "files": [
package/r/viewers.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "title": "Viewers",
5
5
  "description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, a PDF viewer, and LocalFileViewer for browser-only files. Owned, editable source over the borrowed controllers; no AI SDK.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.8",
8
- "@hachej/boring-files@^0.4.8",
7
+ "@hachej/boring-ui-kit@^0.4.9",
8
+ "@hachej/boring-files@^0.4.9",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -25,18 +25,18 @@
25
25
  "pdfjs-dist@6.3.289"
26
26
  ],
27
27
  "registryDependencies": [
28
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/theme.json",
29
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/utils.json",
30
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/button.json",
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
29
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json",
30
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/button.json",
31
31
  "button",
32
32
  "toggle",
33
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/version-history.json",
33
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/version-history.json",
34
34
  "toggle-group",
35
35
  "dialog",
36
36
  "textarea",
37
37
  "badge",
38
38
  "dropdown-menu",
39
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/markdown-editor.json"
39
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/markdown-editor.json"
40
40
  ],
41
41
  "files": [
42
42
  {
@@ -89,7 +89,7 @@
89
89
  },
90
90
  {
91
91
  "path": "registry/viewers/pdf-pane.tsx",
92
- "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { DownloadIcon, FileTextIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { downloadFile } from './download';\nimport { ZoomControls } from './image-pane';\nimport { formatBytes, useMediaUrl } from './media';\nimport { PdfPages } from './pdf-pages';\nimport type { MediaSource } from './media';\nimport { ViewerFrame } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { useViewerText } from './viewer-window';\n\nexport interface PdfPaneProps extends MediaSource {\n readonly name: string;\n readonly subtitle?: ReactNode;\n readonly status?: ViewerStatus;\n readonly target?: unknown;\n readonly revision?: string;\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** `false` draws the pages with pdf.js (for a host that knows its browser cannot render PDFs), `true` uses the browser's own viewer. Defaults to the browser's own report. */\n readonly inline?: boolean;\n /** How the browser's viewer opens the page: `width` (default) fits the page to the pane's width, `page` fits the whole page, `none` leaves the browser's own zoom. */\n readonly fit?: 'width' | 'page' | 'none';\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n}\n\n/** The browser reports whether it has a built-in PDF viewer. Unknown (older browsers) is treated as yes: the frame is tried. */\nexport function browserShowsPdf(): boolean {\n const nav = globalThis.navigator as (Navigator & { pdfViewerEnabled?: boolean }) | undefined;\n return nav?.pdfViewerEnabled !== false;\n}\n\nconst STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];\n\n/**\n * A read-only PDF viewer in the standard frame, as in boring-ui v2: the browser's own PDF viewer in a frame over an object URL\n * (so its page navigation, zoom, search and print come with it). Where the browser cannot render PDFs inline (phones), pdf.js, loaded\n * only then, draws the pages with zoom and a page indicator; if that fails too (a damaged or password-protected file, no worker), a clear\n * message with Download and Open in new tab replaces it.\n */\nexport function PdfPane({ name, bytes, blob, url: hostUrl, subtitle, status, target, revision, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, inline, fit = 'width', testId = 'viewer', titleTestId, className }: PdfPaneProps) {\n const source = useMemo<MediaSource>(() => ({ ...(bytes ? { bytes } : {}), ...(blob ? { blob } : {}), ...(hostUrl ? { url: hostUrl } : {}) }), [bytes, blob, hostUrl]);\n const { url, size, blob: made } = useMediaUrl(source, 'application/pdf');\n const canShow = inline ?? browserShowsPdf();\n const { labels } = useViewerText();\n const downloadName = name.split('/').pop() || 'document.pdf';\n const download = () => { if (made) downloadFile(downloadName, made, 'application/pdf'); else if (url) { const link = Object.assign(document.createElement('a'), { href: url, download: downloadName }); document.body.append(link); link.click(); link.remove(); } };\n const open = onOpenInNewTab ?? (url ? () => { window.open(url, '_blank', 'noopener,noreferrer'); } : undefined);\n const [zoom, setZoom] = useState(1);\n const [pages, setPages] = useState<{ readonly count: number; readonly current: number }>();\n const [failed, setFailed] = useState(false);\n useEffect(() => { setPages(undefined); setFailed(false); }, [url]);\n const step = (direction: 1 | -1) => setZoom(now => (direction > 0 ? STEPS.find(value => value > now) : [...STEPS].reverse().find(value => value < now)) ?? now);\n const drawn = Boolean(url) && !canShow && !failed;\n return <ViewerFrame title={downloadName} subtitle={<>{subtitle}{subtitle && size !== undefined && <span aria-hidden=\"true\">·</span>}{['PDF', size !== undefined && formatBytes(size)].filter(Boolean).join(' · ')}</>} status={status} target={target} revision={revision} headerStart={headerStart} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh} onShare={onShare} onDownload={url ? download : undefined} onOpenInNewTab={open} onClose={onClose}\n controls={drawn && pages ? <><ZoomControls testId={testId} percent={`${Math.round(zoom * 100)}%`} ready onStep={step} onFit={() => setZoom(1)} />{controls}</> : controls}>\n <div data-testid={`${testId}-stage`} data-pdf={url ? (canShow ? 'inline' : drawn ? 'pages' : 'fallback') : 'loading'} className=\"boring-viewer-stage relative flex min-h-0 flex-1\">\n {!url ? <p role=\"status\" className=\"m-auto p-6 text-sm text-muted-foreground\">{labels.loading}</p>\n : canShow ? <iframe src={fit === 'none' ? url : `${url}#view=${fit === 'page' ? 'Fit' : 'FitH'}`} title={`${downloadName} (PDF)`} data-testid={`${testId}-frame-pdf`} className=\"size-full min-h-0 flex-1 bg-background\" />\n : drawn ? <><PdfPages key={url} url={url} name={labels.pdfDocument(downloadName)} zoom={zoom} testId={testId} onLoad={count => setPages({ count, current: 1 })}\n onPage={current => setPages(now => now && now.current !== current ? { ...now, current } : now)} onError={() => setFailed(true)}\n onKey={key => { if (key === '+' || key === '=') step(1); else if (key === '-') step(-1); else if (key === '0') setZoom(1); }} />\n {pages && <Badge variant=\"secondary\" data-testid={`${testId}-pdf-page-indicator`} aria-live=\"polite\" className=\"pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2\">{labels.pdfPage(pages.current, pages.count)}</Badge>}</>\n : <div role=\"alert\" data-testid={`${testId}-pdf-fallback`} className=\"m-auto flex max-w-sm flex-col items-center gap-3 p-6 text-center\">\n <span className=\"grid size-12 place-items-center bg-muted text-muted-foreground\"><FileTextIcon className=\"size-6\" aria-hidden=\"true\" /></span>\n <p className=\"m-0 text-sm font-medium\">{labels.pdfUnsupported}</p>\n <p className=\"m-0 text-xs text-muted-foreground\">{labels.pdfHint(downloadName, Boolean(open))}</p>\n <div className=\"flex flex-wrap justify-center gap-2\">\n <Button variant=\"default\" size=\"sm\" data-testid={`${testId}-pdf-download`} onClick={download}><DownloadIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.download}</Button>\n {open && <Button variant=\"outline\" size=\"sm\" onClick={() => open()}>{labels.openInNewTab}</Button>}\n </div>\n </div>}\n </div>\n </ViewerFrame>;\n}\n",
92
+ "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { DownloadIcon, FileTextIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { downloadFile } from './download';\nimport { ZoomControls } from './image-pane';\nimport { formatBytes, useMediaUrl } from './media';\nimport { PdfPages } from './pdf-pages';\nimport type { MediaSource } from './media';\nimport { ViewerFrame } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { useViewerText } from './viewer-window';\n\nexport interface PdfPaneProps extends MediaSource {\n readonly name: string;\n readonly subtitle?: ReactNode;\n readonly status?: ViewerStatus;\n readonly target?: unknown;\n readonly revision?: string;\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** `false` draws the pages with pdf.js (for a host that knows its browser cannot render PDFs), `true` uses the browser's own viewer. Defaults to the browser's own report. */\n readonly inline?: boolean;\n /** How the browser's viewer opens the page: `width` (default) fits the page to the pane's width, `page` fits the whole page, `none` leaves the browser's own zoom. */\n readonly fit?: 'width' | 'page' | 'none';\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n}\n\n/** The browser reports whether it has a built-in PDF viewer. Unknown (older browsers) is treated as yes: the frame is tried. */\nexport function browserShowsPdf(): boolean {\n const nav = globalThis.navigator as (Navigator & { pdfViewerEnabled?: boolean }) | undefined;\n return nav?.pdfViewerEnabled !== false;\n}\n\nconst STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];\n\n/**\n * A read-only PDF viewer in the standard frame, as in boring-ui v2: the browser's own PDF viewer in a frame over an object URL\n * (so its page navigation, zoom, search and print come with it). Where the browser cannot render PDFs inline (phones), pdf.js, loaded\n * only then, draws the pages with zoom and a page indicator; if that fails too (a damaged or password-protected file, no worker), a clear\n * message with Download and Open in new tab replaces it.\n */\nexport function PdfPane({ name, bytes, blob, url: hostUrl, subtitle, status, target, revision, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, inline, fit = 'width', testId = 'viewer', titleTestId, className }: PdfPaneProps) {\n const source = useMemo<MediaSource>(() => ({ ...(bytes ? { bytes } : {}), ...(blob ? { blob } : {}), ...(hostUrl ? { url: hostUrl } : {}) }), [bytes, blob, hostUrl]);\n const { url, size, blob: made } = useMediaUrl(source, 'application/pdf');\n // The browser's PDF viewer does not run in a sandboxed frame, so the frame is unsandboxed and loads only a blob typed application/pdf (a host `url` is the host's own).\n const isPdf = !made || made.type === 'application/pdf';\n const canShow = isPdf && (inline ?? browserShowsPdf());\n const { labels } = useViewerText();\n const downloadName = name.split('/').pop() || 'document.pdf';\n const download = () => { if (made) downloadFile(downloadName, made, 'application/pdf'); else if (url) { const link = Object.assign(document.createElement('a'), { href: url, download: downloadName }); document.body.append(link); link.click(); link.remove(); } };\n const open = onOpenInNewTab ?? (url ? () => { window.open(url, '_blank', 'noopener,noreferrer'); } : undefined);\n const [zoom, setZoom] = useState(1);\n const [pages, setPages] = useState<{ readonly count: number; readonly current: number }>();\n const [failed, setFailed] = useState(false);\n useEffect(() => { setPages(undefined); setFailed(false); }, [url]);\n const step = (direction: 1 | -1) => setZoom(now => (direction > 0 ? STEPS.find(value => value > now) : [...STEPS].reverse().find(value => value < now)) ?? now);\n const drawn = Boolean(url) && isPdf && !canShow && !failed;\n return <ViewerFrame title={downloadName} subtitle={<>{subtitle}{subtitle && size !== undefined && <span aria-hidden=\"true\">·</span>}{['PDF', size !== undefined && formatBytes(size)].filter(Boolean).join(' · ')}</>} status={status} target={target} revision={revision} headerStart={headerStart} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh} onShare={onShare} onDownload={url ? download : undefined} onOpenInNewTab={open} onClose={onClose}\n controls={drawn && pages ? <><ZoomControls testId={testId} percent={`${Math.round(zoom * 100)}%`} ready onStep={step} onFit={() => setZoom(1)} />{controls}</> : controls}>\n <div data-testid={`${testId}-stage`} data-pdf={url ? (canShow ? 'inline' : drawn ? 'pages' : 'fallback') : 'loading'} className=\"boring-viewer-stage relative flex min-h-0 flex-1\">\n {!url ? <p role=\"status\" className=\"m-auto p-6 text-sm text-muted-foreground\">{labels.loading}</p>\n : canShow ? <iframe src={fit === 'none' ? url : `${url}#view=${fit === 'page' ? 'Fit' : 'FitH'}`} title={`${downloadName} (PDF)`} data-testid={`${testId}-frame-pdf`} className=\"size-full min-h-0 flex-1 bg-background\" />\n : drawn ? <><PdfPages key={url} url={url} name={labels.pdfDocument(downloadName)} zoom={zoom} testId={testId} onLoad={count => setPages({ count, current: 1 })}\n onPage={current => setPages(now => now && now.current !== current ? { ...now, current } : now)} onError={() => setFailed(true)}\n onKey={key => { if (key === '+' || key === '=') step(1); else if (key === '-') step(-1); else if (key === '0') setZoom(1); }} />\n {pages && <Badge variant=\"secondary\" data-testid={`${testId}-pdf-page-indicator`} aria-live=\"polite\" className=\"pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2\">{labels.pdfPage(pages.current, pages.count)}</Badge>}</>\n : <div role=\"alert\" data-testid={`${testId}-pdf-fallback`} className=\"m-auto flex max-w-sm flex-col items-center gap-3 p-6 text-center\">\n <span className=\"grid size-12 place-items-center bg-muted text-muted-foreground\"><FileTextIcon className=\"size-6\" aria-hidden=\"true\" /></span>\n <p className=\"m-0 text-sm font-medium\">{labels.pdfUnsupported}</p>\n <p className=\"m-0 text-xs text-muted-foreground\">{labels.pdfHint(downloadName, Boolean(open))}</p>\n <div className=\"flex flex-wrap justify-center gap-2\">\n <Button variant=\"default\" size=\"sm\" data-testid={`${testId}-pdf-download`} onClick={download}><DownloadIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.download}</Button>\n {open && <Button variant=\"outline\" size=\"sm\" onClick={() => open()}>{labels.openInNewTab}</Button>}\n </div>\n </div>}\n </div>\n </ViewerFrame>;\n}\n",
93
93
  "type": "registry:component",
94
94
  "target": "components/viewers/pdf-pane.tsx"
95
95
  },
package/r/windows.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.8/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json",
13
13
  "button",
14
14
  "card",
15
15
  "badge",