@hachej/boring-ui-kit 0.4.39 → 0.4.40

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.
Files changed (50) hide show
  1. package/package.json +2 -2
  2. package/r/agent-target.json +4 -4
  3. package/r/app-shell.json +10 -10
  4. package/r/board-viewer.json +6 -6
  5. package/r/button.json +1 -1
  6. package/r/dock.json +1 -1
  7. package/r/documents.json +12 -12
  8. package/r/feedback-preview.json +1 -1
  9. package/r/feedback.json +6 -6
  10. package/r/file-tree.json +4 -4
  11. package/r/highlights.json +3 -3
  12. package/r/html-viewer.json +3 -3
  13. package/r/looks-viewer.json +2 -2
  14. package/r/maquette-viewer.json +1 -1
  15. package/r/markdown-editor.json +3 -3
  16. package/r/mockup-viewer.json +1 -1
  17. package/r/pi-ambient.json +8 -8
  18. package/r/pi-app-kinds.json +10 -10
  19. package/r/pi-app.json +10 -10
  20. package/r/pi-artifacts.json +2 -2
  21. package/r/pi-chat-approval.json +2 -2
  22. package/r/pi-chat-attachments.json +1 -1
  23. package/r/pi-chat-markdown.json +2 -2
  24. package/r/pi-chat-mentions.json +1 -1
  25. package/r/pi-chat-pickers.json +1 -1
  26. package/r/pi-chat-plugins.json +11 -11
  27. package/r/pi-chat-question.json +1 -1
  28. package/r/pi-chat-slash.json +2 -2
  29. package/r/pi-chat-subagent.json +1 -1
  30. package/r/pi-chat-voice.json +3 -3
  31. package/r/pi-chat.json +5 -5
  32. package/r/pi-workspace.json +3 -3
  33. package/r/placed-agent.json +4 -4
  34. package/r/pm-panel.json +13 -13
  35. package/r/provider-setup.json +1 -1
  36. package/r/registry.json +188 -183
  37. package/r/requests-board.json +1 -1
  38. package/r/shared-chat.json +6 -6
  39. package/r/task-board.json +10 -4
  40. package/r/task-list-viewer.json +2 -2
  41. package/r/task-mentions.json +4 -4
  42. package/r/version-history.json +2 -2
  43. package/r/viewer-code.json +2 -2
  44. package/r/viewer-frame.json +4 -4
  45. package/r/viewer-html.json +4 -4
  46. package/r/viewer-image.json +2 -2
  47. package/r/viewer-markdown.json +5 -5
  48. package/r/viewer-pdf.json +3 -3
  49. package/r/viewers.json +6 -6
  50. package/r/windows.json +1 -1
@@ -7,7 +7,7 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {
@@ -4,16 +4,16 @@
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.39",
7
+ "@hachej/boring-ui-kit@^0.4.40",
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.39/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat-plugins.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat-markdown.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat-mentions.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-plugins.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-markdown.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-mentions.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json"
17
17
  ],
18
18
  "files": [
19
19
  {
package/r/task-board.json CHANGED
@@ -4,23 +4,29 @@
4
4
  "title": "Task board",
5
5
  "description": "Host-owned business requests: board, stacked task/file overlays and compact own-request rows. No agent or task backend required.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.39",
7
+ "@hachej/boring-ui-kit@^0.4.40",
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.39/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json"
13
13
  ],
14
14
  "files": [
15
+ {
16
+ "path": "registry/task-board/compact-presentation.ts",
17
+ "content": "import type { TaskRecord } from '@hachej/boring-ui-kit/task-board';\n\n/** Exact adapter tokens only; terminal records must never revive stale viewer flags. Presentation is not authorization. */\nexport function compactTaskStatus(task: TaskRecord) {\n if (task.status === 'withdrawn' || task.status === 'abandoned' || task.status === 'closed' || (task.state === 'closed' && task.status !== 'shipped')) return 'closed';\n if (task.state === 'done' || task.status === 'shipped') return 'live';\n if (task.state === 'waiting' && task.waiting_on_you === true) return 'needs-you';\n if (task.status === 'blocked') return 'blocked';\n if (task.status === 'verifying') return 'verifying';\n return 'preparation';\n}\n\n/** A display-only cap: opening the row still passes the untouched record to the host. */\nexport function compactTaskTitle(task: TaskRecord): string {\n const words = (task.display?.trim() || task.title.trim()).split(/\\s+/u);\n return words.length > 6 ? `${words.slice(0, 6).join(' ')}…` : words.join(' ');\n}\n\nexport const compactLabels = {\n en: { 'needs-you': 'Needs you', confirmation: 'To confirm', blocked: 'Blocked', verifying: 'Verification', live: 'Live', closed: 'Closed', withdrawn: 'Withdrawn', preparation: 'Preparing', finished: 'Finished', technical: 'Technical', empty: 'No requests', emptyDescription: 'No tasks match this view.' },\n fr: { 'needs-you': 'À vous', confirmation: 'À confirmer', blocked: 'Bloquée', verifying: 'Vérification', live: 'En ligne', closed: 'Fermée', withdrawn: 'Retirée', preparation: 'En préparation', finished: 'Terminées', technical: 'Technique', empty: 'Aucune demande', emptyDescription: 'Aucune demande dans cette vue.' },\n} as const;\n",
18
+ "type": "registry:lib",
19
+ "target": "components/task-board/compact-presentation.ts"
20
+ },
15
21
  {
16
22
  "path": "registry/task-board/task-board.tsx",
17
- "content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { MouseEvent, ReactNode } from 'react';\nimport { selectTasks, taskFromUrl, taskReference, taskUrl, TASK_STATES } from '@hachej/boring-ui-kit/task-board';\nimport type { TaskFile, TaskFilter, TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { ChevronLeftIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Separator } from '@/components/ui/separator';\nimport { cn } from '../utils/utils';\nimport { TaskRequestCard, TaskStatusPill, taskMeta, taskStateLabels, taskTokens } from './task-request-card';\nimport type { TaskLocale, TaskViewerAction } from './task-request-card';\nimport { TaskDetail } from './task-detail';\nimport type { TaskDetailProps } from './task-detail';\n\nexport { TaskRequestCard, TaskRequestCard as TaskCard, TaskStatusPill, TaskStatusChip, TaskAttention, taskMeta, taskTokens } from './task-request-card';\nexport { TaskDetail } from './task-detail';\nexport type { TaskRequestCardProps, TaskRequestCardProps as TaskCardProps, TaskLocale, TaskViewerAction } from './task-request-card';\nexport type { TaskDetailProps } from './task-detail';\nexport type { TaskFile, TaskRecord, TaskFilter, TaskState } from '@hachej/boring-ui-kit/task-board';\n\nconst navigation = 'boring:task-navigation';\nconst subscribe = (changed: () => void) => {\n window.addEventListener('popstate', changed); window.addEventListener(navigation, changed);\n return () => { window.removeEventListener('popstate', changed); window.removeEventListener(navigation, changed); };\n};\nconst readSelection = () => taskFromUrl(window.location.href);\nconst serverSelection = () => null;\nconst BOARD_STATES = TASK_STATES.slice(0, 3);\ntype TaskList = 'open' | 'done' | 'closed';\n\nexport interface TaskFileViewApi {\n readonly fullscreen: boolean;\n readonly onFullscreenChange: (fullscreen: boolean) => void;\n readonly onClose: () => void;\n readonly onBack: () => void;\n /** The host's PM pointing service; absent unless onShowFile was supplied. */\n readonly onShowFile?: (() => void) | undefined;\n}\nexport interface TaskBoardProps {\n readonly tasks: readonly TaskRecord[];\n /** Controlled Mine/All. Uncontrolled, the tasks bar starts on Mine when `currentUser` is known, otherwise All. */\n readonly filter?: TaskFilter;\n readonly onFilterChange?: ((filter: TaskFilter) => void) | undefined;\n readonly currentUser?: string;\n readonly limit?: number;\n readonly variant?: 'board' | 'compact';\n readonly title?: string;\n /** Column names, shown uppercase. */\n readonly labels?: Readonly<Record<TaskState, string>>;\n /** The tasks bar (« 5 open · Done 12 · Closed 1 », Mine/All). Hosts that draw it in their own header pass false. */\n readonly bar?: boolean;\n /** Host content at the start of the tasks bar, e.g. the collapsed Studio header's app name and « Show the app ». */\n readonly barStart?: ReactNode;\n readonly locale?: TaskLocale;\n /** Explicit host context for compact French labels (« À vous » or « À confirmer »). */\n readonly viewerAction?: TaskViewerAction;\n /** Omit to use ?task= with native history. A router may instead supply controlled selection. */\n readonly selectedTask?: string | null;\n readonly onSelectedTaskChange?: ((id: string | null) => void) | undefined;\n /** Optional host navigation/present path, particularly for compact lists inside the PM panel. */\n readonly onOpen?: ((task: TaskRecord) => void) | undefined;\n /** Host content under the detail's title card. */\n readonly actions?: ((task: TaskRecord) => ReactNode) | undefined;\n /** Host-authorized compact card actions (« Modifier », « Confirmer »). */\n readonly cardActions?: ((task: TaskRecord) => ReactNode) | undefined;\n readonly onAnswer?: TaskDetailProps['onAnswer'];\n readonly onOther?: TaskDetailProps['onOther'];\n readonly onDiscussQuestion?: TaskDetailProps['onDiscussQuestion'];\n readonly onAsk?: TaskDetailProps['onAsk'];\n /** Adapter paths only. The host reauthorizes them when opening the regular viewer. */\n readonly onOpenFile?: TaskDetailProps['onOpenFile'];\n readonly onShowFile?: TaskDetailProps['onOpenFile'];\n /** Supply the host's regular FileViewer; it opens 760px wide over the task detail. No second store or composer. */\n readonly renderFile?: ((task: TaskRecord, file: TaskFile, api: TaskFileViewApi) => ReactNode) | undefined;\n /** Host-owned overlay appearance (for example the host's overlay shadow token). */\n readonly overlayClassName?: string;\n readonly className?: string;\n}\n\n/**\n * The tasks panel (spec 3 §3–§5): tasks bar, WAITING · WORKING · OPEN, and panel-scoped overlays. The task detail opens 400px wide\n * from the right over the board, which keeps its columns; a file opens 760px wide over the detail. `variant=\"compact\"` stacks C7 cards.\n */\nexport function TaskBoard({ tasks, filter, onFilterChange, currentUser, limit, variant = 'board', title = 'Tasks', labels = taskStateLabels, bar = true, barStart, locale = 'en', viewerAction,\n selectedTask, onSelectedTaskChange, onOpen, actions, cardActions, onAnswer, onOther, onDiscussQuestion, onAsk, onOpenFile, onShowFile, renderFile, overlayClassName, className }: TaskBoardProps) {\n const urlSelection = useSyncExternalStore(subscribe, readSelection, serverSelection);\n const [ownFilter, setOwnFilter] = useState<TaskFilter>(currentUser === undefined ? 'all' : 'mine');\n const [list, setList] = useState<TaskList>('open');\n const shownFilter = filter ?? ownFilter;\n const selected = selectedTask === undefined ? onOpen ? null : urlSelection : selectedTask;\n const detail = selected === null ? undefined : tasks.find(task => task.id === selected || taskReference(task) === selected);\n const shown = selectTasks(tasks, { filter: shownFilter, ...(currentUser === undefined ? {} : { currentUser }), ...(limit === undefined ? {} : { limit }) });\n const [fileSelection, setFileSelection] = useState<{ readonly taskId: string; readonly path: string } | null>(null);\n const filePath = fileSelection?.path;\n const file = detail?.id === fileSelection?.taskId ? detail?.files?.find(candidate => candidate.path === filePath) : undefined;\n const fileOpen = !!file && !!renderFile;\n const [fileFullscreen, setFileFullscreen] = useState(false);\n const closeFile = () => { setFileSelection(null); setFileFullscreen(false); };\n const openFile = (task: TaskRecord, file: TaskFile) => { setFileSelection({ taskId: task.id, path: file.path }); onOpenFile?.(task, file); };\n const root = useRef<HTMLElement>(null);\n const back = useRef<HTMLButtonElement>(null);\n const previous = useRef<{ readonly id: string; readonly state: TaskState | undefined } | null>(null);\n const pendingFocus = useRef<string | null>(null);\n const cardLink = (id: string) => [...root.current?.querySelectorAll<HTMLAnchorElement>('[data-task-open]') ?? []].find(candidate => candidate.dataset['taskOpen'] === id);\n useEffect(() => {\n if (selected !== null) back.current?.focus();\n else if (previous.current !== null) {\n const { id, state } = previous.current, link = cardLink(id);\n if (link) link.focus();\n else if (state === 'done' || state === 'closed') { pendingFocus.current = id; setList(state); }\n }\n previous.current = selected === null ? null : { id: detail?.id ?? selected, state: detail?.state };\n }, [selected, detail?.id, detail?.state]);\n useEffect(() => { if (pendingFocus.current !== null) { cardLink(pendingFocus.current)?.focus(); pendingFocus.current = null; } }, [list]);\n const select = (id: string | null) => {\n closeFile();\n if (selectedTask === undefined) {\n window.history.pushState(null, '', taskUrl(window.location.href, id));\n window.dispatchEvent(new Event(navigation));\n }\n onSelectedTaskChange?.(id);\n };\n const open = (task: TaskRecord) => { if (onOpen) onOpen(task); else select(task.id); };\n const href = (task: TaskRecord) => typeof window === 'undefined' ? `?task=${encodeURIComponent(task.id)}` : taskUrl(window.location.href, task.id);\n const card = (task: TaskRecord) => <TaskRequestCard key={task.id} task={task} variant={variant === 'compact' ? 'compact' : 'card'} selected={task.id === detail?.id} href={href(task)} onOpen={open}\n locale={locale} viewerAction={viewerAction} actions={variant === 'compact' ? cardActions?.(task) : undefined} />;\n const of = (state: TaskState) => shown.filter(task => task.state === state);\n const empty = <Empty><EmptyHeader><EmptyTitle>No requests</EmptyTitle><EmptyDescription>No tasks match this view.</EmptyDescription></EmptyHeader></Empty>;\n const switcher = (target: TaskList, text: string) => list === target ? <span data-task-list-link={target} aria-current=\"page\">{text}</span>\n : <a href=\"#\" data-task-list-link={target} className=\"text-foreground underline underline-offset-2\" onClick={(event: MouseEvent) => { event.preventDefault(); setList(target); }}>{text}</a>;\n const chooseFilter = (next: TaskFilter) => (event: MouseEvent) => { event.preventDefault(); if (filter === undefined) setOwnFilter(next); onFilterChange?.(next); };\n if (variant === 'compact') return <section ref={root} data-boring=\"task-board\" data-variant=\"compact\" aria-label={title} className={cn('min-w-0', className)}>\n <div data-task-list role=\"list\" aria-label={title} className=\"flex flex-col gap-2\">{shown.length ? shown.map(card) : empty}</div>\n </section>;\n return <section ref={root} data-boring=\"task-board\" data-variant={variant} aria-label={title} className={cn('flex min-h-0 min-w-0 flex-col', className)} onKeyDown={event => {\n if (event.key === 'Escape' && selected !== null && !event.defaultPrevented) { event.preventDefault(); if (fileOpen) closeFile(); else select(null); }\n }}>\n {bar && <nav data-task-bar aria-label=\"Task lists\" className=\"flex h-13 shrink-0 items-center gap-2.5 px-3.5 text-[13px] text-muted-foreground\">\n {barStart}\n {switcher('open', `${BOARD_STATES.reduce((sum, state) => sum + of(state).length, 0)} open`)}<span aria-hidden=\"true\">·</span>\n {switcher('done', `Done ${of('done').length}`)}<span aria-hidden=\"true\">·</span>\n {switcher('closed', `Closed ${of('closed').length}`)}\n {currentUser !== undefined && <span className={cn('ml-auto flex items-center gap-2.5', taskTokens.pill)} data-task-filter={shownFilter}>\n {(['mine', 'all'] as const).map(value => <Badge key={value} variant={shownFilter === value ? 'default' : 'secondary'} render={<a href=\"#\" aria-current={shownFilter === value ? 'true' : undefined} onClick={chooseFilter(value)} />}>{value === 'mine' ? 'Mine' : 'All'}</Badge>)}\n </span>}\n </nav>}\n <div data-task-stage className=\"relative min-h-0 flex-1 overflow-hidden\">\n <div data-task-list className=\"@container/task-rails h-full min-w-0 overflow-auto\">\n {list === 'open' ? <div className=\"grid min-w-0 content-start gap-2.5 p-3.5 @2xl/task-rails:grid-cols-3\">\n {BOARD_STATES.map(state => {\n const column = of(state).sort((a, b) => Number(b.waiting_on_you === true) - Number(a.waiting_on_you === true));\n const onYou = column.filter(task => task.waiting_on_you === true).length;\n return <section key={state} data-task-column={state} aria-label={labels[state]} className={cn('flex min-w-0 flex-col gap-[7px] self-start bg-muted p-2', taskTokens.column)}>\n <h3 className=\"flex min-h-6 items-center justify-between gap-2 px-0.5 pb-0.5 text-[11px] font-semibold tracking-[0.06em] text-muted-foreground\"><span className=\"uppercase\">{labels[state]} {column.length}</span>{onYou > 0 && <span className={cn('contents', taskTokens.pill)}><Badge data-task-on-you=\"\">{onYou} on you</Badge></span>}</h3>\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{column.map(card)}</div>\n </section>;\n })}\n </div> : <section data-task-column={list} aria-label={labels[list]} className=\"flex max-w-xl min-w-0 flex-col gap-[7px] p-3.5\">\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{of(list).map(card)}</div>\n {!of(list).length && empty}\n </section>}\n </div>\n {selected !== null && <aside aria-label=\"Task detail\" data-task-side className={cn('absolute inset-y-0 right-0 flex w-[400px] max-w-full flex-col bg-popover text-popover-foreground', overlayClassName)} aria-live=\"polite\">\n <Separator orientation=\"vertical\" className=\"absolute inset-y-0 left-0\" />\n <div className=\"flex h-10 shrink-0 items-center gap-2 pr-1.5 pl-3.5\">\n <Button ref={back} variant=\"ghost\" size=\"icon\" data-task-back=\"\" aria-label=\"Back to the tasks\" title=\"Back to the tasks\" onClick={() => select(null)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {detail && <><span className=\"text-[11px] text-muted-foreground\">{taskMeta(detail)}</span><TaskStatusPill task={detail} locale={locale} viewerAction={viewerAction} /></>}\n </div>\n <Separator />\n <div className=\"min-h-0 flex-1 overflow-auto p-3.5\">\n {detail ? <TaskDetail key={detail.id} task={detail} actions={actions?.(detail)} onAnswer={onAnswer} onOther={onOther} onDiscussQuestion={onDiscussQuestion} onAsk={onAsk} onOpenFile={renderFile || onOpenFile ? openFile : undefined} />\n : <Empty><EmptyHeader><EmptyTitle>Task unavailable</EmptyTitle><EmptyDescription>The host has not supplied this task.</EmptyDescription></EmptyHeader></Empty>}\n </div>\n </aside>}\n {fileOpen && detail && file && <section aria-label=\"Task file viewer\" data-task-file-pane={detail.id} className={cn('flex min-w-0 flex-col bg-background', fileFullscreen ? 'fixed inset-0 z-50' : 'absolute inset-y-0 right-0 w-[760px] max-w-full', overlayClassName)}>\n {renderFile!(detail, file, { fullscreen: fileFullscreen, onFullscreenChange: setFileFullscreen, onClose: closeFile, onBack: closeFile, onShowFile: onShowFile ? () => onShowFile(detail, file) : undefined })}\n </section>}\n </div>\n </section>;\n}\n",
23
+ "content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { MouseEvent, ReactNode } from 'react';\nimport { selectTasks, taskFromUrl, taskReference, taskUrl, TASK_STATES } from '@hachej/boring-ui-kit/task-board';\nimport type { TaskFile, TaskFilter, TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { ChevronLeftIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Separator } from '@/components/ui/separator';\nimport { cn } from '../utils/utils';\nimport { TaskRequestCard, TaskStatusPill, taskMeta, taskStateLabels, taskTokens } from './task-request-card';\nimport type { TaskLocale, TaskViewerAction } from './task-request-card';\nimport { TaskDetail } from './task-detail';\nimport type { TaskDetailProps } from './task-detail';\nimport { compactLabels, compactTaskStatus } from './compact-presentation';\n\nexport { TaskRequestCard, TaskRequestCard as TaskCard, TaskStatusPill, TaskStatusChip, TaskAttention, taskMeta, taskTokens } from './task-request-card';\nexport { TaskDetail } from './task-detail';\nexport type { TaskRequestCardProps, TaskRequestCardProps as TaskCardProps, TaskLocale, TaskViewerAction } from './task-request-card';\nexport type { TaskDetailProps } from './task-detail';\nexport type { TaskFile, TaskRecord, TaskFilter, TaskState } from '@hachej/boring-ui-kit/task-board';\n\nconst navigation = 'boring:task-navigation';\nconst subscribe = (changed: () => void) => {\n window.addEventListener('popstate', changed); window.addEventListener(navigation, changed);\n return () => { window.removeEventListener('popstate', changed); window.removeEventListener(navigation, changed); };\n};\nconst readSelection = () => taskFromUrl(window.location.href);\nconst serverSelection = () => null;\nconst BOARD_STATES = TASK_STATES.slice(0, 3);\ntype TaskList = 'open' | 'done' | 'closed';\n\nexport interface TaskFileViewApi {\n readonly fullscreen: boolean;\n readonly onFullscreenChange: (fullscreen: boolean) => void;\n readonly onClose: () => void;\n readonly onBack: () => void;\n /** The host's PM pointing service; absent unless onShowFile was supplied. */\n readonly onShowFile?: (() => void) | undefined;\n}\nexport interface TaskBoardProps {\n readonly tasks: readonly TaskRecord[];\n /** Controlled Mine/All. Uncontrolled, the tasks bar starts on Mine when `currentUser` is known, otherwise All. */\n readonly filter?: TaskFilter;\n readonly onFilterChange?: ((filter: TaskFilter) => void) | undefined;\n readonly currentUser?: string;\n readonly limit?: number;\n readonly variant?: 'board' | 'compact';\n /** Compact-only presentation, never server authorization. Supply only records the viewer may already read. */\n readonly compactPresentation?: { readonly includeTechnical?: boolean };\n readonly title?: string;\n /** Column names, shown uppercase. */\n readonly labels?: Readonly<Record<TaskState, string>>;\n /** The tasks bar (« 5 open · Done 12 · Closed 1 », Mine/All). Hosts that draw it in their own header pass false. */\n readonly bar?: boolean;\n /** Host content at the start of the tasks bar, e.g. the collapsed Studio header's app name and « Show the app ». */\n readonly barStart?: ReactNode;\n readonly locale?: TaskLocale;\n /** Explicit host context for compact French labels (« À vous » or « À confirmer »). */\n readonly viewerAction?: TaskViewerAction;\n /** Omit to use ?task= with native history. A router may instead supply controlled selection. */\n readonly selectedTask?: string | null;\n readonly onSelectedTaskChange?: ((id: string | null) => void) | undefined;\n /** Optional host navigation/present path, particularly for compact lists inside the PM panel. */\n readonly onOpen?: ((task: TaskRecord) => void) | undefined;\n /** Host content under the detail's title card. */\n readonly actions?: ((task: TaskRecord) => ReactNode) | undefined;\n /** Host-authorized compact card actions (« Modifier », « Confirmer »). */\n readonly cardActions?: ((task: TaskRecord) => ReactNode) | undefined;\n readonly onAnswer?: TaskDetailProps['onAnswer'];\n readonly onOther?: TaskDetailProps['onOther'];\n readonly onDiscussQuestion?: TaskDetailProps['onDiscussQuestion'];\n readonly onAsk?: TaskDetailProps['onAsk'];\n /** Adapter paths only. The host reauthorizes them when opening the regular viewer. */\n readonly onOpenFile?: TaskDetailProps['onOpenFile'];\n readonly onShowFile?: TaskDetailProps['onOpenFile'];\n /** Supply the host's regular FileViewer; it opens 760px wide over the task detail. No second store or composer. */\n readonly renderFile?: ((task: TaskRecord, file: TaskFile, api: TaskFileViewApi) => ReactNode) | undefined;\n /** Host-owned overlay appearance (for example the host's overlay shadow token). */\n readonly overlayClassName?: string;\n readonly className?: string;\n}\n\n/**\n * The tasks panel (spec 3 §3–§5): tasks bar, WAITING · WORKING · OPEN, and panel-scoped overlays. The task detail opens 400px wide\n * from the right over the board, which keeps its columns; a file opens 760px wide over the detail. `variant=\"compact\"` stacks C7 cards.\n */\nexport function TaskBoard({ tasks, filter, onFilterChange, currentUser, limit, variant = 'board', compactPresentation, title = 'Tasks', labels = taskStateLabels, bar = true, barStart, locale = 'en', viewerAction,\n selectedTask, onSelectedTaskChange, onOpen, actions, cardActions, onAnswer, onOther, onDiscussQuestion, onAsk, onOpenFile, onShowFile, renderFile, overlayClassName, className }: TaskBoardProps) {\n const urlSelection = useSyncExternalStore(subscribe, readSelection, serverSelection);\n const [ownFilter, setOwnFilter] = useState<TaskFilter>(currentUser === undefined ? 'all' : 'mine');\n const [list, setList] = useState<TaskList>('open');\n const shownFilter = filter ?? ownFilter;\n const selected = selectedTask === undefined ? onOpen ? null : urlSelection : selectedTask;\n const detail = selected === null ? undefined : tasks.find(task => task.id === selected || taskReference(task) === selected);\n const shown = selectTasks(tasks, { filter: shownFilter, ...(currentUser === undefined ? {} : { currentUser }), ...(limit === undefined ? {} : { limit }) });\n const [fileSelection, setFileSelection] = useState<{ readonly taskId: string; readonly path: string } | null>(null);\n const filePath = fileSelection?.path;\n const file = detail?.id === fileSelection?.taskId ? detail?.files?.find(candidate => candidate.path === filePath) : undefined;\n const fileOpen = !!file && !!renderFile;\n const [fileFullscreen, setFileFullscreen] = useState(false);\n const closeFile = () => { setFileSelection(null); setFileFullscreen(false); };\n const openFile = (task: TaskRecord, file: TaskFile) => { setFileSelection({ taskId: task.id, path: file.path }); onOpenFile?.(task, file); };\n const root = useRef<HTMLElement>(null);\n const back = useRef<HTMLButtonElement>(null);\n const previous = useRef<{ readonly id: string; readonly state: TaskState | undefined } | null>(null);\n const pendingFocus = useRef<string | null>(null);\n const cardLink = (id: string) => [...root.current?.querySelectorAll<HTMLAnchorElement>('[data-task-open]') ?? []].find(candidate => candidate.dataset['taskOpen'] === id);\n useEffect(() => {\n if (selected !== null) back.current?.focus();\n else if (previous.current !== null) {\n const { id, state } = previous.current, link = cardLink(id);\n if (link) link.focus();\n else if (state === 'done' || state === 'closed') { pendingFocus.current = id; setList(state); }\n }\n previous.current = selected === null ? null : { id: detail?.id ?? selected, state: detail?.state };\n }, [selected, detail?.id, detail?.state]);\n useEffect(() => { if (pendingFocus.current !== null) { cardLink(pendingFocus.current)?.focus(); pendingFocus.current = null; } }, [list]);\n const select = (id: string | null) => {\n closeFile();\n if (selectedTask === undefined) {\n window.history.pushState(null, '', taskUrl(window.location.href, id));\n window.dispatchEvent(new Event(navigation));\n }\n onSelectedTaskChange?.(id);\n };\n const open = (task: TaskRecord) => { if (onOpen) onOpen(task); else select(task.id); };\n const href = (task: TaskRecord) => typeof window === 'undefined' ? `?task=${encodeURIComponent(task.id)}` : taskUrl(window.location.href, task.id);\n const card = (task: TaskRecord) => <TaskRequestCard key={task.id} task={task} variant={variant === 'compact' ? 'compact' : 'card'} selected={task.id === detail?.id} href={href(task)} onOpen={open}\n locale={locale} viewerAction={viewerAction} actions={variant === 'compact' ? cardActions?.(task) : undefined} />;\n const of = (state: TaskState) => shown.filter(task => task.state === state);\n const empty = <Empty><EmptyHeader><EmptyTitle>No requests</EmptyTitle><EmptyDescription>No tasks match this view.</EmptyDescription></EmptyHeader></Empty>;\n const switcher = (target: TaskList, text: string) => list === target ? <span data-task-list-link={target} aria-current=\"page\">{text}</span>\n : <a href=\"#\" data-task-list-link={target} className=\"text-foreground underline underline-offset-2\" onClick={(event: MouseEvent) => { event.preventDefault(); setList(target); }}>{text}</a>;\n const chooseFilter = (next: TaskFilter) => (event: MouseEvent) => { event.preventDefault(); if (filter === undefined) setOwnFilter(next); onFilterChange?.(next); };\n if (variant === 'compact') {\n const strings = compactLabels[locale];\n // Filter hidden records and prioritize current viewer needs before applying the visible limit.\n const compact = selectTasks(tasks.filter(task => task.technical !== true || compactPresentation?.includeTechnical === true), { filter: shownFilter, ...(currentUser === undefined ? {} : { currentUser }) })\n .slice().sort((a, b) => Number(compactTaskStatus(b) === 'needs-you') - Number(compactTaskStatus(a) === 'needs-you'))\n .slice(0, limit);\n const ordinary = compact.filter(task => task.technical !== true && compactTaskStatus(task) !== 'closed');\n const closed = compact.filter(task => task.technical !== true && compactTaskStatus(task) === 'closed');\n const technical = compact.filter(task => task.technical === true);\n const group = (kind: 'closed' | 'technical', records: readonly TaskRecord[]) => records.length > 0 && <Collapsible data-task-group={kind}>\n <Separator />\n <CollapsibleTrigger render={<Button variant=\"ghost\" />}>{kind === 'closed' ? strings.finished : strings.technical} · {records.length}</CollapsibleTrigger>\n <CollapsibleContent><div role=\"list\" aria-label={kind === 'closed' ? strings.finished : strings.technical} className=\"flex flex-col gap-2\">{records.map(card)}</div></CollapsibleContent>\n </Collapsible>;\n return <section ref={root} data-boring=\"task-board\" data-variant=\"compact\" aria-label={title} className={cn('flex min-w-0 flex-col gap-2', className)}>\n <div data-task-list role=\"list\" aria-label={title} className=\"flex flex-col gap-2\">{ordinary.map(card)}</div>\n {!compact.length && <Empty><EmptyHeader><EmptyTitle>{strings.empty}</EmptyTitle><EmptyDescription>{strings.emptyDescription}</EmptyDescription></EmptyHeader></Empty>}\n {group('closed', closed)}\n {group('technical', technical)}\n </section>;\n }\n return <section ref={root} data-boring=\"task-board\" data-variant={variant} aria-label={title} className={cn('flex min-h-0 min-w-0 flex-col', className)} onKeyDown={event => {\n if (event.key === 'Escape' && selected !== null && !event.defaultPrevented) { event.preventDefault(); if (fileOpen) closeFile(); else select(null); }\n }}>\n {bar && <nav data-task-bar aria-label=\"Task lists\" className=\"flex h-13 shrink-0 items-center gap-2.5 px-3.5 text-[13px] text-muted-foreground\">\n {barStart}\n {switcher('open', `${BOARD_STATES.reduce((sum, state) => sum + of(state).length, 0)} open`)}<span aria-hidden=\"true\">·</span>\n {switcher('done', `Done ${of('done').length}`)}<span aria-hidden=\"true\">·</span>\n {switcher('closed', `Closed ${of('closed').length}`)}\n {currentUser !== undefined && <span className={cn('ml-auto flex items-center gap-2.5', taskTokens.pill)} data-task-filter={shownFilter}>\n {(['mine', 'all'] as const).map(value => <Badge key={value} variant={shownFilter === value ? 'default' : 'secondary'} render={<a href=\"#\" aria-current={shownFilter === value ? 'true' : undefined} onClick={chooseFilter(value)} />}>{value === 'mine' ? 'Mine' : 'All'}</Badge>)}\n </span>}\n </nav>}\n <div data-task-stage className=\"relative min-h-0 flex-1 overflow-hidden\">\n <div data-task-list className=\"@container/task-rails h-full min-w-0 overflow-auto\">\n {list === 'open' ? <div className=\"grid min-w-0 content-start gap-2.5 p-3.5 @2xl/task-rails:grid-cols-3\">\n {BOARD_STATES.map(state => {\n const column = of(state).sort((a, b) => Number(b.waiting_on_you === true) - Number(a.waiting_on_you === true));\n const onYou = column.filter(task => task.waiting_on_you === true).length;\n return <section key={state} data-task-column={state} aria-label={labels[state]} className={cn('flex min-w-0 flex-col gap-[7px] self-start bg-muted p-2', taskTokens.column)}>\n <h3 className=\"flex min-h-6 items-center justify-between gap-2 px-0.5 pb-0.5 text-[11px] font-semibold tracking-[0.06em] text-muted-foreground\"><span className=\"uppercase\">{labels[state]} {column.length}</span>{onYou > 0 && <span className={cn('contents', taskTokens.pill)}><Badge data-task-on-you=\"\">{onYou} on you</Badge></span>}</h3>\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{column.map(card)}</div>\n </section>;\n })}\n </div> : <section data-task-column={list} aria-label={labels[list]} className=\"flex max-w-xl min-w-0 flex-col gap-[7px] p-3.5\">\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{of(list).map(card)}</div>\n {!of(list).length && empty}\n </section>}\n </div>\n {selected !== null && <aside aria-label=\"Task detail\" data-task-side className={cn('absolute inset-y-0 right-0 flex w-[400px] max-w-full flex-col bg-popover text-popover-foreground', overlayClassName)} aria-live=\"polite\">\n <Separator orientation=\"vertical\" className=\"absolute inset-y-0 left-0\" />\n <div className=\"flex h-10 shrink-0 items-center gap-2 pr-1.5 pl-3.5\">\n <Button ref={back} variant=\"ghost\" size=\"icon\" data-task-back=\"\" aria-label=\"Back to the tasks\" title=\"Back to the tasks\" onClick={() => select(null)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {detail && <><span className=\"text-[11px] text-muted-foreground\">{taskMeta(detail)}</span><TaskStatusPill task={detail} locale={locale} viewerAction={viewerAction} /></>}\n </div>\n <Separator />\n <div className=\"min-h-0 flex-1 overflow-auto p-3.5\">\n {detail ? <TaskDetail key={detail.id} task={detail} actions={actions?.(detail)} onAnswer={onAnswer} onOther={onOther} onDiscussQuestion={onDiscussQuestion} onAsk={onAsk} onOpenFile={renderFile || onOpenFile ? openFile : undefined} />\n : <Empty><EmptyHeader><EmptyTitle>Task unavailable</EmptyTitle><EmptyDescription>The host has not supplied this task.</EmptyDescription></EmptyHeader></Empty>}\n </div>\n </aside>}\n {fileOpen && detail && file && <section aria-label=\"Task file viewer\" data-task-file-pane={detail.id} className={cn('flex min-w-0 flex-col bg-background', fileFullscreen ? 'fixed inset-0 z-50' : 'absolute inset-y-0 right-0 w-[760px] max-w-full', overlayClassName)}>\n {renderFile!(detail, file, { fullscreen: fileFullscreen, onFullscreenChange: setFileFullscreen, onClose: closeFile, onBack: closeFile, onShowFile: onShowFile ? () => onShowFile(detail, file) : undefined })}\n </section>}\n </div>\n </section>;\n}\n",
18
24
  "type": "registry:component",
19
25
  "target": "components/task-board/task-board.tsx"
20
26
  },
21
27
  {
22
28
  "path": "registry/task-board/task-request-card.tsx",
23
- "content": "'use client';\n\nimport type { MouseEvent, ReactNode } from 'react';\nimport type { TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { deriveTaskStatus, taskReference, taskStatusLabel } from '@hachej/boring-ui-kit/task-board';\nimport { Badge } from '@/components/ui/badge';\nimport { Item } from '@/components/ui/item';\nimport { cn } from '../utils/utils';\n\nexport type TaskLocale = 'en' | 'fr';\nexport type TaskViewerAction = 'question' | 'confirmation';\nexport const taskStateLabels: Readonly<Record<TaskState, string>> = { waiting: 'Waiting', working: 'Working', open: 'Open', done: 'Done', closed: 'Closed' };\nconst captures = (count: number) => `${count} capture${count === 1 ? '' : 's'}`;\n/** Compact (C7) strings. French uses a non-breaking space before the colon. */\nconst compactStrings = {\n en: { label: 'REQUEST', screen: (screen: string) => `Screen: ${screen}` },\n fr: { label: 'DEMANDE', screen: (screen: string) => `Écran : ${screen}` },\n} as const;\n\n/**\n * Task-board tokens, set by the host theme. Each falls back to the stock shadcn value, so a host without them looks unchanged.\n * `--task-pill-height` / `--task-pill-font-size` (pills), `--task-control-height` (question controls), `--task-column-radius`,\n * `--task-card-padding-y` / `--task-card-padding-x` (board cards).\n */\nexport const taskTokens = {\n pill: '*:h-[var(--task-pill-height,1.25rem)] *:text-[length:var(--task-pill-font-size,0.75rem)]',\n control: '*:h-[var(--task-control-height,2rem)]',\n card: '[&_[data-slot=item]]:px-[var(--task-card-padding-x,0.75rem)] [&_[data-slot=item]]:py-[var(--task-card-padding-y,0.625rem)]',\n column: 'rounded-[var(--task-column-radius,var(--radius-xl))]',\n} as const;\n\n/** `id · age`, e.g. « D-201 · 12m ». */\nexport const taskMeta = (task: TaskRecord) => [taskReference(task), task.age].filter(Boolean).join(' · ');\n\n/**\n * One pill per task: viewer, explicit builder, then high risk (`deriveTaskStatus`); none renders nothing. Colours are theme roles:\n * primary (waiting on you), secondary (builder, sent), destructive (risk). The French app's « À confirmer / À vous » pill uses the host's\n * warning-soft tokens when it defines them. `sent` opts into « Envoyée » (the compact card only; the board and the detail keep theirs).\n */\nexport function TaskStatusPill({ task, locale = 'en', viewerAction = 'question', sent = false }: { readonly task: Pick<TaskRecord, 'waiting_on_you' | 'waiting_on' | 'risk'> & Partial<Pick<TaskRecord, 'state' | 'confirmed_at'>>; readonly locale?: TaskLocale | undefined; readonly viewerAction?: TaskViewerAction | undefined; readonly sent?: boolean | undefined }) {\n const status = deriveTaskStatus(task, { sent });\n const label = taskStatusLabel(status, locale, viewerAction);\n if (!label) return null;\n const warning = status === 'waiting-on-viewer' && locale === 'fr';\n const badge = <Badge data-task-status={status} variant={status === 'waiting-on-viewer' && !warning ? 'default' : status === 'high-risk' ? 'destructive' : 'secondary'}>{label}</Badge>;\n return <span className={cn('contents', taskTokens.pill, warning && '[--secondary:var(--warning-soft,var(--secondary))] [--secondary-foreground:var(--warning-soft-foreground,var(--secondary-foreground))]')}>{badge}</span>;\n}\n/** @deprecated The pill now follows `deriveTaskStatus`; `labels` is ignored. */\nexport const TaskStatusChip = TaskStatusPill as (props: { readonly task: Pick<TaskRecord, 'waiting_on_you' | 'waiting_on' | 'risk'>; readonly labels?: unknown; readonly locale?: TaskLocale; readonly viewerAction?: TaskViewerAction }) => ReactNode;\n\n/** A card waiting on the viewer carries a 2px foreground edge: the outline border plus a 1px ring, both in the primary role. */\nexport function TaskAttention({ active, children }: { readonly active: boolean; readonly children: ReactNode }) {\n return active ? <div data-task-attention=\"\" className=\"rounded-[var(--radius-lg)] ring-1 ring-primary [--border:var(--primary)]\">{children}</div> : <>{children}</>;\n}\n\nexport interface TaskRequestCardProps {\n readonly task: TaskRecord;\n /** `card`: the board card (D2). `compact`: the app's request card (C7). */\n readonly variant?: 'card' | 'compact';\n readonly selected?: boolean;\n readonly href?: string;\n readonly onOpen?: ((task: TaskRecord) => void) | undefined;\n /** Host-authorized actions of the compact card, e.g. « Modifier » and « Confirmer »; rendered as two equal columns. */\n readonly actions?: ReactNode;\n readonly locale?: TaskLocale | undefined;\n /** Explicit host context for the French viewer label: « À vous » (question) or « À confirmer » (confirmation). */\n readonly viewerAction?: TaskViewerAction | undefined;\n /** Compact only: replaces the derived status pill, e.g. a finished request's muted « En ligne » in « Mes demandes ». */\n readonly pill?: ReactNode;\n /** @deprecated Ignored; kept for source compatibility. */\n readonly labels?: unknown;\n}\n\n/** One record, two renderings: the board card (D2) and the compact request card (C7). Actions remain host-authorized. */\nexport function TaskRequestCard({ task, variant = 'card', selected, href, onOpen, actions, locale = 'en', viewerAction, pill: shownPill }: TaskRequestCardProps) {\n const activate = (event: MouseEvent<HTMLAnchorElement>) => {\n if (!onOpen || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;\n event.preventDefault(); onOpen(task);\n };\n const anchor = { href, onClick: activate, 'data-task-open': task.id, 'aria-current': selected ? 'true' as const : undefined };\n const link = href ? <a {...anchor} /> : undefined;\n if (variant === 'compact') {\n const pill = shownPill ?? <TaskStatusPill task={task} locale={locale} viewerAction={viewerAction} sent />;\n const strings = compactStrings[locale];\n const meta = [task.screen && strings.screen(task.screen), task.captureCount !== undefined && captures(task.captureCount)].filter(Boolean).join(' · ');\n const title = link && actions ? <a {...anchor} className=\"underline-offset-4 hover:underline\">{task.display ?? task.title}</a> : task.display ?? task.title;\n return <div role=\"listitem\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-2\" data-task-card={task.id} data-variant=\"compact\" {...(link && !actions ? { render: link } : {})}>\n <div className=\"flex min-w-0 items-center justify-between gap-2\"><span className=\"text-xs font-semibold text-muted-foreground\">{strings.label}</span>{pill}</div>\n <div className=\"min-w-0 text-[15px] leading-tight font-semibold break-words\">{title}</div>\n {meta && <div className=\"text-[13px] text-muted-foreground\">{meta}</div>}\n {actions && <div data-task-actions=\"\" className=\"mt-1 grid grid-cols-2 gap-2 *:min-h-11 *:w-full\">{actions}</div>}\n </Item></div>;\n }\n const pill = <TaskStatusPill task={task} locale={locale} viewerAction={viewerAction} />;\n return <div role=\"listitem\" className={cn('rounded-[var(--radius-lg)] bg-card', taskTokens.card)}><TaskAttention active={task.waiting_on_you === true}>\n <Item variant=\"outline\" className=\"flex-col items-stretch gap-1\" data-task-card={task.id} data-selected={selected || undefined} {...(link ? { render: link } : {})}>\n <span className=\"flex min-h-5 min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground\"><span>{taskMeta(task)}</span>{pill}</span>\n <span className=\"min-w-0 text-sm break-words text-foreground\">{task.display ?? task.title}</span>\n </Item>\n </TaskAttention></div>;\n}\n",
29
+ "content": "'use client';\n\nimport type { MouseEvent, ReactNode } from 'react';\nimport type { TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { deriveTaskStatus, taskReference, taskStatusLabel } from '@hachej/boring-ui-kit/task-board';\nimport { Badge } from '@/components/ui/badge';\nimport { Item } from '@/components/ui/item';\nimport { cn } from '../utils/utils';\nimport { compactLabels, compactTaskStatus, compactTaskTitle } from './compact-presentation';\n\nexport type TaskLocale = 'en' | 'fr';\nexport type TaskViewerAction = 'question' | 'confirmation';\nexport const taskStateLabels: Readonly<Record<TaskState, string>> = { waiting: 'Waiting', working: 'Working', open: 'Open', done: 'Done', closed: 'Closed' };\n/**\n * Task-board tokens, set by the host theme. Each falls back to the stock shadcn value, so a host without them looks unchanged.\n * `--task-pill-height` / `--task-pill-font-size` (pills), `--task-control-height` (question controls), `--task-column-radius`,\n * `--task-card-padding-y` / `--task-card-padding-x` (board cards).\n */\nexport const taskTokens = {\n pill: '*:h-[var(--task-pill-height,1.25rem)] *:text-[length:var(--task-pill-font-size,0.75rem)]',\n control: '*:h-[var(--task-control-height,2rem)]',\n card: '[&_[data-slot=item]]:px-[var(--task-card-padding-x,0.75rem)] [&_[data-slot=item]]:py-[var(--task-card-padding-y,0.625rem)]',\n column: 'rounded-[var(--task-column-radius,var(--radius-xl))]',\n} as const;\n\n/** `id · age`, e.g. « D-201 · 12m ». */\nexport const taskMeta = (task: TaskRecord) => [taskReference(task), task.age].filter(Boolean).join(' · ');\n\n/**\n * One pill per task: viewer, explicit builder, then high risk (`deriveTaskStatus`); none renders nothing. Colours are theme roles:\n * primary (waiting on you), secondary (builder, sent), destructive (risk). The French app's « À confirmer / À vous » pill uses the host's\n * warning-soft tokens when it defines them. `sent` lets standalone callers opt into « Envoyée »; compact rows use their separate product status.\n */\nexport function TaskStatusPill({ task, locale = 'en', viewerAction = 'question', sent = false }: { readonly task: Pick<TaskRecord, 'waiting_on_you' | 'waiting_on' | 'risk'> & Partial<Pick<TaskRecord, 'state' | 'confirmed_at'>>; readonly locale?: TaskLocale | undefined; readonly viewerAction?: TaskViewerAction | undefined; readonly sent?: boolean | undefined }) {\n const status = deriveTaskStatus(task, { sent });\n const label = taskStatusLabel(status, locale, viewerAction);\n if (!label) return null;\n const warning = status === 'waiting-on-viewer' && locale === 'fr';\n const badge = <Badge data-task-status={status} variant={status === 'waiting-on-viewer' && !warning ? 'default' : status === 'high-risk' ? 'destructive' : 'secondary'}>{label}</Badge>;\n return <span className={cn('contents', taskTokens.pill, warning && '[--secondary:var(--warning-soft,var(--secondary))] [--secondary-foreground:var(--warning-soft-foreground,var(--secondary-foreground))]')}>{badge}</span>;\n}\n/** @deprecated The pill now follows `deriveTaskStatus`; `labels` is ignored. */\nexport const TaskStatusChip = TaskStatusPill as (props: { readonly task: Pick<TaskRecord, 'waiting_on_you' | 'waiting_on' | 'risk'>; readonly labels?: unknown; readonly locale?: TaskLocale; readonly viewerAction?: TaskViewerAction }) => ReactNode;\n\n/** A card waiting on the viewer carries a 2px foreground edge: the outline border plus a 1px ring, both in the primary role. */\nexport function TaskAttention({ active, children }: { readonly active: boolean; readonly children: ReactNode }) {\n return active ? <div data-task-attention=\"\" className=\"rounded-[var(--radius-lg)] ring-1 ring-primary [--border:var(--primary)]\">{children}</div> : <>{children}</>;\n}\n\nexport interface TaskRequestCardProps {\n readonly task: TaskRecord;\n /** `card`: the board card (D2). `compact`: the app's request card (C7). */\n readonly variant?: 'card' | 'compact';\n readonly selected?: boolean;\n readonly href?: string;\n readonly onOpen?: ((task: TaskRecord) => void) | undefined;\n /** Host-authorized actions of the compact card, e.g. « Modifier » and « Confirmer »; rendered as two equal columns. */\n readonly actions?: ReactNode;\n readonly locale?: TaskLocale | undefined;\n /** Explicit host context for the French viewer label: « À vous » (question) or « À confirmer » (confirmation). */\n readonly viewerAction?: TaskViewerAction | undefined;\n /** Compact only: replaces the derived status pill, e.g. a finished request's muted « En ligne » in « Mes demandes ». */\n readonly pill?: ReactNode;\n /** @deprecated Ignored; kept for source compatibility. */\n readonly labels?: unknown;\n}\n\n/** One record, two renderings: the board card (D2) and the compact request card (C7). Actions remain host-authorized. */\nexport function TaskRequestCard({ task, variant = 'card', selected, href, onOpen, actions, locale = 'en', viewerAction, pill: shownPill }: TaskRequestCardProps) {\n const activate = (event: MouseEvent<HTMLAnchorElement>) => {\n if (!onOpen || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;\n event.preventDefault(); onOpen(task);\n };\n const anchor = { href, onClick: activate, 'data-task-open': task.id, 'aria-current': selected ? 'true' as const : undefined };\n const link = href ? <a {...anchor} /> : undefined;\n if (variant === 'compact') {\n const status = compactTaskStatus(task);\n const strings = compactLabels[locale];\n const label = status === 'needs-you' && viewerAction === 'confirmation' ? strings.confirmation\n : status === 'closed' && (task.status === 'withdrawn' || task.status === 'abandoned') ? strings.withdrawn : strings[status];\n const pill = shownPill ?? <Badge data-task-status={status} variant={status === 'needs-you' ? 'default' : status === 'blocked' ? 'destructive' : 'secondary'}>{label}</Badge>;\n const shortTitle = compactTaskTitle(task);\n const title = link && actions ? <a {...anchor} className=\"underline-offset-4 hover:underline\">{shortTitle}</a> : shortTitle;\n return <div role=\"listitem\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-2\" data-task-card={task.id} data-variant=\"compact\" {...(link && !actions ? { render: link } : {})}>\n <div className=\"flex min-w-0 items-start justify-between gap-2\">\n <span data-task-title=\"\" className=\"min-w-0 text-sm font-semibold break-words\">{title}</span>\n <span className=\"shrink-0\">{pill}</span>\n </div>\n {actions && <div data-task-actions=\"\" className=\"mt-1 grid grid-cols-2 gap-2 *:min-h-11 *:w-full\">{actions}</div>}\n </Item></div>;\n }\n const pill = <TaskStatusPill task={task} locale={locale} viewerAction={viewerAction} />;\n return <div role=\"listitem\" className={cn('rounded-[var(--radius-lg)] bg-card', taskTokens.card)}><TaskAttention active={task.waiting_on_you === true}>\n <Item variant=\"outline\" className=\"flex-col items-stretch gap-1\" data-task-card={task.id} data-selected={selected || undefined} {...(link ? { render: link } : {})}>\n <span className=\"flex min-h-5 min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground\"><span>{taskMeta(task)}</span>{pill}</span>\n <span className=\"min-w-0 text-sm break-words text-foreground\">{task.display ?? task.title}</span>\n </Item>\n </TaskAttention></div>;\n}\n",
24
30
  "type": "registry:component",
25
31
  "target": "components/task-board/task-request-card.tsx"
26
32
  },
@@ -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.39",
8
- "@hachej/boring-files@^0.4.39",
7
+ "@hachej/boring-ui-kit@^0.4.40",
8
+ "@hachej/boring-files@^0.4.40",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "zod@^4.6.5"
@@ -4,14 +4,14 @@
4
4
  "title": "Task mentions",
5
5
  "description": "Optional authorized task catalog for the existing PM composer, composed with host file search.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.39",
7
+ "@hachej/boring-ui-kit@^0.4.40",
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.39/r/task-board.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat-mentions.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/task-board.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-mentions.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -4,11 +4,11 @@
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.39",
7
+ "@hachej/boring-files@^0.4.40",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/button.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/button.json"
12
12
  ],
13
13
  "files": [
14
14
  {
@@ -8,8 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,15 +4,15 @@
4
4
  "title": "Viewer frame",
5
5
  "description": "The standard viewer top bar every viewer draws in (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu), its window labels, download, share and media helpers and the sandboxed interactive HTML frame. The viewers' styles live here. Each viewer is its own item (viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code) with a cheap kind module and a view loaded on first open.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.39",
7
+ "@hachej/boring-ui-kit@^0.4.40",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/button.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/version-history.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/version-history.json"
16
16
  ],
17
17
  "files": [
18
18
  {
@@ -4,14 +4,14 @@
4
4
  "title": "HTML viewer",
5
5
  "description": "The HTML kind: `htmlKind` (match, loader) and `HtmlPane`, the passive HTML preview (sandboxed when the host opts in) and source editor, in the viewer frame. The pane loads the first time an HTML file opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.39",
7
+ "@hachej/boring-ui-kit@^0.4.40",
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.39/r/viewer-frame.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/button.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-frame.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/button.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -8,8 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,7 +4,7 @@
4
4
  "title": "Markdown viewer",
5
5
  "description": "The Markdown kind: `markdownKind` (match, loader) and `MarkdownPane`, the tiptap editor with a formatting toolbar and prose styling over the kit's revisioned Markdown controller, in the viewer frame. The pane and tiptap load the first time a Markdown file opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.39",
7
+ "@hachej/boring-ui-kit@^0.4.40",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
10
  "marked@^17.0.6",
@@ -22,10 +22,10 @@
22
22
  "@tiptap/extension-floating-menu@^3.31.4"
23
23
  ],
24
24
  "registryDependencies": [
25
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-frame.json",
26
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/markdown-editor.json",
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json",
28
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/button.json"
25
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-frame.json",
26
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/markdown-editor.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json",
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/button.json"
29
29
  ],
30
30
  "files": [
31
31
  {
package/r/viewer-pdf.json CHANGED
@@ -4,14 +4,14 @@
4
4
  "title": "PDF viewer",
5
5
  "description": "The PDF kind: `pdfKind` (match, loader) and `PdfPane` in the viewer frame (the browser's viewer, or pdf.js pages where there is none), and LocalFileViewer for browser-only files. The pane loads the first time a PDF opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.39",
7
+ "@hachej/boring-files@^0.4.40",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
10
  "pdfjs-dist@6.4.299"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-frame.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-image.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-frame.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-image.json"
15
15
  ],
16
16
  "files": [
17
17
  {
package/r/viewers.json CHANGED
@@ -4,12 +4,12 @@
4
4
  "title": "Viewers",
5
5
  "description": "Deprecated aggregate: installs every viewer item (viewer-frame, viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code). Add the items you use instead.",
6
6
  "registryDependencies": [
7
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-frame.json",
8
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-markdown.json",
9
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-html.json",
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-image.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-pdf.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/viewer-code.json"
7
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-frame.json",
8
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-markdown.json",
9
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-html.json",
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-image.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-pdf.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-code.json"
13
13
  ],
14
14
  "files": [],
15
15
  "type": "registry:component"
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.39/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {