@hachej/boring-ui-kit 0.4.41 → 0.4.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +18 -24
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +13 -13
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +2 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +8 -8
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +13 -13
- package/r/pi-artifacts.json +2 -2
- package/r/pi-chat-approval.json +2 -2
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +3 -3
- package/r/pi-chat.json +5 -5
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +4 -4
- package/r/pm-panel.json +43 -29
- package/r/provider-setup.json +1 -1
- package/r/registry.json +206 -194
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +6 -6
- package/r/task-board.json +4 -4
- package/r/task-list-viewer.json +2 -2
- package/r/task-mentions.json +4 -4
- package/r/utils.json +6 -0
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +6 -6
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
package/r/requests-board.json
CHANGED
package/r/shared-chat.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.43",
|
|
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.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat-plugins.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat-markdown.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat-mentions.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/utils.json"
|
|
17
17
|
],
|
|
18
18
|
"files": [
|
|
19
19
|
{
|
package/r/task-board.json
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.43",
|
|
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.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/utils.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"path": "registry/task-board/task-board.tsx",
|
|
23
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { MouseEvent, ReactNode } from 'react';\nimport { selectTasks, taskFromUrl, taskReference, taskUrl } from '@hachej/boring-ui-kit/task-board';\nimport type { TaskFile, TaskFilter, TaskRecord } 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, 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 { columnLabels, compactLabels, compactTaskStatus, TASK_COLUMNS, taskColumn } from './compact-presentation';\nimport type { TaskColumn } 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 FOLDED = ['finished', 'technical'] as const;\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 /** @deprecated Ignored: columns follow who acts next and `locale`. */\n readonly labels?: unknown;\n /** The tasks bar (host content, app filter, 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 one task board: columns by who acts next (needs you, blocked, in progress, recently live), finished and technical folded. The\n * panel's own width decides the layout, so a narrow pane or phone gets the same sections stacked. The detail opens 400px wide over the\n * board, whole-pane when narrower; 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', 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 [unfolded, setUnfolded] = useState<ReadonlySet<TaskColumn>>(() => new Set());\n const [app, setApp] = useState<string | null>(null);\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 column: TaskColumn | undefined } | null>(null);\n const detailColumn = detail && taskColumn(detail);\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 const unfold = (column: TaskColumn, open: boolean) => setUnfolded(current => { const next = new Set(current); if (open) next.add(column); else next.delete(column); return next; });\n useEffect(() => {\n if (selected !== null) back.current?.focus();\n else if (previous.current !== null) {\n const { id, column } = previous.current, link = cardLink(id);\n if (link) link.focus();\n else if (column) { pendingFocus.current = id; unfold(column, true); }\n }\n previous.current = selected === null ? null : { id: detail?.id ?? selected, column: detailColumn };\n }, [selected, detail?.id, detailColumn]);\n useEffect(() => { if (pendingFocus.current !== null) { cardLink(pendingFocus.current)?.focus(); pendingFocus.current = null; } }, [unfolded]);\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 apps = [...new Set(shown.flatMap(task => task.app ? [task.app] : []))].sort();\n const listed = app === null ? shown : shown.filter(task => task.app === app);\n const words = columnLabels[locale];\n const of = (column: TaskColumn) => listed.filter(task => taskColumn(task) === column).sort((a, b) => (Date.parse(b.updated_at ?? '') || 0) - (Date.parse(a.updated_at ?? '') || 0));\n const appChip = (value: string | null, text: string) => <Badge key={text} data-task-app-filter={text} variant={app === value ? 'default' : 'secondary'} render={<a href=\"#\" aria-current={app === value ? 'true' : undefined} onClick={(event: MouseEvent) => { event.preventDefault(); setApp(value); }} />}>{text}</Badge>;\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 {apps.length > 1 && <span className={cn('flex min-w-0 flex-wrap items-center gap-1.5', taskTokens.pill)} data-task-apps=\"\">{appChip(null, locale === 'fr' ? 'Toutes' : 'All apps')}{apps.map(name => appChip(name, name))}</span>}\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 <div className=\"grid min-w-0 content-start gap-2.5 p-3.5 @3xl/task-rails:grid-cols-4\">\n {TASK_COLUMNS.map(column => {\n const rows = of(column);\n // Stacked, an empty section is noise; side by side, a column keeps its place so the board does not shift.\n return <section key={column} data-task-column={column} aria-label={words[column]} className={cn('min-w-0 flex-col gap-[7px] self-start bg-muted p-2', rows.length ? 'flex' : 'hidden @3xl/task-rails:flex', taskTokens.column)}>\n <h3 className=\"flex min-h-6 items-center gap-1.5 px-0.5 pb-0.5 text-xs font-semibold text-muted-foreground\">{words[column]}<span className=\"font-normal\">{rows.length}</span></h3>\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{rows.map(card)}</div>\n </section>;\n })}\n </div>\n {!listed.length && <Empty><EmptyHeader><EmptyTitle>{compactLabels[locale].empty}</EmptyTitle><EmptyDescription>{compactLabels[locale].emptyDescription}</EmptyDescription></EmptyHeader></Empty>}\n {FOLDED.map(column => {\n const rows = of(column);\n return rows.length > 0 && <div key={column} className=\"px-3.5 pb-2\"><Collapsible data-task-group={column} open={unfolded.has(column)} onOpenChange={open => unfold(column, open)}>\n <CollapsibleTrigger render={<Button variant=\"ghost\" size=\"sm\" />}>{words[column]} · {rows.length}</CollapsibleTrigger>\n <CollapsibleContent><div role=\"list\" aria-label={words[column]} className=\"grid gap-[7px] pt-1.5 @3xl/task-rails:grid-cols-4\">{rows.map(card)}</div></CollapsibleContent>\n </Collapsible></div>;\n })}\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\">{[detail.app, taskMeta(detail)].filter(Boolean).join(' · ')}</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} locale={locale} 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 } from '@hachej/boring-ui-kit/task-board';\nimport type { TaskFile, TaskFilter, TaskRecord } 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, 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 { columnLabels, compactLabels, TASK_COLUMNS, taskColumn } from './compact-presentation';\nimport type { TaskColumn } 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 FOLDED = ['finished', 'technical'] as const;\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 title?: string;\n /** @deprecated Ignored: columns follow who acts next and `locale`. */\n readonly labels?: unknown;\n /** The tasks bar (host content, app filter, 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 in the detail, after the question and the request. */\n readonly actions?: ((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 one task board: columns by who acts next (needs you, blocked, in progress, recently live), finished and technical folded. The\n * panel's own width decides the layout, so a narrow pane or phone gets the same sections stacked. The detail opens 400px wide over the\n * board, whole-pane when narrower; a file opens 760px wide over the detail.\n */\nexport function TaskBoard({ tasks, filter, onFilterChange, currentUser, limit, title = 'Tasks', bar = true, barStart, locale = 'en', viewerAction,\n selectedTask, onSelectedTaskChange, onOpen, actions, 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 [unfolded, setUnfolded] = useState<ReadonlySet<TaskColumn>>(() => new Set());\n const [app, setApp] = useState<string | null>(null);\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 column: TaskColumn | undefined } | null>(null);\n const detailColumn = detail && taskColumn(detail);\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 const unfold = (column: TaskColumn, open: boolean) => setUnfolded(current => { const next = new Set(current); if (open) next.add(column); else next.delete(column); return next; });\n useEffect(() => {\n if (selected !== null) back.current?.focus();\n else if (previous.current !== null) {\n const { id, column } = previous.current, link = cardLink(id);\n if (link) link.focus();\n else if (column) { pendingFocus.current = id; unfold(column, true); }\n }\n previous.current = selected === null ? null : { id: detail?.id ?? selected, column: detailColumn };\n }, [selected, detail?.id, detailColumn]);\n useEffect(() => { if (pendingFocus.current !== null) { cardLink(pendingFocus.current)?.focus(); pendingFocus.current = null; } }, [unfolded]);\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} selected={task.id === detail?.id} href={href(task)} onOpen={open}\n locale={locale} viewerAction={viewerAction} />;\n const apps = [...new Set(shown.flatMap(task => task.app ? [task.app] : []))].sort();\n const listed = app === null ? shown : shown.filter(task => task.app === app);\n const words = columnLabels[locale];\n const of = (column: TaskColumn) => listed.filter(task => taskColumn(task) === column).sort((a, b) => (Date.parse(b.updated_at ?? '') || 0) - (Date.parse(a.updated_at ?? '') || 0));\n const appChip = (value: string | null, text: string) => <Badge key={text} data-task-app-filter={text} variant={app === value ? 'default' : 'secondary'} render={<a href=\"#\" aria-current={app === value ? 'true' : undefined} onClick={(event: MouseEvent) => { event.preventDefault(); setApp(value); }} />}>{text}</Badge>;\n const chooseFilter = (next: TaskFilter) => (event: MouseEvent) => { event.preventDefault(); if (filter === undefined) setOwnFilter(next); onFilterChange?.(next); };\n return <section ref={root} data-boring=\"task-board\" 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 && (barStart || apps.length > 1 || currentUser !== undefined) && <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 {apps.length > 1 && <span className={cn('flex min-w-0 items-center gap-1.5 overflow-x-auto [scrollbar-width:none] *:shrink-0', taskTokens.pill)} data-task-apps=\"\">{appChip(null, locale === 'fr' ? 'Toutes' : 'All apps')}{apps.map(name => appChip(name, name))}</span>}\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 {!!listed.length && <div className=\"grid min-w-0 content-start gap-2.5 p-3.5 @3xl/task-rails:grid-cols-4\">\n {TASK_COLUMNS.map(column => {\n const rows = of(column);\n // Stacked, an empty section is noise; side by side, a column keeps its place so the board does not shift.\n return <section key={column} data-task-column={column} aria-label={words[column]} className={cn('min-w-0 flex-col gap-[7px] self-start bg-muted p-2', rows.length ? 'flex' : 'hidden @3xl/task-rails:flex', taskTokens.column)}>\n <h3 className=\"flex min-h-6 items-center gap-1.5 px-0.5 pb-0.5 text-xs font-semibold text-muted-foreground\">{words[column]}<span className=\"font-normal\">{rows.length}</span></h3>\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{rows.map(card)}</div>\n </section>;\n })}\n </div>}\n {!listed.length && <Empty><EmptyHeader><EmptyTitle>{compactLabels[locale].empty}</EmptyTitle><EmptyDescription>{compactLabels[locale].emptyDescription}</EmptyDescription></EmptyHeader></Empty>}\n {FOLDED.map(column => {\n const rows = of(column);\n return rows.length > 0 && <div key={column} className=\"px-3.5 pb-2\"><Collapsible data-task-group={column} open={unfolded.has(column)} onOpenChange={open => unfold(column, open)}>\n <CollapsibleTrigger render={<Button variant=\"ghost\" size=\"sm\" />}>{words[column]} · {rows.length}</CollapsibleTrigger>\n <CollapsibleContent><div role=\"list\" aria-label={words[column]} className=\"grid gap-[7px] pt-1.5 @3xl/task-rails:grid-cols-4\">{rows.map(card)}</div></CollapsibleContent>\n </Collapsible></div>;\n })}\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\">{[detail.app, taskMeta(detail)].filter(Boolean).join(' · ')}</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} locale={locale} 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",
|
|
24
24
|
"type": "registry:component",
|
|
25
25
|
"target": "components/task-board/task-board.tsx"
|
|
26
26
|
},
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
"path": "registry/task-board/task-detail.tsx",
|
|
35
|
-
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { AppWindowIcon, ChevronRightIcon, FileIcon, ImageIcon } from 'lucide-react';\nimport type { TaskFile, TaskQuestion, TaskRecord } from '@hachej/boring-ui-kit/task-board';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Item } from '@/components/ui/item';\nimport { TaskAttention, taskTokens } from './task-request-card';\nimport type { TaskLocale } from './task-request-card';\nimport { compactLabels, compactTaskStatus, compactTaskTitle, taskWaitingText } from './compact-presentation';\n\nexport interface TaskDetailProps {\n readonly task: TaskRecord;\n readonly locale?: TaskLocale | undefined;\n /** Host content placed after the title card (for example a mount report or host-authorized request actions). */\n readonly actions?: ReactNode;\n /** The host reauthorizes and resolves the original question. Rendering never supplies approval. */\n readonly onAnswer?: ((task: TaskRecord, question: TaskQuestion, value: string) => void) | undefined;\n /** « Other… » opens an inline field; Send passes the typed text. No synthetic option value or approval is generated. */\n readonly onOther?: ((task: TaskRecord, question: TaskQuestion, text: string) => void) | undefined;\n /** « Discuss in chat »: the host stages `@ D-201 · <title>` and the question in its one composer, without sending. */\n readonly onDiscussQuestion?: ((task: TaskRecord, question: TaskQuestion) => void) | undefined;\n readonly onOpenFile?: ((task: TaskRecord, file: TaskFile) => void) | undefined;\n /** « Ask in the conversation »: the host focuses its composer with the task mention. */\n readonly onAsk?: ((task: TaskRecord) => void) | undefined;\n /** @deprecated Ignored; kept for source compatibility. */\n readonly labels?: unknown;\n}\nconst detailWords = {\n en: { question: 'QUESTION FOR YOU', waiting: 'WAITING ON YOU', elsewhere: 'Reply in the conversation.', request: 'THE REQUEST', yourAnswer: 'Your answer', send: 'Send', other: 'Other…', discuss: 'Discuss in chat', ask: 'Ask in the conversation' },\n fr: { question: 'QUESTION POUR VOUS', waiting: 'ATTEND VOTRE AVIS', elsewhere: 'Répondez dans la conversation.', request: 'LA DEMANDE', yourAnswer: 'Votre réponse', send: 'Envoyer', other: 'Autre…', discuss: 'En parler dans la conversation', ask: 'Demander dans la conversation' },\n} as const;\nconst preview = (value: string) => value.length > 160 ? `${value.slice(0, 160)}…` : value;\nconst sectionLabel = 'text-[11px] font-semibold tracking-[0.06em] text-muted-foreground';\nconst kinds = { markdown: ['Markdown', FileIcon], mockup: ['Mockup', AppWindowIcon], capture: ['Capture', ImageIcon] } as const;\nconst fileKind = (file: TaskFile) => file.kind ?? (/\\.(?:md|markdown)$/i.test(file.path) ? 'markdown' : /\\.html?$/i.test(file.path) ? 'mockup' : /\\.(?:png|jpe?g|gif|webp|svg)$/i.test(file.path) ? 'capture' : undefined);\n/** ISO times show as local HH:MM; host-formatted strings show as given. */\nconst clock = (value: string) => /^\\d{4}-\\d\\d-\\d\\dT/.test(value) && Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) : value;\n\nfunction AnsweredQuestion({ question }: { readonly question: TaskQuestion }) {\n const [expanded, setExpanded] = useState(false);\n return <li data-task-answered={question.id} className=\"min-w-0\">\n <Button variant=\"ghost\" className=\"grid h-auto w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] justify-normal gap-x-2 gap-y-0.5 text-start whitespace-normal\" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>\n <span className=\"truncate text-[13px] font-normal\">{preview(question.text)}</span>\n <ChevronRightIcon aria-hidden=\"true\" className=\"text-muted-foreground\" />\n <span data-task-answer={question.id} className=\"col-span-full truncate text-[13px] font-normal text-muted-foreground\">{question.answeredBy ? `${preview(question.answeredBy)}: ` : ''}{preview(question.answer ?? '')}</span>\n </Button>\n {expanded && <div data-task-answer-full className=\"flex flex-col gap-1 px-2 pb-2 text-[13px] break-words whitespace-pre-wrap\"><p>{question.text}</p><p className=\"text-muted-foreground\">{question.answeredBy && <>{question.answeredBy}: </>}{question.answer}</p></div>}\n </li>;\n}\n\nfunction OpenQuestion({ task, question, locale = 'en', onAnswer, onOther, onDiscussQuestion }: Pick<TaskDetailProps, 'task' | 'locale' | 'onAnswer' | 'onOther' | 'onDiscussQuestion'> & { readonly question: TaskQuestion }) {\n // Without options typing is the only way to answer, so the field is simply there.\n const typed = !question.options?.length;\n const [other, setOther] = useState<string | null>(typed && onOther ? '' : null);\n const words = detailWords[locale];\n const send = () => { const text = other?.trim(); if (text) { onOther?.(task, question, text); setOther(typed ? '' : null); } };\n const asked = [question.askedBy, question.askedAt && clock(question.askedAt)].filter(Boolean).join(' · ');\n return <TaskAttention active>\n <Item variant=\"outline\" className=\"flex-col items-stretch gap-2\" data-task-question={question.id}>\n <div className=\"flex items-baseline justify-between gap-2\"><span className={sectionLabel}>{words.question}</span>{asked && <span className=\"text-[11px] text-muted-foreground\">{asked}</span>}</div>\n <p className=\"text-sm\">{question.text}</p>\n <div className={`flex items-center gap-2 ${taskTokens.control}`}>\n {question.options?.map((option, index) => {\n const value = typeof option === 'string' ? option : option.value;\n const label = typeof option === 'string' ? option : option.label;\n return <Button key={index} variant={index === 0 ? 'default' : 'outline'} disabled={!onAnswer} onClick={() => onAnswer?.(task, question, value)}>{label}</Button>;\n })}\n {onOther && !typed && <Button variant=\"outline\" data-task-other={question.id} aria-expanded={other !== null} onClick={() => setOther(value => value === null ? '' : null)}>{words.other}</Button>}\n {onDiscussQuestion && <Button variant=\"link\" className=\"h-auto min-w-0 shrink text-center leading-tight whitespace-normal underline\" data-task-discuss={question.id} onClick={() => onDiscussQuestion(task, question)}>{words.discuss}</Button>}\n </div>\n {other !== null && <form className={`flex gap-2 ${taskTokens.control}`} data-task-other-form={question.id} onSubmit={event => { event.preventDefault(); send(); }}>\n <Input autoFocus={!typed} aria-label={words.yourAnswer} maxLength={4096} value={other} onChange={event => setOther(event.target.value)} onKeyDown={event => { if (event.key === 'Escape' && !typed) { event.preventDefault(); setOther(null); } }} />\n <Button type=\"submit\" disabled={!other.trim()}>{words.send}</Button>\n </form>}\n </Item>\n </TaskAttention>;\n}\n\n/** Task detail (spec 3 §4), in order: title, the open question for the viewer, Answered, Files, « Ask in the conversation ». */\nexport function TaskDetail({ task, locale = 'en', actions, onAnswer, onOther, onDiscussQuestion, onOpenFile, onAsk }: TaskDetailProps) {\n const [showAll, setShowAll] = useState(false);\n const open = task.waiting_on_you === true ? task.questions?.find(question => question.answer === undefined) : undefined;\n const words = detailWords[locale];\n const status = compactTaskStatus(task);\n // Nothing openable may say it needs someone without saying what: the projected reason shows when there is no full question.\n const reason = !open && (status === 'needs-you' || status === 'blocked') ? taskWaitingText(task) : undefined;\n const answered = task.questions?.filter(question => question.answer !== undefined).toReversed().sort((a, b) => (Date.parse(b.answeredAt ?? '') || 0) - (Date.parse(a.answeredAt ?? '') || 0)) ?? [];\n const meta = [task.screen && `Screen: ${task.screen}`, task.requester && `asked by ${task.requester}`, task.refiner && `refined by ${task.refiner}`].filter(Boolean).join(' · ');\n const folder = task.folder?.replace(/\\/?$/, '/');\n return <div className=\"flex min-w-0 flex-col gap-2.5\" data-task-detail={task.id}>\n <div className=\"rounded-[var(--radius-lg)] bg-card\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\" data-task-title-card=\"\">\n <h2 className=\"text-base font-semibold break-words\">{compactTaskTitle(task)}</h2>\n {compactTaskTitle(task) !== task.title.trim() && <p className=\"text-[13px] break-words text-muted-foreground\">{task.title}</p>}\n {meta && <p className=\"text-xs text-muted-foreground\">{meta}</p>}\n </Item></div>\n {actions}\n {open && <div className=\"rounded-[var(--radius-lg)] bg-card\"><OpenQuestion key={open.id} task={task} question={open} locale={locale} onAnswer={onAnswer} onOther={onOther} onDiscussQuestion={onDiscussQuestion} /></div>}\n {(reason || status === 'needs-you' && !open) && <div className=\"rounded-[var(--radius-lg)] bg-card\" data-task-reason=\"\"><TaskAttention active={status === 'needs-you'}><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\">\n <span className={sectionLabel}>{status === 'needs-you' ? words.waiting : compactLabels[locale].blocked}</span>\n {reason && <p className=\"text-sm break-words\">{reason}</p>}\n {status === 'needs-you' && <p className=\"text-[13px] text-muted-foreground\">{words.elsewhere}</p>}\n </Item></TaskAttention></div>}\n {task.spec && task.spec.trim() !== task.title.trim() && <section aria-label={words.request} className=\"rounded-[var(--radius-lg)] bg-card\" data-task-spec=\"\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\">\n <h3 className={sectionLabel}>{words.request}</h3>\n <p className=\"text-sm break-words whitespace-pre-wrap\">{task.spec}</p>\n </Item></section>}\n {!!answered.length && <section aria-label=\"Answered questions\" className=\"rounded-[var(--radius-lg)] bg-card\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-0\">\n <div className=\"flex items-baseline justify-between gap-2 pl-2.5\"><h3 className={sectionLabel}>ANSWERED · {answered.length}</h3>\n {answered.length > 3 && <Button variant=\"link\" size=\"xs\" className=\"underline\" data-task-show-answers aria-expanded={showAll} onClick={() => setShowAll(value => !value)}>{showAll ? 'Show latest' : 'Show all'}</Button>}</div>\n <ul className=\"flex min-w-0 flex-col\">{(showAll ? answered : answered.slice(0, 3)).map(question => <AnsweredQuestion key={question.id} question={question} />)}</ul>\n </Item></section>}\n {!!task.files?.length && <section aria-label=\"Task files\" className=\"rounded-[var(--radius-lg)] bg-card\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-0.5\">\n <h3 className={`${sectionLabel} px-2.5 pb-1`}>FILES{folder && ` · ${folder}`}</h3>\n {task.files.map(file => {\n const kind = fileKind(file), [label, Icon] = kind ? kinds[kind] : ['', FileIcon];\n const name = file.name ?? (folder && file.path.startsWith(folder) ? file.path.slice(folder.length) : file.path.split('/').pop());\n const content = <><Icon aria-hidden=\"true\" /><span className=\"min-w-0 flex-1 truncate text-start text-[13px] font-normal\">{name}</span>{label && <span className=\"text-[11px] font-normal text-muted-foreground\">{label}</span>}</>;\n return onOpenFile ? <Button key={file.path} variant=\"ghost\" className=\"w-full justify-start\" data-task-file={file.path} onClick={() => onOpenFile(task, file)}>{content}</Button> : <p key={file.path} className=\"flex items-center gap-2\">{content}</p>;\n })}\n </Item></section>}\n {onAsk && <Button variant=\"link\" className=\"self-start underline\" data-task-ask={task.id} onClick={() => onAsk(task)}>{words.ask}</Button>}\n </div>;\n}\n",
|
|
35
|
+
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { AppWindowIcon, ChevronRightIcon, FileIcon, ImageIcon } from 'lucide-react';\nimport type { TaskFile, TaskQuestion, TaskRecord } from '@hachej/boring-ui-kit/task-board';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Item } from '@/components/ui/item';\nimport { TaskAttention, taskTokens } from './task-request-card';\nimport type { TaskLocale } from './task-request-card';\nimport { compactLabels, compactTaskStatus, compactTaskTitle, taskWaitingText } from './compact-presentation';\n\nexport interface TaskDetailProps {\n readonly task: TaskRecord;\n readonly locale?: TaskLocale | undefined;\n /** Host content after the question and the request (for example a mount report or host-authorized request actions). */\n readonly actions?: ReactNode;\n /** The host reauthorizes and resolves the original question. Rendering never supplies approval. */\n readonly onAnswer?: ((task: TaskRecord, question: TaskQuestion, value: string) => void) | undefined;\n /** « Other… » opens an inline field; Send passes the typed text. No synthetic option value or approval is generated. */\n readonly onOther?: ((task: TaskRecord, question: TaskQuestion, text: string) => void) | undefined;\n /** « Discuss in chat »: the host stages `@ D-201 · <title>` and the question in its one composer, without sending. */\n readonly onDiscussQuestion?: ((task: TaskRecord, question: TaskQuestion) => void) | undefined;\n readonly onOpenFile?: ((task: TaskRecord, file: TaskFile) => void) | undefined;\n /** « Ask in the conversation »: the host focuses its composer with the task mention. */\n readonly onAsk?: ((task: TaskRecord) => void) | undefined;\n /** @deprecated Ignored; kept for source compatibility. */\n readonly labels?: unknown;\n}\nconst detailWords = {\n en: { question: 'QUESTION FOR YOU', waiting: 'WAITING ON YOU', elsewhere: 'Reply in the conversation.', request: 'THE REQUEST', yourAnswer: 'Your answer', send: 'Send', other: 'Other…', discuss: 'Discuss in chat', ask: 'Ask in the conversation' },\n fr: { question: 'QUESTION POUR VOUS', waiting: 'ATTEND VOTRE AVIS', elsewhere: 'Répondez dans la conversation.', request: 'LA DEMANDE', yourAnswer: 'Votre réponse', send: 'Envoyer', other: 'Autre…', discuss: 'En parler dans la conversation', ask: 'Demander dans la conversation' },\n} as const;\nconst preview = (value: string) => value.length > 160 ? `${value.slice(0, 160)}…` : value;\nconst sectionLabel = 'text-[11px] font-semibold tracking-[0.06em] text-muted-foreground';\nconst kinds = { markdown: ['Markdown', FileIcon], mockup: ['Mockup', AppWindowIcon], capture: ['Capture', ImageIcon] } as const;\nconst fileKind = (file: TaskFile) => file.kind ?? (/\\.(?:md|markdown)$/i.test(file.path) ? 'markdown' : /\\.html?$/i.test(file.path) ? 'mockup' : /\\.(?:png|jpe?g|gif|webp|svg)$/i.test(file.path) ? 'capture' : undefined);\n/** ISO times show as local HH:MM; host-formatted strings show as given. */\nconst clock = (value: string) => /^\\d{4}-\\d\\d-\\d\\dT/.test(value) && Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) : value;\n\nfunction AnsweredQuestion({ question }: { readonly question: TaskQuestion }) {\n const [expanded, setExpanded] = useState(false);\n return <li data-task-answered={question.id} className=\"min-w-0\">\n <Button variant=\"ghost\" className=\"grid h-auto w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] justify-normal gap-x-2 gap-y-0.5 text-start whitespace-normal\" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>\n <span className=\"truncate text-[13px] font-normal\">{preview(question.text)}</span>\n <ChevronRightIcon aria-hidden=\"true\" className=\"text-muted-foreground\" />\n <span data-task-answer={question.id} className=\"col-span-full truncate text-[13px] font-normal text-muted-foreground\">{question.answeredBy ? `${preview(question.answeredBy)}: ` : ''}{preview(question.answer ?? '')}</span>\n </Button>\n {expanded && <div data-task-answer-full className=\"flex flex-col gap-1 px-2 pb-2 text-[13px] break-words whitespace-pre-wrap\"><p>{question.text}</p><p className=\"text-muted-foreground\">{question.answeredBy && <>{question.answeredBy}: </>}{question.answer}</p></div>}\n </li>;\n}\n\nfunction OpenQuestion({ task, question, locale = 'en', onAnswer, onOther, onDiscussQuestion }: Pick<TaskDetailProps, 'task' | 'locale' | 'onAnswer' | 'onOther' | 'onDiscussQuestion'> & { readonly question: TaskQuestion }) {\n // Without options typing is the only way to answer, so the field is simply there.\n const typed = !question.options?.length;\n const [other, setOther] = useState<string | null>(typed && onOther ? '' : null);\n const words = detailWords[locale];\n const send = () => { const text = other?.trim(); if (text) { onOther?.(task, question, text); setOther(typed ? '' : null); } };\n const asked = [question.askedBy, question.askedAt && clock(question.askedAt)].filter(Boolean).join(' · ');\n return <TaskAttention active>\n <Item variant=\"outline\" className=\"flex-col items-stretch gap-2\" data-task-question={question.id}>\n <div className=\"flex items-baseline justify-between gap-2\"><span className={sectionLabel}>{words.question}</span>{asked && <span className=\"text-[11px] text-muted-foreground\">{asked}</span>}</div>\n <p className=\"text-sm\">{question.text}</p>\n <div className={`flex items-center gap-2 ${taskTokens.control}`}>\n {question.options?.map((option, index) => {\n const value = typeof option === 'string' ? option : option.value;\n const label = typeof option === 'string' ? option : option.label;\n return <Button key={index} variant={index === 0 ? 'default' : 'outline'} disabled={!onAnswer} onClick={() => onAnswer?.(task, question, value)}>{label}</Button>;\n })}\n {onOther && !typed && <Button variant=\"outline\" data-task-other={question.id} aria-expanded={other !== null} onClick={() => setOther(value => value === null ? '' : null)}>{words.other}</Button>}\n {onDiscussQuestion && <Button variant=\"link\" className=\"h-auto min-w-0 shrink text-center leading-tight whitespace-normal underline\" data-task-discuss={question.id} onClick={() => onDiscussQuestion(task, question)}>{words.discuss}</Button>}\n </div>\n {other !== null && <form className={`flex gap-2 ${taskTokens.control}`} data-task-other-form={question.id} onSubmit={event => { event.preventDefault(); send(); }}>\n <Input autoFocus={!typed} aria-label={words.yourAnswer} maxLength={4096} value={other} onChange={event => setOther(event.target.value)} onKeyDown={event => { if (event.key === 'Escape' && !typed) { event.preventDefault(); setOther(null); } }} />\n <Button type=\"submit\" disabled={!other.trim()}>{words.send}</Button>\n </form>}\n </Item>\n </TaskAttention>;\n}\n\n/** Task detail (spec 3 §4), in order: title, the open question for the viewer, Answered, Files, « Ask in the conversation ». */\nexport function TaskDetail({ task, locale = 'en', actions, onAnswer, onOther, onDiscussQuestion, onOpenFile, onAsk }: TaskDetailProps) {\n const [showAll, setShowAll] = useState(false);\n const open = task.waiting_on_you === true ? task.questions?.find(question => question.answer === undefined) : undefined;\n const words = detailWords[locale];\n const status = compactTaskStatus(task);\n // Nothing openable may say it needs someone without saying what: the projected reason shows when there is no full question.\n const reason = !open && (status === 'needs-you' || status === 'blocked') ? taskWaitingText(task) : undefined;\n const answered = task.questions?.filter(question => question.answer !== undefined).toReversed().sort((a, b) => (Date.parse(b.answeredAt ?? '') || 0) - (Date.parse(a.answeredAt ?? '') || 0)) ?? [];\n const meta = [task.screen && `Screen: ${task.screen}`, task.requester && `asked by ${task.requester}`, task.refiner && `refined by ${task.refiner}`].filter(Boolean).join(' · ');\n const folder = task.folder?.replace(/\\/?$/, '/');\n return <div className=\"flex min-w-0 flex-col gap-2.5\" data-task-detail={task.id}>\n <div className=\"rounded-[var(--radius-lg)] bg-card\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\" data-task-title-card=\"\">\n <h2 className=\"text-base font-semibold break-words\">{compactTaskTitle(task)}</h2>\n {compactTaskTitle(task) !== task.title.trim() && <p className=\"text-[13px] break-words text-muted-foreground\">{task.title}</p>}\n {meta && <p className=\"text-xs text-muted-foreground\">{meta}</p>}\n </Item></div>\n {open && <div className=\"rounded-[var(--radius-lg)] bg-card\"><OpenQuestion key={open.id} task={task} question={open} locale={locale} onAnswer={onAnswer} onOther={onOther} onDiscussQuestion={onDiscussQuestion} /></div>}\n {(reason || status === 'needs-you' && !open) && <div className=\"rounded-[var(--radius-lg)] bg-card\" data-task-reason=\"\"><TaskAttention active={status === 'needs-you'}><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\">\n <span className={sectionLabel}>{status === 'needs-you' ? words.waiting : compactLabels[locale].blocked}</span>\n {reason && <p className=\"text-sm break-words\">{reason}</p>}\n {status === 'needs-you' && <p className=\"text-[13px] text-muted-foreground\">{words.elsewhere}</p>}\n </Item></TaskAttention></div>}\n {task.spec && task.spec.trim() !== task.title.trim() && <section aria-label={words.request} className=\"rounded-[var(--radius-lg)] bg-card\" data-task-spec=\"\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\">\n <h3 className={sectionLabel}>{words.request}</h3>\n <p className=\"text-sm break-words whitespace-pre-wrap\">{task.spec}</p>\n </Item></section>}\n {actions}\n {!!answered.length && <section aria-label=\"Answered questions\" className=\"rounded-[var(--radius-lg)] bg-card\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-0\">\n <div className=\"flex items-baseline justify-between gap-2 pl-2.5\"><h3 className={sectionLabel}>ANSWERED · {answered.length}</h3>\n {answered.length > 3 && <Button variant=\"link\" size=\"xs\" className=\"underline\" data-task-show-answers aria-expanded={showAll} onClick={() => setShowAll(value => !value)}>{showAll ? 'Show latest' : 'Show all'}</Button>}</div>\n <ul className=\"flex min-w-0 flex-col\">{(showAll ? answered : answered.slice(0, 3)).map(question => <AnsweredQuestion key={question.id} question={question} />)}</ul>\n </Item></section>}\n {!!task.files?.length && <section aria-label=\"Task files\" className=\"rounded-[var(--radius-lg)] bg-card\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-0.5\">\n <h3 className={`${sectionLabel} px-2.5 pb-1`}>FILES{folder && ` · ${folder}`}</h3>\n {task.files.map(file => {\n const kind = fileKind(file), [label, Icon] = kind ? kinds[kind] : ['', FileIcon];\n const name = file.name ?? (folder && file.path.startsWith(folder) ? file.path.slice(folder.length) : file.path.split('/').pop());\n const content = <><Icon aria-hidden=\"true\" /><span className=\"min-w-0 flex-1 truncate text-start text-[13px] font-normal\">{name}</span>{label && <span className=\"text-[11px] font-normal text-muted-foreground\">{label}</span>}</>;\n return onOpenFile ? <Button key={file.path} variant=\"ghost\" className=\"w-full justify-start\" data-task-file={file.path} onClick={() => onOpenFile(task, file)}>{content}</Button> : <p key={file.path} className=\"flex items-center gap-2\">{content}</p>;\n })}\n </Item></section>}\n {!!task.history?.length && <section aria-label=\"Task history\" className=\"rounded-[var(--radius-lg)] bg-card\" data-task-history=\"\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\">\n <h3 className={sectionLabel}>HISTORY</h3>\n {task.history.map((item, index) => <p key={item.id ?? index} className=\"text-[13px] break-words text-muted-foreground\">{item.note}</p>)}\n </Item></section>}\n {onAsk && <Button variant=\"link\" className=\"self-start underline\" data-task-ask={task.id} onClick={() => onAsk(task)}>{words.ask}</Button>}\n </div>;\n}\n",
|
|
36
36
|
"type": "registry:component",
|
|
37
37
|
"target": "components/task-board/task-detail.tsx"
|
|
38
38
|
}
|
package/r/task-list-viewer.json
CHANGED
|
@@ -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
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.43",
|
|
8
|
+
"@hachej/boring-files@^0.4.43",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"zod@^4.6.5"
|
package/r/task-mentions.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.43",
|
|
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.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/task-board.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat-mentions.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
package/r/utils.json
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
"react@^19.0.0"
|
|
10
10
|
],
|
|
11
11
|
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/utils/back.ts",
|
|
14
|
+
"content": "// The one Back manager of the app shell. A layer that is open (the agent sheet, another sheet, point mode, a pushed screen) asks for one\n// history entry with the same URL; the browser's Back closes the topmost layer instead of leaving the page, and closing a layer any\n// other way removes its entry, so the history stays as it was. Hosts: the AgentLayer (order `agent`) and, later, sheets and stacks.\nimport { useEffect, useLayoutEffect, useRef } from 'react';\n\n/** Local Back priority: pointing, artifact view, sheet, assistant/PM, pushed screen, then browser. */\nexport type BackOrder = 'point' | 'artifact' | 'sheet' | 'agent' | 'stack';\nconst RANK: Readonly<Record<BackOrder, number>> = { point: 0, artifact: 1, sheet: 2, agent: 3, stack: 4 };\n\ninterface Layer { readonly order: BackOrder; readonly close: () => unknown; popped: boolean }\n\nconst layers = new Set<Layer>();\n/** `popstate` events caused by our own `history.back()`, which must not close anything. */\nlet ignored = 0;\nlet listening = false;\n/** The layer kind the page was loaded on top of (history state survives a reload), until a layer adopts it. */\nlet persisted: unknown = typeof window === 'undefined' || !window.history ? undefined : (window.history.state as { appShell?: unknown } | null)?.appShell;\n/** Layers that opened while our own `history.back()` was still pending: they push their entry once it has landed (a push in between cancels it). */\nconst waiting: (() => void)[] = [];\n\n/**\n * Runs `action` once the history entry of a sheet that is closing has really gone. A choice that navigates (the app's router replaces the\n * URL) must not run before: the sheet's own `history.back()` would undo it. Call it in the same handler that closes the sheet.\n */\nexport function afterBack(action: () => void): void {\n // The sheet's effect cleanup (which starts its `history.back()`) runs after this handler returns.\n setTimeout(() => { if (ignored > 0) waiting.push(action); else action(); }, 0);\n}\n\n/**\n * Runs `action` after the open stack layers (a ListDetail's pushed detail) have gone back. A nav choice that replaces the URL must not\n * run before: the stack layer's cleanup `history.back()` would restore the detail's URL over it. The layers are released here, not in\n * their cleanup (which only runs once the choice has hidden the screen, too late), and their cleanup then does nothing.\n */\nexport function afterStacksBack(action: () => void): void {\n const stacks = [...layers].filter(layer => layer.order === 'stack' && !layer.popped);\n if (!stacks.length) { action(); return; }\n waiting.push(action);\n for (const layer of stacks) { layer.popped = true; layers.delete(layer); ignored += 1; window.history.back(); }\n}\n\nconst topmost = (): Layer | undefined => [...layers].toSorted((a, b) => RANK[a.order] - RANK[b.order])[0];\n\nfunction onPopState(): void {\n if (ignored > 0) {\n ignored -= 1;\n if (ignored === 0) for (const start of waiting.splice(0)) start();\n return;\n }\n const top = topmost();\n if (!top) return;\n // The entry is already gone: the layer closes without calling `history.back()` again.\n if (top.popped) return;\n top.popped = true;\n const restore = () => {\n if (!layers.has(top) || !top.popped) return;\n top.popped = false;\n window.history.pushState({ appShell: top.order }, '');\n };\n try { void Promise.resolve(top.close()).then(closed => { if (closed === false) restore(); }, restore); }\n catch { restore(); }\n}\n\n/**\n * While `open`, the layer owns one history entry (`history.pushState({ appShell: order }, '')`, same URL). `popstate` closes only the\n * topmost open layer. Any other close (the effect ends because `open` became false or the component unmounted) calls `history.back()`\n * once, and that `popstate` is ignored. `close` may change between renders.\n */\nexport function useBackLayer(open: boolean, close: () => unknown, order: BackOrder): void {\n const latest = useRef(close);\n useLayoutEffect(() => { latest.current = close; });\n useEffect(() => {\n if (!open) return;\n const layer: Layer = { order, close: () => latest.current(), popped: false };\n let started = false;\n const start = () => {\n started = true;\n // After a reload the page still sits on the entry its layer pushed before: that one layer adopts it instead of stacking a second.\n // An artifact sheet pushes its own: its entry can be stale by then, and closing an adopted one could leave the page.\n const adopt = persisted === order && order !== 'artifact';\n persisted = undefined;\n if (!adopt) window.history.pushState({ appShell: order }, '');\n layers.add(layer);\n };\n if (!listening) { window.addEventListener('popstate', onPopState); listening = true; }\n // A sheet that closes as this layer opens (the account menu choosing « Améliorer l'app ») is still going back: wait for it.\n if (ignored > 0) waiting.push(start); else start();\n return () => {\n const at = waiting.indexOf(start);\n if (at >= 0) waiting.splice(at, 1);\n if (!started) return;\n layers.delete(layer);\n if (!layer.popped) { ignored += 1; window.history.back(); }\n };\n }, [open, order]);\n}\n",
|
|
15
|
+
"type": "registry:lib",
|
|
16
|
+
"target": "components/utils/back.ts"
|
|
17
|
+
},
|
|
12
18
|
{
|
|
13
19
|
"path": "registry/utils/use-draggable.ts",
|
|
14
20
|
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { PointerEvent, RefObject } from 'react';\n\nexport interface DragPosition { readonly left: number; readonly top: number }\n\n/** Pointer-capture drag shared with the agent layer. Controls remain clickable and the whole surface stays reachable. */\nexport function useDraggable({ box, position, onPositionChange, enabled }: {\n readonly box: RefObject<HTMLElement | null>;\n readonly position: DragPosition | null;\n readonly onPositionChange: (position: DragPosition) => void;\n readonly enabled: boolean;\n}) {\n const drag = useRef<{ dx: number; dy: number } | null>(null);\n const [viewport, setViewport] = useState(0);\n useEffect(() => { const resize = () => setViewport(count => count + 1); window.addEventListener('resize', resize); return () => window.removeEventListener('resize', resize); }, []);\n useLayoutEffect(() => {\n const rect = box.current?.getBoundingClientRect();\n if (!position || !enabled || !rect || !rect.width) return;\n const left = Math.min(Math.max(position.left, 0), Math.max(0, window.innerWidth - rect.width)), top = Math.min(Math.max(position.top, 0), Math.max(0, window.innerHeight - rect.height));\n if (left !== position.left || top !== position.top) onPositionChange({ left, top });\n }, [box, position, enabled, viewport, onPositionChange]);\n const onPointerDown = (event: PointerEvent<HTMLElement>) => {\n const rect = box.current?.getBoundingClientRect();\n if (!enabled || !rect || event.button !== 0 || (event.target as HTMLElement).closest('button, a[href]')) return;\n drag.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n };\n const onPointerMove = (event: PointerEvent<HTMLElement>) => {\n const grab = drag.current, rect = box.current?.getBoundingClientRect();\n if (!grab || !rect) return;\n onPositionChange({ left: Math.min(Math.max(event.clientX - grab.dx, 0), Math.max(0, window.innerWidth - rect.width)), top: Math.min(Math.max(event.clientY - grab.dy, 0), Math.max(0, window.innerHeight - rect.height)) });\n };\n const end = () => { drag.current = null; };\n return { onPointerDown, onPointerMove, onPointerUp: end, onPointerCancel: end };\n}\n",
|
package/r/version-history.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-files@^0.4.43",
|
|
8
8
|
"react@^19.0.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/button.json"
|
|
12
12
|
],
|
|
13
13
|
"files": [
|
|
14
14
|
{
|
package/r/viewer-code.json
CHANGED
|
@@ -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.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-frame.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/pi-chat.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
package/r/viewer-frame.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.43",
|
|
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.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/utils.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/button.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/version-history.json"
|
|
16
16
|
],
|
|
17
17
|
"files": [
|
|
18
18
|
{
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"path": "registry/viewer-frame/media.ts",
|
|
32
|
-
"content": "import { useEffect, useMemo, useState } from 'react';\n\n/** What a read-only media viewer shows: bytes or a Blob (the viewer owns the object URL it makes) or a URL the host owns. */\nexport interface MediaSource {\n readonly bytes?: Uint8Array | ArrayBuffer;\n readonly blob?: Blob;\n readonly url?: string;\n}\n\n/** A Blob for the source, or `undefined` when the host gave a URL (or nothing). */\nexport function mediaBlob(source: MediaSource, mediaType: string): Blob | undefined {\n if (source.blob) return source.blob;\n if (source.bytes) return new Blob([source.bytes as BlobPart], { type: mediaType });\n return undefined;\n}\n\n/** An object URL for the source, revoked when the source changes or the viewer unmounts. A host `url` is passed through untouched. */\nexport function useMediaUrl(source: MediaSource, mediaType: string): { url: string | undefined; size: number | undefined; blob: Blob | undefined } {\n const { blob: given, bytes } = source;\n const blob = useMemo(() => mediaBlob({ blob: given, bytes }, mediaType), [given, bytes, mediaType]);\n // The object URL of the current blob only: a URL made for an earlier blob is never returned, and is revoked with it.\n const [made, setMade] = useState<{ readonly blob: Blob; readonly url: string }>();\n useEffect(() => {\n if (!blob) return undefined;\n const url = URL.createObjectURL(blob);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the object URL is an external resource owned by this effect run; it exists only after commit and is revoked in the cleanup\n setMade({ blob, url });\n return () => { URL.revokeObjectURL(url); setMade(current => current?.url === url ? undefined : current); };\n }, [blob]);\n return { url: blob ? (made?.blob === blob ? made.url : undefined) : source.url, size: blob?.size, blob };\n}\n\nexport function formatBytes(size: number): string {\n if (size < 1024) return `${size} B`;\n if (size < 1024 * 1024) return `${(size / 1024).toFixed(size < 10240 ? 1 : 0)} KB`;\n return `${(size / 1024 / 1024).toFixed(1)} MB`;\n}\n",
|
|
32
|
+
"content": "import { useEffect, useMemo, useState } from 'react';\n\n/** What a read-only media viewer shows: bytes or a Blob (the viewer owns the object URL it makes) or a URL the host owns. */\nexport interface MediaSource {\n readonly bytes?: Uint8Array | ArrayBuffer | undefined;\n readonly blob?: Blob | undefined;\n readonly url?: string | undefined;\n}\n\n/** A Blob for the source, or `undefined` when the host gave a URL (or nothing). */\nexport function mediaBlob(source: MediaSource, mediaType: string): Blob | undefined {\n if (source.blob) return source.blob;\n if (source.bytes) return new Blob([source.bytes as BlobPart], { type: mediaType });\n return undefined;\n}\n\n/** An object URL for the source, revoked when the source changes or the viewer unmounts. A host `url` is passed through untouched. */\nexport function useMediaUrl(source: MediaSource, mediaType: string): { url: string | undefined; size: number | undefined; blob: Blob | undefined } {\n const { blob: given, bytes } = source;\n const blob = useMemo(() => mediaBlob({ blob: given, bytes }, mediaType), [given, bytes, mediaType]);\n // The object URL of the current blob only: a URL made for an earlier blob is never returned, and is revoked with it.\n const [made, setMade] = useState<{ readonly blob: Blob; readonly url: string }>();\n useEffect(() => {\n if (!blob) return undefined;\n const url = URL.createObjectURL(blob);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the object URL is an external resource owned by this effect run; it exists only after commit and is revoked in the cleanup\n setMade({ blob, url });\n return () => { URL.revokeObjectURL(url); setMade(current => current?.url === url ? undefined : current); };\n }, [blob]);\n return { url: blob ? (made?.blob === blob ? made.url : undefined) : source.url, size: blob?.size, blob };\n}\n\nexport function formatBytes(size: number): string {\n if (size < 1024) return `${size} B`;\n if (size < 1024 * 1024) return `${(size / 1024).toFixed(size < 10240 ? 1 : 0)} KB`;\n return `${(size / 1024 / 1024).toFixed(1)} MB`;\n}\n",
|
|
33
33
|
"type": "registry:lib",
|
|
34
34
|
"target": "components/viewer-frame/media.ts"
|
|
35
35
|
},
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
"path": "registry/viewer-frame/viewer-frame.tsx",
|
|
50
|
-
"content": "'use client';\n\nimport { useMedia, usePhone } from '../utils/use-media';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[];\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onCopy?: () => unknown;\n readonly onDownload?: () => unknown;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string;\n readonly titleTestId?: string;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean;\n readonly className?: string;\n readonly children?: ReactNode;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const phone = usePhone(), coarse = useMedia('(pointer: coarse)');\n const tightWidth = phone || coarse ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer;\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < tightWidth };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader, tightWidth]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n // A result after unmount updates nothing: React ignores a state update on an unmounted component.\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); return undefined; }\n finally { setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => setCopied(false), 1500); })\n .catch(error => { if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => setNotice({ text: labels.actionFailed, tone: 'danger' })); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-md:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"md:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden md:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-md:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 md:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-md:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 md:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
|
|
50
|
+
"content": "'use client';\n\nimport { useMedia, usePhone } from '../utils/use-media';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode | undefined;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown | undefined;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode | undefined;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode | undefined;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode | undefined;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[] | undefined;\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode | undefined;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: (() => unknown) | undefined;\n readonly onShare?: ViewerShare | undefined;\n readonly onCopy?: (() => unknown) | undefined;\n readonly onDownload?: (() => unknown) | undefined;\n readonly onOpenInNewTab?: (() => unknown) | undefined;\n readonly onClose?: (() => unknown) | undefined;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string | undefined;\n readonly titleTestId?: string | undefined;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean | undefined;\n readonly className?: string | undefined;\n readonly children?: ReactNode | undefined;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const phone = usePhone(), coarse = useMedia('(pointer: coarse)');\n const tightWidth = phone || coarse ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer;\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < tightWidth };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader, tightWidth]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n // A result after unmount updates nothing: React ignores a state update on an unmounted component.\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); return undefined; }\n finally { setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => setCopied(false), 1500); })\n .catch(error => { if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => setNotice({ text: labels.actionFailed, tone: 'danger' })); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-md:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"md:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden md:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-md:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 md:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-md:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 md:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
|
|
51
51
|
"type": "registry:component",
|
|
52
52
|
"target": "components/viewer-frame/viewer-frame.tsx"
|
|
53
53
|
},
|
package/r/viewer-html.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.43",
|
|
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.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-frame.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/utils.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/button.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
package/r/viewer-image.json
CHANGED
|
@@ -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.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-frame.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/utils.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
package/r/viewer-markdown.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.43",
|
|
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.
|
|
26
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
27
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
28
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
25
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-frame.json",
|
|
26
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/markdown-editor.json",
|
|
27
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/utils.json",
|
|
28
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/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.
|
|
7
|
+
"@hachej/boring-files@^0.4.43",
|
|
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.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-frame.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/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.
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
10
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
7
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-frame.json",
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-markdown.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-html.json",
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-image.json",
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-pdf.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.43/r/viewer-code.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [],
|
|
15
15
|
"type": "registry:component"
|
package/r/windows.json
CHANGED