@oprt/manifest 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0",
2
+ "version": "0.3.0",
3
3
  "components": {
4
4
  "ActionStatus": {
5
5
  "description": "Stateless authoritative action outcome display with optional application-supported recovery callbacks. When the focused recovery is withdrawn or disabled, focus moves to the result, a group named by its label. Long text wraps.",
@@ -1322,7 +1322,7 @@
1322
1322
  "label": {
1323
1323
  "type": "string",
1324
1324
  "required": true,
1325
- "description": "Accessible action name identifying the value. Feedback reads this label, then the outcome: \"Copy equipment ID: copied\"."
1325
+ "description": "Accessible action name identifying the value. It must contain \"Copy\", the word the button shows. Feedback reads this label, then the outcome: \"Copy equipment ID: copied\"."
1326
1326
  },
1327
1327
  "size": {
1328
1328
  "type": "enum",
@@ -4487,7 +4487,7 @@
4487
4487
  "example": "<ToolbarSeparator />"
4488
4488
  },
4489
4489
  "Tooltip": {
4490
- "description": "Supplementary text on hover or keyboard focus of a trigger, wrapping at about 20rem. A disabled Button trigger stays focusable and announced unavailable, so focus or a tap shows the explanation. Built on React Aria Components (TooltipTrigger + Tooltip), portal'd, with a bordered surface.",
4490
+ "description": "Supplementary text on hover or keyboard focus of a trigger, wrapping at about 20rem. The pointer can move onto the tooltip without closing it; it closes 150 ms after the pointer has left both. A disabled Button trigger stays focusable and announced unavailable, so focus or a tap shows the explanation. Built on React Aria Components (TooltipTrigger + Tooltip), portal'd, with a bordered surface.",
4491
4491
  "props": {
4492
4492
  "side": {
4493
4493
  "type": "enum",
@@ -4750,11 +4750,11 @@
4750
4750
  "layout": "import { CommunicationsConsoleDemo } from \"./patterns/communications-console\";\n\n<CommunicationsConsoleDemo />",
4751
4751
  "files": {
4752
4752
  "patterns/communications-console/index.ts": "export { CommunicationsConsole } from \"./communications-console\";\nexport { CommunicationsConsoleDemo } from \"./demo\";\nexport { createCommunicationsSnapshot } from \"./fixtures\";\nexport { applyCommunicationsAction } from \"./communications-state\";\nexport type {\n CommunicationsPerson,\n CommunicationsChannel,\n CommunicationsMessage,\n CommunicationsSnapshot,\n CommunicationsActionRequest,\n CommunicationsActionResult,\n CommunicationsConsoleProps,\n} from \"./types\";\n",
4753
- "patterns/communications-console/communications-console.tsx": "\"use client\";\n\nimport { Fragment, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { Badge, Button, Checkbox, EntityPreview, Field, Textarea } from \"@oprt/react\";\nimport { formatUtcInstant } from \"../operational-domain\";\nimport type {\n CommunicationsActionRequest,\n CommunicationsActionResult,\n CommunicationsConsoleProps,\n CommunicationsMessage,\n} from \"./types\";\n\ntype Draft = { body: string; acknowledgementRequested: boolean };\nconst emptyDraft: Draft = { body: \"\", acknowledgementRequested: false };\nconst focus =\n \"outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-oprt-border-focus\";\n\n/** \"HH:MM\" in UTC. Kept for callers of the earlier helper; the console itself shows zone and date. */\nexport function communicationsTime(at: number) {\n return new Date(at).toISOString().slice(11, 16);\n}\n\nconst isoOf = (at: number) => new Date(at).toISOString();\nconst dayOf = (at: number) => isoOf(at).slice(0, 10);\n/** \"HH:MM:SS UTC\" for a message, \"HH:MM UTC\" where the room is tight. */\nconst clock = (at: number, seconds: boolean) => `${isoOf(at).slice(11, seconds ? 19 : 16)} UTC`;\n/** \"YYYY-MM-DD HH:MM:SS UTC\". Whole seconds: the console does not show sub-second timing. */\nconst stamp = (at: number) => formatUtcInstant(isoOf(at), { milliseconds: \"never\" });\n/** One line of room for the end of a list, in pixels: closer than this counts as at the end. */\nconst END_SLACK = 24;\n\n/** A time with its date and zone; a narrow column breaks between the date and the time, never inside either. */\nfunction Stamp({ at, className }: { at: number; className?: string }) {\n const text = stamp(at);\n return (\n <time dateTime={isoOf(at)} {...(className ? { className } : {})}>\n <span className=\"inline-block\">{text.slice(0, 10)}</span>{\" \"}\n <span className=\"inline-block\">{text.slice(11)}</span>\n </time>\n );\n}\n\n/** Channel history is authoritative; local state holds drafts and request feedback only. */\nexport function CommunicationsConsole(props: CommunicationsConsoleProps) {\n return <ConsoleWorkspace key={props.snapshot.id} {...props} />;\n}\n\nfunction ConsoleWorkspace({\n snapshot,\n selectedChannelId,\n onSelectedChannelChange,\n onAction,\n}: CommunicationsConsoleProps) {\n const instanceId = useId();\n const [drafts, setDrafts] = useState<Record<string, Draft>>({});\n const [pending, setPending] = useState<Record<string, string | undefined>>({});\n const [feedback, setFeedback] = useState<Record<string, CommunicationsActionResult | undefined>>(\n {},\n );\n const inFlight = useRef(new Set<string>());\n const attempts = useRef(new Map<string, string>());\n const sequence = useRef(0);\n const conversation = useRef<HTMLElement>(null);\n const composer = useRef<HTMLTextAreaElement>(null);\n const messageList = useRef<HTMLOListElement>(null);\n const rail = useRef<HTMLUListElement>(null);\n // Where the message list was last left: at the newest message or not, and how many it held.\n const nearEnd = useRef(true);\n const listState = useRef<{ channelId: string | null; count: number }>({\n channelId: null,\n count: 0,\n });\n const [unseen, setUnseen] = useState(0);\n const selection = useRef(selectedChannelId);\n const selectionFocus = useRef<string | null>(null);\n const latestRequested = useRef(false);\n const returnFocus = useRef<{\n channelId: string;\n origin: Element | null;\n target: \"composer\" | \"conversation\";\n } | null>(null);\n const channel = snapshot.channels.find((item) => item.id === selectedChannelId);\n const connected = snapshot.connection === \"connected\";\n const draft = channel ? (drafts[channel.id] ?? emptyDraft) : emptyDraft;\n const channelPending = channel ? pending[channel.id] : undefined;\n const result = channel ? feedback[channel.id] : undefined;\n const messages = snapshot.messages\n .filter((message) => message.channelId === channel?.id)\n .sort((left, right) => left.sentAt - right.sentAt);\n const needsAcknowledgement = (message: CommunicationsMessage) =>\n message.sender.id !== snapshot.operator.id &&\n message.delivery === \"delivered\" &&\n message.acknowledgementRequested &&\n !message.acknowledgement;\n const awaitingOperator = messages.filter(needsAcknowledgement).length;\n\n const channelId = channel?.id ?? null;\n const messageCount = messages.length;\n useLayoutEffect(() => {\n selection.current = selectedChannelId;\n if (selectionFocus.current !== null && selectionFocus.current === selectedChannelId) {\n conversation.current?.focus();\n selectionFocus.current = null;\n }\n const list = messageList.current;\n const wantsLatest = latestRequested.current;\n latestRequested.current = false;\n const known = listState.current;\n if (known.channelId !== channelId) {\n // A channel opens at its newest message, where the operator's attention is needed.\n listState.current = { channelId, count: messageCount };\n nearEnd.current = true;\n if (list) list.scrollTop = list.scrollHeight;\n setUnseen(0);\n } else {\n const added = messageCount - known.count;\n known.count = messageCount;\n if (list && (wantsLatest || (added > 0 && nearEnd.current))) {\n list.scrollTop = list.scrollHeight;\n nearEnd.current = true;\n setUnseen(0);\n } else if (added > 0) {\n // The operator is reading earlier messages: do not move the list under them, say so.\n setUnseen((count) => count + added);\n }\n }\n const requested = returnFocus.current;\n if (requested && !pending[requested.channelId]) {\n returnFocus.current = null;\n // The browser may leave focus on the control it was on (a disabled one, in some engines)\n // or drop it to the page; in either case the operator has not chosen to go elsewhere.\n if (\n requested.channelId === selectedChannelId &&\n (document.activeElement === requested.origin || document.activeElement === document.body)\n ) {\n (requested.target === \"composer\" ? composer.current : conversation.current)?.focus();\n }\n }\n }, [selectedChannelId, channelId, messageCount, snapshot.messages, pending]);\n\n // The channel list is a sideways strip on a narrow window: keep the open channel in view after\n // a selection and after any resize, scrolling the strip only (never the page).\n useLayoutEffect(() => {\n const strip = rail.current;\n if (!strip) return;\n const reveal = () => {\n const active = strip.querySelector<HTMLElement>('[aria-pressed=\"true\"]');\n if (!active || strip.scrollWidth <= strip.clientWidth) return;\n const box = strip.getBoundingClientRect();\n const item = active.getBoundingClientRect();\n if (item.left < box.left) strip.scrollLeft -= box.left - item.left;\n else if (item.right > box.right) strip.scrollLeft += item.right - box.right;\n };\n reveal();\n const observer = new ResizeObserver(reveal);\n observer.observe(strip);\n return () => observer.disconnect();\n }, [selectedChannelId]);\n\n function jumpToLatest() {\n const list = messageList.current;\n if (!list) return;\n list.scrollTop = list.scrollHeight;\n nearEnd.current = true;\n setUnseen(0);\n list.focus({ preventScroll: true });\n }\n\n function showFirstAwaiting() {\n const first = messages.find(needsAcknowledgement);\n const control = first ? document.getElementById(`${instanceId}-ack-${first.id}`) : null;\n control?.scrollIntoView?.({ block: \"nearest\" });\n control?.focus();\n }\n\n function selectChannel(channelId: string) {\n if (selectedChannelId === channelId) {\n selectionFocus.current = null;\n conversation.current?.focus();\n return;\n }\n selectionFocus.current = channelId;\n onSelectedChannelChange(channelId);\n }\n\n function updateDraft(next: Partial<Draft>) {\n if (!channel) return;\n setDrafts((current) => ({ ...current, [channel.id]: { ...draft, ...next } }));\n }\n\n async function act(\n action:\n | Omit<Extract<CommunicationsActionRequest, { type: \"send\" }>, \"requestId\">\n | Omit<Extract<CommunicationsActionRequest, { type: \"acknowledge\" }>, \"requestId\">,\n ) {\n if (!onAction || !connected || inFlight.current.has(action.channelId)) return;\n const channelId = action.channelId;\n const fingerprint = JSON.stringify(action);\n const requestId =\n attempts.current.get(fingerprint) ??\n globalThis.crypto?.randomUUID?.() ??\n `${instanceId}-${++sequence.current}`;\n attempts.current.set(fingerprint, requestId);\n inFlight.current.add(channelId);\n setPending((current) => ({\n ...current,\n [channelId]: action.type === \"send\" ? \"send\" : action.messageId,\n }));\n setFeedback((current) => ({ ...current, [channelId]: undefined }));\n const origin = document.activeElement;\n try {\n const response = await onAction({ ...action, requestId });\n setFeedback((current) => ({ ...current, [channelId]: response }));\n // Closing this operation must not discard another payload's uncertain outcome.\n attempts.current.delete(fingerprint);\n if (response.kind === \"accepted\") {\n if (action.type === \"send\") {\n setDrafts((current) => ({ ...current, [channelId]: emptyDraft }));\n if (selection.current === channelId) {\n latestRequested.current = true;\n returnFocus.current = { channelId, origin, target: \"composer\" };\n }\n } else if (selection.current === channelId) {\n returnFocus.current = { channelId, origin, target: \"conversation\" };\n }\n }\n } catch {\n setFeedback((current) => ({\n ...current,\n [channelId]: {\n kind: \"rejected\",\n message:\n \"The action could not be confirmed. Your draft is retained. Review channel history before retrying.\",\n },\n }));\n } finally {\n inFlight.current.delete(channelId);\n setPending((current) => ({ ...current, [channelId]: undefined }));\n }\n }\n\n return (\n <section\n aria-label={snapshot.title}\n className=\"w-full min-w-0 font-sans text-oprt-base normal-case tracking-normal text-oprt-text\"\n >\n <header className=\"mb-3u flex flex-wrap items-start justify-between gap-2u\">\n <div className=\"min-w-0\">\n <h2 className=\"m-0 text-oprt-xl font-semibold normal-case tracking-normal\">\n {snapshot.title}\n </h2>\n <p className=\"m-0 mt-0.5u text-oprt-text-dim\">{snapshot.location}</p>\n </div>\n <div className=\"flex flex-wrap items-center gap-2u pt-1u\">\n <Badge appearance=\"text\" variant={connected ? \"success\" : \"warning\"}>\n {connected ? \"Relay connected\" : \"Connection lost\"}\n </Badge>\n <Stamp at={snapshot.observedAt} className=\"font-mono text-oprt-text-dim\" />\n </div>\n </header>\n\n {!connected && (\n <p\n role=\"alert\"\n className=\"m-0 mb-2u border border-oprt-border bg-oprt-surface-2 p-2u text-oprt-warning\"\n >\n Connection lost. Messages show the last received snapshot. Drafts remain editable; sending\n and acknowledgements are unavailable until the connection returns.\n </p>\n )}\n\n <div className=\"flex min-w-0 flex-wrap border border-oprt-border bg-oprt-surface-1\">\n <aside\n aria-label=\"Channel navigation\"\n className=\"min-w-0 flex-[1_1_13rem] bg-oprt-surface-2\"\n >\n <div className=\"flex items-baseline justify-between gap-2u border-b border-oprt-border px-2u py-2u\">\n <h3 className=\"m-0 text-oprt-md font-semibold normal-case\">Channels</h3>\n <span className=\"font-mono text-oprt-text-dim\">\n {String(snapshot.channels.length).padStart(2, \"0\")}\n </span>\n </div>\n <nav aria-label=\"Communications channels\">\n <ul ref={rail} className=\"m-0 flex list-none overflow-x-auto p-0 sm:block\">\n {snapshot.channels.map((item) => {\n const channelMessages = snapshot.messages.filter(\n (message) => message.channelId === item.id,\n );\n const attention = channelMessages.filter(needsAcknowledgement).length;\n const lastMessage = [...channelMessages].sort((a, b) => b.sentAt - a.sentAt)[0];\n const hasDraft = Boolean(drafts[item.id]?.body);\n const today = dayOf(snapshot.observedAt);\n // The last message's time: \"HH:MM UTC\" today, with its date above when it is older.\n const lastDay = lastMessage ? dayOf(lastMessage.sentAt) : today;\n const badge = hasDraft\n ? \"Draft saved\"\n : attention\n ? `${attention} to acknowledge`\n : item.canPost\n ? `${channelMessages.length} ${channelMessages.length === 1 ? \"message\" : \"messages\"}`\n : \"Read only\";\n const isOpen = channel?.id === item.id;\n return (\n <li\n key={item.id}\n className=\"min-w-[11rem] shrink-0 border-b border-oprt-border sm:min-w-0\"\n >\n <button\n type=\"button\"\n // The name carries what the button shows, after the name the tests and\n // readers expect first: the ID, the last message time and the badge.\n aria-label={`${item.name} channel, ${item.id}${lastMessage ? `, last message ${lastDay === today ? \"\" : `${lastDay} `}${clock(lastMessage.sentAt, false)}` : \"\"}, ${badge}${attention && hasDraft ? `, ${attention} to acknowledge` : \"\"}`}\n aria-pressed={isOpen}\n onClick={() => selectChannel(item.id)}\n // The open channel also gets a bar down its edge: a tint and a text color\n // are not a shape, and the bar survives the focus outline.\n className={`relative block w-full min-w-0 px-2u py-2u text-left hover:bg-oprt-surface-3 ${focus} ${isOpen ? \"bg-oprt-surface-3 text-oprt-text before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-oprt-accent forced-colors:before:bg-[Highlight]\" : \"text-oprt-text-dim\"}`}\n >\n <span className=\"flex min-w-0 items-baseline justify-between gap-1u\">\n <span className=\"break-words text-oprt-md font-medium\">{item.name}</span>\n {lastMessage && (\n <time\n dateTime={isoOf(lastMessage.sentAt)}\n className=\"shrink-0 text-right font-mono text-oprt-sm text-oprt-text-dim\"\n >\n {lastDay !== today && <span className=\"block\">{lastDay}</span>}\n <span className=\"inline-block\">{clock(lastMessage.sentAt, false)}</span>\n </time>\n )}\n </span>\n <span className=\"mt-0.5u flex min-w-0 flex-wrap items-center justify-between gap-1u\">\n <span className=\"font-mono text-oprt-sm text-oprt-text-dim\">{item.id}</span>\n {hasDraft ? (\n <Badge appearance=\"text\" variant=\"info\">\n {badge}\n </Badge>\n ) : attention ? (\n <Badge appearance=\"text\" variant=\"warning\">\n {badge}\n </Badge>\n ) : (\n <Badge appearance=\"text\">{badge}</Badge>\n )}\n </span>\n </button>\n </li>\n );\n })}\n </ul>\n </nav>\n <div className=\"flex flex-wrap items-baseline gap-x-2u gap-y-0.5u p-2u\">\n <p className=\"m-0 font-medium\">{snapshot.operator.name}</p>\n <p className=\"m-0 text-oprt-text-dim\">{snapshot.operator.role}</p>\n </div>\n {channel && (\n <details className=\"min-w-0 border-t border-oprt-border p-2u\">\n <summary className={`min-h-3u cursor-pointer font-medium ${focus}`}>\n Channel details\n </summary>\n <div className=\"mt-2u\">\n <EntityPreview\n headingLevel={4}\n entity={{\n id: channel.id,\n name: channel.name,\n typeLabel: \"Support communications\",\n summary: channel.purpose,\n facts: [\n { id: \"lead\", label: \"Channel lead\", value: channel.lead },\n { id: \"operator\", label: \"Your identity\", value: snapshot.operator.name },\n {\n id: \"participants\",\n label: \"Participants\",\n value: String(channel.participants.length),\n },\n {\n id: \"posting\",\n label: \"Posting\",\n value: channel.canPost ? \"Available to you\" : \"Read only\",\n },\n ],\n quality: {\n state: connected ? \"current\" : \"stale\",\n source: snapshot.source,\n updatedAt: snapshot.observedAt,\n detail: connected\n ? \"Latest supplied channel snapshot.\"\n : \"Connection lost; last received snapshot retained.\",\n },\n }}\n />\n <h4 className=\"m-0 mt-3u text-oprt-base font-medium normal-case\">Participants</h4>\n <ul className=\"m-0 mt-1u list-none flex flex-col gap-1u p-0\">\n {channel.participants.map((person) => (\n <li key={person.id} className=\"min-w-0 break-words\">\n <span className=\"block\">{person.name}</span>\n <span className=\"text-oprt-text-dim\">{person.role}</span>\n </li>\n ))}\n </ul>\n </div>\n </details>\n )}\n </aside>\n\n <section\n ref={conversation}\n tabIndex={-1}\n aria-label=\"Channel conversation\"\n className={`min-w-0 flex-[4_1_29rem] border-l border-oprt-border ${focus}`}\n >\n {channel ? (\n <>\n <header className=\"flex flex-wrap items-center justify-between gap-1u border-b border-oprt-border px-3u py-2u\">\n <div className=\"min-w-0\">\n <h3 className=\"m-0 text-oprt-lg font-semibold normal-case tracking-normal\">\n {channel.name}\n </h3>\n <p className=\"m-0 mt-0.5u text-oprt-text-dim\">{channel.purpose}</p>\n </div>\n <span className=\"font-mono text-oprt-sm text-oprt-text-dim\">{channel.id}</span>\n </header>\n <div className=\"flex flex-wrap items-center justify-between gap-1u border-b border-oprt-border px-3u py-1u\">\n <span className=\"text-oprt-text-dim\">\n <span className=\"inline-block font-mono\">{dayOf(snapshot.observedAt)}</span> · All\n times UTC\n </span>\n <div className=\"flex flex-wrap items-center gap-1u\">\n <p aria-live=\"polite\" className=\"m-0 text-oprt-text-dim\">\n {unseen > 0\n ? `${unseen} new ${unseen === 1 ? \"message\" : \"messages\"} below.`\n : \"\"}\n </p>\n {unseen > 0 && (\n <Button size=\"sm\" variant=\"secondary\" onClick={jumpToLatest}>\n Jump to latest\n </Button>\n )}\n {awaitingOperator > 0 && (\n <Button size=\"sm\" variant=\"tertiary\" onClick={showFirstAwaiting}>\n {awaitingOperator} awaiting your acknowledgement\n </Button>\n )}\n </div>\n </div>\n {messages.length ? (\n <ol\n ref={messageList}\n aria-label={`${channel.name} messages`}\n tabIndex={0}\n onScroll={(event) => {\n const list = event.currentTarget;\n nearEnd.current =\n list.scrollHeight - list.scrollTop - list.clientHeight <= END_SLACK;\n if (nearEnd.current) setUnseen(0);\n }}\n className={`m-0 max-h-[34rem] min-w-0 list-none overflow-y-auto p-0 ${focus}`}\n >\n {messages.map((message, index) => {\n const ownMessage = message.sender.id === snapshot.operator.id;\n // A new day starts with its date; the first message needs one only when it is\n // not on the snapshot's day, which the strip above the list already names.\n const day = dayOf(message.sentAt);\n const previousDay = index > 0 ? dayOf(messages[index - 1]!.sentAt) : null;\n const newDay =\n previousDay === null\n ? day !== dayOf(snapshot.observedAt)\n : day !== previousDay;\n return (\n <Fragment key={message.id}>\n {newDay && (\n <li className=\"border-b border-oprt-border bg-oprt-surface-2 px-3u py-1u font-mono text-oprt-sm text-oprt-text-dim\">\n {day}\n </li>\n )}\n <li\n className={`min-w-0 border-b border-oprt-border px-3u py-2u ${ownMessage ? \"bg-oprt-surface-2\" : \"\"}`}\n >\n <div className=\"flex flex-wrap items-baseline justify-between gap-x-2u gap-y-0.5u\">\n <div className=\"flex min-w-0 flex-wrap items-baseline gap-x-1u gap-y-0.5u\">\n <span className=\"break-words text-oprt-md font-semibold\">\n {message.sender.name}\n </span>\n <span className=\"text-oprt-text-dim\">\n {ownMessage ? \"You\" : message.sender.role}\n </span>\n </div>\n <time\n dateTime={isoOf(message.sentAt)}\n className=\"whitespace-nowrap font-mono text-oprt-text-dim\"\n >\n {clock(message.sentAt, true)}\n </time>\n </div>\n <p className=\"m-0 mt-1u max-w-[72ch] whitespace-pre-wrap text-oprt-md leading-relaxed [overflow-wrap:anywhere]\">\n {message.body}\n </p>\n <div className=\"mt-1u flex flex-wrap items-center justify-between gap-x-2u gap-y-1u\">\n <div className=\"flex min-w-0 flex-wrap items-center gap-x-2u gap-y-0.5u\">\n <span className=\"font-mono text-oprt-sm text-oprt-text-dim\">\n {message.id}\n </span>\n <Badge\n appearance=\"text\"\n variant={\n message.delivery === \"failed\"\n ? \"error\"\n : message.delivery === \"pending\"\n ? \"warning\"\n : \"neutral\"\n }\n >\n {message.delivery === \"delivered\"\n ? \"Delivered to channel\"\n : message.delivery === \"pending\"\n ? \"Delivery pending\"\n : \"Delivery failed\"}\n </Badge>\n {message.acknowledgement ? (\n <span className=\"text-oprt-success\">\n Acknowledged by {message.acknowledgement.by.name} ·{\" \"}\n <time\n dateTime={isoOf(message.acknowledgement.at)}\n className=\"whitespace-nowrap font-mono\"\n >\n {dayOf(message.acknowledgement.at) === day\n ? clock(message.acknowledgement.at, true)\n : `${dayOf(message.acknowledgement.at)} ${clock(message.acknowledgement.at, true)}`}\n </time>\n </span>\n ) : message.acknowledgementRequested ? (\n <Badge appearance=\"text\" variant=\"warning\">\n {ownMessage\n ? \"Awaiting acknowledgement\"\n : \"Acknowledgement requested\"}\n </Badge>\n ) : (\n <span className=\"text-oprt-sm text-oprt-text-dim\">\n No acknowledgement requested\n </span>\n )}\n </div>\n {needsAcknowledgement(message) && (\n <Button\n id={`${instanceId}-ack-${message.id}`}\n variant=\"secondary\"\n size=\"sm\"\n aria-label={`Acknowledge message ${message.id}`}\n disabled={!connected || !onAction}\n // While a request is out the button says so but keeps focus: a\n // disabled button can drop the operator's place to the page.\n {...(channelPending\n ? { \"aria-disabled\": true, \"data-disabled\": \"true\" }\n : {})}\n onClick={() => {\n if (channelPending) return;\n void act({\n type: \"acknowledge\",\n channelId: channel.id,\n messageId: message.id,\n });\n }}\n >\n {channelPending === message.id ? \"Acknowledging…\" : \"Acknowledge\"}\n </Button>\n )}\n </div>\n </li>\n </Fragment>\n );\n })}\n </ol>\n ) : (\n <div className=\"px-3u py-5u\">\n <p className=\"m-0 text-oprt-md font-medium\">No messages in {channel.name} yet.</p>\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n Start a conversation with the channel participants.\n </p>\n </div>\n )}\n\n <div className=\"px-3u py-2u\">\n {channel.canPost ? (\n <form\n aria-label={`Compose to ${channel.name}`}\n onSubmit={(event) => {\n event.preventDefault();\n if (!draft.body.trim() || channelPending) return;\n void act({\n type: \"send\",\n channelId: channel.id,\n body: draft.body.trim(),\n acknowledgementRequested: draft.acknowledgementRequested,\n });\n }}\n >\n <Field\n label={`Message to ${channel.name}`}\n help={\n connected\n ? \"Enter adds a new line. Use Send message to submit.\"\n : \"Draft retained in this session. Sending resumes when the connection returns.\"\n }\n >\n <Textarea\n ref={composer}\n rows={3}\n maxLength={2_000}\n value={draft.body}\n disabled={!onAction}\n // Read-only while a request is out, so a focused composer keeps focus.\n readOnly={Boolean(channelPending)}\n placeholder=\"Write an update for this channel…\"\n onChange={(event) => updateDraft({ body: event.target.value })}\n />\n </Field>\n <div className=\"mt-2u flex flex-wrap items-center justify-between gap-2u\">\n <div className=\"flex items-center gap-1u\">\n <Checkbox\n id={`${instanceId}-acknowledgement`}\n aria-label=\"Request acknowledgement\"\n checked={draft.acknowledgementRequested}\n onCheckedChange={(checked) =>\n updateDraft({ acknowledgementRequested: checked === true })\n }\n disabled={Boolean(channelPending) || !onAction}\n />\n <label htmlFor={`${instanceId}-acknowledgement`} className=\"cursor-pointer\">\n Request acknowledgement\n </label>\n </div>\n <div className=\"flex items-center gap-2u\">\n <span\n className=\"font-mono text-oprt-sm text-oprt-text-dim\"\n aria-label={`${draft.body.length} of 2000 characters`}\n >\n {draft.body.length} / 2000\n </span>\n <Button\n type=\"submit\"\n size=\"sm\"\n disabled={!connected || !onAction || !draft.body.trim()}\n // Unavailable while the request is out, yet still focused: a disabled\n // button can drop the operator's place to the page.\n {...(channelPending\n ? { \"aria-disabled\": true, \"data-disabled\": \"true\" }\n : {})}\n >\n {channelPending === \"send\" ? \"Sending…\" : \"Send message\"}\n </Button>\n </div>\n </div>\n {!onAction && (\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n Posting is unavailable in this view.\n </p>\n )}\n </form>\n ) : (\n <p className=\"m-0 text-oprt-text-dim\">\n {channel.readOnlyReason ?? \"This channel is read-only.\"}\n </p>\n )}\n {/* Two regions that are always mounted, each for one outcome: a region whose role\n changes, or one marked polite, is not reliably announced as an alert. */}\n <p\n role=\"status\"\n className={`m-0 text-oprt-text-dim ${result?.kind === \"accepted\" ? \"mt-2u\" : \"\"}`}\n >\n {result?.kind === \"accepted\" ? result.message : \"\"}\n </p>\n <p\n role=\"alert\"\n className={`m-0 text-oprt-error ${result?.kind === \"rejected\" ? \"mt-2u\" : \"\"}`}\n >\n {result?.kind === \"rejected\" ? result.message : \"\"}\n </p>\n </div>\n <footer className=\"flex flex-wrap items-center justify-between gap-1u border-t border-oprt-border px-3u py-1u text-oprt-sm text-oprt-text-dim\">\n <span>Source: {snapshot.source}</span>\n <span>\n Snapshot <Stamp at={snapshot.observedAt} className=\"font-mono\" />\n </span>\n </footer>\n </>\n ) : (\n <p className=\"m-0 p-3u text-oprt-text-dim\">Select a channel to view its messages.</p>\n )}\n </section>\n </div>\n </section>\n );\n}\n",
4753
+ "patterns/communications-console/communications-console.tsx": "\"use client\";\n\nimport { Fragment, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { Badge, Button, Checkbox, EntityPreview, Field, Textarea } from \"@oprt/react\";\nimport { formatUtcInstant } from \"../operational-domain\";\nimport type {\n CommunicationsActionRequest,\n CommunicationsActionResult,\n CommunicationsConsoleProps,\n CommunicationsMessage,\n} from \"./types\";\n\ntype Draft = { body: string; acknowledgementRequested: boolean };\nconst emptyDraft: Draft = { body: \"\", acknowledgementRequested: false };\nconst focus =\n \"outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-oprt-border-focus\";\n\n/** \"HH:MM\" in UTC. Kept for callers of the earlier helper; the console itself shows zone and date. */\nexport function communicationsTime(at: number) {\n return new Date(at).toISOString().slice(11, 16);\n}\n\nconst isoOf = (at: number) => new Date(at).toISOString();\nconst dayOf = (at: number) => isoOf(at).slice(0, 10);\n/** \"HH:MM:SS UTC\" for a message, \"HH:MM UTC\" where the room is tight. */\nconst clock = (at: number, seconds: boolean) => `${isoOf(at).slice(11, seconds ? 19 : 16)} UTC`;\n/** \"YYYY-MM-DD HH:MM:SS UTC\". Whole seconds: the console does not show sub-second timing. */\nconst stamp = (at: number) => formatUtcInstant(isoOf(at), { milliseconds: \"never\" });\n/** One line of room for the end of a list, in pixels: closer than this counts as at the end. */\nconst END_SLACK = 24;\n\n/** A time with its date and zone; a narrow column breaks between the date and the time, never inside either. */\nfunction Stamp({ at, className }: { at: number; className?: string }) {\n const text = stamp(at);\n return (\n <time dateTime={isoOf(at)} {...(className ? { className } : {})}>\n <span className=\"inline-block\">{text.slice(0, 10)}</span>{\" \"}\n <span className=\"inline-block\">{text.slice(11)}</span>\n </time>\n );\n}\n\n/** Channel history is authoritative; local state holds drafts and request feedback only. */\nexport function CommunicationsConsole(props: CommunicationsConsoleProps) {\n return <ConsoleWorkspace key={props.snapshot.id} {...props} />;\n}\n\nfunction ConsoleWorkspace({\n snapshot,\n selectedChannelId,\n onSelectedChannelChange,\n onAction,\n}: CommunicationsConsoleProps) {\n const instanceId = useId();\n const [drafts, setDrafts] = useState<Record<string, Draft>>({});\n const [pending, setPending] = useState<Record<string, string | undefined>>({});\n const [feedback, setFeedback] = useState<Record<string, CommunicationsActionResult | undefined>>(\n {},\n );\n const inFlight = useRef(new Set<string>());\n const attempts = useRef(new Map<string, string>());\n const sequence = useRef(0);\n const conversation = useRef<HTMLElement>(null);\n const composer = useRef<HTMLTextAreaElement>(null);\n const messageList = useRef<HTMLOListElement>(null);\n const rail = useRef<HTMLUListElement>(null);\n // Where the message list was last left: at the newest message or not, and how many it held.\n const nearEnd = useRef(true);\n const listState = useRef<{ channelId: string | null; count: number }>({\n channelId: null,\n count: 0,\n });\n const [unseen, setUnseen] = useState(0);\n const selection = useRef(selectedChannelId);\n const selectionFocus = useRef<string | null>(null);\n const latestRequested = useRef(false);\n const returnFocus = useRef<{\n channelId: string;\n origin: Element | null;\n target: \"composer\" | \"conversation\";\n } | null>(null);\n const channel = snapshot.channels.find((item) => item.id === selectedChannelId);\n const connected = snapshot.connection === \"connected\";\n const draft = channel ? (drafts[channel.id] ?? emptyDraft) : emptyDraft;\n const channelPending = channel ? pending[channel.id] : undefined;\n const result = channel ? feedback[channel.id] : undefined;\n const messages = snapshot.messages\n .filter((message) => message.channelId === channel?.id)\n .sort((left, right) => left.sentAt - right.sentAt);\n const needsAcknowledgement = (message: CommunicationsMessage) =>\n message.sender.id !== snapshot.operator.id &&\n message.delivery === \"delivered\" &&\n message.acknowledgementRequested &&\n !message.acknowledgement;\n const awaitingOperator = messages.filter(needsAcknowledgement).length;\n\n const channelId = channel?.id ?? null;\n const messageCount = messages.length;\n useLayoutEffect(() => {\n selection.current = selectedChannelId;\n if (selectionFocus.current !== null && selectionFocus.current === selectedChannelId) {\n conversation.current?.focus();\n selectionFocus.current = null;\n }\n const list = messageList.current;\n const wantsLatest = latestRequested.current;\n latestRequested.current = false;\n const known = listState.current;\n if (known.channelId !== channelId) {\n // A channel opens at its newest message, where the operator's attention is needed.\n listState.current = { channelId, count: messageCount };\n nearEnd.current = true;\n if (list) list.scrollTop = list.scrollHeight;\n setUnseen(0);\n } else {\n const added = messageCount - known.count;\n known.count = messageCount;\n if (list && (wantsLatest || (added > 0 && nearEnd.current))) {\n list.scrollTop = list.scrollHeight;\n nearEnd.current = true;\n setUnseen(0);\n } else if (added > 0) {\n // The operator is reading earlier messages: do not move the list under them, say so.\n setUnseen((count) => count + added);\n }\n }\n const requested = returnFocus.current;\n if (requested && !pending[requested.channelId]) {\n returnFocus.current = null;\n // The browser may leave focus on the control it was on (a disabled one, in some engines)\n // or drop it to the page; in either case the operator has not chosen to go elsewhere.\n if (\n requested.channelId === selectedChannelId &&\n (document.activeElement === requested.origin || document.activeElement === document.body)\n ) {\n (requested.target === \"composer\" ? composer.current : conversation.current)?.focus();\n }\n }\n }, [selectedChannelId, channelId, messageCount, snapshot.messages, pending]);\n\n // The channel list is a sideways strip on a narrow window: keep the open channel in view after\n // a selection and after any resize, scrolling the strip only (never the page).\n useLayoutEffect(() => {\n const strip = rail.current;\n if (!strip) return;\n const reveal = () => {\n const active = strip.querySelector<HTMLElement>('[aria-pressed=\"true\"]');\n if (!active || strip.scrollWidth <= strip.clientWidth) return;\n const box = strip.getBoundingClientRect();\n const item = active.getBoundingClientRect();\n if (item.left < box.left) strip.scrollLeft -= box.left - item.left;\n else if (item.right > box.right) strip.scrollLeft += item.right - box.right;\n };\n reveal();\n const observer = new ResizeObserver(reveal);\n observer.observe(strip);\n return () => observer.disconnect();\n }, [selectedChannelId]);\n\n function jumpToLatest() {\n const list = messageList.current;\n if (!list) return;\n list.scrollTop = list.scrollHeight;\n nearEnd.current = true;\n setUnseen(0);\n list.focus({ preventScroll: true });\n }\n\n function showFirstAwaiting() {\n const first = messages.find(needsAcknowledgement);\n const control = first ? document.getElementById(`${instanceId}-ack-${first.id}`) : null;\n control?.scrollIntoView?.({ block: \"nearest\" });\n control?.focus();\n }\n\n function selectChannel(channelId: string) {\n if (selectedChannelId === channelId) {\n selectionFocus.current = null;\n conversation.current?.focus();\n return;\n }\n selectionFocus.current = channelId;\n onSelectedChannelChange(channelId);\n }\n\n function updateDraft(next: Partial<Draft>) {\n if (!channel) return;\n setDrafts((current) => ({ ...current, [channel.id]: { ...draft, ...next } }));\n }\n\n async function act(\n action:\n | Omit<Extract<CommunicationsActionRequest, { type: \"send\" }>, \"requestId\">\n | Omit<Extract<CommunicationsActionRequest, { type: \"acknowledge\" }>, \"requestId\">,\n ) {\n if (!onAction || !connected || inFlight.current.has(action.channelId)) return;\n const channelId = action.channelId;\n const fingerprint = JSON.stringify(action);\n const requestId =\n attempts.current.get(fingerprint) ??\n globalThis.crypto?.randomUUID?.() ??\n `${instanceId}-${++sequence.current}`;\n attempts.current.set(fingerprint, requestId);\n inFlight.current.add(channelId);\n setPending((current) => ({\n ...current,\n [channelId]: action.type === \"send\" ? \"send\" : action.messageId,\n }));\n setFeedback((current) => ({ ...current, [channelId]: undefined }));\n const origin = document.activeElement;\n try {\n const response = await onAction({ ...action, requestId });\n setFeedback((current) => ({ ...current, [channelId]: response }));\n // Closing this operation must not discard another payload's uncertain outcome.\n attempts.current.delete(fingerprint);\n if (response.kind === \"accepted\") {\n if (action.type === \"send\") {\n setDrafts((current) => ({ ...current, [channelId]: emptyDraft }));\n if (selection.current === channelId) {\n latestRequested.current = true;\n returnFocus.current = { channelId, origin, target: \"composer\" };\n }\n } else if (selection.current === channelId) {\n returnFocus.current = { channelId, origin, target: \"conversation\" };\n }\n }\n } catch {\n setFeedback((current) => ({\n ...current,\n [channelId]: {\n kind: \"rejected\",\n message:\n \"The action could not be confirmed. Your draft is retained. Review channel history before retrying.\",\n },\n }));\n } finally {\n inFlight.current.delete(channelId);\n setPending((current) => ({ ...current, [channelId]: undefined }));\n }\n }\n\n return (\n <section\n aria-label={snapshot.title}\n className=\"w-full min-w-0 font-sans text-oprt-base normal-case tracking-normal text-oprt-text\"\n >\n <header className=\"mb-3u flex flex-wrap items-start justify-between gap-2u\">\n <div className=\"min-w-0\">\n <h2 className=\"m-0 text-oprt-xl font-semibold normal-case tracking-normal\">\n {snapshot.title}\n </h2>\n <p className=\"m-0 mt-0.5u text-oprt-text-dim\">{snapshot.location}</p>\n </div>\n <div className=\"flex flex-wrap items-center gap-2u pt-1u\">\n <Badge appearance=\"text\" variant={connected ? \"success\" : \"warning\"}>\n {connected ? \"Relay connected\" : \"Connection lost\"}\n </Badge>\n <Stamp at={snapshot.observedAt} className=\"font-mono text-oprt-text-dim\" />\n </div>\n </header>\n\n {!connected && (\n <p\n role=\"alert\"\n className=\"m-0 mb-2u border border-oprt-border bg-oprt-surface-2 p-2u text-oprt-warning\"\n >\n Connection lost. Messages show the last received snapshot. Drafts remain editable; sending\n and acknowledgements are unavailable until the connection returns.\n </p>\n )}\n\n <div className=\"flex min-w-0 flex-wrap border border-oprt-border bg-oprt-surface-1\">\n <aside\n aria-label=\"Channel navigation\"\n className=\"min-w-0 flex-[1_1_13rem] bg-oprt-surface-2\"\n >\n <div className=\"flex items-baseline justify-between gap-2u border-b border-oprt-border px-2u py-2u\">\n <h3 className=\"m-0 text-oprt-md font-semibold normal-case\">Channels</h3>\n <span className=\"font-mono text-oprt-text-dim\">\n {String(snapshot.channels.length).padStart(2, \"0\")}\n </span>\n </div>\n <nav aria-label=\"Communications channels\">\n <ul ref={rail} className=\"m-0 flex list-none overflow-x-auto p-0 sm:block\">\n {snapshot.channels.map((item) => {\n const channelMessages = snapshot.messages.filter(\n (message) => message.channelId === item.id,\n );\n const attention = channelMessages.filter(needsAcknowledgement).length;\n const lastMessage = [...channelMessages].sort((a, b) => b.sentAt - a.sentAt)[0];\n const hasDraft = Boolean(drafts[item.id]?.body);\n const today = dayOf(snapshot.observedAt);\n // The last message's time: \"HH:MM UTC\" today, with its date above when it is older.\n const lastDay = lastMessage ? dayOf(lastMessage.sentAt) : today;\n const badge = hasDraft\n ? \"Draft saved\"\n : attention\n ? `${attention} to acknowledge`\n : item.canPost\n ? `${channelMessages.length} ${channelMessages.length === 1 ? \"message\" : \"messages\"}`\n : \"Read only\";\n const isOpen = channel?.id === item.id;\n return (\n <li\n key={item.id}\n className=\"min-w-[11rem] shrink-0 border-b border-oprt-border sm:min-w-0\"\n >\n <button\n type=\"button\"\n // The name carries what the button shows in the order it shows it (WCAG\n // 2.5.3): the name, the time of the last message, the ID and the badge; a\n // count to acknowledge that a draft badge hides follows.\n aria-label={`${item.name}${lastMessage ? `, ${lastDay === today ? \"\" : `${lastDay} `}${clock(lastMessage.sentAt, false)}` : \"\"}, ${item.id}, ${badge}${attention && hasDraft ? `, ${attention} to acknowledge` : \"\"}`}\n aria-pressed={isOpen}\n onClick={() => selectChannel(item.id)}\n // The open channel also gets a bar down its edge: a tint and a text color\n // are not a shape, and the bar survives the focus outline.\n className={`relative block w-full min-w-0 px-2u py-2u text-left hover:bg-oprt-surface-3 ${focus} ${isOpen ? \"bg-oprt-surface-3 text-oprt-text before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-oprt-accent forced-colors:before:bg-[Highlight]\" : \"text-oprt-text-dim\"}`}\n >\n <span className=\"flex min-w-0 items-baseline justify-between gap-1u\">\n <span className=\"break-words text-oprt-md font-medium\">{item.name}</span>\n {lastMessage && (\n <time\n dateTime={isoOf(lastMessage.sentAt)}\n className=\"shrink-0 text-right font-mono text-oprt-sm text-oprt-text-dim\"\n >\n {lastDay !== today && <span className=\"block\">{lastDay}</span>}\n <span className=\"inline-block\">{clock(lastMessage.sentAt, false)}</span>\n </time>\n )}\n </span>\n <span className=\"mt-0.5u flex min-w-0 flex-wrap items-center justify-between gap-1u\">\n <span className=\"font-mono text-oprt-sm text-oprt-text-dim\">{item.id}</span>\n {hasDraft ? (\n <Badge appearance=\"text\" variant=\"info\">\n {badge}\n </Badge>\n ) : attention ? (\n <Badge appearance=\"text\" variant=\"warning\">\n {badge}\n </Badge>\n ) : (\n <Badge appearance=\"text\">{badge}</Badge>\n )}\n </span>\n </button>\n </li>\n );\n })}\n </ul>\n </nav>\n <div className=\"flex flex-wrap items-baseline gap-x-2u gap-y-0.5u p-2u\">\n <p className=\"m-0 font-medium\">{snapshot.operator.name}</p>\n <p className=\"m-0 text-oprt-text-dim\">{snapshot.operator.role}</p>\n </div>\n {channel && (\n <details className=\"min-w-0 border-t border-oprt-border p-2u\">\n <summary className={`min-h-3u cursor-pointer font-medium ${focus}`}>\n Channel details\n </summary>\n <div className=\"mt-2u\">\n <EntityPreview\n headingLevel={4}\n entity={{\n id: channel.id,\n name: channel.name,\n typeLabel: \"Support communications\",\n summary: channel.purpose,\n facts: [\n { id: \"lead\", label: \"Channel lead\", value: channel.lead },\n { id: \"operator\", label: \"Your identity\", value: snapshot.operator.name },\n {\n id: \"participants\",\n label: \"Participants\",\n value: String(channel.participants.length),\n },\n {\n id: \"posting\",\n label: \"Posting\",\n value: channel.canPost ? \"Available to you\" : \"Read only\",\n },\n ],\n quality: {\n state: connected ? \"current\" : \"stale\",\n source: snapshot.source,\n updatedAt: snapshot.observedAt,\n detail: connected\n ? \"Latest supplied channel snapshot.\"\n : \"Connection lost; last received snapshot retained.\",\n },\n }}\n />\n <h4 className=\"m-0 mt-3u text-oprt-base font-medium normal-case\">Participants</h4>\n <ul className=\"m-0 mt-1u list-none flex flex-col gap-1u p-0\">\n {channel.participants.map((person) => (\n <li key={person.id} className=\"min-w-0 break-words\">\n <span className=\"block\">{person.name}</span>\n <span className=\"text-oprt-text-dim\">{person.role}</span>\n </li>\n ))}\n </ul>\n </div>\n </details>\n )}\n </aside>\n\n <section\n ref={conversation}\n tabIndex={-1}\n aria-label=\"Channel conversation\"\n className={`min-w-0 flex-[4_1_29rem] border-l border-oprt-border ${focus}`}\n >\n {channel ? (\n <>\n <header className=\"flex flex-wrap items-center justify-between gap-1u border-b border-oprt-border px-3u py-2u\">\n <div className=\"min-w-0\">\n <h3 className=\"m-0 text-oprt-lg font-semibold normal-case tracking-normal\">\n {channel.name}\n </h3>\n <p className=\"m-0 mt-0.5u text-oprt-text-dim\">{channel.purpose}</p>\n </div>\n <span className=\"font-mono text-oprt-sm text-oprt-text-dim\">{channel.id}</span>\n </header>\n <div className=\"flex flex-wrap items-center justify-between gap-1u border-b border-oprt-border px-3u py-1u\">\n <span className=\"text-oprt-text-dim\">\n <span className=\"inline-block font-mono\">{dayOf(snapshot.observedAt)}</span> · All\n times UTC\n </span>\n <div className=\"flex flex-wrap items-center gap-1u\">\n <p aria-live=\"polite\" className=\"m-0 text-oprt-text-dim\">\n {unseen > 0\n ? `${unseen} new ${unseen === 1 ? \"message\" : \"messages\"} below.`\n : \"\"}\n </p>\n {unseen > 0 && (\n <Button size=\"sm\" variant=\"secondary\" onClick={jumpToLatest}>\n Jump to latest\n </Button>\n )}\n {awaitingOperator > 0 && (\n <Button size=\"sm\" variant=\"tertiary\" onClick={showFirstAwaiting}>\n {awaitingOperator} awaiting your acknowledgement\n </Button>\n )}\n </div>\n </div>\n {messages.length ? (\n <ol\n ref={messageList}\n aria-label={`${channel.name} messages`}\n tabIndex={0}\n onScroll={(event) => {\n const list = event.currentTarget;\n nearEnd.current =\n list.scrollHeight - list.scrollTop - list.clientHeight <= END_SLACK;\n if (nearEnd.current) setUnseen(0);\n }}\n className={`m-0 max-h-[34rem] min-w-0 list-none overflow-y-auto p-0 ${focus}`}\n >\n {messages.map((message, index) => {\n const ownMessage = message.sender.id === snapshot.operator.id;\n // A new day starts with its date; the first message needs one only when it is\n // not on the snapshot's day, which the strip above the list already names.\n const day = dayOf(message.sentAt);\n const previousDay = index > 0 ? dayOf(messages[index - 1]!.sentAt) : null;\n const newDay =\n previousDay === null\n ? day !== dayOf(snapshot.observedAt)\n : day !== previousDay;\n return (\n <Fragment key={message.id}>\n {newDay && (\n <li className=\"border-b border-oprt-border bg-oprt-surface-2 px-3u py-1u font-mono text-oprt-sm text-oprt-text-dim\">\n {day}\n </li>\n )}\n <li\n className={`min-w-0 border-b border-oprt-border px-3u py-2u ${ownMessage ? \"bg-oprt-surface-2\" : \"\"}`}\n >\n <div className=\"flex flex-wrap items-baseline justify-between gap-x-2u gap-y-0.5u\">\n <div className=\"flex min-w-0 flex-wrap items-baseline gap-x-1u gap-y-0.5u\">\n <span className=\"break-words text-oprt-md font-semibold\">\n {message.sender.name}\n </span>\n <span className=\"text-oprt-text-dim\">\n {ownMessage ? \"You\" : message.sender.role}\n </span>\n </div>\n <time\n dateTime={isoOf(message.sentAt)}\n className=\"whitespace-nowrap font-mono text-oprt-text-dim\"\n >\n {clock(message.sentAt, true)}\n </time>\n </div>\n <p className=\"m-0 mt-1u max-w-[72ch] whitespace-pre-wrap text-oprt-md leading-relaxed [overflow-wrap:anywhere]\">\n {message.body}\n </p>\n <div className=\"mt-1u flex flex-wrap items-center justify-between gap-x-2u gap-y-1u\">\n <div className=\"flex min-w-0 flex-wrap items-center gap-x-2u gap-y-0.5u\">\n <span className=\"font-mono text-oprt-sm text-oprt-text-dim\">\n {message.id}\n </span>\n <Badge\n appearance=\"text\"\n variant={\n message.delivery === \"failed\"\n ? \"error\"\n : message.delivery === \"pending\"\n ? \"warning\"\n : \"neutral\"\n }\n >\n {message.delivery === \"delivered\"\n ? \"Delivered to channel\"\n : message.delivery === \"pending\"\n ? \"Delivery pending\"\n : \"Delivery failed\"}\n </Badge>\n {message.acknowledgement ? (\n <span className=\"text-oprt-success\">\n Acknowledged by {message.acknowledgement.by.name} ·{\" \"}\n <time\n dateTime={isoOf(message.acknowledgement.at)}\n className=\"whitespace-nowrap font-mono\"\n >\n {dayOf(message.acknowledgement.at) === day\n ? clock(message.acknowledgement.at, true)\n : `${dayOf(message.acknowledgement.at)} ${clock(message.acknowledgement.at, true)}`}\n </time>\n </span>\n ) : message.acknowledgementRequested ? (\n <Badge appearance=\"text\" variant=\"warning\">\n {ownMessage\n ? \"Awaiting acknowledgement\"\n : \"Acknowledgement requested\"}\n </Badge>\n ) : (\n <span className=\"text-oprt-sm text-oprt-text-dim\">\n No acknowledgement requested\n </span>\n )}\n </div>\n {needsAcknowledgement(message) && (\n <Button\n id={`${instanceId}-ack-${message.id}`}\n variant=\"secondary\"\n size=\"sm\"\n aria-label={`${channelPending === message.id ? \"Acknowledging…\" : \"Acknowledge\"} message ${message.id}`}\n disabled={!connected || !onAction}\n // While a request is out the button says so but keeps focus: a\n // disabled button can drop the operator's place to the page.\n {...(channelPending\n ? { \"aria-disabled\": true, \"data-disabled\": \"true\" }\n : {})}\n onClick={() => {\n if (channelPending) return;\n void act({\n type: \"acknowledge\",\n channelId: channel.id,\n messageId: message.id,\n });\n }}\n >\n {channelPending === message.id ? \"Acknowledging…\" : \"Acknowledge\"}\n </Button>\n )}\n </div>\n </li>\n </Fragment>\n );\n })}\n </ol>\n ) : (\n <div className=\"px-3u py-5u\">\n <p className=\"m-0 text-oprt-md font-medium\">No messages in {channel.name} yet.</p>\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n Start a conversation with the channel participants.\n </p>\n </div>\n )}\n\n <div className=\"px-3u py-2u\">\n {channel.canPost ? (\n <form\n aria-label={`Compose to ${channel.name}`}\n onSubmit={(event) => {\n event.preventDefault();\n if (!draft.body.trim() || channelPending) return;\n void act({\n type: \"send\",\n channelId: channel.id,\n body: draft.body.trim(),\n acknowledgementRequested: draft.acknowledgementRequested,\n });\n }}\n >\n <Field\n label={`Message to ${channel.name}`}\n help={\n connected\n ? \"Enter adds a new line. Use Send message to submit.\"\n : \"Draft retained in this session. Sending resumes when the connection returns.\"\n }\n >\n <Textarea\n ref={composer}\n rows={3}\n maxLength={2_000}\n value={draft.body}\n disabled={!onAction}\n // Read-only while a request is out, so a focused composer keeps focus.\n readOnly={Boolean(channelPending)}\n placeholder=\"Write an update for this channel…\"\n onChange={(event) => updateDraft({ body: event.target.value })}\n />\n </Field>\n <div className=\"mt-2u flex flex-wrap items-center justify-between gap-2u\">\n <div className=\"flex items-center gap-1u\">\n <Checkbox\n id={`${instanceId}-acknowledgement`}\n aria-label=\"Request acknowledgement\"\n checked={draft.acknowledgementRequested}\n onCheckedChange={(checked) =>\n updateDraft({ acknowledgementRequested: checked === true })\n }\n disabled={Boolean(channelPending) || !onAction}\n />\n <label htmlFor={`${instanceId}-acknowledgement`} className=\"cursor-pointer\">\n Request acknowledgement\n </label>\n </div>\n <div className=\"flex items-center gap-2u\">\n <span\n className=\"font-mono text-oprt-sm text-oprt-text-dim\"\n aria-label={`${draft.body.length} of 2000 characters`}\n >\n {draft.body.length} / 2000\n </span>\n <Button\n type=\"submit\"\n size=\"sm\"\n disabled={!connected || !onAction || !draft.body.trim()}\n // Unavailable while the request is out, yet still focused: a disabled\n // button can drop the operator's place to the page.\n {...(channelPending\n ? { \"aria-disabled\": true, \"data-disabled\": \"true\" }\n : {})}\n >\n {channelPending === \"send\" ? \"Sending…\" : \"Send message\"}\n </Button>\n </div>\n </div>\n {!onAction && (\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n Posting is unavailable in this view.\n </p>\n )}\n </form>\n ) : (\n <p className=\"m-0 text-oprt-text-dim\">\n {channel.readOnlyReason ?? \"This channel is read-only.\"}\n </p>\n )}\n {/* Two regions that are always mounted, each for one outcome: a region whose role\n changes, or one marked polite, is not reliably announced as an alert. */}\n <p\n role=\"status\"\n className={`m-0 text-oprt-text-dim ${result?.kind === \"accepted\" ? \"mt-2u\" : \"\"}`}\n >\n {result?.kind === \"accepted\" ? result.message : \"\"}\n </p>\n <p\n role=\"alert\"\n className={`m-0 text-oprt-error ${result?.kind === \"rejected\" ? \"mt-2u\" : \"\"}`}\n >\n {result?.kind === \"rejected\" ? result.message : \"\"}\n </p>\n </div>\n <footer className=\"flex flex-wrap items-center justify-between gap-1u border-t border-oprt-border px-3u py-1u text-oprt-sm text-oprt-text-dim\">\n <span>Source: {snapshot.source}</span>\n <span>\n Snapshot <Stamp at={snapshot.observedAt} className=\"font-mono\" />\n </span>\n </footer>\n </>\n ) : (\n <p className=\"m-0 p-3u text-oprt-text-dim\">Select a channel to view its messages.</p>\n )}\n </section>\n </div>\n </section>\n );\n}\n",
4754
4754
  "patterns/operational-domain.ts": "/** Fixture context used by reusable application recipes. */\nexport type OperationalDomain = \"military\" | \"industrial\";\n\nexport interface UtcInstantOptions {\n /**\n * Whether the milliseconds are shown. \"auto\" (the default) shows them only when the supplied\n * instant carries a non-zero fraction, because sub-second timing matters only where it exists.\n */\n milliseconds?: \"auto\" | \"always\" | \"never\";\n}\n\nconst INSTANT =\n /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,3}))?(Z|[+-]\\d{2}:\\d{2})$/;\n\n/**\n * Reads an ISO 8601 instant as \"YYYY-MM-DD HH:MM:SS UTC\", with \".mmm\" after the seconds where the\n * options ask for it. Text that is not a real instant (an invented date such as 30 February, or\n * free text) is returned as supplied rather than reinterpreted. It never uses Intl, so the server\n * and every browser print the same characters.\n */\nexport function formatUtcInstant(value: string, options: UtcInstantOptions = {}): string {\n const parts = INSTANT.exec(value);\n if (!parts) return value;\n const [year, month, day, hour, minute, second] = parts.slice(1, 7).map(Number) as [\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n const calendar = new Date(Date.UTC(year, month - 1, day));\n // Date rolls an invented day into the next month; compare it back to what was written.\n if (\n calendar.getUTCFullYear() !== year ||\n calendar.getUTCMonth() !== month - 1 ||\n calendar.getUTCDate() !== day ||\n hour > 23 ||\n minute > 59 ||\n second > 59\n )\n return value;\n const fraction = (parts[7] ?? \"\").padEnd(3, \"0\");\n const parsed = new Date(`${value.slice(0, 19)}.${fraction}${parts[8]}`);\n if (Number.isNaN(parsed.getTime())) return value;\n const { milliseconds = \"auto\" } = options;\n const showFraction = milliseconds === \"always\" || (milliseconds === \"auto\" && fraction !== \"000\");\n const iso = parsed.toISOString();\n return `${iso.slice(0, 10)} ${iso.slice(11, showFraction ? 23 : 19)} UTC`;\n}\n\n/**\n * Reads epoch milliseconds as \"YYYY-MM-DD HH:MM:SS UTC\", with the same options as\n * formatUtcInstant. A value that is not a real time reads \"Unknown time\" rather than throwing.\n */\nexport function formatUtcEpoch(ms: number, options: UtcInstantOptions = {}): string {\n const date = new Date(ms);\n if (!Number.isFinite(ms) || Number.isNaN(date.getTime())) return \"Unknown time\";\n return formatUtcInstant(date.toISOString(), options);\n}\n",
4755
4755
  "patterns/communications-console/types.ts": "export interface CommunicationsPerson {\n id: string;\n name: string;\n role: string;\n}\n\nexport interface CommunicationsChannel {\n id: string;\n name: string;\n purpose: string;\n lead: string;\n participants: readonly CommunicationsPerson[];\n canPost: boolean;\n readOnlyReason?: string;\n}\n\nexport interface CommunicationsMessage {\n id: string;\n channelId: string;\n sender: CommunicationsPerson;\n body: string;\n sentAt: number;\n delivery: \"pending\" | \"delivered\" | \"failed\";\n acknowledgementRequested: boolean;\n acknowledgement?: { by: CommunicationsPerson; at: number };\n /** Application idempotency key, retained when an uncertain send is retried. */\n requestId?: string;\n}\n\nexport interface CommunicationsSnapshot {\n /** Replacing this identity resets channel drafts and pending feedback. */\n id: string;\n title: string;\n location: string;\n source: string;\n observedAt: number;\n connection: \"connected\" | \"disconnected\";\n operator: CommunicationsPerson;\n channels: readonly CommunicationsChannel[];\n messages: readonly CommunicationsMessage[];\n}\n\nexport type CommunicationsActionRequest = {\n requestId: string;\n channelId: string;\n} & (\n | { type: \"send\"; body: string; acknowledgementRequested: boolean }\n | { type: \"acknowledge\"; messageId: string }\n);\n\nexport interface CommunicationsActionResult {\n kind: \"accepted\" | \"rejected\";\n message: string;\n}\n\nexport interface CommunicationsConsoleProps {\n snapshot: CommunicationsSnapshot;\n selectedChannelId: string | null;\n onSelectedChannelChange: (channelId: string) => void;\n /** Publish authoritative messages separately. Keep requestId idempotent when retrying. */\n onAction?: (\n request: CommunicationsActionRequest,\n ) => CommunicationsActionResult | Promise<CommunicationsActionResult>;\n}\n",
4756
4756
  "patterns/communications-console/demo.tsx": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { Badge, Button } from \"@oprt/react\";\nimport { CommunicationsConsole } from \"./communications-console\";\nimport { createCommunicationsSnapshot } from \"./fixtures\";\nimport { applyCommunicationsAction } from \"./communications-state\";\nimport type { CommunicationsSnapshot } from \"./types\";\n\nexport function CommunicationsConsoleDemo() {\n const [snapshot, setSnapshot] = useState(createCommunicationsSnapshot);\n const source = useRef(snapshot);\n const [selectedChannelId, setSelectedChannelId] = useState<string | null>(\"NET-01\");\n const connected = snapshot.connection === \"connected\";\n const receiptTarget = [...snapshot.messages]\n .reverse()\n .find(\n (message) =>\n message.channelId === selectedChannelId &&\n message.sender.id === snapshot.operator.id &&\n message.delivery === \"delivered\" &&\n message.acknowledgementRequested &&\n !message.acknowledgement,\n );\n\n function publish(next: CommunicationsSnapshot) {\n source.current = next;\n setSnapshot(next);\n }\n\n return (\n <div className=\"flex w-full min-w-0 flex-col gap-2u\">\n <CommunicationsConsole\n snapshot={snapshot}\n selectedChannelId={selectedChannelId}\n onSelectedChannelChange={setSelectedChannelId}\n onAction={(request) => {\n const next = applyCommunicationsAction(\n source.current,\n request,\n source.current.observedAt + 60_000,\n );\n publish(next.snapshot);\n return next.result;\n }}\n />\n <div className=\"flex flex-wrap items-center gap-x-2u gap-y-0.5u\">\n <Badge appearance=\"text\" variant=\"neutral\">\n Fictional exercise\n </Badge>\n <span className=\"text-oprt-base text-oprt-text-dim\">\n Local messages only · No external service connected\n </span>\n </div>\n <details className=\"min-w-0 border-t border-oprt-border pt-2u text-oprt-base\">\n <summary className=\"min-h-3u cursor-pointer font-medium text-oprt-text-dim focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-oprt-border-focus\">\n Exercise controls\n </summary>\n <p className=\"m-0 mt-2u text-oprt-text-dim\">\n Change the local relay state or supply a recipient receipt. Restoring the connection does\n not submit a saved draft.\n </p>\n <div className=\"mt-2u flex flex-wrap gap-1u\">\n <Button\n variant=\"tertiary\"\n size=\"sm\"\n onClick={() =>\n publish({\n ...source.current,\n connection: connected ? \"disconnected\" : \"connected\",\n observedAt: connected\n ? source.current.observedAt\n : source.current.observedAt + 60_000,\n })\n }\n >\n {connected ? \"Simulate connection loss\" : \"Restore connection\"}\n </Button>\n <Button\n variant=\"tertiary\"\n size=\"sm\"\n disabled={!connected || !receiptTarget}\n onClick={() => {\n if (!receiptTarget) return;\n const channel = source.current.channels.find(\n (item) => item.id === receiptTarget.channelId,\n );\n const recipient = channel?.participants.find(\n (person) => person.id !== source.current.operator.id,\n );\n if (!recipient) return;\n const at = source.current.observedAt + 60_000;\n publish({\n ...source.current,\n observedAt: at,\n messages: source.current.messages.map((message) =>\n message.id === receiptTarget.id\n ? { ...message, acknowledgement: { by: recipient, at } }\n : message,\n ),\n });\n }}\n >\n Receive acknowledgement\n </Button>\n </div>\n </details>\n </div>\n );\n}\n",
4757
- "patterns/communications-console/fixtures.ts": "import type { CommunicationsPerson, CommunicationsSnapshot } from \"./types\";\n\nconst operator: CommunicationsPerson = {\n id: \"watch-operator\",\n name: \"Sgt Alex Morgan\",\n role: \"Support watch\",\n};\nconst facilities: CommunicationsPerson = {\n id: \"facilities-desk\",\n name: \"Facilities desk\",\n role: \"Base services\",\n};\nconst transport: CommunicationsPerson = {\n id: \"transport-desk\",\n name: \"Transport desk\",\n role: \"Vehicle support\",\n};\nconst supervisor: CommunicationsPerson = {\n id: \"watch-supervisor\",\n name: \"Lt Jordan Hayes\",\n role: \"Watch supervisor\",\n};\nconst supply: CommunicationsPerson = {\n id: \"supply-desk\",\n name: \"Supply desk\",\n role: \"Stores and receipts\",\n};\nconst at = (time: string) => Date.parse(`2026-10-02T${time}:00Z`);\n\n/** Fictional support traffic. Every action stays inside the current browser session. */\nexport function createCommunicationsSnapshot(): CommunicationsSnapshot {\n return {\n id: \"kestrel-support-exercise\",\n title: \"Communications Console\",\n location: \"Camp Kestrel support watch\",\n source: \"Kestrel exercise relay\",\n observedAt: at(\"09:20\"),\n connection: \"connected\",\n operator,\n channels: [\n {\n id: \"NET-01\",\n name: \"Base support\",\n purpose: \"Coordinate base services and the next watch handover.\",\n lead: \"Lt Jordan Hayes\",\n participants: [operator, facilities, transport, supervisor, supply],\n canPost: true,\n },\n {\n id: \"NET-02\",\n name: \"Transport\",\n purpose: \"Vehicle readiness, driver handovers, and routine base movements.\",\n lead: \"Transport desk\",\n participants: [operator, transport, supervisor],\n canPost: true,\n },\n {\n id: \"NET-03\",\n name: \"Facilities\",\n purpose: \"Building access, inspections, and maintenance coordination.\",\n lead: \"Facilities desk\",\n participants: [operator, facilities, supervisor],\n canPost: true,\n },\n {\n id: \"NET-04\",\n name: \"Supply\",\n purpose: \"Record routine stores requests and confirm receipt of supplies.\",\n lead: \"Supply desk\",\n participants: [operator, supply],\n canPost: true,\n },\n {\n id: \"NET-05\",\n name: \"Watch notices\",\n purpose: \"Published notices for the current support watch.\",\n lead: \"Lt Jordan Hayes\",\n participants: [operator, facilities, transport, supervisor, supply],\n canPost: false,\n readOnlyReason: \"Only the watch supervisor can post to this channel.\",\n },\n ],\n messages: [\n {\n id: \"MSG-0410\",\n channelId: \"NET-01\",\n sender: supervisor,\n body: \"Morning support handover is open. Keep facilities, transport, and supply updates in this channel.\",\n sentAt: at(\"09:02\"),\n delivery: \"delivered\",\n acknowledgementRequested: false,\n },\n {\n id: \"MSG-0411\",\n channelId: \"NET-01\",\n sender: operator,\n body: \"The standby generator inspection is complete. Facilities, confirm the log is ready for the 10:00 handover.\",\n sentAt: at(\"09:08\"),\n delivery: \"delivered\",\n acknowledgementRequested: true,\n },\n {\n id: \"MSG-0412\",\n channelId: \"NET-01\",\n sender: facilities,\n body: \"Please acknowledge the east service entrance reopening at 09:45, after the safety check. The inspection log is ready for handover.\",\n sentAt: at(\"09:16\"),\n delivery: \"delivered\",\n acknowledgementRequested: true,\n },\n {\n id: \"MSG-0413\",\n channelId: \"NET-02\",\n sender: transport,\n body: \"Two utility vehicles have completed their daily checks. Keys and inspection sheets are at the transport desk.\",\n sentAt: at(\"09:12\"),\n delivery: \"delivered\",\n acknowledgementRequested: false,\n },\n {\n id: \"MSG-0414\",\n channelId: \"NET-03\",\n sender: facilities,\n body: \"Workshop ventilation service is scheduled for 11:00. Please keep the service bay clear until the inspection is signed off.\",\n sentAt: at(\"09:14\"),\n delivery: \"delivered\",\n acknowledgementRequested: true,\n },\n {\n id: \"MSG-0415\",\n channelId: \"NET-05\",\n sender: supervisor,\n body: \"Support watch handover begins at 10:00 in the services office. Bring current inspection logs and outstanding supply requests.\",\n sentAt: at(\"09:18\"),\n delivery: \"delivered\",\n acknowledgementRequested: false,\n },\n ],\n };\n}\n",
4757
+ "patterns/communications-console/fixtures.ts": "import type { CommunicationsPerson, CommunicationsSnapshot } from \"./types\";\n\nconst operator: CommunicationsPerson = {\n id: \"watch-operator\",\n name: \"Sgt Alex Morgan\",\n role: \"Support watch\",\n};\nconst facilities: CommunicationsPerson = {\n id: \"facilities-desk\",\n name: \"Facilities desk\",\n role: \"Base services\",\n};\nconst transport: CommunicationsPerson = {\n id: \"transport-desk\",\n name: \"Transport desk\",\n role: \"Vehicle support\",\n};\nconst supervisor: CommunicationsPerson = {\n id: \"watch-supervisor\",\n name: \"Lt Jordan Hayes\",\n role: \"Watch supervisor\",\n};\nconst supply: CommunicationsPerson = {\n id: \"supply-desk\",\n name: \"Supply desk\",\n role: \"Stores and receipts\",\n};\nconst at = (time: string) => Date.parse(`2026-10-02T${time}:00Z`);\n\n/** Fictional support traffic. Every action stays inside the current browser session. */\nexport function createCommunicationsSnapshot(): CommunicationsSnapshot {\n return {\n id: \"kestrel-support-exercise\",\n title: \"Camp Kestrel communications\",\n location: \"Camp Kestrel support watch\",\n source: \"Kestrel exercise relay\",\n observedAt: at(\"09:20\"),\n connection: \"connected\",\n operator,\n channels: [\n {\n id: \"NET-01\",\n name: \"Base support\",\n purpose: \"Coordinate base services and the next watch handover.\",\n lead: \"Lt Jordan Hayes\",\n participants: [operator, facilities, transport, supervisor, supply],\n canPost: true,\n },\n {\n id: \"NET-02\",\n name: \"Transport\",\n purpose: \"Vehicle readiness, driver handovers, and routine base movements.\",\n lead: \"Transport desk\",\n participants: [operator, transport, supervisor],\n canPost: true,\n },\n {\n id: \"NET-03\",\n name: \"Facilities\",\n purpose: \"Building access, inspections, and maintenance coordination.\",\n lead: \"Facilities desk\",\n participants: [operator, facilities, supervisor],\n canPost: true,\n },\n {\n id: \"NET-04\",\n name: \"Supply\",\n purpose: \"Record routine stores requests and confirm receipt of supplies.\",\n lead: \"Supply desk\",\n participants: [operator, supply],\n canPost: true,\n },\n {\n id: \"NET-05\",\n name: \"Watch notices\",\n purpose: \"Published notices for the current support watch.\",\n lead: \"Lt Jordan Hayes\",\n participants: [operator, facilities, transport, supervisor, supply],\n canPost: false,\n readOnlyReason: \"Only the watch supervisor can post to this channel.\",\n },\n ],\n messages: [\n {\n id: \"MSG-0410\",\n channelId: \"NET-01\",\n sender: supervisor,\n body: \"Morning support handover is open. Keep facilities, transport, and supply updates in this channel.\",\n sentAt: at(\"09:02\"),\n delivery: \"delivered\",\n acknowledgementRequested: false,\n },\n {\n id: \"MSG-0411\",\n channelId: \"NET-01\",\n sender: operator,\n body: \"The standby generator inspection is complete. Facilities, confirm the log is ready for the 10:00 handover.\",\n sentAt: at(\"09:08\"),\n delivery: \"delivered\",\n acknowledgementRequested: true,\n },\n {\n id: \"MSG-0412\",\n channelId: \"NET-01\",\n sender: facilities,\n body: \"Please acknowledge the east service entrance reopening at 09:45, after the safety check. The inspection log is ready for handover.\",\n sentAt: at(\"09:16\"),\n delivery: \"delivered\",\n acknowledgementRequested: true,\n },\n {\n id: \"MSG-0413\",\n channelId: \"NET-02\",\n sender: transport,\n body: \"Two utility vehicles have completed their daily checks. Keys and inspection sheets are at the transport desk.\",\n sentAt: at(\"09:12\"),\n delivery: \"delivered\",\n acknowledgementRequested: false,\n },\n {\n id: \"MSG-0414\",\n channelId: \"NET-03\",\n sender: facilities,\n body: \"Workshop ventilation service is scheduled for 11:00. Please keep the service bay clear until the inspection is signed off.\",\n sentAt: at(\"09:14\"),\n delivery: \"delivered\",\n acknowledgementRequested: true,\n },\n {\n id: \"MSG-0415\",\n channelId: \"NET-05\",\n sender: supervisor,\n body: \"Support watch handover begins at 10:00 in the services office. Bring current inspection logs and outstanding supply requests.\",\n sentAt: at(\"09:18\"),\n delivery: \"delivered\",\n acknowledgementRequested: false,\n },\n ],\n };\n}\n",
4758
4758
  "patterns/communications-console/communications-state.ts": "import type {\n CommunicationsActionRequest,\n CommunicationsActionResult,\n CommunicationsSnapshot,\n} from \"./types\";\n\nexport function applyCommunicationsAction(\n snapshot: CommunicationsSnapshot,\n request: CommunicationsActionRequest,\n at: number,\n): { snapshot: CommunicationsSnapshot; result: CommunicationsActionResult } {\n const reject = (message: string) => ({\n snapshot,\n result: { kind: \"rejected\" as const, message },\n });\n const channel = snapshot.channels.find((item) => item.id === request.channelId);\n if (!channel) return reject(\"This channel is no longer available. Select another channel.\");\n if (!request.requestId.trim()) return reject(\"A request identity is required. Try again.\");\n\n // A transport retry can arrive after the first accepted response was lost.\n if (request.type === \"send\") {\n const previous = snapshot.messages.find((message) => message.requestId === request.requestId);\n if (previous) {\n if (\n previous.channelId !== request.channelId ||\n previous.body !== request.body.trim() ||\n previous.acknowledgementRequested !== request.acknowledgementRequested\n )\n return reject(\"This request identity was already used for a different message.\");\n return {\n snapshot,\n result: {\n kind: \"accepted\",\n message: `${previous.id} was already accepted in this local exercise.`,\n },\n };\n }\n }\n if (snapshot.connection !== \"connected\") {\n return reject(\n \"Connection lost. Your draft is retained; restore the connection before sending.\",\n );\n }\n if (!Number.isFinite(at) || at < snapshot.observedAt) {\n return reject(\"The action time is older than the source. Refresh the channel before retrying.\");\n }\n if (!channel.participants.some((person) => person.id === snapshot.operator.id)) {\n return reject(\"You are no longer a participant in this channel.\");\n }\n\n if (request.type === \"send\") {\n if (!channel.canPost) return reject(channel.readOnlyReason ?? \"This channel is read-only.\");\n const body = request.body.trim();\n if (!body || body.length > 2_000)\n return reject(\"Write a message between 1 and 2,000 characters.\");\n const number =\n Math.max(\n 0,\n ...snapshot.messages.map((message) => Number(message.id.replace(/^MSG-/, \"\")) || 0),\n ) + 1;\n const id = `MSG-${String(number).padStart(4, \"0\")}`;\n return {\n snapshot: {\n ...snapshot,\n observedAt: at,\n messages: [\n ...snapshot.messages,\n {\n id,\n channelId: channel.id,\n sender: snapshot.operator,\n body,\n sentAt: at,\n delivery: \"delivered\",\n acknowledgementRequested: request.acknowledgementRequested,\n requestId: request.requestId,\n },\n ],\n },\n result: {\n kind: \"accepted\",\n message: `${id} delivered in this local exercise.${request.acknowledgementRequested ? \" Recipient acknowledgement is still pending.\" : \"\"}`,\n },\n };\n }\n\n const message = snapshot.messages.find(\n (item) => item.id === request.messageId && item.channelId === channel.id,\n );\n if (!message) return reject(\"The message is no longer available in this channel.\");\n if (message.sender.id === snapshot.operator.id)\n return reject(\"The sender cannot acknowledge their own message.\");\n if (message.delivery !== \"delivered\")\n return reject(\"Delivery must be confirmed before acknowledging this message.\");\n if (!message.acknowledgementRequested)\n return reject(\"This message does not request an acknowledgement.\");\n if (message.acknowledgement) {\n return {\n snapshot,\n result: { kind: \"accepted\", message: `${message.id} already has an acknowledgement.` },\n };\n }\n return {\n snapshot: {\n ...snapshot,\n observedAt: at,\n messages: snapshot.messages.map((item) =>\n item.id === message.id ? { ...item, acknowledgement: { by: snapshot.operator, at } } : item,\n ),\n },\n result: {\n kind: \"accepted\",\n message: `${message.id} acknowledged by ${snapshot.operator.name} in this local exercise.`,\n },\n };\n}\n"
4759
4759
  },
4760
4760
  "dependencies": [
@@ -4783,8 +4783,8 @@
4783
4783
  "layout": "import { FacilityMonitoringDemo } from \"./patterns/facility-monitoring\";\n\n<FacilityMonitoringDemo />",
4784
4784
  "files": {
4785
4785
  "patterns/facility-monitoring/index.ts": "export { FacilityMonitoring } from \"./facility-monitoring\";\nexport { FacilityMonitoringDemo } from \"./demo\";\nexport {\n initialReview,\n transitionReview,\n resolveEquipment,\n resolveRecording,\n reviewAt,\n} from \"./review-state\";\nexport { createFacilitySnapshot } from \"./fixtures\";\nexport type {\n FacilitySnapshot,\n FacilityReviewState,\n FacilityMonitoringProps,\n FacilityCamera,\n FacilityEquipment,\n FacilityRecording,\n FacilityBookmark,\n CameraCommandRequest,\n CameraCommandResult,\n} from \"./types\";\n",
4786
- "patterns/facility-monitoring/facility-monitoring.tsx": "\"use client\";\n\nimport { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from \"react\";\nimport {\n ActionStatus,\n AttachmentPreview,\n Badge,\n Button,\n Chart,\n EntityPreview,\n EventTimeline,\n Inspector,\n MediaFrame,\n PlaybackControls,\n TimeRange,\n type ActionState,\n type MediaFrameProps,\n} from \"@oprt/react\";\nimport { GeoMap } from \"../geospatial-workspace/geo-map\";\nimport type { GeoSnapshot } from \"../geospatial-workspace/types\";\nimport { formatUtcEpoch } from \"../operational-domain\";\nimport { initialFacilityLayers, initialFacilityView } from \"./fixtures\";\nimport { resolveEquipment, resolveRecording, reviewAt, transitionReview } from \"./review-state\";\nimport {\n SynchronizedVideo,\n type VideoCaptureHandle,\n type VideoFrameState,\n} from \"./synchronized-video\";\nimport type {\n FacilityBookmark,\n FacilityCamera,\n FacilityMonitoringProps,\n FacilitySnapshot,\n} from \"./types\";\n\n/**\n * A time as \"YYYY-MM-DD HH:MM:SS UTC\", always in whole seconds: playback moves the cursor between\n * seconds, and nothing here needs sub-second timing. `zone: false` drops the trailing \"UTC\" for the\n * chart, which appends its own unit.\n */\nconst utc = (time: number, zone = true) => {\n const text = formatUtcEpoch(time, { milliseconds: \"never\" });\n return zone ? text : text.replace(/ UTC$/, \"\");\n};\nconst chartTime = (time: number) => utc(time, false);\n/** Shared by every render, so a memoized child sees the same value each time. */\nconst noAnnotations: readonly never[] = [];\nconst noop = () => {};\nconst StableChart = memo(Chart);\nconst StableEventTimeline = memo(EventTimeline);\n\ntype FrameRecord = { key: string; src: string | undefined; value: VideoFrameState };\n/**\n * Whether a new frame report changes nothing the console shows. The video reports every few\n * hundred milliseconds while it plays; the console shows the state, the message, the whole second\n * it captured, and whether the frame is close enough to the cursor to count as synchronized.\n */\nfunction sameFrame(previous: FrameRecord, next: FrameRecord, wanted: number | null): boolean {\n if (previous.key !== next.key || previous.src !== next.src) return false;\n const a = previous.value;\n const b = next.value;\n if (a.state !== b.state || a.message !== b.message) return false;\n if (a.mediaTime === b.mediaTime) return true;\n if (a.mediaTime === null || b.mediaTime === null) return false;\n const close = (mediaTime: number) => wanted !== null && Math.abs(mediaTime - wanted) <= 0.25;\n return (\n Math.floor(a.mediaTime) === Math.floor(b.mediaTime) && close(a.mediaTime) === close(b.mediaTime)\n );\n}\nconst focusStyle =\n \"outline-hidden focus-visible:shadow-oprt-focus forced-colors:focus-visible:outline-solid forced-colors:focus-visible:outline-2\";\nconst titleStyle = \"m-0 text-oprt-base font-semibold uppercase tracking-oprt-caps\";\n\n/** One application-owned cursor drives camera, equipment, map and trend evidence. */\nexport function FacilityMonitoring({\n snapshot,\n review,\n onReviewChange,\n onCameraCommand,\n}: FacilityMonitoringProps) {\n const [cameraId, setCameraId] = useState<string | null>(snapshot.cameras[0]?.id ?? null);\n const [equipmentId, setEquipmentId] = useState<string | null>(\n snapshot.cameras[0]?.equipmentId ?? snapshot.equipment[0]?.id ?? null,\n );\n const [wall, setWall] = useState(false);\n const [showRange, setShowRange] = useState(false);\n const [bookmarks, setBookmarks] = useState<FacilityBookmark[]>([]);\n const [eventId, setEventId] = useState<string | null>(null);\n const [view, setView] = useState(initialFacilityView);\n const frameReport = useRef<FrameRecord | null>(null);\n const [, redrawForFrame] = useReducer((count: number) => count + 1, 0);\n const [retry, setRetry] = useState(0);\n const [capture, setCapture] = useState<{ src: string; at: number; camera: string } | null>(null);\n const [captureStatus, setCaptureStatus] = useState<{\n state: ActionState;\n message: string;\n } | null>(null);\n const [command, setCommand] = useState<{\n state: ActionState;\n message: string;\n requestId?: string;\n } | null>(null);\n const inspector = useRef<HTMLElement>(null);\n const media = useRef<VideoCaptureHandle>(null);\n const requestVersion = useRef(0);\n const captureVersion = useRef(0);\n const bookmarkCount = useRef(0);\n const bookmarkList = useRef<HTMLDivElement>(null);\n const bookmarkButton = useRef<HTMLButtonElement>(null);\n const focusAfterBookmarks = useRef<string | null>(null);\n const [bookmarkNote, setBookmarkNote] = useState(\"\");\n const sourceKey = JSON.stringify([snapshot.id, snapshot.source]);\n const visibleBookmarks = bookmarks.filter((bookmark) => bookmark.sourceKey === sourceKey);\n const selectedCamera = snapshot.cameras.find((camera) => camera.id === cameraId);\n const time = reviewAt(snapshot, review);\n const resolved = useMemo(() => resolveEquipment(snapshot, time), [snapshot, time]);\n // The map only needs a new snapshot when the evidence changes, not on every tick of the cursor.\n const held = useRef<{ snapshot: FacilitySnapshot; value: GeoSnapshot } | null>(null);\n if (\n !held.current ||\n held.current.snapshot !== snapshot ||\n held.current.value.revision !== resolved.revision\n )\n held.current = { snapshot, value: resolved };\n const mapSnapshot = held.current.value;\n const selectedEquipment = mapSnapshot.entities.find((entity) => entity.record.id === equipmentId);\n const cameraTime =\n review.mode === \"live\" &&\n selectedCamera &&\n Number.isFinite(selectedCamera.latencyMs) &&\n selectedCamera.latencyMs! >= 0\n ? time - selectedCamera.latencyMs!\n : time;\n const clip =\n selectedCamera && (review.mode === \"review\" || selectedCamera.connection === \"connected\")\n ? resolveRecording(selectedCamera, cameraTime)\n : null;\n const frameKey = JSON.stringify([\n sourceKey,\n selectedCamera?.id,\n clip?.recording.id,\n clip?.recording.start,\n clip?.recording.end,\n clip?.recording.mediaOffset,\n retry,\n ]);\n // The latest report is read here, so a render between two redraws still sees the newest frame.\n const frame = frameReport.current;\n const actualFrame =\n frame?.key === frameKey && frame.src === clip?.recording.src ? frame.value : null;\n const wantedMediaTime = useRef<number | null>(null);\n wantedMediaTime.current = clip?.mediaTime ?? null;\n const synchronized =\n !!clip &&\n !!actualFrame &&\n actualFrame.state === \"ready\" &&\n actualFrame.mediaTime !== null &&\n Math.abs(actualFrame.mediaTime - clip.mediaTime) <= 0.25;\n const capturedAt =\n clip && actualFrame?.mediaTime != null\n ? clip.recording.start + (actualFrame.mediaTime - clip.recording.mediaOffset) * 1000\n : null;\n const clipSrc = clip?.recording.src;\n const onFrameChange = useCallback(\n (value: VideoFrameState) => {\n const previous = frameReport.current;\n const next = { key: frameKey, src: clipSrc, value };\n frameReport.current = next;\n if (!previous || !sameFrame(previous, next, wantedMediaTime.current)) redrawForFrame();\n },\n [frameKey, clipSrc],\n );\n const currentKey = useRef(frameKey);\n currentKey.current = frameKey;\n\n // Keyed to whether a camera service exists, not to the function: a host that passes a new\n // function on every render would otherwise clear each confirmation within a quarter second.\n const hasCameraService = Boolean(onCameraCommand);\n useEffect(() => {\n requestVersion.current++;\n captureVersion.current++;\n setCommand(null);\n setCaptureStatus(null);\n }, [sourceKey, cameraId, hasCameraService]);\n useEffect(\n () => () => {\n requestVersion.current++;\n captureVersion.current++;\n },\n [],\n );\n\n // Handlers that go to memoized children keep one identity and read the latest props from here.\n const latest = useRef({ snapshot, review, onReviewChange });\n latest.current = { snapshot, review, onReviewChange };\n const selectCamera = useCallback((camera: FacilityCamera) => {\n setCameraId(camera.id);\n setEquipmentId(camera.equipmentId);\n }, []);\n const cameras = snapshot.cameras;\n const selectEquipment = useCallback(\n (id: string | null) => {\n setEquipmentId(id);\n const related = cameras.find((camera) => camera.equipmentId === id);\n setCameraId(related?.id ?? null);\n },\n [cameras],\n );\n const seek = useCallback((at: number) => {\n const current = latest.current;\n current.onReviewChange(\n transitionReview(current.review, { type: \"seek\", time: at }, current.snapshot),\n );\n }, []);\n const openFromWall = useCallback(\n (camera: FacilityCamera) => {\n selectCamera(camera);\n setWall(false);\n },\n [selectCamera],\n );\n const inspectOnMap = useCallback(\n (id: string | null) => {\n selectEquipment(id);\n inspector.current?.focus();\n },\n [selectEquipment],\n );\n const selectFromTimeline = useCallback(\n (id: string | null) => {\n const { snapshot: current } = latest.current;\n const event = current.events.find((item) => item.id === id);\n if (!event) return;\n setEventId(event.id);\n if (event.cameraId) {\n const camera = current.cameras.find((item) => item.id === event.cameraId);\n if (camera) selectCamera(camera);\n } else if (event.equipmentId) selectEquipment(event.equipmentId);\n seek(event.start);\n },\n [selectCamera, selectEquipment, seek],\n );\n function addBookmark() {\n bookmarkCount.current++;\n const name = utc(time);\n setBookmarks((current) => [\n ...current,\n {\n id: `${snapshot.id}:${bookmarkCount.current}`,\n name,\n cursor: time,\n cameraId,\n equipmentId,\n sourceKey,\n },\n ]);\n setBookmarkNote(`Bookmark ${name} added.`);\n }\n function removeBookmark(bookmark: FacilityBookmark) {\n const index = visibleBookmarks.findIndex((item) => item.id === bookmark.id);\n const neighbor = visibleBookmarks[index + 1] ?? visibleBookmarks[index - 1];\n // Focus moves to the neighboring bookmark, or back to the control that adds one.\n focusAfterBookmarks.current = neighbor?.id ?? \"add\";\n setBookmarks((current) => current.filter((item) => item.id !== bookmark.id));\n setBookmarkNote(`Bookmark ${bookmark.name} removed.`);\n }\n useEffect(() => {\n const target = focusAfterBookmarks.current;\n if (target === null) return;\n focusAfterBookmarks.current = null;\n const restore = [\n ...(bookmarkList.current?.querySelectorAll<HTMLElement>(\"[data-restore-bookmark]\") ?? []),\n ].find((element) => element.dataset.restoreBookmark === target);\n (target === \"add\" ? bookmarkButton.current : restore)?.focus();\n }, [bookmarks]);\n async function takeSnapshot() {\n if (!synchronized || !media.current || !selectedCamera || !clip) return;\n const version = ++captureVersion.current;\n const key = frameKey;\n const cameraName = selectedCamera.name;\n setCaptureStatus({ state: \"in-progress\", message: \"Capturing the confirmed frame…\" });\n try {\n const result = await media.current.capture();\n if (version !== captureVersion.current || key !== currentKey.current) return;\n if (Math.abs(result.mediaTime - clip.mediaTime) > 0.25)\n throw new Error(\"The displayed frame moved. Pause review and try again.\");\n const at = clip.recording.start + (result.mediaTime - clip.recording.mediaOffset) * 1000;\n setCapture({ src: result.src, at, camera: cameraName });\n setCaptureStatus({\n state: \"complete\",\n message: `Snapshot captured at ${utc(at)}. Stored in this session.`,\n });\n } catch (error) {\n if (version === captureVersion.current && key === currentKey.current)\n setCaptureStatus({\n state: \"failed\",\n message:\n error instanceof Error\n ? error.message\n : \"Snapshot unavailable. Try again when the frame is ready.\",\n });\n }\n }\n const commandReason = !selectedCamera\n ? \"Select a camera to see its controls.\"\n : review.mode !== \"live\"\n ? \"Camera controls are read-only during historical review.\"\n : !selectedCamera.canControl\n ? (selectedCamera.controlReason ?? \"Camera access is read-only.\")\n : selectedCamera.connection !== \"connected\"\n ? \"Camera controls are unavailable while disconnected.\"\n : !onCameraCommand\n ? \"No camera-control integration supplied.\"\n : null;\n async function resetCamera() {\n if (commandReason || !selectedCamera || !onCameraCommand || command?.state === \"in-progress\")\n return;\n const version = ++requestVersion.current;\n setCommand({ state: \"in-progress\", message: \"Requesting the configured camera view…\" });\n try {\n const result = await onCameraCommand({\n cameraId: selectedCamera.id,\n command: \"reset-view\",\n requestedAt: time,\n });\n if (version === requestVersion.current) setCommand(result);\n } catch {\n if (version === requestVersion.current)\n setCommand({\n state: \"failed\",\n message: \"Camera request failed. No outcome was confirmed.\",\n });\n }\n }\n const mediaState: MediaFrameProps[\"state\"] = !clip\n ? selectedCamera?.connection === \"reconnecting\"\n ? \"reconnecting\"\n : \"unavailable\"\n : actualFrame?.state === \"error\"\n ? \"unavailable\"\n : !synchronized\n ? \"reconnecting\"\n : review.mode === \"live\"\n ? selectedCamera?.latencyMs === null ||\n !Number.isFinite(selectedCamera?.latencyMs) ||\n selectedCamera!.latencyMs! < 0\n ? \"recorded\"\n : selectedCamera && (selectedCamera.latencyMs ?? 0) > 0\n ? \"delayed\"\n : \"live\"\n : review.playing\n ? \"recorded\"\n : \"paused\";\n const detail = !selectedCamera\n ? \"No camera is associated with this equipment.\"\n : review.mode === \"live\" && selectedCamera.connection !== \"connected\"\n ? \"Camera access is unavailable. Equipment and history remain accessible.\"\n : !clip\n ? \"No recording at the selected time.\"\n : actualFrame?.state === \"error\"\n ? (actualFrame.message ?? \"Media could not be decoded.\")\n : !synchronized\n ? \"Waiting for the requested frame. Displayed imagery may be from the previous position.\"\n : review.mode === \"live\" &&\n (selectedCamera.latencyMs === null ||\n !Number.isFinite(selectedCamera.latencyMs) ||\n selectedCamera.latencyMs < 0)\n ? \"Live alignment unknown. Showing the recording at the requested source time.\"\n : \"Generated silent still-scene recording for this training example.\";\n const pressure = snapshot.equipment.find((equipment) => equipment.id === equipmentId);\n const series = useMemo(\n () => [\n {\n id: equipmentId ?? \"pressure\",\n label: pressure?.name ?? \"No equipment\",\n data: pressure?.history.map((report) => ({ x: report.at, y: report.pressure })) ?? [],\n },\n ],\n [equipmentId, pressure],\n );\n const selectedEvent =\n snapshot.events.find((event) => event.id === eventId && event.start === time) ??\n snapshot.events.find((event) => event.start === time);\n\n return (\n <section\n aria-label=\"Facility monitoring\"\n className=\"not-prose flex min-w-0 flex-col gap-3u font-sans text-oprt-base text-oprt-text\"\n >\n <header className=\"flex flex-wrap items-start justify-between gap-2u border-b border-oprt-border pb-2u\">\n <div className=\"flex min-w-0 flex-col gap-1u\">\n <h2 className=\"m-0 text-oprt-xl font-semibold\">\n {snapshot.title ?? \"West utility plant\"}\n </h2>\n <p className=\"m-0 text-oprt-text-dim\">{snapshot.source}</p>\n </div>\n <div className=\"flex flex-col items-end gap-1u\">\n <Badge\n appearance=\"text\"\n indicator=\"none\"\n variant={review.mode === \"live\" ? \"success\" : \"info\"}\n >\n {review.mode === \"live\" ? \"Following source\" : \"Historical review\"}\n </Badge>\n <span className=\"font-mono\">{utc(time)}</span>\n </div>\n </header>\n\n <div\n role=\"group\"\n aria-label=\"Camera selection\"\n className=\"flex flex-wrap items-center gap-1u\"\n >\n {snapshot.cameras.map((camera) => (\n <Button\n key={camera.id}\n size=\"sm\"\n variant={camera.id === cameraId ? \"secondary\" : \"ghost\"}\n aria-label={`Select ${camera.name}`}\n aria-pressed={camera.id === cameraId}\n onClick={() => selectCamera(camera)}\n >\n {camera.name}\n </Button>\n ))}\n <Button size=\"sm\" variant=\"tertiary\" aria-pressed={wall} onClick={() => setWall(!wall)}>\n Camera wall\n </Button>\n </div>\n\n {wall && (\n <div\n aria-label=\"Camera wall\"\n className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-2u\"\n >\n {snapshot.cameras.map((camera) => (\n <CameraWallTile\n key={`${sourceKey}:${camera.id}`}\n camera={camera}\n time={time}\n historical={review.mode === \"review\"}\n playing={review.mode === \"review\" && review.playing}\n rate={review.playbackRate}\n onOpen={openFromWall}\n />\n ))}\n </div>\n )}\n\n <div className=\"flex min-w-0 flex-wrap items-start gap-3u\">\n <div className=\"flex min-w-0 flex-[2_1_32rem] flex-col gap-2u\">\n <MediaFrame\n label={selectedCamera?.name ?? \"Camera view\"}\n state={mediaState}\n capturedAt={capturedAt}\n {...(review.mode === \"live\" && selectedCamera\n ? { latencyMs: selectedCamera.latencyMs }\n : {})}\n {...(selectedCamera ? { source: selectedCamera.source } : {})}\n retained={!!clip && !!actualFrame && !synchronized && actualFrame.mediaTime !== null}\n detail={detail}\n actions={\n <>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={!synchronized || captureStatus?.state === \"in-progress\"}\n onClick={takeSnapshot}\n >\n Capture snapshot\n </Button>\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n disabled={!selectedCamera || !clip}\n onClick={() => setRetry((value) => value + 1)}\n >\n Reload media\n </Button>\n </>\n }\n >\n {clip && (\n <SynchronizedVideo\n key={frameKey}\n ref={media}\n src={clip.recording.src}\n time={clip.mediaTime}\n playing={review.mode === \"review\" && review.playing}\n playbackRate={review.playbackRate}\n label={`${selectedCamera!.name} footage`}\n onFrameChange={onFrameChange}\n />\n )}\n </MediaFrame>\n <div className=\"flex flex-wrap items-center gap-2u\">\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n disabled={!!commandReason || command?.state === \"in-progress\"}\n onClick={resetCamera}\n >\n Reset camera view\n </Button>\n {commandReason && <p className=\"m-0 text-oprt-text-dim\">{commandReason}</p>}\n </div>\n {command && <ActionStatus label=\"Camera request\" {...command} />}\n {captureStatus && <ActionStatus label=\"Snapshot\" {...captureStatus} />}\n </div>\n <div className=\"flex min-w-0 flex-[1_1_21rem] flex-col gap-2u\">\n <div role=\"group\" aria-label=\"Equipment list\" className=\"flex flex-wrap gap-1u\">\n {snapshot.equipment.map((equipment) => (\n <Button\n key={equipment.id}\n size=\"sm\"\n variant={equipment.id === equipmentId ? \"secondary\" : \"tertiary\"}\n aria-pressed={equipment.id === equipmentId}\n onClick={() => selectEquipment(equipment.id)}\n >\n {equipment.name}\n </Button>\n ))}\n </div>\n <Inspector\n ref={inspector}\n title=\"Equipment at review time\"\n tabIndex={-1}\n className={focusStyle}\n >\n {selectedEquipment ? (\n <EntityPreview\n entity={{\n ...selectedEquipment.record,\n facts: [\n { id: \"review-time\", label: \"Review time\", value: utc(time), mono: true },\n ...selectedEquipment.record.facts,\n ],\n }}\n />\n ) : (\n <p className=\"m-0 text-oprt-text-dim\">\n Select equipment to inspect the available evidence.\n </p>\n )}\n </Inspector>\n </div>\n </div>\n\n <div className=\"border-y border-oprt-border py-2u\">\n <PlaybackControls\n label=\"Review time\"\n start={review.range.start}\n end={review.range.end}\n value={time}\n playing={review.playing}\n onValueChange={seek}\n onPlayingChange={(playing) => {\n const ready =\n playing && time >= review.range.end\n ? transitionReview(review, { type: \"seek\", time: review.range.start }, snapshot)\n : review;\n onReviewChange(transitionReview(ready, { type: \"play\", playing }, snapshot));\n }}\n playbackRate={review.playbackRate}\n onPlaybackRateChange={(rate) =>\n onReviewChange(transitionReview(review, { type: \"rate\", rate }, snapshot))\n }\n eventTimes={snapshot.events.map((event) => event.start)}\n liveTime={snapshot.liveTime}\n followingLive={review.mode === \"live\"}\n onReturnToLive={() =>\n onReviewChange(transitionReview(review, { type: \"live\" }, snapshot))\n }\n />\n <div className=\"mt-2u flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n aria-expanded={showRange}\n onClick={() => setShowRange(!showRange)}\n >\n Review window\n </Button>\n <Button ref={bookmarkButton} size=\"sm\" variant=\"tertiary\" onClick={addBookmark}>\n Bookmark this time\n </Button>\n </div>\n <p\n role=\"status\"\n aria-label=\"Bookmarks\"\n className={`m-0 text-oprt-text-dim ${bookmarkNote ? \"mt-2u\" : \"\"}`}\n >\n {bookmarkNote}\n </p>\n {showRange && (\n <div className=\"mt-2u\">\n <TimeRange\n label=\"Review window\"\n value={review.range}\n min={snapshot.range.start}\n max={snapshot.range.end}\n onValueChange={(range) =>\n onReviewChange(transitionReview(review, { type: \"range\", range }, snapshot))\n }\n />\n </div>\n )}\n </div>\n\n {visibleBookmarks.length > 0 && (\n <div\n ref={bookmarkList}\n className=\"flex flex-col gap-1u\"\n aria-label=\"Saved review bookmarks\"\n >\n <h3 className={titleStyle}>Session bookmarks</h3>\n {visibleBookmarks.map((bookmark) => (\n <div key={bookmark.id} className=\"flex flex-wrap items-center gap-1u\">\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n aria-label={`Restore bookmark ${bookmark.name}`}\n data-restore-bookmark={bookmark.id}\n onClick={() => {\n const camera = snapshot.cameras.find((item) => item.id === bookmark.cameraId);\n if (camera) selectCamera(camera);\n else {\n setCameraId(null);\n setEquipmentId(bookmark.equipmentId);\n }\n onReviewChange(\n transitionReview(\n { ...review, range: snapshot.range },\n { type: \"seek\", time: bookmark.cursor },\n snapshot,\n ),\n );\n }}\n >\n {bookmark.name} ·{\" \"}\n {snapshot.cameras.find((item) => item.id === bookmark.cameraId)?.name ??\n \"Equipment review\"}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n aria-label={`Remove bookmark ${bookmark.name}`}\n onClick={() => removeBookmark(bookmark)}\n >\n Remove\n </Button>\n </div>\n ))}\n </div>\n )}\n\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,28rem),1fr))] gap-3u\">\n <div className=\"flex min-w-0 flex-col gap-2u\">\n <GeoMap\n snapshot={mapSnapshot}\n entities={mapSnapshot.entities}\n selectedId={equipmentId}\n onSelectedIdChange={selectEquipment}\n view={view}\n onViewChange={setView}\n layers={initialFacilityLayers}\n annotations={noAnnotations}\n measurement={noAnnotations}\n mode=\"inspect\"\n onPoint={noop}\n onFrame={noop}\n onInspect={inspectOnMap}\n />\n <p className=\"m-0 text-oprt-text-dim\">\n Locations use reports available at the review time. Equipment without a position remains\n in the list.\n </p>\n </div>\n <StableChart\n label=\"Equipment pressure history\"\n series={series}\n xDomain={snapshot.range}\n range={review.range}\n cursor={time}\n cursorLabel=\"Review time\"\n unit=\"bar\"\n xLabel=\"Time\"\n xUnit=\"UTC\"\n formatX={chartTime}\n description=\"Supplied samples. Gaps stay missing; the solid review marker does not interpolate a pressure reading.\"\n />\n </div>\n\n <section aria-label=\"Facility event history\" className=\"flex min-w-0 flex-col gap-2u\">\n <h3 className={titleStyle}>Event history</h3>\n <StableEventTimeline\n label=\"Facility events\"\n events={snapshot.events}\n start={review.range.start}\n end={review.range.end}\n selectedId={selectedEvent?.id ?? null}\n onSelectionChange={selectFromTimeline}\n />\n </section>\n {capture && (\n <AttachmentPreview\n label=\"Captured camera frame\"\n attachment={{\n id: \"local-frame\",\n name: `${capture.camera} · ${utc(capture.at)}`,\n sizeBytes: Math.floor(((capture.src.split(\",\")[1]?.length ?? 0) * 3) / 4),\n mediaType: \"image/png\",\n }}\n preview={{\n kind: \"image\",\n src: capture.src,\n alt: `Generated training image from ${capture.camera}, captured ${utc(capture.at)}`,\n }}\n onDownload={() => {\n const anchor = document.createElement(\"a\");\n anchor.href = capture.src;\n anchor.download = \"facility-training-snapshot.png\";\n anchor.click();\n }}\n onRemove={() => setCapture(null)}\n />\n )}\n </section>\n );\n}\n\nconst CameraWallTile = memo(function CameraWallTile({\n camera,\n time,\n historical,\n playing,\n rate,\n onOpen,\n}: {\n camera: FacilityCamera;\n time: number;\n historical: boolean;\n playing: boolean;\n rate: number;\n onOpen: (camera: FacilityCamera) => void;\n}) {\n const [frame, setFrame] = useState<{ src: string; value: VideoFrameState } | null>(null);\n const latency =\n camera.latencyMs !== null && Number.isFinite(camera.latencyMs) && camera.latencyMs >= 0\n ? camera.latencyMs\n : null;\n const clip =\n historical || camera.connection === \"connected\"\n ? resolveRecording(camera, time - (historical ? 0 : (latency ?? 0)))\n : null;\n const src = clip?.recording.src ?? \"\";\n const onFrameChange = useCallback((value: VideoFrameState) => setFrame({ src, value }), [src]);\n const actual = frame?.src === src ? frame.value : null;\n const ready =\n !!clip &&\n actual?.state === \"ready\" &&\n actual.mediaTime !== null &&\n Math.abs(actual.mediaTime - clip.mediaTime) <= 0.25;\n const capturedAt =\n clip && actual?.mediaTime != null\n ? clip.recording.start + (actual.mediaTime - clip.recording.mediaOffset) * 1000\n : null;\n const state =\n !clip || actual?.state === \"error\"\n ? \"unavailable\"\n : !ready\n ? \"reconnecting\"\n : historical\n ? playing\n ? \"recorded\"\n : \"paused\"\n : latency === null\n ? \"recorded\"\n : latency > 0\n ? \"delayed\"\n : \"live\";\n return (\n <MediaFrame\n label={`${camera.name} overview`}\n state={state}\n capturedAt={capturedAt}\n source={camera.source}\n retained={!!clip && capturedAt !== null && !ready}\n detail={\n !clip\n ? \"No camera image available.\"\n : actual?.state === \"error\"\n ? (actual.message ?? \"Media could not be decoded.\")\n : !ready\n ? \"Waiting for the requested frame.\"\n : !historical && latency === null\n ? \"Live alignment unknown.\"\n : \"Frame aligned to the shared review time.\"\n }\n actions={\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => onOpen(camera)}>\n Open {camera.name}\n </Button>\n }\n >\n {clip && (\n <SynchronizedVideo\n key={clip.recording.id}\n src={src}\n time={clip.mediaTime}\n playing={playing}\n playbackRate={rate}\n label={`${camera.name} overview footage`}\n onFrameChange={onFrameChange}\n />\n )}\n </MediaFrame>\n );\n});\n",
4787
- "patterns/geospatial-workspace/geo-map.tsx": "\"use client\";\n\nimport {\n memo,\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type RefObject,\n} from \"react\";\nimport { Button, EntityPreview, Popover } from \"@oprt/react\";\nimport type { EngineCallbacks, EngineState, GeoEngine, TileState } from \"./engine-contract\";\nimport { isGeoPoint, normalizeGeoView, sameGeoAnchor, sameGeoFrame } from \"./geometry\";\nimport { mapDataIssues } from \"./renderer-data\";\nimport type {\n GeoAnnotation,\n GeoEntity,\n GeoFrame,\n GeoMapProps,\n GeoMarkerGroup,\n GeoPoint,\n} from \"./types\";\nimport { UtcStamp } from \"./utc-stamp\";\n\n/** One shared empty collection, so a host that passes a new `[]` each render changes nothing. */\nconst NO_ANNOTATIONS: readonly GeoAnnotation[] = [];\nconst NO_POINTS: readonly GeoPoint[] = [];\nconst useBrowserLayoutEffect = typeof document === \"undefined\" ? useEffect : useLayoutEffect;\nconst finitePixel = (point: readonly number[] | null) =>\n point !== null && point.length === 2 && point.every(Number.isFinite);\n\n/** One engine start. Retiring it (dispose, failure, unmount) silences every callback it issued. */\ninterface EngineInstance {\n live: boolean;\n}\n\ninterface MarkerProps {\n group: GeoMarkerGroup;\n records: ReadonlyMap<string, GeoEntity>;\n isSelected: boolean;\n open: boolean;\n /** The location choice made inside this group while it is open. */\n choice: string | null;\n /** The supplied selection, which a choice waits for; only read while the group is open. */\n selectedId: string | null;\n region: RefObject<HTMLDivElement | null>;\n onOpenChange: (groupId: string, open: boolean, singleId: string | null) => void;\n onChoose: (groupId: string, entityId: string, origin: HTMLElement) => void;\n onPin: (groupId: string, entityId: string) => void;\n onCloseAutoFocus: (groupId: string, event: Event) => void;\n}\n\nconst sameIds = (a: readonly string[], b: readonly string[]) =>\n a === b || (a.length === b.length && a.every((item, index) => item === b[index]));\n/** A marker is drawn again for what it shows, not for where it is: a pan only moves its wrapper. */\nconst sameMarker = (previous: MarkerProps, next: MarkerProps) =>\n previous.group.id === next.group.id &&\n sameIds(previous.group.ids, next.group.ids) &&\n previous.records === next.records &&\n previous.isSelected === next.isSelected &&\n previous.open === next.open &&\n previous.choice === next.choice &&\n previous.selectedId === next.selectedId &&\n previous.region === next.region &&\n previous.onOpenChange === next.onOpenChange &&\n previous.onChoose === next.onChoose &&\n previous.onPin === next.onPin &&\n previous.onCloseAutoFocus === next.onCloseAutoFocus;\n\nconst ClusterMarker = memo(function ClusterMarker({\n group,\n records,\n isSelected,\n open,\n choice,\n selectedId,\n region,\n onOpenChange,\n onChoose,\n onPin,\n onCloseAutoFocus,\n}: MarkerProps) {\n const members = group.ids.flatMap((entityId) =>\n records.has(entityId) ? [records.get(entityId)!] : [],\n );\n const selected =\n choice && choice === selectedId && group.ids.includes(choice) ? records.get(choice) : undefined;\n const single = members.length === 1 ? members[0] : undefined;\n const label = single\n ? `${single.record.name} — ${single.record.id}`\n : `${members.length} equipment at this location`;\n return (\n <Popover\n title={selected ? \"Equipment preview\" : \"Equipment at this location\"}\n open={open}\n onOpenChange={(next) => onOpenChange(group.id, next, single?.record.id ?? null)}\n returnFocusRef={region}\n onCloseAutoFocus={(event) => onCloseAutoFocus(group.id, event)}\n content={\n <div className=\"flex max-w-[calc(var(--oprt-space-8)*5)] flex-col gap-2u\">\n {selected ? (\n <>\n <EntityPreview\n entity={{\n ...selected.record,\n facts: [\n ...selected.record.facts.map((fact) => ({\n ...fact,\n id: `entity:${fact.id}`,\n })),\n {\n id: \"geo:position\",\n label: \"Position\",\n value: selected.positionState,\n },\n {\n id: \"geo:observed\",\n label: \"Observed\",\n value: <UtcStamp value={selected.observedAt} />,\n mono: true,\n },\n ],\n }}\n actions={\n <Button size=\"sm\" onClick={() => onPin(group.id, selected.record.id)}>\n Pin in Inspector\n </Button>\n }\n />\n </>\n ) : (\n <>\n <p className=\"m-0 normal-case\">Choose a supplied record to preview its details.</p>\n {members.map((entity) => (\n <Button\n key={entity.record.id}\n size=\"sm\"\n variant=\"secondary\"\n className=\"h-auto! min-h-[var(--oprt-height-sm)] whitespace-normal! break-words text-left normal-case!\"\n onClick={(event) => onChoose(group.id, entity.record.id, event.currentTarget)}\n >\n {entity.record.name} — {entity.record.id}\n </Button>\n ))}\n {choice && (\n <p role=\"status\" className=\"normal-case\">\n Selection requested; waiting for the supplied selection.\n </p>\n )}\n </>\n )}\n </div>\n }\n >\n <Button\n size=\"sm\"\n variant={isSelected ? \"primary\" : \"secondary\"}\n aria-label={label}\n aria-pressed={isSelected}\n className={`whitespace-nowrap normal-case!${isSelected ? \"\" : \" bg-oprt-surface-1!\"}`}\n >\n {single ? single.record.id : members.length}\n </Button>\n </Popover>\n );\n}, sameMarker);\n\n/**\n * Optional renderer. All source, view and selection authority stays with its host.\n *\n * `snapshot`, `entities`, `annotations`, `measurement` and `layers` are compared by identity: the\n * renderer redraws when the host supplies a new collection, and not when it supplies an equal one\n * (a map of 2,000 reports cannot be compared by value on every pan frame). Keep a collection\n * stable between changes, for example in state or memoized.\n */\nexport const GeoMap = memo(function GeoMap(props: GeoMapProps) {\n const id = useId();\n const target = useRef<HTMLDivElement>(null);\n const region = useRef<HTMLDivElement>(null);\n const adapter = useRef<GeoEngine | null>(null);\n const instance = useRef<EngineInstance | null>(null);\n const anchorFrame = useRef<GeoFrame | null>(null);\n // The marker group whose preview or choices are open, for the engine callbacks, which never change\n // identity and so cannot read the state.\n const openGroupRef = useRef<string | null>(null);\n const pinnedGroup = useRef<string | null>(null);\n const choiceFocus = useRef<{\n id: string;\n groupId: string;\n origin: HTMLElement;\n dialog: HTMLElement;\n source: string;\n } | null>(null);\n const { snapshot, entities, selectedId, layers, mode, tileSource } = props;\n const annotations = props.annotations.length ? props.annotations : NO_ANNOTATIONS;\n const measurement = props.measurement.length ? props.measurement : NO_POINTS;\n // The view is a few numbers, not an object to compare: a pan changes these and nothing else.\n const view = normalizeGeoView(props.view);\n const [longitude, latitude] = view.center;\n const { zoom, bearing } = view;\n const tileUrl = tileSource?.url;\n const tileAttribution = tileSource?.attribution;\n const sourceKey = useMemo(\n () => JSON.stringify([snapshot.id, snapshot.revision]),\n [snapshot.id, snapshot.revision],\n );\n // What the engine draws. Its identity changes exactly when one of these inputs does, and it is\n // the stamp that tells a current engine callback from one the next update superseded.\n const state = useMemo<EngineState>(\n () => ({\n snapshot,\n entities,\n selectedId,\n view: { center: [longitude, latitude], zoom, bearing },\n layers,\n annotations,\n measurement,\n mode,\n ...(tileUrl !== undefined && tileAttribution !== undefined\n ? { tileSource: { url: tileUrl, attribution: tileAttribution } }\n : {}),\n }),\n [\n snapshot,\n entities,\n selectedId,\n longitude,\n latitude,\n zoom,\n bearing,\n layers,\n annotations,\n measurement,\n mode,\n tileUrl,\n tileAttribution,\n ],\n );\n // The supplied data is checked once per change of the data, never for a change of the view.\n const issues = useMemo(\n () => mapDataIssues({ snapshot, entities, annotations, measurement }),\n [snapshot, entities, annotations, measurement],\n );\n const positionedIds = useMemo(\n () =>\n new Set(\n entities.filter((entity) => isGeoPoint(entity.position)).map((entity) => entity.record.id),\n ),\n [entities],\n );\n const records = useMemo(\n () => new Map(entities.map((entity) => [entity.record.id, entity])),\n [entities],\n );\n const enabled = !props.disabled && !issues.duplicates && snapshot.availability !== \"unavailable\";\n const latest = useRef({ props, state, enabled, positionedIds });\n latest.current = { props, state, enabled, positionedIds };\n const [attempt, setAttempt] = useState(0);\n const [status, setStatus] = useState<\"loading\" | \"ready\" | \"error\">(\"loading\");\n const [error, setError] = useState(\"\");\n const [frame, setFrame] = useState<GeoFrame | null>(null);\n const [tileState, setTileState] = useState<TileState>(\"loading\");\n const [openGroup, setOpenGroup] = useState<string | null>(null);\n openGroupRef.current = openGroup;\n const [choice, setChoice] = useState<string | null>(null);\n /** Callbacks for one engine start and one state; they go quiet once either is superseded. */\n const makeCallbacks = useCallback(\n (expected: EngineState, owner: EngineInstance): EngineCallbacks => {\n const current = () =>\n owner.live && latest.current.enabled && latest.current.state === expected;\n return {\n onFrame(next) {\n if (!current()) return;\n const ids = latest.current.positionedIds;\n if (\n next.visibleIds.some((entityId) => !ids.has(entityId)) ||\n new Set(next.visibleIds).size !== next.visibleIds.length ||\n next.groups.some(\n (group) =>\n !isGeoPoint(group.point) ||\n !finitePixel(group.pixel) ||\n !group.ids.length ||\n group.ids.some((entityId) => !ids.has(entityId)) ||\n new Set(group.ids).size !== group.ids.length,\n ) ||\n (next.selectedPixel !== null && !finitePixel(next.selectedPixel)) ||\n (next.metersPerPixel !== null &&\n (!Number.isFinite(next.metersPerPixel) || next.metersPerPixel <= 0))\n )\n return;\n // What is open belongs to its marker group, not to the pixel it is drawn at. A frame that\n // only moves the view (Fit map, a zoom, a pan, or the second frame of the renderer settling)\n // moves the marker and what is open from it; it is dismissed when its group is gone or its\n // equipment moved on the ground.\n const open = openGroupRef.current;\n if (open !== null && !sameGeoAnchor(anchorFrame.current, next, open)) {\n choiceFocus.current = null;\n openGroupRef.current = null;\n setOpenGroup(null);\n setChoice(null);\n }\n anchorFrame.current = next;\n setFrame((previous) => (sameGeoFrame(previous, next) ? previous : next));\n latest.current.props.onFrame(next);\n },\n onViewChange(next) {\n if (current()) latest.current.props.onViewChange(normalizeGeoView(next));\n },\n onPoint(point) {\n if (current() && latest.current.props.mode !== \"inspect\" && isGeoPoint(point))\n latest.current.props.onPoint([point[0], point[1]]);\n },\n onTileState(next) {\n if (current()) setTileState(next);\n },\n };\n },\n [],\n );\n\n useEffect(() => {\n // This start owns the callbacks it issues. Retiring it is a write to its own object, not to a\n // counter held in a ref, so the cleanup reads nothing that React might have moved on.\n const owner: EngineInstance = { live: true };\n instance.current = owner;\n anchorFrame.current = null;\n choiceFocus.current = null;\n setFrame(null);\n setOpenGroup(null);\n setChoice(null);\n latest.current.props.onFrame(null);\n if (!enabled || !target.current) return;\n setStatus(\"loading\");\n setError(\"\");\n setTileState(\"loading\");\n void import(\"./engine\")\n .then(({ createGeoEngine }) => {\n if (!owner.live || !latest.current.enabled || !target.current) return;\n const current = latest.current;\n adapter.current = createGeoEngine(\n target.current,\n current.state,\n makeCallbacks(current.state, owner),\n );\n setStatus(\"ready\");\n })\n .catch((cause: unknown) => {\n if (!owner.live) return;\n owner.live = false;\n adapter.current?.dispose();\n adapter.current = null;\n setError(cause instanceof Error ? cause.message : \"Map renderer could not start.\");\n setStatus(\"error\");\n setFrame(null);\n latest.current.props.onFrame(null);\n });\n return () => {\n owner.live = false;\n adapter.current?.dispose();\n adapter.current = null;\n latest.current.props.onFrame(null);\n };\n }, [enabled, attempt, makeCallbacks]);\n\n useEffect(() => {\n const owner = instance.current;\n if (!adapter.current || !enabled || !owner?.live) return;\n try {\n adapter.current.update(state, makeCallbacks(state, owner));\n } catch (cause: unknown) {\n adapter.current.dispose();\n adapter.current = null;\n owner.live = false;\n setError(cause instanceof Error ? cause.message : \"Map renderer could not update.\");\n setStatus(\"error\");\n setFrame(null);\n latest.current.props.onFrame(null);\n }\n }, [state, enabled, makeCallbacks]);\n\n useEffect(() => {\n const leave = (event: Event) => {\n const pending = choiceFocus.current;\n if (pending && !pending.dialog.contains(event.target as Node | null))\n choiceFocus.current = null;\n };\n const blur = (event: FocusEvent) => {\n const pending = choiceFocus.current;\n // A still-connected choice explicitly blurred to nowhere is a departure;\n // removing the focused choice during acceptance is handled after commit.\n if (\n pending &&\n event.target === pending.origin &&\n pending.origin.isConnected &&\n !event.relatedTarget\n )\n choiceFocus.current = null;\n };\n document.addEventListener(\"focusin\", leave, true);\n document.addEventListener(\"pointerdown\", leave, true);\n document.addEventListener(\"focusout\", blur, true);\n return () => {\n choiceFocus.current = null;\n document.removeEventListener(\"focusin\", leave, true);\n document.removeEventListener(\"pointerdown\", leave, true);\n document.removeEventListener(\"focusout\", blur, true);\n };\n }, []);\n useBrowserLayoutEffect(() => {\n const pending = choiceFocus.current;\n if (!pending) return;\n if (\n !enabled ||\n status !== \"ready\" ||\n pending.groupId !== openGroup ||\n pending.id !== choice ||\n pending.source !== sourceKey ||\n !pending.dialog.isConnected\n ) {\n choiceFocus.current = null;\n return;\n }\n if (selectedId !== pending.id) return;\n choiceFocus.current = null;\n const active = document.activeElement;\n if (\n pending.dialog.contains(active) ||\n (active === document.body && !pending.origin.isConnected)\n )\n pending.dialog.focus({ preventScroll: true });\n }, [choice, selectedId, openGroup, enabled, status, sourceKey]);\n\n // The marker handlers read the latest props and write state, so they never change identity and\n // a marker is not drawn again because the map around it was.\n const onMarkerOpenChange = useCallback(\n (groupId: string, open: boolean, singleId: string | null) => {\n setOpenGroup(open ? groupId : null);\n choiceFocus.current = null;\n if (open) {\n setChoice(singleId);\n if (singleId) latest.current.props.onSelectedIdChange(singleId);\n }\n },\n [],\n );\n const onMarkerChoose = useCallback(\n (groupId: string, entityId: string, origin: HTMLElement) => {\n const dialog = origin.closest<HTMLElement>(\"[role=dialog]\");\n choiceFocus.current = dialog\n ? { id: entityId, groupId, origin, dialog, source: sourceKey }\n : null;\n setChoice(entityId);\n latest.current.props.onSelectedIdChange(entityId);\n },\n [sourceKey],\n );\n const onMarkerPin = useCallback((groupId: string, entityId: string) => {\n pinnedGroup.current = groupId;\n setOpenGroup(null);\n latest.current.props.onInspect(entityId);\n }, []);\n const onMarkerCloseAutoFocus = useCallback((groupId: string, event: Event) => {\n if (pinnedGroup.current === groupId) {\n pinnedGroup.current = null;\n event.preventDefault();\n }\n }, []);\n\n const entityLayer = layers.find((layer) => layer.id === \"entities\");\n const groups =\n enabled &&\n status === \"ready\" &&\n mode === \"inspect\" &&\n entityLayer?.visible &&\n entityLayer.opacity > 0\n ? (frame?.groups ?? [])\n : [];\n return (\n <section\n aria-labelledby={`${id}-title`}\n className={`min-w-0 flex flex-col gap-2u font-sans text-oprt-base text-oprt-text ${props.className ?? \"\"}`}\n >\n <h3\n id={`${id}-title`}\n className=\"m-0 text-oprt-base font-medium uppercase tracking-oprt-base\"\n >\n Equipment map\n </h3>\n <p id={`${id}-help`} className=\"sr-only\">\n Focus the viewport and use arrow keys to pan, plus or minus to zoom. Use equipment markers\n for location choices. Equipment records remain available for inspection, including unknown\n positions.\n </p>\n {issues.invalid > 0 && (\n <p role=\"status\">\n {issues.invalid} invalid geometr{issues.invalid === 1 ? \"y\" : \"ies\"} excluded from the\n map. Review supplied coordinates in the table.{\" \"}\n {issues.uncertainty > 0 &&\n `${issues.uncertainty} uncertainty footprint${issues.uncertainty === 1 ? \" is\" : \"s are\"} excluded because of an invalid radius or unsupported projection bounds.`}\n </p>\n )}\n {issues.unknown > 0 && (\n <p>\n {issues.unknown} record{issues.unknown === 1 ? \" has\" : \"s have\"} an unknown position.\n </p>\n )}\n {issues.duplicates ? (\n <p role=\"alert\">\n Duplicate or empty IDs prevent unambiguous map selection. Correct the supplied source; the\n equipment records remain available.\n </p>\n ) : props.disabled ? (\n <p role=\"status\">Map disabled. Use the table to review equipment.</p>\n ) : snapshot.availability === \"unavailable\" ? (\n <p role=\"status\">Map source unavailable. Use the table and source recovery controls.</p>\n ) : null}\n <div\n ref={region}\n role=\"group\"\n aria-label=\"Map viewport\"\n aria-describedby={`${id}-help`}\n tabIndex={enabled && status === \"ready\" ? 0 : -1}\n className=\"relative h-[calc(var(--oprt-space-8)*8)] min-w-0 overflow-hidden border border-oprt-border bg-oprt-surface-2 outline-hidden focus-visible:border-oprt-border-focus focus-visible:shadow-oprt-focus focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-oprt-border-focus forced-colors:focus-visible:outline-[CanvasText]\"\n >\n <div\n ref={target}\n aria-hidden=\"true\"\n role=\"presentation\"\n className=\"absolute inset-0 [&_.ol-viewport]:touch-none!\"\n />\n {enabled && status === \"loading\" && (\n <p role=\"status\" className=\"absolute inset-x-0 top-0 m-0 bg-oprt-surface-1 p-2u\">\n Loading map renderer…\n </p>\n )}\n {enabled && status === \"error\" && (\n <div className=\"absolute inset-0 flex flex-col items-start gap-2u bg-oprt-surface-1 p-3u\">\n <p role=\"alert\">Map renderer unavailable: {error}</p>\n <Button size=\"sm\" onClick={() => setAttempt((value) => value + 1)}>\n Retry map\n </Button>\n </div>\n )}\n {groups.map((group) => {\n if (!group.ids.every((entityId) => records.has(entityId))) return null;\n const open = openGroup === group.id;\n return (\n <div\n key={group.id}\n className=\"absolute -translate-x-1/2 -translate-y-1/2\"\n style={{ left: group.pixel[0], top: group.pixel[1] }}\n >\n <ClusterMarker\n group={group}\n records={records}\n isSelected={selectedId !== null && group.ids.includes(selectedId)}\n open={open}\n choice={open ? choice : null}\n selectedId={open ? selectedId : null}\n region={region}\n onOpenChange={onMarkerOpenChange}\n onChoose={onMarkerChoose}\n onPin={onMarkerPin}\n onCloseAutoFocus={onMarkerCloseAutoFocus}\n />\n </div>\n );\n })}\n </div>\n {enabled && tileSource && (\n <div className=\"flex flex-wrap items-center gap-2u text-oprt-text-dim\">\n <span className=\"[overflow-wrap:anywhere]\">{tileSource.attribution}</span>\n {tileState === \"loading\" && <span role=\"status\">Loading background tiles…</span>}\n {tileState === \"error\" && (\n <>\n <span role=\"status\">\n Background tiles unavailable. Local vectors and the table remain available.\n </span>\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => setAttempt((value) => value + 1)}>\n Retry tiles\n </Button>\n </>\n )}\n </div>\n )}\n </section>\n );\n});\n",
4786
+ "patterns/facility-monitoring/facility-monitoring.tsx": "\"use client\";\n\nimport { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from \"react\";\nimport {\n ActionStatus,\n AttachmentPreview,\n Badge,\n Button,\n Chart,\n EntityPreview,\n EventTimeline,\n Inspector,\n MediaFrame,\n PlaybackControls,\n TimeRange,\n type ActionState,\n type MediaFrameProps,\n} from \"@oprt/react\";\nimport { GeoMap } from \"../geospatial-workspace/geo-map\";\nimport type { GeoSnapshot } from \"../geospatial-workspace/types\";\nimport { formatUtcEpoch } from \"../operational-domain\";\nimport { initialFacilityLayers, initialFacilityView } from \"./fixtures\";\nimport { resolveEquipment, resolveRecording, reviewAt, transitionReview } from \"./review-state\";\nimport {\n SynchronizedVideo,\n type VideoCaptureHandle,\n type VideoFrameState,\n} from \"./synchronized-video\";\nimport type {\n FacilityBookmark,\n FacilityCamera,\n FacilityMonitoringProps,\n FacilitySnapshot,\n} from \"./types\";\n\n/**\n * A time as \"YYYY-MM-DD HH:MM:SS UTC\", always in whole seconds: playback moves the cursor between\n * seconds, and nothing here needs sub-second timing. `zone: false` drops the trailing \"UTC\" for the\n * chart, which appends its own unit.\n */\nconst utc = (time: number, zone = true) => {\n const text = formatUtcEpoch(time, { milliseconds: \"never\" });\n return zone ? text : text.replace(/ UTC$/, \"\");\n};\nconst chartTime = (time: number) => utc(time, false);\n/** Shared by every render, so a memoized child sees the same value each time. */\nconst noAnnotations: readonly never[] = [];\nconst noop = () => {};\nconst StableChart = memo(Chart);\nconst StableEventTimeline = memo(EventTimeline);\n\ntype FrameRecord = { key: string; src: string | undefined; value: VideoFrameState };\n/**\n * Whether a new frame report changes nothing the console shows. The video reports every few\n * hundred milliseconds while it plays; the console shows the state, the message, the whole second\n * it captured, and whether the frame is close enough to the cursor to count as synchronized.\n */\nfunction sameFrame(previous: FrameRecord, next: FrameRecord, wanted: number | null): boolean {\n if (previous.key !== next.key || previous.src !== next.src) return false;\n const a = previous.value;\n const b = next.value;\n if (a.state !== b.state || a.message !== b.message) return false;\n if (a.mediaTime === b.mediaTime) return true;\n if (a.mediaTime === null || b.mediaTime === null) return false;\n const close = (mediaTime: number) => wanted !== null && Math.abs(mediaTime - wanted) <= 0.25;\n return (\n Math.floor(a.mediaTime) === Math.floor(b.mediaTime) && close(a.mediaTime) === close(b.mediaTime)\n );\n}\nconst focusStyle =\n \"outline-hidden focus-visible:shadow-oprt-focus forced-colors:focus-visible:outline-solid forced-colors:focus-visible:outline-2\";\nconst titleStyle = \"m-0 text-oprt-base font-semibold uppercase tracking-oprt-caps\";\n\n/** One application-owned cursor drives camera, equipment, map and trend evidence. */\nexport function FacilityMonitoring({\n snapshot,\n review,\n onReviewChange,\n onCameraCommand,\n}: FacilityMonitoringProps) {\n const [cameraId, setCameraId] = useState<string | null>(snapshot.cameras[0]?.id ?? null);\n const [equipmentId, setEquipmentId] = useState<string | null>(\n snapshot.cameras[0]?.equipmentId ?? snapshot.equipment[0]?.id ?? null,\n );\n const [wall, setWall] = useState(false);\n const [showRange, setShowRange] = useState(false);\n const [bookmarks, setBookmarks] = useState<FacilityBookmark[]>([]);\n const [eventId, setEventId] = useState<string | null>(null);\n const [view, setView] = useState(initialFacilityView);\n const frameReport = useRef<FrameRecord | null>(null);\n const [, redrawForFrame] = useReducer((count: number) => count + 1, 0);\n const [retry, setRetry] = useState(0);\n const [capture, setCapture] = useState<{ src: string; at: number; camera: string } | null>(null);\n const [captureStatus, setCaptureStatus] = useState<{\n state: ActionState;\n message: string;\n } | null>(null);\n const [command, setCommand] = useState<{\n state: ActionState;\n message: string;\n requestId?: string;\n } | null>(null);\n const inspector = useRef<HTMLElement>(null);\n const media = useRef<VideoCaptureHandle>(null);\n const requestVersion = useRef(0);\n const captureVersion = useRef(0);\n const bookmarkCount = useRef(0);\n const bookmarkList = useRef<HTMLDivElement>(null);\n const bookmarkButton = useRef<HTMLButtonElement>(null);\n const focusAfterBookmarks = useRef<string | null>(null);\n const [bookmarkNote, setBookmarkNote] = useState(\"\");\n const sourceKey = JSON.stringify([snapshot.id, snapshot.source]);\n const visibleBookmarks = bookmarks.filter((bookmark) => bookmark.sourceKey === sourceKey);\n /** What a bookmark restores, as its button shows it after the bookmark's name, and as it is named. */\n const bookmarkTarget = (bookmark: FacilityBookmark) =>\n snapshot.cameras.find((item) => item.id === bookmark.cameraId)?.name ?? \"Equipment review\";\n const selectedCamera = snapshot.cameras.find((camera) => camera.id === cameraId);\n const time = reviewAt(snapshot, review);\n const resolved = useMemo(() => resolveEquipment(snapshot, time), [snapshot, time]);\n // The map only needs a new snapshot when the evidence changes, not on every tick of the cursor.\n const held = useRef<{ snapshot: FacilitySnapshot; value: GeoSnapshot } | null>(null);\n if (\n !held.current ||\n held.current.snapshot !== snapshot ||\n held.current.value.revision !== resolved.revision\n )\n held.current = { snapshot, value: resolved };\n const mapSnapshot = held.current.value;\n const selectedEquipment = mapSnapshot.entities.find((entity) => entity.record.id === equipmentId);\n const cameraTime =\n review.mode === \"live\" &&\n selectedCamera &&\n Number.isFinite(selectedCamera.latencyMs) &&\n selectedCamera.latencyMs! >= 0\n ? time - selectedCamera.latencyMs!\n : time;\n const clip =\n selectedCamera && (review.mode === \"review\" || selectedCamera.connection === \"connected\")\n ? resolveRecording(selectedCamera, cameraTime)\n : null;\n const frameKey = JSON.stringify([\n sourceKey,\n selectedCamera?.id,\n clip?.recording.id,\n clip?.recording.start,\n clip?.recording.end,\n clip?.recording.mediaOffset,\n retry,\n ]);\n // The latest report is read here, so a render between two redraws still sees the newest frame.\n const frame = frameReport.current;\n const actualFrame =\n frame?.key === frameKey && frame.src === clip?.recording.src ? frame.value : null;\n const wantedMediaTime = useRef<number | null>(null);\n wantedMediaTime.current = clip?.mediaTime ?? null;\n const synchronized =\n !!clip &&\n !!actualFrame &&\n actualFrame.state === \"ready\" &&\n actualFrame.mediaTime !== null &&\n Math.abs(actualFrame.mediaTime - clip.mediaTime) <= 0.25;\n const capturedAt =\n clip && actualFrame?.mediaTime != null\n ? clip.recording.start + (actualFrame.mediaTime - clip.recording.mediaOffset) * 1000\n : null;\n const clipSrc = clip?.recording.src;\n const onFrameChange = useCallback(\n (value: VideoFrameState) => {\n const previous = frameReport.current;\n const next = { key: frameKey, src: clipSrc, value };\n frameReport.current = next;\n if (!previous || !sameFrame(previous, next, wantedMediaTime.current)) redrawForFrame();\n },\n [frameKey, clipSrc],\n );\n const currentKey = useRef(frameKey);\n currentKey.current = frameKey;\n\n // Keyed to whether a camera service exists, not to the function: a host that passes a new\n // function on every render would otherwise clear each confirmation within a quarter second.\n const hasCameraService = Boolean(onCameraCommand);\n useEffect(() => {\n requestVersion.current++;\n captureVersion.current++;\n setCommand(null);\n setCaptureStatus(null);\n }, [sourceKey, cameraId, hasCameraService]);\n useEffect(\n () => () => {\n requestVersion.current++;\n captureVersion.current++;\n },\n [],\n );\n\n // Handlers that go to memoized children keep one identity and read the latest props from here.\n const latest = useRef({ snapshot, review, onReviewChange });\n latest.current = { snapshot, review, onReviewChange };\n const selectCamera = useCallback((camera: FacilityCamera) => {\n setCameraId(camera.id);\n setEquipmentId(camera.equipmentId);\n }, []);\n const cameras = snapshot.cameras;\n const selectEquipment = useCallback(\n (id: string | null) => {\n setEquipmentId(id);\n const related = cameras.find((camera) => camera.equipmentId === id);\n setCameraId(related?.id ?? null);\n },\n [cameras],\n );\n const seek = useCallback((at: number) => {\n const current = latest.current;\n current.onReviewChange(\n transitionReview(current.review, { type: \"seek\", time: at }, current.snapshot),\n );\n }, []);\n const openFromWall = useCallback(\n (camera: FacilityCamera) => {\n selectCamera(camera);\n setWall(false);\n },\n [selectCamera],\n );\n const inspectOnMap = useCallback(\n (id: string | null) => {\n selectEquipment(id);\n inspector.current?.focus();\n },\n [selectEquipment],\n );\n const selectFromTimeline = useCallback(\n (id: string | null) => {\n const { snapshot: current } = latest.current;\n const event = current.events.find((item) => item.id === id);\n if (!event) return;\n setEventId(event.id);\n if (event.cameraId) {\n const camera = current.cameras.find((item) => item.id === event.cameraId);\n if (camera) selectCamera(camera);\n } else if (event.equipmentId) selectEquipment(event.equipmentId);\n seek(event.start);\n },\n [selectCamera, selectEquipment, seek],\n );\n function addBookmark() {\n bookmarkCount.current++;\n const name = utc(time);\n setBookmarks((current) => [\n ...current,\n {\n id: `${snapshot.id}:${bookmarkCount.current}`,\n name,\n cursor: time,\n cameraId,\n equipmentId,\n sourceKey,\n },\n ]);\n setBookmarkNote(`Bookmark ${name} added.`);\n }\n function removeBookmark(bookmark: FacilityBookmark) {\n const index = visibleBookmarks.findIndex((item) => item.id === bookmark.id);\n const neighbor = visibleBookmarks[index + 1] ?? visibleBookmarks[index - 1];\n // Focus moves to the neighboring bookmark, or back to the control that adds one.\n focusAfterBookmarks.current = neighbor?.id ?? \"add\";\n setBookmarks((current) => current.filter((item) => item.id !== bookmark.id));\n setBookmarkNote(`Bookmark ${bookmark.name} removed.`);\n }\n useEffect(() => {\n const target = focusAfterBookmarks.current;\n if (target === null) return;\n focusAfterBookmarks.current = null;\n const restore = [\n ...(bookmarkList.current?.querySelectorAll<HTMLElement>(\"[data-restore-bookmark]\") ?? []),\n ].find((element) => element.dataset.restoreBookmark === target);\n (target === \"add\" ? bookmarkButton.current : restore)?.focus();\n }, [bookmarks]);\n async function takeSnapshot() {\n if (!synchronized || !media.current || !selectedCamera || !clip) return;\n const version = ++captureVersion.current;\n const key = frameKey;\n const cameraName = selectedCamera.name;\n setCaptureStatus({ state: \"in-progress\", message: \"Capturing the confirmed frame…\" });\n try {\n const result = await media.current.capture();\n if (version !== captureVersion.current || key !== currentKey.current) return;\n if (Math.abs(result.mediaTime - clip.mediaTime) > 0.25)\n throw new Error(\"The displayed frame moved. Pause review and try again.\");\n const at = clip.recording.start + (result.mediaTime - clip.recording.mediaOffset) * 1000;\n setCapture({ src: result.src, at, camera: cameraName });\n setCaptureStatus({\n state: \"complete\",\n message: `Snapshot captured at ${utc(at)}. Stored in this session.`,\n });\n } catch (error) {\n if (version === captureVersion.current && key === currentKey.current)\n setCaptureStatus({\n state: \"failed\",\n message:\n error instanceof Error\n ? error.message\n : \"Snapshot unavailable. Try again when the frame is ready.\",\n });\n }\n }\n const commandReason = !selectedCamera\n ? \"Select a camera to see its controls.\"\n : review.mode !== \"live\"\n ? \"Camera controls are read-only during historical review.\"\n : !selectedCamera.canControl\n ? (selectedCamera.controlReason ?? \"Camera access is read-only.\")\n : selectedCamera.connection !== \"connected\"\n ? \"Camera controls are unavailable while disconnected.\"\n : !onCameraCommand\n ? \"No camera-control integration supplied.\"\n : null;\n async function resetCamera() {\n if (commandReason || !selectedCamera || !onCameraCommand || command?.state === \"in-progress\")\n return;\n const version = ++requestVersion.current;\n setCommand({ state: \"in-progress\", message: \"Requesting the configured camera view…\" });\n try {\n const result = await onCameraCommand({\n cameraId: selectedCamera.id,\n command: \"reset-view\",\n requestedAt: time,\n });\n if (version === requestVersion.current) setCommand(result);\n } catch {\n if (version === requestVersion.current)\n setCommand({\n state: \"failed\",\n message: \"Camera request failed. No outcome was confirmed.\",\n });\n }\n }\n const mediaState: MediaFrameProps[\"state\"] = !clip\n ? selectedCamera?.connection === \"reconnecting\"\n ? \"reconnecting\"\n : \"unavailable\"\n : actualFrame?.state === \"error\"\n ? \"unavailable\"\n : !synchronized\n ? \"reconnecting\"\n : review.mode === \"live\"\n ? selectedCamera?.latencyMs === null ||\n !Number.isFinite(selectedCamera?.latencyMs) ||\n selectedCamera!.latencyMs! < 0\n ? \"recorded\"\n : selectedCamera && (selectedCamera.latencyMs ?? 0) > 0\n ? \"delayed\"\n : \"live\"\n : review.playing\n ? \"recorded\"\n : \"paused\";\n const detail = !selectedCamera\n ? \"No camera is associated with this equipment.\"\n : review.mode === \"live\" && selectedCamera.connection !== \"connected\"\n ? \"Camera access is unavailable. Equipment and history remain accessible.\"\n : !clip\n ? \"No recording at the selected time.\"\n : actualFrame?.state === \"error\"\n ? (actualFrame.message ?? \"Media could not be decoded.\")\n : !synchronized\n ? \"Waiting for the requested frame. Displayed imagery may be from the previous position.\"\n : review.mode === \"live\" &&\n (selectedCamera.latencyMs === null ||\n !Number.isFinite(selectedCamera.latencyMs) ||\n selectedCamera.latencyMs < 0)\n ? \"Live alignment unknown. Showing the recording at the requested source time.\"\n : \"Generated silent still-scene recording for this training example.\";\n const pressure = snapshot.equipment.find((equipment) => equipment.id === equipmentId);\n const series = useMemo(\n () => [\n {\n id: equipmentId ?? \"pressure\",\n label: pressure?.name ?? \"No equipment\",\n data: pressure?.history.map((report) => ({ x: report.at, y: report.pressure })) ?? [],\n },\n ],\n [equipmentId, pressure],\n );\n const selectedEvent =\n snapshot.events.find((event) => event.id === eventId && event.start === time) ??\n snapshot.events.find((event) => event.start === time);\n\n return (\n <section\n aria-label=\"Facility monitoring\"\n className=\"not-prose flex min-w-0 flex-col gap-3u font-sans text-oprt-base text-oprt-text\"\n >\n <header className=\"flex flex-wrap items-start justify-between gap-2u border-b border-oprt-border pb-2u\">\n <div className=\"flex min-w-0 flex-col gap-1u\">\n <h2 className=\"m-0 text-oprt-xl font-semibold\">\n {snapshot.title ?? \"West utility plant\"}\n </h2>\n <p className=\"m-0 text-oprt-text-dim\">{snapshot.source}</p>\n </div>\n <div className=\"flex flex-col items-end gap-1u\">\n <Badge\n appearance=\"text\"\n indicator=\"none\"\n variant={review.mode === \"live\" ? \"success\" : \"info\"}\n >\n {review.mode === \"live\" ? \"Following source\" : \"Historical review\"}\n </Badge>\n <span className=\"font-mono\">{utc(time)}</span>\n </div>\n </header>\n\n <div\n role=\"group\"\n aria-label=\"Camera selection\"\n className=\"flex flex-wrap items-center gap-1u\"\n >\n {snapshot.cameras.map((camera) => (\n <Button\n key={camera.id}\n size=\"sm\"\n variant={camera.id === cameraId ? \"secondary\" : \"ghost\"}\n aria-label={`Select ${camera.name}`}\n aria-pressed={camera.id === cameraId}\n onClick={() => selectCamera(camera)}\n >\n {camera.name}\n </Button>\n ))}\n <Button size=\"sm\" variant=\"tertiary\" aria-pressed={wall} onClick={() => setWall(!wall)}>\n Camera wall\n </Button>\n </div>\n\n {wall && (\n <div\n aria-label=\"Camera wall\"\n className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-2u\"\n >\n {snapshot.cameras.map((camera) => (\n <CameraWallTile\n key={`${sourceKey}:${camera.id}`}\n camera={camera}\n time={time}\n historical={review.mode === \"review\"}\n playing={review.mode === \"review\" && review.playing}\n rate={review.playbackRate}\n onOpen={openFromWall}\n />\n ))}\n </div>\n )}\n\n <div className=\"flex min-w-0 flex-wrap items-start gap-3u\">\n <div className=\"flex min-w-0 flex-[2_1_32rem] flex-col gap-2u\">\n <MediaFrame\n label={selectedCamera?.name ?? \"Camera view\"}\n state={mediaState}\n capturedAt={capturedAt}\n {...(review.mode === \"live\" && selectedCamera\n ? { latencyMs: selectedCamera.latencyMs }\n : {})}\n {...(selectedCamera ? { source: selectedCamera.source } : {})}\n retained={!!clip && !!actualFrame && !synchronized && actualFrame.mediaTime !== null}\n detail={detail}\n actions={\n <>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={!synchronized || captureStatus?.state === \"in-progress\"}\n onClick={takeSnapshot}\n >\n Capture snapshot\n </Button>\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n disabled={!selectedCamera || !clip}\n onClick={() => setRetry((value) => value + 1)}\n >\n Reload media\n </Button>\n </>\n }\n >\n {clip && (\n <SynchronizedVideo\n key={frameKey}\n ref={media}\n src={clip.recording.src}\n time={clip.mediaTime}\n playing={review.mode === \"review\" && review.playing}\n playbackRate={review.playbackRate}\n label={`${selectedCamera!.name} footage`}\n onFrameChange={onFrameChange}\n />\n )}\n </MediaFrame>\n <div className=\"flex flex-wrap items-center gap-2u\">\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n disabled={!!commandReason || command?.state === \"in-progress\"}\n onClick={resetCamera}\n >\n Reset camera view\n </Button>\n {commandReason && <p className=\"m-0 text-oprt-text-dim\">{commandReason}</p>}\n </div>\n {command && <ActionStatus label=\"Camera request\" {...command} />}\n {captureStatus && <ActionStatus label=\"Snapshot\" {...captureStatus} />}\n </div>\n <div className=\"flex min-w-0 flex-[1_1_21rem] flex-col gap-2u\">\n <div role=\"group\" aria-label=\"Equipment list\" className=\"flex flex-wrap gap-1u\">\n {snapshot.equipment.map((equipment) => (\n <Button\n key={equipment.id}\n size=\"sm\"\n variant={equipment.id === equipmentId ? \"secondary\" : \"tertiary\"}\n aria-pressed={equipment.id === equipmentId}\n onClick={() => selectEquipment(equipment.id)}\n >\n {equipment.name}\n </Button>\n ))}\n </div>\n <Inspector\n ref={inspector}\n title=\"Equipment at review time\"\n tabIndex={-1}\n className={focusStyle}\n >\n {selectedEquipment ? (\n <EntityPreview\n entity={{\n ...selectedEquipment.record,\n facts: [\n { id: \"review-time\", label: \"Review time\", value: utc(time), mono: true },\n ...selectedEquipment.record.facts,\n ],\n }}\n />\n ) : (\n <p className=\"m-0 text-oprt-text-dim\">\n Select equipment to inspect the available evidence.\n </p>\n )}\n </Inspector>\n </div>\n </div>\n\n <div className=\"border-y border-oprt-border py-2u\">\n <PlaybackControls\n label=\"Review time\"\n start={review.range.start}\n end={review.range.end}\n value={time}\n playing={review.playing}\n onValueChange={seek}\n onPlayingChange={(playing) => {\n const ready =\n playing && time >= review.range.end\n ? transitionReview(review, { type: \"seek\", time: review.range.start }, snapshot)\n : review;\n onReviewChange(transitionReview(ready, { type: \"play\", playing }, snapshot));\n }}\n playbackRate={review.playbackRate}\n onPlaybackRateChange={(rate) =>\n onReviewChange(transitionReview(review, { type: \"rate\", rate }, snapshot))\n }\n eventTimes={snapshot.events.map((event) => event.start)}\n liveTime={snapshot.liveTime}\n followingLive={review.mode === \"live\"}\n onReturnToLive={() =>\n onReviewChange(transitionReview(review, { type: \"live\" }, snapshot))\n }\n />\n <div className=\"mt-2u flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n aria-expanded={showRange}\n onClick={() => setShowRange(!showRange)}\n >\n Review window\n </Button>\n <Button ref={bookmarkButton} size=\"sm\" variant=\"tertiary\" onClick={addBookmark}>\n Bookmark this time\n </Button>\n </div>\n <p\n role=\"status\"\n aria-label=\"Bookmarks\"\n className={`m-0 text-oprt-text-dim ${bookmarkNote ? \"mt-2u\" : \"\"}`}\n >\n {bookmarkNote}\n </p>\n {showRange && (\n <div className=\"mt-2u\">\n <TimeRange\n label=\"Review window\"\n value={review.range}\n min={snapshot.range.start}\n max={snapshot.range.end}\n onValueChange={(range) =>\n onReviewChange(transitionReview(review, { type: \"range\", range }, snapshot))\n }\n />\n </div>\n )}\n </div>\n\n {visibleBookmarks.length > 0 && (\n <div\n ref={bookmarkList}\n className=\"flex flex-col gap-1u\"\n aria-label=\"Saved review bookmarks\"\n >\n <h3 className={titleStyle}>Session bookmarks</h3>\n {visibleBookmarks.map((bookmark) => (\n <div key={bookmark.id} className=\"flex flex-wrap items-center gap-1u\">\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n aria-label={`Restore bookmark ${bookmark.name} · ${bookmarkTarget(bookmark)}`}\n data-restore-bookmark={bookmark.id}\n onClick={() => {\n const camera = snapshot.cameras.find((item) => item.id === bookmark.cameraId);\n if (camera) selectCamera(camera);\n else {\n setCameraId(null);\n setEquipmentId(bookmark.equipmentId);\n }\n onReviewChange(\n transitionReview(\n { ...review, range: snapshot.range },\n { type: \"seek\", time: bookmark.cursor },\n snapshot,\n ),\n );\n }}\n >\n {bookmark.name} · {bookmarkTarget(bookmark)}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n aria-label={`Remove bookmark ${bookmark.name}`}\n onClick={() => removeBookmark(bookmark)}\n >\n Remove\n </Button>\n </div>\n ))}\n </div>\n )}\n\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,28rem),1fr))] gap-3u\">\n <div className=\"flex min-w-0 flex-col gap-2u\">\n <GeoMap\n snapshot={mapSnapshot}\n entities={mapSnapshot.entities}\n selectedId={equipmentId}\n onSelectedIdChange={selectEquipment}\n view={view}\n onViewChange={setView}\n layers={initialFacilityLayers}\n annotations={noAnnotations}\n measurement={noAnnotations}\n mode=\"inspect\"\n onPoint={noop}\n onFrame={noop}\n onInspect={inspectOnMap}\n />\n <p className=\"m-0 text-oprt-text-dim\">\n Locations use reports available at the review time. Equipment without a position remains\n in the list.\n </p>\n </div>\n <StableChart\n label=\"Equipment pressure history\"\n series={series}\n xDomain={snapshot.range}\n range={review.range}\n cursor={time}\n cursorLabel=\"Review time\"\n unit=\"bar\"\n xLabel=\"Time\"\n xUnit=\"UTC\"\n formatX={chartTime}\n description=\"Supplied samples. Gaps stay missing; the solid review marker does not interpolate a pressure reading.\"\n />\n </div>\n\n <section aria-label=\"Facility event history\" className=\"flex min-w-0 flex-col gap-2u\">\n <h3 className={titleStyle}>Event history</h3>\n <StableEventTimeline\n label=\"Facility events\"\n events={snapshot.events}\n start={review.range.start}\n end={review.range.end}\n selectedId={selectedEvent?.id ?? null}\n onSelectionChange={selectFromTimeline}\n />\n </section>\n {capture && (\n <AttachmentPreview\n label=\"Captured camera frame\"\n attachment={{\n id: \"local-frame\",\n name: `${capture.camera} · ${utc(capture.at)}`,\n sizeBytes: Math.floor(((capture.src.split(\",\")[1]?.length ?? 0) * 3) / 4),\n mediaType: \"image/png\",\n }}\n preview={{\n kind: \"image\",\n src: capture.src,\n alt: `Generated training image from ${capture.camera}, captured ${utc(capture.at)}`,\n }}\n onDownload={() => {\n const anchor = document.createElement(\"a\");\n anchor.href = capture.src;\n anchor.download = \"facility-training-snapshot.png\";\n anchor.click();\n }}\n onRemove={() => setCapture(null)}\n />\n )}\n </section>\n );\n}\n\nconst CameraWallTile = memo(function CameraWallTile({\n camera,\n time,\n historical,\n playing,\n rate,\n onOpen,\n}: {\n camera: FacilityCamera;\n time: number;\n historical: boolean;\n playing: boolean;\n rate: number;\n onOpen: (camera: FacilityCamera) => void;\n}) {\n const [frame, setFrame] = useState<{ src: string; value: VideoFrameState } | null>(null);\n const latency =\n camera.latencyMs !== null && Number.isFinite(camera.latencyMs) && camera.latencyMs >= 0\n ? camera.latencyMs\n : null;\n const clip =\n historical || camera.connection === \"connected\"\n ? resolveRecording(camera, time - (historical ? 0 : (latency ?? 0)))\n : null;\n const src = clip?.recording.src ?? \"\";\n const onFrameChange = useCallback((value: VideoFrameState) => setFrame({ src, value }), [src]);\n const actual = frame?.src === src ? frame.value : null;\n const ready =\n !!clip &&\n actual?.state === \"ready\" &&\n actual.mediaTime !== null &&\n Math.abs(actual.mediaTime - clip.mediaTime) <= 0.25;\n const capturedAt =\n clip && actual?.mediaTime != null\n ? clip.recording.start + (actual.mediaTime - clip.recording.mediaOffset) * 1000\n : null;\n const state =\n !clip || actual?.state === \"error\"\n ? \"unavailable\"\n : !ready\n ? \"reconnecting\"\n : historical\n ? playing\n ? \"recorded\"\n : \"paused\"\n : latency === null\n ? \"recorded\"\n : latency > 0\n ? \"delayed\"\n : \"live\";\n return (\n <MediaFrame\n label={`${camera.name} overview`}\n state={state}\n capturedAt={capturedAt}\n source={camera.source}\n retained={!!clip && capturedAt !== null && !ready}\n detail={\n !clip\n ? \"No camera image available.\"\n : actual?.state === \"error\"\n ? (actual.message ?? \"Media could not be decoded.\")\n : !ready\n ? \"Waiting for the requested frame.\"\n : !historical && latency === null\n ? \"Live alignment unknown.\"\n : \"Frame aligned to the shared review time.\"\n }\n actions={\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => onOpen(camera)}>\n Open {camera.name}\n </Button>\n }\n >\n {clip && (\n <SynchronizedVideo\n key={clip.recording.id}\n src={src}\n time={clip.mediaTime}\n playing={playing}\n playbackRate={rate}\n label={`${camera.name} overview footage`}\n onFrameChange={onFrameChange}\n />\n )}\n </MediaFrame>\n );\n});\n",
4787
+ "patterns/geospatial-workspace/geo-map.tsx": "\"use client\";\n\nimport {\n memo,\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type RefObject,\n} from \"react\";\nimport { Button, EntityPreview, Popover } from \"@oprt/react\";\nimport type { EngineCallbacks, EngineState, GeoEngine, TileState } from \"./engine-contract\";\nimport { isGeoPoint, normalizeGeoView, sameGeoAnchor, sameGeoFrame } from \"./geometry\";\nimport { mapDataIssues } from \"./renderer-data\";\nimport type {\n GeoAnnotation,\n GeoEntity,\n GeoFrame,\n GeoMapProps,\n GeoMarkerGroup,\n GeoPoint,\n} from \"./types\";\nimport { UtcStamp } from \"./utc-stamp\";\n\n/** One shared empty collection, so a host that passes a new `[]` each render changes nothing. */\nconst NO_ANNOTATIONS: readonly GeoAnnotation[] = [];\nconst NO_POINTS: readonly GeoPoint[] = [];\nconst useBrowserLayoutEffect = typeof document === \"undefined\" ? useEffect : useLayoutEffect;\nconst finitePixel = (point: readonly number[] | null) =>\n point !== null && point.length === 2 && point.every(Number.isFinite);\n\n/** One engine start. Retiring it (dispose, failure, unmount) silences every callback it issued. */\ninterface EngineInstance {\n live: boolean;\n}\n\ninterface MarkerProps {\n group: GeoMarkerGroup;\n records: ReadonlyMap<string, GeoEntity>;\n isSelected: boolean;\n open: boolean;\n /** The location choice made inside this group while it is open. */\n choice: string | null;\n /** The supplied selection, which a choice waits for; only read while the group is open. */\n selectedId: string | null;\n region: RefObject<HTMLDivElement | null>;\n onOpenChange: (groupId: string, open: boolean, singleId: string | null) => void;\n onChoose: (groupId: string, entityId: string, origin: HTMLElement) => void;\n onPin: (groupId: string, entityId: string) => void;\n onCloseAutoFocus: (groupId: string, event: Event) => void;\n}\n\nconst sameIds = (a: readonly string[], b: readonly string[]) =>\n a === b || (a.length === b.length && a.every((item, index) => item === b[index]));\n/** A marker is drawn again for what it shows, not for where it is: a pan only moves its wrapper. */\nconst sameMarker = (previous: MarkerProps, next: MarkerProps) =>\n previous.group.id === next.group.id &&\n sameIds(previous.group.ids, next.group.ids) &&\n previous.records === next.records &&\n previous.isSelected === next.isSelected &&\n previous.open === next.open &&\n previous.choice === next.choice &&\n previous.selectedId === next.selectedId &&\n previous.region === next.region &&\n previous.onOpenChange === next.onOpenChange &&\n previous.onChoose === next.onChoose &&\n previous.onPin === next.onPin &&\n previous.onCloseAutoFocus === next.onCloseAutoFocus;\n\nconst ClusterMarker = memo(function ClusterMarker({\n group,\n records,\n isSelected,\n open,\n choice,\n selectedId,\n region,\n onOpenChange,\n onChoose,\n onPin,\n onCloseAutoFocus,\n}: MarkerProps) {\n const members = group.ids.flatMap((entityId) =>\n records.has(entityId) ? [records.get(entityId)!] : [],\n );\n const selected =\n choice && choice === selectedId && group.ids.includes(choice) ? records.get(choice) : undefined;\n const single = members.length === 1 ? members[0] : undefined;\n const label = single\n ? `${single.record.name} — ${single.record.id}`\n : `${members.length} equipment at this location`;\n return (\n <Popover\n title={selected ? \"Equipment preview\" : \"Equipment at this location\"}\n open={open}\n onOpenChange={(next) => onOpenChange(group.id, next, single?.record.id ?? null)}\n returnFocusRef={region}\n onCloseAutoFocus={(event) => onCloseAutoFocus(group.id, event)}\n content={\n <div className=\"flex max-w-[calc(var(--oprt-space-8)*5)] flex-col gap-2u\">\n {selected ? (\n <>\n <EntityPreview\n entity={{\n ...selected.record,\n facts: [\n ...selected.record.facts.map((fact) => ({\n ...fact,\n id: `entity:${fact.id}`,\n })),\n {\n id: \"geo:position\",\n label: \"Position\",\n value: selected.positionState,\n },\n {\n id: \"geo:observed\",\n label: \"Observed\",\n value: <UtcStamp value={selected.observedAt} />,\n mono: true,\n },\n ],\n }}\n actions={\n <Button size=\"sm\" onClick={() => onPin(group.id, selected.record.id)}>\n Pin in Inspector\n </Button>\n }\n />\n </>\n ) : (\n <>\n <p className=\"m-0 normal-case\">Choose a supplied record to preview its details.</p>\n {members.map((entity) => (\n <Button\n key={entity.record.id}\n size=\"sm\"\n variant=\"secondary\"\n className=\"h-auto! min-h-[var(--oprt-height-sm)] whitespace-normal! break-words text-left normal-case!\"\n onClick={(event) => onChoose(group.id, entity.record.id, event.currentTarget)}\n >\n {entity.record.name} — {entity.record.id}\n </Button>\n ))}\n {/* In the preview from the start, empty and out of the flow, and the text is\n written into it (docs/verification/2026-10-21-screen-readers.md). */}\n <p role=\"status\" className={choice ? \"normal-case\" : \"sr-only\"}>\n {choice ? \"Selection requested; waiting for the supplied selection.\" : \"\"}\n </p>\n </>\n )}\n </div>\n }\n >\n <Button\n size=\"sm\"\n variant={isSelected ? \"primary\" : \"secondary\"}\n aria-label={label}\n aria-pressed={isSelected}\n className={`whitespace-nowrap normal-case!${isSelected ? \"\" : \" bg-oprt-surface-1!\"}`}\n >\n {single ? single.record.id : members.length}\n </Button>\n </Popover>\n );\n}, sameMarker);\n\n/**\n * Optional renderer. All source, view and selection authority stays with its host.\n *\n * `snapshot`, `entities`, `annotations`, `measurement` and `layers` are compared by identity: the\n * renderer redraws when the host supplies a new collection, and not when it supplies an equal one\n * (a map of 2,000 reports cannot be compared by value on every pan frame). Keep a collection\n * stable between changes, for example in state or memoized.\n */\nexport const GeoMap = memo(function GeoMap(props: GeoMapProps) {\n const id = useId();\n const target = useRef<HTMLDivElement>(null);\n const region = useRef<HTMLDivElement>(null);\n const adapter = useRef<GeoEngine | null>(null);\n const instance = useRef<EngineInstance | null>(null);\n const anchorFrame = useRef<GeoFrame | null>(null);\n // The marker group whose preview or choices are open, for the engine callbacks, which never change\n // identity and so cannot read the state.\n const openGroupRef = useRef<string | null>(null);\n const pinnedGroup = useRef<string | null>(null);\n const choiceFocus = useRef<{\n id: string;\n groupId: string;\n origin: HTMLElement;\n dialog: HTMLElement;\n source: string;\n } | null>(null);\n const { snapshot, entities, selectedId, layers, mode, tileSource } = props;\n const annotations = props.annotations.length ? props.annotations : NO_ANNOTATIONS;\n const measurement = props.measurement.length ? props.measurement : NO_POINTS;\n // The view is a few numbers, not an object to compare: a pan changes these and nothing else.\n const view = normalizeGeoView(props.view);\n const [longitude, latitude] = view.center;\n const { zoom, bearing } = view;\n const tileUrl = tileSource?.url;\n const tileAttribution = tileSource?.attribution;\n const sourceKey = useMemo(\n () => JSON.stringify([snapshot.id, snapshot.revision]),\n [snapshot.id, snapshot.revision],\n );\n // What the engine draws. Its identity changes exactly when one of these inputs does, and it is\n // the stamp that tells a current engine callback from one the next update superseded.\n const state = useMemo<EngineState>(\n () => ({\n snapshot,\n entities,\n selectedId,\n view: { center: [longitude, latitude], zoom, bearing },\n layers,\n annotations,\n measurement,\n mode,\n ...(tileUrl !== undefined && tileAttribution !== undefined\n ? { tileSource: { url: tileUrl, attribution: tileAttribution } }\n : {}),\n }),\n [\n snapshot,\n entities,\n selectedId,\n longitude,\n latitude,\n zoom,\n bearing,\n layers,\n annotations,\n measurement,\n mode,\n tileUrl,\n tileAttribution,\n ],\n );\n // The supplied data is checked once per change of the data, never for a change of the view.\n const issues = useMemo(\n () => mapDataIssues({ snapshot, entities, annotations, measurement }),\n [snapshot, entities, annotations, measurement],\n );\n const positionedIds = useMemo(\n () =>\n new Set(\n entities.filter((entity) => isGeoPoint(entity.position)).map((entity) => entity.record.id),\n ),\n [entities],\n );\n const records = useMemo(\n () => new Map(entities.map((entity) => [entity.record.id, entity])),\n [entities],\n );\n const enabled = !props.disabled && !issues.duplicates && snapshot.availability !== \"unavailable\";\n const latest = useRef({ props, state, enabled, positionedIds });\n latest.current = { props, state, enabled, positionedIds };\n const [attempt, setAttempt] = useState(0);\n const [status, setStatus] = useState<\"loading\" | \"ready\" | \"error\">(\"loading\");\n const [error, setError] = useState(\"\");\n const [frame, setFrame] = useState<GeoFrame | null>(null);\n const [tileState, setTileState] = useState<TileState>(\"loading\");\n const [openGroup, setOpenGroup] = useState<string | null>(null);\n openGroupRef.current = openGroup;\n const [choice, setChoice] = useState<string | null>(null);\n /** Callbacks for one engine start and one state; they go quiet once either is superseded. */\n const makeCallbacks = useCallback(\n (expected: EngineState, owner: EngineInstance): EngineCallbacks => {\n const current = () =>\n owner.live && latest.current.enabled && latest.current.state === expected;\n return {\n onFrame(next) {\n if (!current()) return;\n const ids = latest.current.positionedIds;\n if (\n next.visibleIds.some((entityId) => !ids.has(entityId)) ||\n new Set(next.visibleIds).size !== next.visibleIds.length ||\n next.groups.some(\n (group) =>\n !isGeoPoint(group.point) ||\n !finitePixel(group.pixel) ||\n !group.ids.length ||\n group.ids.some((entityId) => !ids.has(entityId)) ||\n new Set(group.ids).size !== group.ids.length,\n ) ||\n (next.selectedPixel !== null && !finitePixel(next.selectedPixel)) ||\n (next.metersPerPixel !== null &&\n (!Number.isFinite(next.metersPerPixel) || next.metersPerPixel <= 0))\n )\n return;\n // What is open belongs to its marker group, not to the pixel it is drawn at. A frame that\n // only moves the view (Fit map, a zoom, a pan, or the second frame of the renderer settling)\n // moves the marker and what is open from it; it is dismissed when its group is gone or its\n // equipment moved on the ground.\n const open = openGroupRef.current;\n if (open !== null && !sameGeoAnchor(anchorFrame.current, next, open)) {\n choiceFocus.current = null;\n openGroupRef.current = null;\n setOpenGroup(null);\n setChoice(null);\n }\n anchorFrame.current = next;\n setFrame((previous) => (sameGeoFrame(previous, next) ? previous : next));\n latest.current.props.onFrame(next);\n },\n onViewChange(next) {\n if (current()) latest.current.props.onViewChange(normalizeGeoView(next));\n },\n onPoint(point) {\n if (current() && latest.current.props.mode !== \"inspect\" && isGeoPoint(point))\n latest.current.props.onPoint([point[0], point[1]]);\n },\n onTileState(next) {\n if (current()) setTileState(next);\n },\n };\n },\n [],\n );\n\n useEffect(() => {\n // This start owns the callbacks it issues. Retiring it is a write to its own object, not to a\n // counter held in a ref, so the cleanup reads nothing that React might have moved on.\n const owner: EngineInstance = { live: true };\n instance.current = owner;\n anchorFrame.current = null;\n choiceFocus.current = null;\n setFrame(null);\n setOpenGroup(null);\n setChoice(null);\n latest.current.props.onFrame(null);\n if (!enabled || !target.current) return;\n setStatus(\"loading\");\n setError(\"\");\n setTileState(\"loading\");\n void import(\"./engine\")\n .then(({ createGeoEngine }) => {\n if (!owner.live || !latest.current.enabled || !target.current) return;\n const current = latest.current;\n adapter.current = createGeoEngine(\n target.current,\n current.state,\n makeCallbacks(current.state, owner),\n );\n setStatus(\"ready\");\n })\n .catch((cause: unknown) => {\n if (!owner.live) return;\n owner.live = false;\n adapter.current?.dispose();\n adapter.current = null;\n setError(cause instanceof Error ? cause.message : \"Map renderer could not start.\");\n setStatus(\"error\");\n setFrame(null);\n latest.current.props.onFrame(null);\n });\n return () => {\n owner.live = false;\n adapter.current?.dispose();\n adapter.current = null;\n latest.current.props.onFrame(null);\n };\n }, [enabled, attempt, makeCallbacks]);\n\n useEffect(() => {\n const owner = instance.current;\n if (!adapter.current || !enabled || !owner?.live) return;\n try {\n adapter.current.update(state, makeCallbacks(state, owner));\n } catch (cause: unknown) {\n adapter.current.dispose();\n adapter.current = null;\n owner.live = false;\n setError(cause instanceof Error ? cause.message : \"Map renderer could not update.\");\n setStatus(\"error\");\n setFrame(null);\n latest.current.props.onFrame(null);\n }\n }, [state, enabled, makeCallbacks]);\n\n useEffect(() => {\n const leave = (event: Event) => {\n const pending = choiceFocus.current;\n if (pending && !pending.dialog.contains(event.target as Node | null))\n choiceFocus.current = null;\n };\n const blur = (event: FocusEvent) => {\n const pending = choiceFocus.current;\n // A still-connected choice explicitly blurred to nowhere is a departure;\n // removing the focused choice during acceptance is handled after commit.\n if (\n pending &&\n event.target === pending.origin &&\n pending.origin.isConnected &&\n !event.relatedTarget\n )\n choiceFocus.current = null;\n };\n document.addEventListener(\"focusin\", leave, true);\n document.addEventListener(\"pointerdown\", leave, true);\n document.addEventListener(\"focusout\", blur, true);\n return () => {\n choiceFocus.current = null;\n document.removeEventListener(\"focusin\", leave, true);\n document.removeEventListener(\"pointerdown\", leave, true);\n document.removeEventListener(\"focusout\", blur, true);\n };\n }, []);\n useBrowserLayoutEffect(() => {\n const pending = choiceFocus.current;\n if (!pending) return;\n if (\n !enabled ||\n status !== \"ready\" ||\n pending.groupId !== openGroup ||\n pending.id !== choice ||\n pending.source !== sourceKey ||\n !pending.dialog.isConnected\n ) {\n choiceFocus.current = null;\n return;\n }\n if (selectedId !== pending.id) return;\n choiceFocus.current = null;\n const active = document.activeElement;\n if (\n pending.dialog.contains(active) ||\n (active === document.body && !pending.origin.isConnected)\n )\n pending.dialog.focus({ preventScroll: true });\n }, [choice, selectedId, openGroup, enabled, status, sourceKey]);\n\n // The marker handlers read the latest props and write state, so they never change identity and\n // a marker is not drawn again because the map around it was.\n const onMarkerOpenChange = useCallback(\n (groupId: string, open: boolean, singleId: string | null) => {\n setOpenGroup(open ? groupId : null);\n choiceFocus.current = null;\n if (open) {\n setChoice(singleId);\n if (singleId) latest.current.props.onSelectedIdChange(singleId);\n }\n },\n [],\n );\n const onMarkerChoose = useCallback(\n (groupId: string, entityId: string, origin: HTMLElement) => {\n const dialog = origin.closest<HTMLElement>(\"[role=dialog]\");\n choiceFocus.current = dialog\n ? { id: entityId, groupId, origin, dialog, source: sourceKey }\n : null;\n setChoice(entityId);\n latest.current.props.onSelectedIdChange(entityId);\n },\n [sourceKey],\n );\n const onMarkerPin = useCallback((groupId: string, entityId: string) => {\n pinnedGroup.current = groupId;\n setOpenGroup(null);\n latest.current.props.onInspect(entityId);\n }, []);\n const onMarkerCloseAutoFocus = useCallback((groupId: string, event: Event) => {\n if (pinnedGroup.current === groupId) {\n pinnedGroup.current = null;\n event.preventDefault();\n }\n }, []);\n\n // Only the first of these three applies: duplicates are an alert of their own.\n const mapNotice = issues.duplicates\n ? \"\"\n : props.disabled\n ? \"Map disabled. Use the table to review equipment.\"\n : snapshot.availability === \"unavailable\"\n ? \"Map source unavailable. Use the table and source recovery controls.\"\n : \"\";\n const tileMessage =\n tileState === \"loading\"\n ? \"Loading background tiles…\"\n : tileState === \"error\"\n ? \"Background tiles unavailable. Local vectors and the table remain available.\"\n : \"\";\n const entityLayer = layers.find((layer) => layer.id === \"entities\");\n const groups =\n enabled &&\n status === \"ready\" &&\n mode === \"inspect\" &&\n entityLayer?.visible &&\n entityLayer.opacity > 0\n ? (frame?.groups ?? [])\n : [];\n return (\n <section\n aria-labelledby={`${id}-title`}\n className={`min-w-0 flex flex-col gap-2u font-sans text-oprt-base text-oprt-text ${props.className ?? \"\"}`}\n >\n <h3\n id={`${id}-title`}\n className=\"m-0 text-oprt-base font-medium uppercase tracking-oprt-base\"\n >\n Equipment map\n </h3>\n <p id={`${id}-help`} className=\"sr-only\">\n Focus the viewport and use arrow keys to pan, plus or minus to zoom. Use equipment markers\n for location choices. Equipment records remain available for inspection, including unknown\n positions.\n </p>\n {/* The notices that come and go with the data and the map's state are in the page from the\n start, empty and out of the flow, and their text is written into them: a screen reader\n speaks text that arrives in a status it already has, and said nothing for one inserted\n together with its text (docs/verification/2026-10-21-screen-readers.md). */}\n <p role=\"status\" className={issues.invalid > 0 ? undefined : \"sr-only\"}>\n {issues.invalid > 0 && (\n <>\n {issues.invalid} invalid geometr{issues.invalid === 1 ? \"y\" : \"ies\"} excluded from the\n map. Review supplied coordinates in the table.{\" \"}\n {issues.uncertainty > 0 &&\n `${issues.uncertainty} uncertainty footprint${issues.uncertainty === 1 ? \" is\" : \"s are\"} excluded because of an invalid radius or unsupported projection bounds.`}\n </>\n )}\n </p>\n {issues.unknown > 0 && (\n <p>\n {issues.unknown} record{issues.unknown === 1 ? \" has\" : \"s have\"} an unknown position.\n </p>\n )}\n {issues.duplicates && (\n <p role=\"alert\">\n Duplicate or empty IDs prevent unambiguous map selection. Correct the supplied source; the\n equipment records remain available.\n </p>\n )}\n <p role=\"status\" className={mapNotice ? undefined : \"sr-only\"}>\n {mapNotice}\n </p>\n <div\n ref={region}\n role=\"group\"\n aria-label=\"Map viewport\"\n aria-describedby={`${id}-help`}\n tabIndex={enabled && status === \"ready\" ? 0 : -1}\n className=\"relative h-[calc(var(--oprt-space-8)*8)] min-w-0 overflow-hidden border border-oprt-border bg-oprt-surface-2 outline-hidden focus-visible:border-oprt-border-focus focus-visible:shadow-oprt-focus focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-oprt-border-focus forced-colors:focus-visible:outline-[CanvasText]\"\n >\n <div\n ref={target}\n aria-hidden=\"true\"\n role=\"presentation\"\n className=\"absolute inset-0 [&_.ol-viewport]:touch-none!\"\n />\n <p\n role=\"status\"\n className={\n enabled && status === \"loading\"\n ? \"absolute inset-x-0 top-0 m-0 bg-oprt-surface-1 p-2u\"\n : \"sr-only\"\n }\n >\n {enabled && status === \"loading\" ? \"Loading map renderer…\" : \"\"}\n </p>\n {enabled && status === \"error\" && (\n <div className=\"absolute inset-0 flex flex-col items-start gap-2u bg-oprt-surface-1 p-3u\">\n <p role=\"alert\">Map renderer unavailable: {error}</p>\n <Button size=\"sm\" onClick={() => setAttempt((value) => value + 1)}>\n Retry map\n </Button>\n </div>\n )}\n {groups.map((group) => {\n if (!group.ids.every((entityId) => records.has(entityId))) return null;\n const open = openGroup === group.id;\n return (\n <div\n key={group.id}\n className=\"absolute -translate-x-1/2 -translate-y-1/2\"\n style={{ left: group.pixel[0], top: group.pixel[1] }}\n >\n <ClusterMarker\n group={group}\n records={records}\n isSelected={selectedId !== null && group.ids.includes(selectedId)}\n open={open}\n choice={open ? choice : null}\n selectedId={open ? selectedId : null}\n region={region}\n onOpenChange={onMarkerOpenChange}\n onChoose={onMarkerChoose}\n onPin={onMarkerPin}\n onCloseAutoFocus={onMarkerCloseAutoFocus}\n />\n </div>\n );\n })}\n </div>\n {enabled && tileSource && (\n <div className=\"flex flex-wrap items-center gap-2u text-oprt-text-dim\">\n <span className=\"[overflow-wrap:anywhere]\">{tileSource.attribution}</span>\n <span role=\"status\" className={tileMessage ? undefined : \"sr-only\"}>\n {tileMessage}\n </span>\n {tileState === \"error\" && (\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => setAttempt((value) => value + 1)}>\n Retry tiles\n </Button>\n )}\n </div>\n )}\n </section>\n );\n});\n",
4788
4788
  "patterns/geospatial-workspace/engine-contract.ts": "import type { GeoFrame, GeoMapProps, GeoPoint, GeoView } from \"./types\";\n\nexport type EngineState = Pick<\n GeoMapProps,\n | \"snapshot\"\n | \"entities\"\n | \"selectedId\"\n | \"view\"\n | \"layers\"\n | \"annotations\"\n | \"measurement\"\n | \"mode\"\n | \"tileSource\"\n>;\nexport type TileState = \"loading\" | \"ready\" | \"error\";\nexport interface EngineCallbacks {\n onFrame: (frame: GeoFrame) => void;\n onViewChange: (view: GeoView) => void;\n onPoint: (point: GeoPoint) => void;\n onTileState: (state: TileState) => void;\n}\nexport interface GeoEngine {\n update: (state: EngineState, callbacks: EngineCallbacks) => void;\n dispose: () => void;\n}\n",
4789
4789
  "patterns/geospatial-workspace/types.ts": "import type { EntityPreviewRecord, LayerControlItem } from \"@oprt/react\";\n\n/** WGS84 longitude, latitude in decimal degrees. */\nexport type GeoPoint = readonly [number, number];\nexport interface GeoView {\n center: GeoPoint;\n zoom: number;\n bearing: number;\n}\nexport interface GeoEntity {\n record: EntityPreviewRecord;\n position: GeoPoint | null;\n positionState: \"reported\" | \"last-known\" | \"unknown\";\n observedAt: string | null;\n uncertaintyMeters?: number;\n coverage?: readonly GeoPoint[];\n track?: readonly { position: GeoPoint; observedAt: string }[];\n}\nexport interface GeoSiteFeature {\n id: string;\n label: string;\n kind: \"building\" | \"road\" | \"water\" | \"boundary\";\n points: readonly GeoPoint[];\n}\nexport interface GeoSnapshot {\n id: string;\n title?: string;\n revision: string;\n source: string;\n observedAt: string | null;\n availability: \"current\" | \"partial\" | \"offline\" | \"unavailable\";\n detail?: string;\n entities: readonly GeoEntity[];\n site: readonly GeoSiteFeature[];\n}\nexport interface GeoAnnotation {\n id: string;\n label: string;\n position: GeoPoint;\n sourceId: string;\n createdAt: string;\n}\nexport interface GeoAnnotationDraft {\n label: string;\n position: GeoPoint;\n sourceId: string;\n}\nexport type GeoAnnotationResult =\n | { status: \"saved\"; annotation: GeoAnnotation }\n | { status: \"failed\"; message: string };\nexport interface GeoBookmark {\n id: string;\n name: string;\n view: GeoView;\n layers: readonly LayerControlItem[];\n}\nexport interface GeoMarkerGroup {\n id: string;\n ids: readonly string[];\n point: GeoPoint;\n pixel: readonly [number, number];\n}\nexport interface GeoFrame {\n view: GeoView;\n visibleIds: readonly string[];\n groups: readonly GeoMarkerGroup[];\n selectedPixel: readonly [number, number] | null;\n metersPerPixel: number | null;\n}\nexport interface GeoTileSource {\n url: string;\n attribution: string;\n}\nexport type GeoMapMode = \"inspect\" | \"measure\" | \"annotate\";\nexport interface GeoMapProps {\n snapshot: GeoSnapshot;\n entities: readonly GeoEntity[];\n selectedId: string | null;\n onSelectedIdChange: (id: string | null) => void;\n view: GeoView;\n onViewChange: (view: GeoView) => void;\n layers: readonly LayerControlItem[];\n annotations: readonly GeoAnnotation[];\n measurement: readonly GeoPoint[];\n mode: GeoMapMode;\n onPoint: (point: GeoPoint) => void;\n onFrame: (frame: GeoFrame | null) => void;\n onInspect: (id: string) => void;\n tileSource?: GeoTileSource;\n disabled?: boolean;\n className?: string;\n}\nexport interface GeospatialWorkspaceProps {\n snapshot: GeoSnapshot;\n selectedId: string | null;\n onSelectedIdChange: (id: string | null) => void;\n annotations: readonly GeoAnnotation[];\n onCreateAnnotation?: (draft: GeoAnnotationDraft) => Promise<GeoAnnotationResult>;\n onRemoveAnnotation?: (id: string) => void;\n bookmarks: readonly GeoBookmark[];\n onBookmarksChange: (bookmarks: readonly GeoBookmark[]) => void;\n initialView?: GeoView;\n tileSource?: GeoTileSource;\n}\n",
4790
4790
  "patterns/geospatial-workspace/geometry.ts": "import type { MapAction } from \"@oprt/react\";\nimport type { GeoEntity, GeoFrame, GeoPoint, GeoView } from \"./types\";\n\nexport const MERCATOR_LATITUDE = 85.05112878;\nconst radians = Math.PI / 180;\nconst wrap = (value: number, period: number) => ((value % period) + period) % period;\nexport const normalizeLongitude = (longitude: number) => wrap(longitude + 180, 360) - 180;\nconst finite = (value: unknown, fallback: number): number =>\n typeof value === \"number\" && Number.isFinite(value) ? value : fallback;\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\nconst mercatorY = (latitude: number) =>\n Math.log(Math.tan(Math.PI / 4 + (latitude * radians) / 2)) / (2 * Math.PI);\nconst latitudeFromY = (y: number) =>\n (2 * Math.atan(Math.exp(y * 2 * Math.PI)) - Math.PI / 2) / radians;\n\nexport function isGeoPoint(value: unknown): value is GeoPoint {\n return (\n Array.isArray(value) &&\n value.length === 2 &&\n value.every((axis) => typeof axis === \"number\" && Number.isFinite(axis)) &&\n Math.abs(value[0]) <= 180 &&\n Math.abs(value[1]) <= MERCATOR_LATITUDE\n );\n}\nexport function normalizeGeoView(view: GeoView): GeoView {\n return {\n center: [\n normalizeLongitude(finite(view?.center?.[0], 0)),\n clamp(finite(view?.center?.[1], 0), -MERCATOR_LATITUDE, MERCATOR_LATITUDE),\n ],\n zoom: clamp(finite(view?.zoom, 2), 2, 20),\n bearing: wrap(finite(view?.bearing, 0), 360),\n };\n}\nexport function distanceMeters(a: GeoPoint, b: GeoPoint): number | null {\n if (!isGeoPoint(a) || !isGeoPoint(b)) return null;\n const dLat = (b[1] - a[1]) * radians,\n dLon = normalizeLongitude(b[0] - a[0]) * radians;\n const arc =\n Math.sin(dLat / 2) ** 2 +\n Math.cos(a[1] * radians) * Math.cos(b[1] * radians) * Math.sin(dLon / 2) ** 2;\n return 2 * 6371008.8 * Math.asin(Math.sqrt(clamp(arc, 0, 1)));\n}\nexport function getFitView(entities: readonly GeoEntity[]): GeoView {\n const points = entities.flatMap((entity) =>\n isGeoPoint(entity.position) ? [entity.position] : [],\n );\n if (!points.length) return { center: [0, 0], zoom: 2, bearing: 0 };\n const longitudes = points.map((point) => wrap(point[0], 360)).sort((a, b) => a - b);\n let largestGap = -1,\n start = longitudes[0]!;\n for (let index = 0; index < longitudes.length; index++) {\n const next =\n longitudes[(index + 1) % longitudes.length]! + (index === longitudes.length - 1 ? 360 : 0);\n const gap = next - longitudes[index]!;\n if (gap > largestGap) {\n largestGap = gap;\n start = next;\n }\n }\n const span = 360 - largestGap;\n const ys = points.map((point) => mercatorY(point[1]));\n const low = Math.min(...ys),\n high = Math.max(...ys);\n const fraction = Math.max(span / 360, high - low);\n return normalizeGeoView({\n center: [start + span / 2, latitudeFromY((low + high) / 2)],\n zoom: fraction > 0 ? Math.floor(Math.log2(0.7 / fraction)) : 16,\n bearing: 0,\n });\n}\nexport function moveGeoView(\n view: GeoView,\n action: MapAction,\n entities: readonly GeoEntity[],\n): GeoView {\n const next = normalizeGeoView(view);\n if (action === \"fit\") return getFitView(entities);\n const step = 0.25 / 2 ** next.zoom;\n return normalizeGeoView({\n center: [\n next.center[0] + (action === \"pan-east\" ? 1 : action === \"pan-west\" ? -1 : 0) * step * 360,\n latitudeFromY(\n mercatorY(next.center[1]) +\n (action === \"pan-north\" ? 1 : action === \"pan-south\" ? -1 : 0) * step,\n ),\n ],\n zoom: next.zoom + (action === \"zoom-in\" ? 1 : action === \"zoom-out\" ? -1 : 0),\n bearing: action === \"reset-north\" ? 0 : next.bearing,\n });\n}\n\n/** Approximate spherical footprint. A rejected cap is omitted and counted by the host. */\nexport function getUncertaintyFootprint(\n center: GeoPoint,\n radius: number,\n): readonly GeoPoint[] | null {\n if (!isGeoPoint(center) || !Number.isFinite(radius) || radius < 0) return null;\n if (radius === 0) return [];\n const angularRadius = radius / 6371008.8;\n // Check the complete cap, including its interior, before sampling its boundary.\n if (Math.abs(center[1]) + angularRadius / radians > MERCATOR_LATITUDE) return null;\n const latitude = center[1] * radians;\n const sinLatitude = Math.sin(latitude),\n cosLatitude = Math.cos(latitude);\n const sinRadius = Math.sin(angularRadius),\n cosRadius = Math.cos(angularRadius);\n const points: GeoPoint[] = Array.from({ length: 64 }, (_, index) => {\n const bearing = (2 * Math.PI * index) / 64;\n const nextLatitude = Math.asin(\n clamp(sinLatitude * cosRadius + cosLatitude * sinRadius * Math.cos(bearing), -1, 1),\n );\n const deltaLongitude = Math.atan2(\n Math.sin(bearing) * sinRadius * cosLatitude,\n cosRadius - sinLatitude * Math.sin(nextLatitude),\n );\n return [normalizeLongitude(center[0] + deltaLongitude / radians), nextLatitude / radians];\n });\n return points.every(isGeoPoint) ? points : null;\n}\n\nconst same = (a: number, b: number) => a === b || (Number.isNaN(a) && Number.isNaN(b));\nconst samePair = (a: readonly number[], b: readonly number[]) =>\n same(a[0]!, b[0]!) && same(a[1]!, b[1]!);\nconst sameList = (a: readonly string[], b: readonly string[]) =>\n a === b || (a.length === b.length && a.every((item, index) => item === b[index]));\n\n/**\n * Whether two map frames report the same view, extent, markers and scale. Frames come about every\n * animation frame, so this walks them once instead of serializing them to compare the text.\n */\nexport function sameGeoFrame(a: GeoFrame | null, b: GeoFrame | null): boolean {\n if (a === b) return true;\n if (!a || !b) return false;\n if (\n !samePair(a.view.center, b.view.center) ||\n !same(a.view.zoom, b.view.zoom) ||\n !same(a.view.bearing, b.view.bearing) ||\n !sameList(a.visibleIds, b.visibleIds) ||\n a.groups.length !== b.groups.length ||\n (a.selectedPixel === null) !== (b.selectedPixel === null) ||\n (a.selectedPixel !== null && !samePair(a.selectedPixel, b.selectedPixel!)) ||\n (a.metersPerPixel === null) !== (b.metersPerPixel === null) ||\n (a.metersPerPixel !== null && !same(a.metersPerPixel, b.metersPerPixel!))\n )\n return false;\n return a.groups.every((group, index) => {\n const other = b.groups[index]!;\n return (\n group.id === other.id &&\n sameList(group.ids, other.ids) &&\n samePair(group.point, other.point) &&\n samePair(group.pixel, other.pixel)\n );\n });\n}\n\n/**\n * Whether the marker group `groupId` is still anchored in `after`: the frame draws a group with that\n * id (made from its members) and, where `before` had it too, at the same point on the ground. Its\n * pixel and the view around it are left out on purpose: a marker moves with the map, so whatever is\n * open from the marker (a preview, a list of choices) moves with it. An open marker is dismissed when\n * its group is gone (out of the viewport, regrouped) or when its equipment has moved on the ground.\n */\nexport function sameGeoAnchor(before: GeoFrame | null, after: GeoFrame, groupId: string): boolean {\n const next = after.groups.find((group) => group.id === groupId);\n if (!next) return false;\n const previous = before?.groups.find((group) => group.id === groupId);\n return !previous || samePair(previous.point, next.point);\n}\n",
@@ -4797,7 +4797,7 @@
4797
4797
  "patterns/geospatial-workspace/fixtures.ts": "import type { LayerControlItem } from \"@oprt/react\";\nimport type { GeoEntity, GeoPoint, GeoSiteFeature, GeoSnapshot, GeoView } from \"./types\";\n\nexport const initialGeoView: GeoView = { center: [-122.3, 37.8], zoom: 16, bearing: 0 };\nexport const initialGeoLayers: readonly LayerControlItem[] = [\n { id: \"annotations\", label: \"Annotations\", visible: true, opacity: 1 },\n { id: \"measurement\", label: \"Measurement\", visible: true, opacity: 1 },\n { id: \"entities\", label: \"Equipment\", visible: true, opacity: 1 },\n { id: \"uncertainty\", label: \"Position uncertainty\", visible: true, opacity: 0.65 },\n { id: \"tracks\", label: \"Last-known tracks\", visible: true, opacity: 0.8 },\n { id: \"coverage\", label: \"Reported coverage\", visible: true, opacity: 0.4 },\n { id: \"site\", label: \"Local site plan\", visible: true, opacity: 1 },\n];\nexport const fixtureObservedAt = \"2026-09-30T12:00:00Z\";\nconst point = (x: number, y: number): GeoPoint => [-122.3 + x, 37.8 + y];\nfunction rectangle(x: number, y: number, width: number, height: number): readonly GeoPoint[] {\n return [\n point(x, y),\n point(x + width, y),\n point(x + width, y + height),\n point(x, y + height),\n point(x, y),\n ];\n}\nconst buildings: GeoSiteFeature[] = [\n {\n id: \"pump-house\",\n label: \"Pump house\",\n kind: \"building\",\n points: rectangle(-0.0021, 0.00025, 0.0006, 0.0005),\n },\n {\n id: \"control-room\",\n label: \"Control room\",\n kind: \"building\",\n points: rectangle(-0.0009, -0.0008, 0.0006, 0.0004),\n },\n {\n id: \"workshop\",\n label: \"Workshop\",\n kind: \"building\",\n points: rectangle(0.0008, -0.00075, 0.001, 0.00045),\n },\n {\n id: \"power-house\",\n label: \"Power house\",\n kind: \"building\",\n points: rectangle(0.0011, 0.0008, 0.0008, 0.00045),\n },\n {\n id: \"stores\",\n label: \"Stores\",\n kind: \"building\",\n points: rectangle(-0.0008, 0.0007, 0.0008, 0.00035),\n },\n {\n id: \"treatment-a\",\n label: \"Treatment A\",\n kind: \"building\",\n points: rectangle(-0.002, -0.0012, 0.0006, 0.0006),\n },\n {\n id: \"treatment-b\",\n label: \"Treatment B\",\n kind: \"building\",\n points: rectangle(-0.002, -0.002, 0.0006, 0.0006),\n },\n {\n id: \"tank-a\",\n label: \"Storage A\",\n kind: \"building\",\n points: rectangle(0.0008, 0.00165, 0.0004, 0.0004),\n },\n {\n id: \"tank-b\",\n label: \"Storage B\",\n kind: \"building\",\n points: rectangle(0.0014, 0.00165, 0.0004, 0.0004),\n },\n];\nexport const utilitySite: readonly GeoSiteFeature[] = [\n {\n id: \"site-boundary\",\n label: \"Utility district\",\n kind: \"boundary\",\n points: rectangle(-0.003, -0.0027, 0.006, 0.0055),\n },\n {\n id: \"water-channel\",\n label: \"Water channel\",\n kind: \"water\",\n points: [\n point(-0.004, -0.004),\n point(-0.0032, -0.004),\n point(-0.0032, 0.004),\n point(-0.004, 0.004),\n point(-0.004, -0.004),\n ],\n },\n {\n id: \"service-road\",\n label: \"Service road\",\n kind: \"road\",\n points: [\n point(-0.0025, -0.003),\n point(-0.0011, -0.0023),\n point(0.0001, -0.0018),\n point(0.0001, 0.0017),\n point(0.0026, 0.0026),\n ],\n },\n {\n id: \"west-access\",\n label: \"West access\",\n kind: \"road\",\n points: [point(-0.003, 0.00005), point(0.0001, 0.00005), point(0.0028, 0.00005)],\n },\n ...buildings,\n];\nfunction equipment(\n id: string,\n name: string,\n typeLabel: string,\n position: GeoPoint | null,\n condition: string,\n variant: \"success\" | \"warning\" | \"neutral\",\n detail: string,\n): GeoEntity {\n return {\n record: {\n id,\n name,\n typeLabel,\n summary: detail,\n condition: { label: condition, variant },\n quality: {\n state: position === null ? \"unknown\" : \"current\",\n source: \"Utility survey / local fixture\",\n updatedAt: Date.parse(fixtureObservedAt),\n detail: \"Supplied fictional observation; no live equipment connection.\",\n },\n facts: [\n { id: \"area\", label: \"Area\", value: \"Harbor utility district\" },\n { id: \"reference\", label: \"Reference\", value: id, mono: true },\n ],\n },\n position,\n positionState: position === null ? \"unknown\" : \"reported\",\n observedAt: position === null ? null : fixtureObservedAt,\n };\n}\nconst vehicle: GeoEntity = {\n ...equipment(\n \"VEH-07\",\n \"Service vehicle 07\",\n \"Maintenance\",\n point(0.0021, -0.0017),\n \"Last-known normal\",\n \"neutral\",\n \"Position is retained from the last report; current movement is unknown.\",\n ),\n positionState: \"last-known\",\n observedAt: \"2026-09-30T11:42:00Z\",\n uncertaintyMeters: 65,\n track: [\n { position: point(-0.0009, -0.0022), observedAt: \"2026-09-30T11:36:00Z\" },\n { position: point(0.0001, -0.0018), observedAt: \"2026-09-30T11:38:00Z\" },\n { position: point(0.0011, -0.00175), observedAt: \"2026-09-30T11:40:00Z\" },\n { position: point(0.0021, -0.0017), observedAt: \"2026-09-30T11:42:00Z\" },\n ],\n};\nvehicle.record = {\n ...vehicle.record,\n quality: {\n ...vehicle.record.quality,\n state: \"stale\",\n updatedAt: vehicle.observedAt === null ? null : Date.parse(vehicle.observedAt),\n detail: \"Last report received at 11:42 UTC. Position uncertainty supplied as 65 m.\",\n },\n};\nexport const utilityEntities: readonly GeoEntity[] = [\n equipment(\n \"PUMP-01\",\n \"Intake pump 01\",\n \"Pump\",\n point(-0.0018, 0.0005),\n \"Ready for service\",\n \"success\",\n \"Duty pump operating normally. Intake valve shares the pump-house location.\",\n ),\n equipment(\n \"VALVE-01\",\n \"Intake valve 01\",\n \"Valve\",\n point(-0.0018, 0.0005),\n \"Inspection due\",\n \"warning\",\n \"Quarterly seal inspection due at 13:00 UTC. Pump operation remains available.\",\n ),\n equipment(\n \"SENSOR-11\",\n \"Pressure sensor 11\",\n \"Sensor\",\n point(0.00032, 0.0003),\n \"Nominal\",\n \"success\",\n \"Reported pressure 6.2 bar. Operating limits belong to the source application.\",\n ),\n equipment(\n \"SENSOR-12\",\n \"Flow sensor 12\",\n \"Sensor\",\n point(0.00038, 0.00033),\n \"Nominal\",\n \"success\",\n \"Reported flow 48 L/s. Nearby sensors remain individually selectable.\",\n ),\n equipment(\n \"POWER-02\",\n \"Power house 02\",\n \"Power\",\n point(0.0015, 0.00105),\n \"Nominal\",\n \"success\",\n \"Supplied equipment position within the eastern service block.\",\n ),\n {\n ...equipment(\n \"CAM-03\",\n \"Perimeter camera 03\",\n \"Camera\",\n point(-0.0007, -0.0006),\n \"Available\",\n \"success\",\n \"Reported observation footprint covers the southern service road.\",\n ),\n coverage: [\n point(-0.0007, -0.0006),\n point(-0.0027, -0.0017),\n point(-0.0017, -0.0025),\n point(0.0006, -0.0024),\n point(-0.0007, -0.0006),\n ],\n },\n vehicle,\n equipment(\n \"GAUGE-04\",\n \"Portable gauge 04\",\n \"Gauge\",\n null,\n \"Location unknown\",\n \"neutral\",\n \"Present in the supplied inventory without geographic coordinates.\",\n ),\n equipment(\n \"PUMP-REMOTE\",\n \"Remote pump station\",\n \"Pump\",\n point(0.027, 0.014),\n \"Inspection due\",\n \"warning\",\n \"Remote station east of the main facility. Seal inspection due at 13:00 UTC.\",\n ),\n];\nexport const utilitySnapshot: GeoSnapshot = {\n title: \"Harbor utilities · field operations\",\n id: \"harbor-utility\",\n revision: \"survey-1\",\n source: \"Harbor utility district / exercise survey\",\n observedAt: fixtureObservedAt,\n availability: \"current\",\n entities: utilityEntities,\n site: utilitySite,\n};\n",
4798
4798
  "patterns/facility-monitoring/types.ts": "import type { TimelineEvent, TimeRangeValue } from \"@oprt/react\";\nimport type { GeoEntity, GeoPoint, GeoSiteFeature } from \"../geospatial-workspace/types\";\n\nexport interface FacilityReviewState {\n mode: \"live\" | \"review\";\n range: { start: number; end: number };\n cursor: number;\n playing: boolean;\n playbackRate: number;\n}\n\nexport interface FacilityRecording {\n id: string;\n /** Inclusive absolute start and exclusive absolute end, in milliseconds. */\n start: number;\n end: number;\n src: string;\n /** Position in the media at start, in seconds. */\n mediaOffset: number;\n}\n\nexport interface FacilityCamera {\n id: string;\n name: string;\n equipmentId: string;\n position: GeoPoint | null;\n connection: \"connected\" | \"reconnecting\" | \"unavailable\";\n latencyMs: number | null;\n source: string;\n recordings: readonly FacilityRecording[];\n canControl: boolean;\n controlReason?: string;\n}\n\nexport interface FacilityEquipment {\n id: string;\n name: string;\n history: readonly { at: number; entity: GeoEntity; pressure: number | null }[];\n}\n\nexport interface FacilityEvent extends TimelineEvent {\n equipmentId?: string;\n cameraId?: string;\n}\n\nexport interface FacilitySnapshot {\n id: string;\n title?: string;\n source: string;\n range: TimeRangeValue;\n liveTime: number;\n equipment: readonly FacilityEquipment[];\n cameras: readonly FacilityCamera[];\n events: readonly FacilityEvent[];\n site: readonly GeoSiteFeature[];\n}\n\nexport interface FacilityBookmark {\n id: string;\n name: string;\n cursor: number;\n cameraId: string | null;\n equipmentId: string | null;\n sourceKey: string;\n}\n\nexport interface CameraCommandRequest {\n cameraId: string;\n command: \"reset-view\";\n requestedAt: number;\n}\n\nexport interface CameraCommandResult {\n state: \"accepted\" | \"rejected\" | \"failed\" | \"complete\";\n message: string;\n requestId?: string;\n}\n\nexport interface FacilityMonitoringProps {\n snapshot: FacilitySnapshot;\n review: FacilityReviewState;\n onReviewChange: (review: FacilityReviewState) => void;\n onCameraCommand?: (request: CameraCommandRequest) => Promise<CameraCommandResult>;\n}\n",
4799
4799
  "patterns/facility-monitoring/review-state.ts": "import type { TimeRangeValue } from \"@oprt/react\";\nimport type { GeoEntity, GeoSnapshot } from \"../geospatial-workspace/types\";\nimport type {\n FacilityCamera,\n FacilityEquipment,\n FacilityRecording,\n FacilityReviewState,\n FacilitySnapshot,\n} from \"./types\";\n\nexport type FacilityReviewAction =\n | { type: \"seek\"; time: number }\n | { type: \"play\"; playing: boolean }\n | { type: \"rate\"; rate: number }\n | { type: \"range\"; range: TimeRangeValue }\n | { type: \"live\" }\n | { type: \"tick\"; elapsedMs: number };\n\nexport const facilityPlaybackRates = [0.5, 1, 2, 4] as const;\nconst validTime = (time: number) =>\n Number.isFinite(time) && !Number.isNaN(new Date(time).getTime());\nconst validRange = ({ start, end }: TimeRangeValue) =>\n validTime(start) && validTime(end) && start < end;\nconst supportedRate = (rate: number) => facilityPlaybackRates.some((value) => value === rate);\nconst clamp = (time: number, range: TimeRangeValue) =>\n validTime(time) ? Math.min(range.end, Math.max(range.start, time)) : range.start;\n\n/** Invalid review preferences fall back to the supplied recording bounds. */\nfunction reviewRange(snapshot: FacilitySnapshot, range: TimeRangeValue): TimeRangeValue | null {\n if (!validRange(snapshot.range)) return null;\n if (!validRange(range)) return { ...snapshot.range };\n const bounded = {\n start: Math.max(snapshot.range.start, range.start),\n end: Math.min(snapshot.range.end, range.end),\n };\n return validRange(bounded) ? bounded : { ...snapshot.range };\n}\n\nexport function initialReview(snapshot: FacilitySnapshot): FacilityReviewState {\n const range = validRange(snapshot.range) ? { ...snapshot.range } : { start: 0, end: 0 };\n return {\n mode: \"live\",\n range,\n cursor: clamp(snapshot.liveTime, range),\n playing: false,\n playbackRate: 1,\n };\n}\n\n/** The caller owns liveTime. Review never reads a clock or advances that reference. */\nexport function reviewAt(snapshot: FacilitySnapshot, review: FacilityReviewState): number {\n const range = reviewRange(snapshot, review.range);\n if (!range) return Number.NaN;\n return clamp(review.mode === \"live\" ? snapshot.liveTime : review.cursor, range);\n}\n\nexport function transitionReview(\n review: FacilityReviewState,\n action: FacilityReviewAction,\n snapshot: FacilitySnapshot,\n): FacilityReviewState {\n const range = reviewRange(snapshot, review.range);\n if (!range) return { ...review, playing: false };\n const cursor = reviewAt(snapshot, review);\n switch (action.type) {\n case \"seek\":\n return validTime(action.time)\n ? { ...review, mode: \"review\", range, cursor: clamp(action.time, range), playing: false }\n : review;\n case \"play\":\n return {\n ...review,\n mode: action.playing ? \"review\" : review.mode,\n range,\n cursor,\n playing: action.playing && cursor < range.end && supportedRate(review.playbackRate),\n };\n case \"rate\":\n return supportedRate(action.rate) ? { ...review, playbackRate: action.rate } : review;\n case \"range\": {\n if (!validRange(action.range)) return review;\n const nextRange = {\n start: Math.max(snapshot.range.start, action.range.start),\n end: Math.min(snapshot.range.end, action.range.end),\n };\n return validRange(nextRange)\n ? {\n ...review,\n mode: \"review\",\n range: nextRange,\n cursor: clamp(cursor, nextRange),\n playing: false,\n }\n : review;\n }\n case \"live\":\n return {\n ...initialReview(snapshot),\n playbackRate: supportedRate(review.playbackRate) ? review.playbackRate : 1,\n };\n case \"tick\": {\n if (\n review.mode !== \"review\" ||\n !review.playing ||\n !Number.isFinite(action.elapsedMs) ||\n action.elapsedMs <= 0\n )\n return review;\n if (!supportedRate(review.playbackRate)) return { ...review, playing: false };\n // Compare remaining time first so a very large finite tick cannot overflow.\n const remaining = range.end - cursor;\n const atEnd = action.elapsedMs >= remaining / review.playbackRate;\n return {\n ...review,\n range,\n cursor: atEnd ? range.end : cursor + action.elapsedMs * review.playbackRate,\n playing: !atEnd,\n };\n }\n }\n}\n\n/** Date.parse accepts rolled-over dates such as February 30; reject those. */\nfunction observationTime(value: string): number {\n const time = Date.parse(value);\n const calendar = /^\\d{4}-\\d{2}-\\d{2}T/.exec(value)?.[0].slice(0, 10);\n if (!validTime(time) || !calendar) return Number.NaN;\n const midnight = Date.parse(`${calendar}T00:00:00Z`);\n return validTime(midnight) && new Date(midnight).toISOString().startsWith(calendar)\n ? time\n : Number.NaN;\n}\n\nfunction availableFrame(frame: FacilityEquipment[\"history\"][number], at: number): boolean {\n if (!validTime(frame.at) || frame.at > at) return false;\n const captured = frame.entity.observedAt;\n if (captured !== null) {\n const capturedAt = observationTime(captured);\n if (!validTime(capturedAt) || capturedAt > at) return false;\n }\n const updated = frame.entity.record.quality.updatedAt;\n return updated == null || (validTime(updated) && updated <= at);\n}\n\nfunction missingEntity(equipment: FacilityEquipment, source: string): GeoEntity {\n return {\n record: {\n id: equipment.id,\n name: equipment.name,\n facts: [],\n quality: {\n state: \"unavailable\",\n source,\n updatedAt: null,\n detail: \"No equipment report is available at this review time.\",\n },\n },\n position: null,\n positionState: \"unknown\",\n observedAt: null,\n };\n}\n\nexport function resolveEquipment(snapshot: FacilitySnapshot, at: number): GeoSnapshot {\n const ceiling =\n validTime(at) && validRange(snapshot.range) ? Math.min(at, snapshot.range.end) : Number.NaN;\n const evidence: (readonly [\n string,\n number | null,\n number | null,\n string | null,\n string | null,\n ])[] = [];\n let available = 0;\n let latestObservation: number | null = null;\n const entities = snapshot.equipment.map((equipment) => {\n let frame: FacilityEquipment[\"history\"][number] | undefined;\n let frameIndex: number | null = null;\n if (validTime(ceiling))\n equipment.history.forEach((candidate, index) => {\n // Last supplied frame wins ties, allowing a corrected observation.\n if (availableFrame(candidate, ceiling) && (!frame || candidate.at >= frame.at)) {\n frame = candidate;\n frameIndex = index;\n }\n });\n if (!frame) {\n evidence.push([equipment.id, null, null, null, null]);\n return missingEntity(equipment, snapshot.source);\n }\n available += 1;\n const entity = frame.entity;\n evidence.push([\n equipment.id,\n frame.at,\n frameIndex,\n entity.record.quality.source ?? null,\n entity.observedAt,\n ]);\n if (entity.observedAt !== null) {\n const observed = observationTime(entity.observedAt);\n if (latestObservation === null || observed > latestObservation) latestObservation = observed;\n }\n return {\n ...entity,\n record: { ...entity.record, id: equipment.id },\n ...(entity.track\n ? {\n track: entity.track.filter((point) => {\n const observed = observationTime(point.observedAt);\n return validTime(observed) && observed <= ceiling;\n }),\n }\n : {}),\n };\n });\n return {\n id: snapshot.id,\n // Stable while the evidence is unchanged; source replacement creates a new frame.\n revision: JSON.stringify([snapshot.source, evidence]),\n source: snapshot.source,\n observedAt: latestObservation === null ? null : new Date(latestObservation).toISOString(),\n availability:\n available === 0\n ? \"unavailable\"\n : entities.every((entity) => entity.record.quality.state === \"current\")\n ? \"current\"\n : \"partial\",\n detail: \"Equipment reports at or before the shared review cursor.\",\n entities,\n site: snapshot.site,\n };\n}\n\n/** No nearest-recording fallback: a gap is unavailable footage. */\nexport function resolveRecording(\n camera: FacilityCamera,\n at: number,\n): { recording: FacilityRecording; mediaTime: number } | null {\n if (!validTime(at)) return null;\n let recording: FacilityRecording | undefined;\n for (const candidate of camera.recordings) {\n if (\n !validRange(candidate) ||\n !candidate.src.trim() ||\n !Number.isFinite(candidate.mediaOffset) ||\n candidate.mediaOffset < 0\n )\n continue;\n if (\n candidate.start <= at &&\n at < candidate.end &&\n (!recording || candidate.start >= recording.start)\n )\n recording = candidate;\n }\n if (!recording) return null;\n const mediaTime = recording.mediaOffset + (at - recording.start) / 1_000;\n return Number.isFinite(mediaTime) ? { recording, mediaTime } : null;\n}\n",
4800
- "patterns/facility-monitoring/synchronized-video.tsx": "\"use client\";\n\nimport { forwardRef, useImperativeHandle, useLayoutEffect, useRef } from \"react\";\n\nexport type VideoFrameState = {\n state: \"loading\" | \"seeking\" | \"ready\" | \"waiting\" | \"ended\" | \"error\";\n mediaTime: number | null;\n message?: string;\n};\n\nexport type VideoCaptureHandle = {\n capture: () => Promise<{ src: string; mediaTime: number }>;\n};\n\nexport type SynchronizedVideoProps = {\n src: string | null;\n /** Requested position in seconds from the start of this recording. */\n time: number;\n playing: boolean;\n playbackRate: number;\n label: string;\n onFrameChange: (frame: VideoFrameState) => void;\n};\n\ntype VideoSession = {\n applyControls: () => void;\n capture: VideoCaptureHandle[\"capture\"];\n};\n\n// This recipe's tolerance is an integration choice, not a core component default.\nconst synchronizationTolerance = 0.25;\nconst pausedSeekDeadlineMs = 5_000;\n\nexport const SynchronizedVideo = forwardRef<VideoCaptureHandle, SynchronizedVideoProps>(\n ({ src, time, playing, playbackRate, label, onFrameChange }, ref) => {\n const videoRef = useRef<HTMLVideoElement>(null);\n const sessionRef = useRef<VideoSession | null>(null);\n const latest = useRef({ src, time, playing, playbackRate, onFrameChange });\n latest.current = { src, time, playing, playbackRate, onFrameChange };\n\n useImperativeHandle(\n ref,\n () => ({\n capture: async () => {\n if (!sessionRef.current)\n throw new Error(\"No synchronized frame is available to capture.\");\n return sessionRef.current.capture();\n },\n }),\n [],\n );\n\n useLayoutEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n let active = true;\n let frame: VideoFrameState | null = null;\n let confirmedTime: number | null = null;\n let pendingSeek = false;\n let waiting = false;\n let failed = false;\n let failureKind: \"source\" | \"request\" | \"playback\" = \"source\";\n let lastPlaying = latest.current.playing;\n let ended = false;\n let playRequested = false;\n let playAttempt = 0;\n let seekDeadline: number | undefined;\n let seekTarget: number | null = null;\n let seekRetried = false;\n const clearSeekDeadline = () => {\n window.clearTimeout(seekDeadline);\n seekDeadline = undefined;\n };\n const isCurrent = () => active && videoRef.current === video && latest.current.src === src;\n const matchesSource = () =>\n Boolean(src && video.currentSrc && video.currentSrc === video.src);\n const drawable = () => video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0;\n const synchronized = () =>\n Number.isFinite(video.currentTime) &&\n Math.abs(video.currentTime - latest.current.time) <= synchronizationTolerance;\n const emit = (next: VideoFrameState) => {\n if (!isCurrent()) return;\n if (\n frame?.state === next.state &&\n frame.mediaTime === next.mediaTime &&\n frame.message === next.message\n )\n return;\n frame = next;\n latest.current.onFrameChange(next);\n };\n const fail = (message: string, kind: typeof failureKind = \"source\") => {\n clearSeekDeadline();\n failed = true;\n failureKind = kind;\n playAttempt += 1;\n playRequested = false;\n video.pause();\n emit({ state: \"error\", mediaTime: confirmedTime, message });\n };\n const armSeekDeadline = () => {\n if (latest.current.playing || failed || seekDeadline !== undefined) return;\n const target = latest.current.time;\n seekDeadline = window.setTimeout(() => {\n seekDeadline = undefined;\n if (!isCurrent() || failed || latest.current.playing || latest.current.time !== target)\n return;\n if (!pendingSeek && !video.seeking && frame?.state === \"ready\") return;\n if (seekRetried) {\n fail(\"Recording seek did not complete. Reload media to try again.\");\n return;\n }\n // A native paused seek can stall without an error or a seeked event.\n // Restart this decoder once; only fresh decoder observations can recover it.\n seekRetried = true;\n pendingSeek = false;\n waiting = false;\n ended = false;\n confirmedTime = null;\n playAttempt += 1;\n playRequested = false;\n emit({\n state: \"loading\",\n mediaTime: null,\n message: \"Recording seek stalled. Reloading the selected recording once.\",\n });\n video.pause();\n video.load();\n armSeekDeadline();\n }, pausedSeekDeadlineMs);\n };\n const playIfRequested = () => {\n if (\n !isCurrent() ||\n !latest.current.playing ||\n failed ||\n pendingSeek ||\n video.seeking ||\n playRequested ||\n video.readyState < 1\n )\n return;\n playRequested = true;\n const attempt = ++playAttempt;\n try {\n const promise = video.play();\n // Older implementations can return undefined from play().\n promise?.catch((error: unknown) => {\n if (!isCurrent() || attempt !== playAttempt || !latest.current.playing) return;\n fail(\n `Playback failed: ${error instanceof Error ? error.message : \"the browser rejected playback.\"}`,\n \"playback\",\n );\n });\n } catch (error) {\n if (isCurrent() && attempt === playAttempt)\n fail(\n `Playback failed: ${error instanceof Error ? error.message : \"the browser rejected playback.\"}`,\n \"playback\",\n );\n }\n };\n\n const applyControls = () => {\n if (!isCurrent()) return;\n const request = latest.current;\n if (request.time !== seekTarget || request.playing) {\n clearSeekDeadline();\n seekTarget = request.time;\n // A target change must not abandon a decoder reload still awaiting metadata.\n if (request.playing || video.readyState >= 1) seekRetried = false;\n }\n const retryPlayback = !lastPlaying && request.playing;\n lastPlaying = request.playing;\n if (!src) {\n fail(\"No recording is available at this position.\");\n return;\n }\n if (!Number.isFinite(request.time) || request.time < 0) {\n fail(\"Requested recording time is invalid.\", \"request\");\n return;\n }\n if (!Number.isFinite(request.playbackRate) || request.playbackRate <= 0) {\n fail(\"Requested playback rate is invalid.\", \"request\");\n return;\n }\n if (!request.playing) {\n playAttempt += 1;\n playRequested = false;\n video.pause();\n }\n try {\n video.playbackRate = request.playbackRate;\n } catch {\n fail(\"The browser does not support the requested playback rate.\", \"request\");\n return;\n }\n if (video.readyState < 1) {\n if (seekRetried) armSeekDeadline();\n return;\n }\n if (!Number.isFinite(video.duration) || request.time > video.duration) {\n fail(\"Requested position is outside the recording range.\", \"request\");\n return;\n }\n const reconfirmRequest = failed && failureKind === \"request\";\n if (reconfirmRequest || (failed && failureKind === \"playback\" && retryPlayback)) {\n failed = false;\n }\n if (failed) return;\n if (reconfirmRequest || !synchronized()) {\n pendingSeek = true;\n ended = false;\n emit({\n state: \"seeking\",\n mediaTime: confirmedTime,\n message: \"Waiting for the requested frame to decode.\",\n });\n try {\n video.currentTime = request.time;\n armSeekDeadline();\n } catch {\n fail(\"The browser could not seek to the requested frame.\", \"request\");\n }\n } else {\n if (pendingSeek || video.seeking) armSeekDeadline();\n playIfRequested();\n }\n };\n\n // Only decoder observations confirm actual frame time. Setting currentTime\n // is a request and does not establish that the displayed frame changed.\n const confirmFrame = () => {\n if (!isCurrent() || failed || ended || waiting || pendingSeek || video.seeking) return;\n if (!drawable()) {\n emit({\n state: \"loading\",\n mediaTime: confirmedTime,\n message: \"Waiting for a decoded frame.\",\n });\n return;\n }\n if (!matchesSource()) {\n fail(\"Decoded frame source does not match the selected recording.\");\n return;\n }\n if (!synchronized()) {\n // Native playback can lead a host's sampled cursor between clock ticks.\n // Only a control update requests a seek; decoder events never rewind it.\n emit({\n state: \"seeking\",\n mediaTime: confirmedTime,\n message: \"Displayed frame is outside the requested position; awaiting synchronization.\",\n });\n return;\n }\n clearSeekDeadline();\n confirmedTime = video.currentTime;\n emit({ state: \"ready\", mediaTime: confirmedTime });\n playIfRequested();\n };\n const onMetadata = () => applyControls();\n const onData = () => confirmFrame();\n const onSeeking = () => {\n if (!isCurrent() || failed) return;\n pendingSeek = true;\n emit({\n state: \"seeking\",\n mediaTime: confirmedTime,\n message: \"Waiting for the requested frame to decode.\",\n });\n };\n const onSeeked = () => {\n if (!isCurrent() || failed) return;\n pendingSeek = false;\n waiting = false;\n ended = false;\n confirmFrame();\n };\n const onWaiting = () => {\n if (!isCurrent() || failed) return;\n waiting = true;\n emit({\n state: \"waiting\",\n mediaTime: confirmedTime,\n message: \"Recording is buffering; the last confirmed frame may be retained.\",\n });\n };\n const onCanPlay = () => {\n if (!isCurrent() || failed) return;\n waiting = false;\n playIfRequested();\n };\n const onEnded = () => {\n if (!isCurrent() || failed) return;\n clearSeekDeadline();\n ended = true;\n playRequested = false;\n emit({ state: \"ended\", mediaTime: confirmedTime, message: \"Recording ended.\" });\n };\n const onError = () => {\n if (isCurrent()) fail(video.error?.message || \"Recording could not be loaded or decoded.\");\n };\n const listeners: [string, EventListener][] = [\n [\"loadedmetadata\", onMetadata],\n [\"loadeddata\", onData],\n [\"seeking\", onSeeking],\n [\"seeked\", onSeeked],\n [\"timeupdate\", confirmFrame],\n [\"waiting\", onWaiting],\n [\"canplay\", onCanPlay],\n [\"playing\", onCanPlay],\n [\"ended\", onEnded],\n [\"error\", onError],\n ];\n for (const [event, listener] of listeners) video.addEventListener(event, listener);\n const session: VideoSession = {\n applyControls,\n capture: async () => {\n if (\n !isCurrent() ||\n frame?.state !== \"ready\" ||\n failed ||\n waiting ||\n ended ||\n pendingSeek ||\n video.seeking ||\n !matchesSource() ||\n !drawable() ||\n !synchronized()\n ) {\n throw new Error(\n \"A decoded, synchronized frame is required to capture. Wait for seeking or buffering to finish.\",\n );\n }\n const canvas = document.createElement(\"canvas\");\n canvas.width = video.videoWidth;\n canvas.height = video.videoHeight;\n const context = canvas.getContext(\"2d\");\n if (!context) throw new Error(\"Canvas capture is unavailable in this browser.\");\n const mediaTime = video.currentTime;\n context.drawImage(video, 0, 0, canvas.width, canvas.height);\n const image = canvas.toDataURL(\"image/png\");\n if (!image.startsWith(\"data:image/png;base64,\"))\n throw new Error(\"The browser could not encode the captured frame as PNG.\");\n return { src: image, mediaTime };\n },\n };\n sessionRef.current = session;\n emit({ state: \"loading\", mediaTime: null, message: \"Loading the selected recording.\" });\n if (src) {\n // Cleanup releases the decoder; StrictMode can then reuse the same node.\n video.setAttribute(\"src\", src);\n video.load();\n }\n applyControls();\n\n return () => {\n active = false;\n clearSeekDeadline();\n playAttempt += 1;\n for (const [event, listener] of listeners) video.removeEventListener(event, listener);\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n if (sessionRef.current === session) sessionRef.current = null;\n };\n }, [src]);\n\n useLayoutEffect(() => {\n sessionRef.current?.applyControls();\n }, [src, time, playing, playbackRate]);\n\n return (\n <video\n key={src ?? \"no-recording\"}\n ref={videoRef}\n src={src ?? undefined}\n muted\n playsInline\n preload=\"auto\"\n aria-label={label}\n className=\"h-full w-full object-contain\"\n />\n );\n },\n);\nSynchronizedVideo.displayName = \"SynchronizedVideo\";\n",
4800
+ "patterns/facility-monitoring/synchronized-video.tsx": "\"use client\";\n\nimport { forwardRef, useImperativeHandle, useLayoutEffect, useRef } from \"react\";\n\nexport type VideoFrameState = {\n state: \"loading\" | \"seeking\" | \"ready\" | \"waiting\" | \"ended\" | \"error\";\n mediaTime: number | null;\n message?: string;\n};\n\nexport type VideoCaptureHandle = {\n capture: () => Promise<{ src: string; mediaTime: number }>;\n};\n\nexport type SynchronizedVideoProps = {\n src: string | null;\n /** Requested position in seconds from the start of this recording. */\n time: number;\n playing: boolean;\n playbackRate: number;\n label: string;\n onFrameChange: (frame: VideoFrameState) => void;\n};\n\ntype VideoSession = {\n applyControls: () => void;\n capture: VideoCaptureHandle[\"capture\"];\n};\n\n// This recipe's tolerance is an integration choice, not a core component default.\nconst synchronizationTolerance = 0.25;\nconst pausedSeekDeadlineMs = 5_000;\n// Under one frame of the 8 fps recordings: a still frame the host set is this close to its time.\nconst exactFrame = 0.02;\n\nexport const SynchronizedVideo = forwardRef<VideoCaptureHandle, SynchronizedVideoProps>(\n ({ src, time, playing, playbackRate, label, onFrameChange }, ref) => {\n const videoRef = useRef<HTMLVideoElement>(null);\n const sessionRef = useRef<VideoSession | null>(null);\n const latest = useRef({ src, time, playing, playbackRate, onFrameChange });\n latest.current = { src, time, playing, playbackRate, onFrameChange };\n\n useImperativeHandle(\n ref,\n () => ({\n capture: async () => {\n if (!sessionRef.current)\n throw new Error(\"No synchronized frame is available to capture.\");\n return sessionRef.current.capture();\n },\n }),\n [],\n );\n\n useLayoutEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n let active = true;\n let frame: VideoFrameState | null = null;\n let confirmedTime: number | null = null;\n let pendingSeek = false;\n let waiting = false;\n let failed = false;\n let failureKind: \"source\" | \"request\" | \"playback\" = \"source\";\n let lastPlaying = latest.current.playing;\n let ended = false;\n let playRequested = false;\n let playAttempt = 0;\n let seekDeadline: number | undefined;\n let seekTarget: number | null = null;\n let seekRetried = false;\n // The engine resumed playback the host did not ask for: the frame is set again exactly, not\n // just within the tolerance that lets playback and a clock drift apart.\n let resumed = false;\n const clearSeekDeadline = () => {\n window.clearTimeout(seekDeadline);\n seekDeadline = undefined;\n };\n const isCurrent = () => active && videoRef.current === video && latest.current.src === src;\n const matchesSource = () =>\n Boolean(src && video.currentSrc && video.currentSrc === video.src);\n const drawable = () => video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0;\n const synchronized = () =>\n Number.isFinite(video.currentTime) &&\n Math.abs(video.currentTime - latest.current.time) <= synchronizationTolerance;\n const emit = (next: VideoFrameState) => {\n if (!isCurrent()) return;\n if (\n frame?.state === next.state &&\n frame.mediaTime === next.mediaTime &&\n frame.message === next.message\n )\n return;\n frame = next;\n latest.current.onFrameChange(next);\n };\n const fail = (message: string, kind: typeof failureKind = \"source\") => {\n clearSeekDeadline();\n failed = true;\n failureKind = kind;\n playAttempt += 1;\n playRequested = false;\n video.pause();\n emit({ state: \"error\", mediaTime: confirmedTime, message });\n };\n const armSeekDeadline = () => {\n if (latest.current.playing || failed || seekDeadline !== undefined) return;\n const target = latest.current.time;\n seekDeadline = window.setTimeout(() => {\n seekDeadline = undefined;\n if (!isCurrent() || failed || latest.current.playing || latest.current.time !== target)\n return;\n if (!pendingSeek && !video.seeking && frame?.state === \"ready\") return;\n if (seekRetried) {\n fail(\"Recording seek did not complete. Reload media to try again.\");\n return;\n }\n // A native paused seek can stall without an error or a seeked event.\n // Restart this decoder once; only fresh decoder observations can recover it.\n seekRetried = true;\n pendingSeek = false;\n waiting = false;\n ended = false;\n confirmedTime = null;\n playAttempt += 1;\n playRequested = false;\n emit({\n state: \"loading\",\n mediaTime: null,\n message: \"Recording seek stalled. Reloading the selected recording once.\",\n });\n video.pause();\n video.load();\n armSeekDeadline();\n }, pausedSeekDeadlineMs);\n };\n const playIfRequested = () => {\n if (\n !isCurrent() ||\n !latest.current.playing ||\n failed ||\n pendingSeek ||\n video.seeking ||\n playRequested ||\n video.readyState < 1\n )\n return;\n playRequested = true;\n const attempt = ++playAttempt;\n try {\n const promise = video.play();\n // Older implementations can return undefined from play().\n promise?.catch((error: unknown) => {\n if (!isCurrent() || attempt !== playAttempt || !latest.current.playing) return;\n fail(\n `Playback failed: ${error instanceof Error ? error.message : \"the browser rejected playback.\"}`,\n \"playback\",\n );\n });\n } catch (error) {\n if (isCurrent() && attempt === playAttempt)\n fail(\n `Playback failed: ${error instanceof Error ? error.message : \"the browser rejected playback.\"}`,\n \"playback\",\n );\n }\n };\n\n const applyControls = () => {\n if (!isCurrent()) return;\n const request = latest.current;\n if (request.time !== seekTarget || request.playing) {\n clearSeekDeadline();\n seekTarget = request.time;\n // A target change must not abandon a decoder reload still awaiting metadata.\n if (request.playing || video.readyState >= 1) seekRetried = false;\n }\n const retryPlayback = !lastPlaying && request.playing;\n lastPlaying = request.playing;\n if (!src) {\n fail(\"No recording is available at this position.\");\n return;\n }\n if (!Number.isFinite(request.time) || request.time < 0) {\n fail(\"Requested recording time is invalid.\", \"request\");\n return;\n }\n if (!Number.isFinite(request.playbackRate) || request.playbackRate <= 0) {\n fail(\"Requested playback rate is invalid.\", \"request\");\n return;\n }\n if (!request.playing) {\n playAttempt += 1;\n playRequested = false;\n video.pause();\n }\n try {\n video.playbackRate = request.playbackRate;\n } catch {\n fail(\"The browser does not support the requested playback rate.\", \"request\");\n return;\n }\n if (video.readyState < 1) {\n if (seekRetried) armSeekDeadline();\n return;\n }\n if (!Number.isFinite(video.duration) || request.time > video.duration) {\n fail(\"Requested position is outside the recording range.\", \"request\");\n return;\n }\n const reconfirmRequest = failed && failureKind === \"request\";\n if (reconfirmRequest || (failed && failureKind === \"playback\" && retryPlayback)) {\n failed = false;\n }\n if (failed) return;\n const moved = resumed && Math.abs(video.currentTime - request.time) > exactFrame;\n resumed = false;\n if (reconfirmRequest || moved || !synchronized()) {\n pendingSeek = true;\n ended = false;\n emit({\n state: \"seeking\",\n mediaTime: confirmedTime,\n message: \"Waiting for the requested frame to decode.\",\n });\n try {\n video.currentTime = request.time;\n armSeekDeadline();\n } catch {\n fail(\"The browser could not seek to the requested frame.\", \"request\");\n }\n } else {\n if (pendingSeek || video.seeking) armSeekDeadline();\n playIfRequested();\n }\n };\n\n // Only decoder observations confirm actual frame time. Setting currentTime\n // is a request and does not establish that the displayed frame changed.\n const confirmFrame = () => {\n if (!isCurrent() || failed || ended || waiting || pendingSeek || video.seeking) return;\n if (!drawable()) {\n emit({\n state: \"loading\",\n mediaTime: confirmedTime,\n message: \"Waiting for a decoded frame.\",\n });\n return;\n }\n if (!matchesSource()) {\n fail(\"Decoded frame source does not match the selected recording.\");\n return;\n }\n if (!synchronized()) {\n // Native playback can lead a host's sampled cursor between clock ticks.\n // Only a control update requests a seek; decoder events never rewind it.\n emit({\n state: \"seeking\",\n mediaTime: confirmedTime,\n message: \"Displayed frame is outside the requested position; awaiting synchronization.\",\n });\n return;\n }\n clearSeekDeadline();\n confirmedTime = video.currentTime;\n emit({ state: \"ready\", mediaTime: confirmedTime });\n playIfRequested();\n };\n const onMetadata = () => applyControls();\n const onData = () => confirmFrame();\n const onSeeking = () => {\n if (!isCurrent() || failed) return;\n pendingSeek = true;\n emit({\n state: \"seeking\",\n mediaTime: confirmedTime,\n message: \"Waiting for the requested frame to decode.\",\n });\n };\n const onSeeked = () => {\n if (!isCurrent() || failed) return;\n pendingSeek = false;\n waiting = false;\n ended = false;\n confirmFrame();\n };\n const onWaiting = () => {\n if (!isCurrent() || failed) return;\n waiting = true;\n emit({\n state: \"waiting\",\n mediaTime: confirmedTime,\n message: \"Recording is buffering; the last confirmed frame may be retained.\",\n });\n };\n const onCanPlay = () => {\n if (!isCurrent() || failed) return;\n waiting = false;\n playIfRequested();\n };\n const onEnded = () => {\n if (!isCurrent() || failed) return;\n clearSeekDeadline();\n ended = true;\n playRequested = false;\n emit({ state: \"ended\", mediaTime: confirmedTime, message: \"Recording ended.\" });\n };\n const onError = () => {\n if (isCurrent()) fail(video.error?.message || \"Recording could not be loaded or decoded.\");\n };\n // The host asked for a still frame. WebKit on Linux can resume a playback begun before the\n // pause, at the frame of the seek made after it or at the position before that, and the\n // recording then runs to its end. The host's request wins: pause, and seek again if it moved.\n const onResumed = () => {\n if (!isCurrent() || failed || latest.current.playing) return;\n resumed = true;\n applyControls();\n };\n const listeners: [string, EventListener][] = [\n [\"loadedmetadata\", onMetadata],\n [\"loadeddata\", onData],\n [\"seeking\", onSeeking],\n [\"seeked\", onSeeked],\n [\"timeupdate\", confirmFrame],\n [\"waiting\", onWaiting],\n [\"canplay\", onCanPlay],\n [\"playing\", onCanPlay],\n [\"ended\", onEnded],\n [\"error\", onError],\n [\"play\", onResumed],\n [\"playing\", onResumed],\n ];\n for (const [event, listener] of listeners) video.addEventListener(event, listener);\n const session: VideoSession = {\n applyControls,\n capture: async () => {\n if (\n !isCurrent() ||\n frame?.state !== \"ready\" ||\n failed ||\n waiting ||\n ended ||\n pendingSeek ||\n video.seeking ||\n !matchesSource() ||\n !drawable() ||\n !synchronized()\n ) {\n throw new Error(\n \"A decoded, synchronized frame is required to capture. Wait for seeking or buffering to finish.\",\n );\n }\n const canvas = document.createElement(\"canvas\");\n canvas.width = video.videoWidth;\n canvas.height = video.videoHeight;\n const context = canvas.getContext(\"2d\");\n if (!context) throw new Error(\"Canvas capture is unavailable in this browser.\");\n const mediaTime = video.currentTime;\n context.drawImage(video, 0, 0, canvas.width, canvas.height);\n const image = canvas.toDataURL(\"image/png\");\n if (!image.startsWith(\"data:image/png;base64,\"))\n throw new Error(\"The browser could not encode the captured frame as PNG.\");\n return { src: image, mediaTime };\n },\n };\n sessionRef.current = session;\n emit({ state: \"loading\", mediaTime: null, message: \"Loading the selected recording.\" });\n if (src) {\n // Cleanup releases the decoder; StrictMode can then reuse the same node.\n video.setAttribute(\"src\", src);\n video.load();\n }\n applyControls();\n\n return () => {\n active = false;\n clearSeekDeadline();\n playAttempt += 1;\n for (const [event, listener] of listeners) video.removeEventListener(event, listener);\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n if (sessionRef.current === session) sessionRef.current = null;\n };\n }, [src]);\n\n useLayoutEffect(() => {\n sessionRef.current?.applyControls();\n }, [src, time, playing, playbackRate]);\n\n return (\n <video\n key={src ?? \"no-recording\"}\n ref={videoRef}\n src={src ?? undefined}\n muted\n playsInline\n preload=\"auto\"\n aria-label={label}\n className=\"h-full w-full object-contain\"\n />\n );\n },\n);\nSynchronizedVideo.displayName = \"SynchronizedVideo\";\n",
4801
4801
  "patterns/facility-monitoring/demo.tsx": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { Checkbox, Field } from \"@oprt/react\";\nimport { FacilityMonitoring } from \"./facility-monitoring\";\nimport { createFacilitySnapshot } from \"./fixtures\";\nimport { initialReview, transitionReview } from \"./review-state\";\nimport type { CameraCommandResult } from \"./types\";\n\n/**\n * The training clip is a 459 KB base64 string. It is imported on demand, so it travels in its own\n * chunk and no page that merely imports the demo carries it. `null` while it loads.\n */\nfunction useTrainingMedia(): { media: string | null; failed: boolean } {\n const [loaded, setLoaded] = useState<{ media: string | null; failed: boolean }>({\n media: null,\n failed: false,\n });\n useEffect(() => {\n let live = true;\n import(\"./training-media\").then(\n (module) => live && setLoaded({ media: module.facilityTrainingMedia, failed: false }),\n () => live && setLoaded({ media: null, failed: true }),\n );\n return () => {\n live = false;\n };\n }, []);\n return loaded;\n}\n\nexport function FacilityMonitoringDemo() {\n const { media, failed } = useTrainingMedia();\n const [rejectCommands, setRejectCommands] = useState(false);\n\n return (\n <div className=\"flex w-full min-w-0 flex-col gap-3u\">\n {media ? (\n <TrainingConsole media={media} rejectCommands={rejectCommands} />\n ) : failed ? (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n The training footage could not be loaded. Reload the page to try again.\n </p>\n ) : (\n <p role=\"status\" className=\"m-0 text-oprt-text-dim\">\n Loading training footage…\n </p>\n )}\n <details className=\"border-t border-oprt-border pt-2u text-oprt-text-dim\">\n <summary className=\"min-h-[var(--oprt-height-sm)] cursor-pointer py-1u text-oprt-base font-medium focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-oprt-border-focus\">\n Simulation controls\n </summary>\n <div className=\"flex flex-col gap-2u\">\n <p className=\"m-0 text-oprt-base text-oprt-text-dim\">\n Training fixture · fixed source time 14:31 UTC. Generated silent still-scene footage;\n equipment reports and camera responses are simulated.\n </p>\n <Field label=\"Reject camera requests\">\n <Checkbox\n checked={rejectCommands}\n onCheckedChange={(checked) => setRejectCommands(checked === true)}\n />\n </Field>\n </div>\n </details>\n </div>\n );\n}\n\nfunction TrainingConsole({ media, rejectCommands }: { media: string; rejectCommands: boolean }) {\n const snapshot = useMemo(() => createFacilitySnapshot(media), [media]);\n const [review, setReview] = useState(() => initialReview(snapshot));\n\n useEffect(() => {\n if (review.mode !== \"review\" || !review.playing) return;\n let previous = performance.now();\n const interval = window.setInterval(() => {\n const now = performance.now();\n const elapsedMs = now - previous;\n previous = now;\n setReview((current) => transitionReview(current, { type: \"tick\", elapsedMs }, snapshot));\n }, 250);\n return () => window.clearInterval(interval);\n }, [review.mode, review.playing, snapshot]);\n\n // One function per setting: a new one on every 250 ms tick would read as a new camera service.\n const onCameraCommand = useCallback(\n async (): Promise<CameraCommandResult> =>\n rejectCommands\n ? {\n state: \"rejected\",\n message: \"Training service rejected the camera request. The view was not changed.\",\n }\n : {\n state: \"accepted\",\n message:\n \"Training service accepted the camera request. Completion has not been reported.\",\n },\n [rejectCommands],\n );\n\n return (\n <FacilityMonitoring\n snapshot={snapshot}\n review={review}\n onReviewChange={setReview}\n onCameraCommand={onCameraCommand}\n />\n );\n}\n",
4802
4802
  "patterns/facility-monitoring/training-media.ts": "/** Generated silent still-scene training recording. Embedded for portable copied recipes. */\nexport const facilityTrainingMedia =\n \"data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAABKObW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAA/egAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAEbl0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAA/egAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAA8AAAAIcAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAP3oAAAQAAABAAAAABExbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAQQABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAQ3G1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAEJxzdGJsAAAArHN0c2QAAAAAAAAAAQAAAJxhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAA8ACHABIAAAASAAAAAAAAAABFUxhdmM2Mi4xMS4xMDAgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBTUAo/+EAGWdNQCi5AiB4CL9wgAAAAwCAAAAIB4wYwjABAAZo6EOUTIAAAAAUYnRydAAAAAAAAEI1AAAAAAAAABhzdHRzAAAAAAAAAAEAAAIIAAAIAAAAABRzdHNzAAAAAAAAAAEAAAABAAAHWGN0dHMAAAAAAAAA6QAAAAEAABAAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAUAAAAAABAAAgAAAAAAMAAAAAAAAABAAACAAAAAABAABQAAAAAAEAACAAAAAAAwAAAAAAAAAEAAAIAAAAAAEAAFAAAAAAAQAAIAAAAAADAAAAAAAAAAQAAAgAAAAAAQAAOAAAAAABAAAYAAAAAAIAAAAAAAAAAgAACAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAggAAAABAAAINHN0c3oAAAAAAAAAAAAAAggAAcy7AAABmAAAAFUAAABlAAAAaQAAAGoAAABXAAAATQAAACEAAAAtAAAAuQAAACkAAAAxAAAANAAAADYAAAA0AAAALgAAACoAAAAsAAAAXQAAAC8AAAAjAAAALAAAAC0AAAAyAAAALQAAACgAAAAhAAAAUQAAACwAAAAhAAAAJQAAADAAAAA7AAAANQAAACQAAAAiAAAATwAAACUAAAAlAAAAIwAAACQAAAAuAAAAKAAAACAAAAAqAAAARQAAACYAAAAgAAAAGwAAAB4AAAApAAAAJAAAABwAAAAjAAAAQAAAACQAAAAjAAAAHQAAACsAAAAuAAAAKwAAAB4AAAAhAAAARgAAACkAAAAiAAAAGwAAABsAAAApAAAAIgAAABwAAAAhAAAAQQAAACoAAAAfAAAAHgAAACAAAAAqAAAAJQAAABoAAAAhAAAARAAAAC8AAAAgAAAAHwAAACMAAAAlAAAAIwAAACIAAAAiAAAAOAAAACkAAAAhAAAAHQAAACUAAAA4AAAALAAAAB4AAAAhAAAASAAAAC0AAAAmAAAAJwAAAC4AAAA+AAAALQAAACQAAAAjAAAAQwAAAC0AAAAjAAAAHwAAAC8AAAA3AAAANQAAAB4AAAAnAAAAQgAAAC8AAAAjAAAAIQAAACQAAAAsAAAAKQAAABwAAAAjAAAAMwAAACsAAAAfAAAAIAAAACMAAAAlAAAAIwAAABsAAAAgAAAAMQAAACoAAAAeAAAAIAAAACEAAAAjAAAAIQAAABwAAAAfAAAAMwAAACoAAAAiAAAAIAAAACQAAAArAAAAJAAAABwAAAAhAAAAMwAAAC0AAAAgAAAAIAAAACQAAAAnAAAAIQAAABsAAAAiAAAAMwAAAC0AAAAjAAAAIAAAACIAAAArAAAAJAAAABsAAAAjAAAALwAAAC0AAAAfAAAAIgAAACMAAAAqAAAAJAAAABwAAAAgAAAALwAAAC0AAAAhAAAAJAAAACgAAAAvAAAAJwAAAB0AAAAiAAAALwAAAC8AAAAfAAAAJAAAACQAAAAlAAAAIwAAABwAAAAhAAAAMwAAACwAAAAiAAAAJQAAACUAAAAvAAAAJgAAABwAAAAiAAAALwAAACwAAAAfAAAAIQAAACIAAAAnAAAAIgAAABwAAAAgAAAALwAAAC0AAAAgAAAAIwAAACIAAAAqAAAAJAAAAB8AAAAjAAAALwAAAC4AAAAjAAAAIQAAACMAAAArAAAAJAAAABwAAAAiAAAALwAAAC4AAAAiAAAAIQAAACQAAAAuAAAAIgAAAB8AAAAlAAAAMgAAAC8AAAAiAAAAIgAAACIAAAAlAAAAIgAAAB4AAAAkAAAALwAAACsAAAAkAAAAIAAAACEAAAAoAAAAIQAAABwAAAAhAAAAMgAAACYAAAAkAAAAHAAAACcAAAAvAAAAJAAAABsAAAAlAAAANwAAACcAAAAfAAAAHAAAAB0AAAAiAAAAGwAAABsAAAAjAAAANwAAACoAAAAfAAAAHAAAABwAAAAfAAAAGwAAABwAAAAgAAAAMQAAACgAAAAhAAAAHAAAABwAAAAhAAAAHQAAABsAAAAhAAAAMgAAACgAAAAfAAAAHAAAABwAAAAfAAAAGwAAABsAAAAhAAAALwAAACgAAAAdAAAAHAAAABwAAAAeAAAAGwAAABwAAAAgAAAALwAAACcAAAAeAAAAHAAAABwAAAAeAAAAGwAAAB4AAAAgAAAALwAAACUAAAAeAAAAHAAAABwAAAAeAAAAGwAAAB4AAAAhAAAALwAAACYAAAAgAAAAHAAAABwAAAAfAAAAHgAAABwAAAAgAAAAMQAAACgAAAAdAAAAHAAAABwAAAAeAAAAHQAAABwAAAAgAAAALwAAACcAAAAgAAAAHAAAAB0AAAAnAAAAIwAAABwAAAAhAAAAMwAAACkAAAAhAAAAHgAAACMAAAAoAAAAIgAAAB4AAAAjAAAALwAAACsAAAAfAAAAHAAAAB0AAAAjAAAAHQAAABsAAAAhAAAAMQAAACgAAAAgAAAAHAAAAB8AAAAlAAAAHQAAABsAAAAgAAAALwAAACcAAAAfAAAAHAAAABwAAAAgAAAAHQAAABsAAAAgAAAALwAAACgAAAAgAAAAHAAAAB0AAAAnAAAAHQAAABsAAAAjAAAALwAAACkAAAAeAAAAHAAAABwAAAAfAAAAGwAAABsAAAAiAAAALwAAACgAAAAdAAAAHAAAABwAAAAeAAAAGwAAABwAAAAgAAAALwAAACcAAAAgAAAAHAAAAB4AAAAnAAAAHwAAAB0AAAAhAAAALwAAACoAAAAdAAAAHAAAABwAAAAfAAAAGwAAABsAAAAhAAAAMQAAACgAAAAeAAAAHAAAABwAAAAgAAAAGwAAABsAAAAhAAAALwAAACgAAAAdAAAAHAAAABwAAAAeAAAAGwAAABwAAAAgAAAALwAAACcAAAAgAAAAHAAAAB4AAAAnAAAAHwAAABsAAAAhAAAAPgAAACwAAAAjAAAAHAAAACMAAAAsAAAAIwAAABwAAAAkAAAA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\";\n"
4803
4803
  },
@@ -4831,9 +4831,9 @@
4831
4831
  "layout": "import { OperationsMapDemo } from \"./patterns/geospatial-workspace\";\n\n<OperationsMapDemo />",
4832
4832
  "files": {
4833
4833
  "patterns/geospatial-workspace/index.ts": "export { GeospatialWorkspace as OperationsMap, GeospatialWorkspace } from \"./geospatial-workspace\";\nexport { OperationsMapDemo, GeospatialWorkspaceDemo } from \"./demo\";\nexport { GeoMap } from \"./geo-map\";\nexport {\n initialGeoLayers,\n initialGeoView,\n utilitySnapshot,\n utilityEntities,\n utilitySite,\n} from \"./fixtures\";\nexport type {\n GeospatialWorkspaceProps,\n GeospatialWorkspaceProps as OperationsMapProps,\n GeoMapProps,\n GeoPoint,\n GeoView,\n GeoEntity,\n GeoSnapshot,\n GeoAnnotation,\n GeoAnnotationDraft,\n GeoAnnotationResult,\n GeoBookmark,\n GeoTileSource,\n} from \"./types\";\n",
4834
- "patterns/geospatial-workspace/geospatial-workspace.tsx": "\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport {\n Badge,\n Button,\n CoordinateInput,\n DescriptionList,\n EntityPreview,\n Input,\n Inspector,\n LayerControl,\n MapControls,\n MapLegend,\n MapScale,\n type CoordinateValue,\n type EntityPreviewRecord,\n type MapLegendItem,\n} from \"@oprt/react\";\nimport { formatUtcInstant } from \"../operational-domain\";\nimport { GeoMap } from \"./geo-map\";\nimport { ReportResults } from \"./report-results\";\nimport { UtcStamp } from \"./utc-stamp\";\nimport { distanceMeters, getFitView, isGeoPoint, moveGeoView } from \"./geometry\";\nimport {\n defaultGeoLayers,\n filterGeoEntities,\n formatGeoPosition,\n geoKnownIds,\n getAnnotationDraftError,\n getGeoFrameError,\n getGeoSnapshotError,\n isValidGeoView,\n parseGeoAnnotationResult,\n parseGeoBookmark,\n retainGeoEntity,\n retainedEntityPreview,\n} from \"./workspace-state\";\nimport type {\n GeoAnnotationDraft,\n GeoEntity,\n GeoFrame,\n GeoMapMode,\n GeoPoint,\n GeoView,\n GeospatialWorkspaceProps,\n} from \"./types\";\n\nconst focusStyle =\n \"outline-hidden focus-visible:shadow-oprt-focus forced-colors:focus-visible:outline-solid forced-colors:focus-visible:outline-2 forced-colors:focus-visible:outline-[CanvasText]\";\nconst headingStyle = \"m-0 font-medium uppercase tracking-oprt-sm\";\nconst summaryStyle = `min-h-[var(--oprt-height-sm)] cursor-pointer py-1u text-oprt-base font-medium uppercase tracking-oprt-sm ${focusStyle}`;\nconst legendItems: readonly MapLegendItem[] = [\n {\n id: \"equipment\",\n label: \"Equipment\",\n kind: \"point\",\n variant: \"accent\",\n description: \"A position does not establish condition or data freshness.\",\n },\n {\n id: \"coverage\",\n label: \"Supplied coverage\",\n kind: \"area\",\n variant: \"info\",\n pattern: \"dashed\",\n description: \"A supplied outline does not establish live sensor coverage.\",\n },\n {\n id: \"track\",\n label: \"Reported track\",\n kind: \"line\",\n variant: \"accent\",\n description: \"Only supplied samples; gaps are not reconstructed.\",\n },\n {\n id: \"uncertainty\",\n label: \"Position uncertainty\",\n kind: \"area\",\n variant: \"warning\",\n pattern: \"dashed\",\n description: \"Approximate supplied radius around a report.\",\n },\n {\n id: \"measurement\",\n label: \"Measurement\",\n kind: \"line\",\n variant: \"warning\",\n description: \"Approximate distance between the two reviewed endpoints.\",\n },\n { id: \"annotation\", label: \"Saved annotation\", kind: \"point\", variant: \"warning\" },\n];\nconst blank: CoordinateValue = { latitude: null, longitude: null };\nconst coordinate = (point: GeoPoint): CoordinateValue => ({\n latitude: point[1],\n longitude: point[0],\n});\nfunction point(value: CoordinateValue): GeoPoint | null {\n const candidate = [value.longitude, value.latitude];\n return isGeoPoint(candidate) ? candidate : null;\n}\nfunction missingRecord(id: string): EntityPreviewRecord {\n return {\n id,\n name: \"Unavailable report\",\n facts: [],\n quality: {\n state: \"unavailable\",\n updatedAt: null,\n detail: \"No last-known detail for this identity is available in this session.\",\n },\n };\n}\ntype FocusRequest = { kind: \"inspect\"; id: string; origin: HTMLElement | null } | { kind: \"close\" };\n\n/** Reference workflow. Supplied identities, annotations and bookmarks remain authoritative. */\nexport function GeospatialWorkspace(props: GeospatialWorkspaceProps) {\n const {\n snapshot,\n selectedId,\n onSelectedIdChange,\n bookmarks,\n onBookmarksChange,\n annotations,\n onCreateAnnotation,\n onRemoveAnnotation,\n initialView,\n tileSource,\n } = props;\n const sourceError = useMemo(() => getGeoSnapshotError(snapshot), [snapshot]);\n const entities = useMemo(() => (sourceError ? [] : snapshot.entities), [sourceError, snapshot]);\n const [view, setView] = useState(() =>\n isValidGeoView(initialView) ? initialView : getFitView(entities),\n );\n const [layers, setLayers] = useState(() => defaultGeoLayers.map((layer) => ({ ...layer })));\n const [query, setQuery] = useState(\"\");\n const filtered = useMemo(() => filterGeoEntities(entities, query), [entities, query]);\n const [mapEnabled, setMapEnabled] = useState(true);\n const [frameReport, setFrameReport] = useState<{\n snapshot: typeof snapshot;\n query: string;\n frame: GeoFrame;\n } | null>(null);\n const frame = frameReport?.snapshot === snapshot && mapEnabled ? frameReport.frame : null;\n const [scope, setScope] = useState<\"all\" | \"extent\">(\"all\");\n const [extentNotice, setExtentNotice] = useState(\"\");\n const [mode, setMode] = useState<GeoMapMode>(\"inspect\");\n const [retained, setRetained] = useState<{\n entity: GeoEntity;\n source: { id: string; revision: string; label: string };\n } | null>(null);\n const currentSelected = entities.find((entity) => entity.record.id === selectedId);\n const retainedSelection = retained?.entity.record.id === selectedId ? retained : null;\n const selected = currentSelected ?? retainedSelection?.entity ?? null;\n const reportSource = currentSelected\n ? { id: snapshot.id, revision: snapshot.revision, label: snapshot.source }\n : (retainedSelection?.source ?? null);\n const fromOtherSource = reportSource !== null && reportSource.id !== snapshot.id;\n const selectedInFilter = filtered.some((entity) => entity.record.id === selectedId);\n const preview =\n selectedId === null\n ? null\n : selected\n ? retainedEntityPreview(selected, !currentSelected)\n : missingRecord(selectedId);\n // The extent narrows the list only in that scope, so a map frame leaves \"all records\" alone.\n const extentFrame = scope === \"extent\" ? frame : null;\n const displayed = useMemo(() => {\n if (!extentFrame) return filtered;\n const visible = new Set(extentFrame.visibleIds);\n return filtered.filter((entity) => visible.has(entity.record.id));\n }, [extentFrame, filtered]);\n const knownIds = useMemo(() => geoKnownIds(filtered), [filtered]);\n const resultsRef = useRef<HTMLElement>(null);\n const inspectorRef = useRef<HTMLElement>(null);\n const originRef = useRef<HTMLElement | null>(null);\n const [focusRequest, setFocusRequest] = useState<FocusRequest | null>(null);\n const previousSelection = useRef(selectedId);\n const inputId = useId();\n const [goValue, setGoValue] = useState<CoordinateValue>(() => coordinate(view.center));\n const [goInvalid, setGoInvalid] = useState(false);\n const [goError, setGoError] = useState(\"\");\n const [bookmarkName, setBookmarkName] = useState(\"\");\n const [bookmarkNotice, setBookmarkNotice] = useState(\"\");\n const bookmarkSequence = useRef(0);\n const [measurement, setMeasurement] = useState<readonly GeoPoint[]>([]);\n const [endpoints, setEndpoints] = useState<readonly CoordinateValue[]>([blank, blank]);\n const [endpointInvalid, setEndpointInvalid] = useState([false, false]);\n const [measurementError, setMeasurementError] = useState(\"\");\n const [measurementKey, setMeasurementKey] = useState(0);\n const [annotationValue, setAnnotationValue] = useState<CoordinateValue>(blank);\n const [annotationInvalid, setAnnotationInvalid] = useState(false);\n const [annotationKey, setAnnotationKey] = useState(0);\n const [annotationLabel, setAnnotationLabel] = useState(\"\");\n const [annotationNotice, setAnnotationNotice] = useState(\"\");\n const [annotationPending, setAnnotationPending] = useState(false);\n const pendingAnnotation = useRef<{\n sourceId: string;\n revision: string;\n draft: GeoAnnotationDraft;\n } | null>(null);\n const mounted = useRef(true);\n const latest = useRef({ snapshot, query, sourceError, onCreateAnnotation });\n latest.current = { snapshot, query, sourceError, onCreateAnnotation };\n\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n pendingAnnotation.current = null;\n };\n }, []);\n useEffect(() => {\n if (selectedId === null) setRetained(null);\n else if (currentSelected)\n setRetained({\n entity: retainGeoEntity(currentSelected),\n source: { id: snapshot.id, revision: snapshot.revision, label: snapshot.source },\n });\n else setRetained((old) => (old?.entity.record.id === selectedId ? old : null));\n }, [currentSelected, selectedId, snapshot.id, snapshot.revision, snapshot.source]);\n useLayoutEffect(() => {\n if (focusRequest?.kind === \"inspect\" && selectedId === focusRequest.id) {\n originRef.current = focusRequest.origin;\n inspectorRef.current?.focus({ preventScroll: true });\n } else if (focusRequest?.kind === \"close\" && selectedId === null) {\n const origin = originRef.current;\n (origin?.isConnected && !origin.hasAttribute(\"disabled\")\n ? origin\n : resultsRef.current\n )?.focus({ preventScroll: true });\n originRef.current = null;\n } else if (previousSelection.current !== selectedId) originRef.current = null;\n previousSelection.current = selectedId;\n if (focusRequest) setFocusRequest(null);\n }, [focusRequest, selectedId]);\n useEffect(() => {\n if (scope === \"extent\" && !frame) {\n setScope(\"all\");\n setExtentNotice(\"Map extent is unavailable; showing all records that match the filter.\");\n }\n }, [scope, frame]);\n useEffect(() => {\n const pending = pendingAnnotation.current;\n if (\n pending &&\n (pending.sourceId !== snapshot.id ||\n pending.revision !== snapshot.revision ||\n sourceError ||\n !onCreateAnnotation)\n ) {\n pendingAnnotation.current = null;\n setAnnotationPending(false);\n setAnnotationNotice(\n \"The source or editing capability changed. The draft is retained; the earlier request may still complete at the service.\",\n );\n }\n if (!onCreateAnnotation) setMode((old) => (old === \"annotate\" ? \"inspect\" : old));\n }, [snapshot.id, snapshot.revision, sourceError, onCreateAnnotation]);\n\n const reportFrame = useCallback(\n (next: GeoFrame | null) => {\n if (\n latest.current.snapshot !== snapshot ||\n latest.current.query !== query ||\n latest.current.sourceError\n )\n return;\n const error = next ? getGeoFrameError(next, filtered, knownIds) : null;\n if (error) setExtentNotice(error);\n setFrameReport(next && !error ? { snapshot, query, frame: next } : null);\n },\n [snapshot, query, filtered, knownIds],\n );\n function inspect(id: string) {\n if (sourceError || !entities.some((entity) => entity.record.id === id)) return;\n setFocusRequest({\n kind: \"inspect\",\n id,\n origin: document.activeElement instanceof HTMLElement ? document.activeElement : null,\n });\n onSelectedIdChange(id);\n }\n function selectOnMap(id: string | null) {\n if (!sourceError && (id === null || entities.some((entity) => entity.record.id === id)))\n onSelectedIdChange(id);\n }\n function acceptPoint(next: GeoPoint) {\n if (!isGeoPoint(next) || sourceError) return;\n if (mode === \"measure\") {\n const updated = measurement.length === 1 ? [measurement[0]!, next] : [next];\n setMeasurement(updated);\n setEndpoints([coordinate(updated[0]!), updated[1] ? coordinate(updated[1]) : blank]);\n setEndpointInvalid([false, false]);\n setMeasurementKey((old) => old + 1);\n setMeasurementError(\"\");\n } else if (mode === \"annotate\" && onCreateAnnotation && !annotationPending) {\n setAnnotationValue(coordinate(next));\n setAnnotationInvalid(false);\n setAnnotationKey((old) => old + 1);\n setAnnotationNotice(\"\");\n }\n }\n // The map is given functions that never change, so a frame that re-renders this workspace does\n // not re-render the map as well. They call the latest version of each handler.\n const handlers = useRef({ inspect, selectOnMap, acceptPoint });\n handlers.current = { inspect, selectOnMap, acceptPoint };\n const inspectStable = useCallback((id: string) => handlers.current.inspect(id), []);\n const selectOnMapStable = useCallback(\n (id: string | null) => handlers.current.selectOnMap(id),\n [],\n );\n const acceptPointStable = useCallback((next: GeoPoint) => handlers.current.acceptPoint(next), []);\n const changeView = useCallback((next: GeoView) => {\n if (isValidGeoView(next)) setView(next);\n }, []);\n function saveBookmark() {\n let id = `view-${inputId}-${++bookmarkSequence.current}`;\n while (bookmarks.some((bookmark) => bookmark.id === id))\n id = `view-${inputId}-${++bookmarkSequence.current}`;\n const parsed = parseGeoBookmark({ id, name: bookmarkName.trim(), view, layers });\n if (!parsed.ok) {\n setBookmarkNotice(parsed.error);\n return;\n }\n if (bookmarks.length >= 100) {\n setBookmarkNotice(\n \"Remove a bookmark before adding another; this example supports up to 100 supplied views.\",\n );\n return;\n }\n try {\n onBookmarksChange([...bookmarks, parsed.value]);\n setBookmarkNotice(\n \"Bookmark update requested. The list shows the views accepted by the host.\",\n );\n } catch {\n setBookmarkNotice(\"The bookmark update failed. The current view and name are retained.\");\n }\n }\n async function saveAnnotation() {\n if (pendingAnnotation.current || !onCreateAnnotation || sourceError) return;\n const location = point(annotationValue);\n const draft = { label: annotationLabel.trim(), position: location, sourceId: snapshot.id };\n const error = annotationInvalid\n ? \"Complete valid annotation coordinates before saving.\"\n : getAnnotationDraftError(draft);\n if (error || !location) {\n setAnnotationNotice(error ?? \"Enter a valid annotation position.\");\n return;\n }\n const request = {\n sourceId: snapshot.id,\n revision: snapshot.revision,\n draft: Object.freeze({ ...draft, position: Object.freeze([...location]) as GeoPoint }),\n };\n const existing = [...annotations];\n const adapter = onCreateAnnotation;\n pendingAnnotation.current = request;\n setAnnotationPending(true);\n setAnnotationNotice(\"\");\n const isCurrent = () =>\n mounted.current &&\n pendingAnnotation.current === request &&\n latest.current.snapshot.id === request.sourceId &&\n latest.current.snapshot.revision === request.revision &&\n !latest.current.sourceError &&\n Boolean(latest.current.onCreateAnnotation);\n try {\n const result = await adapter(request.draft);\n if (!isCurrent()) return;\n const parsed = parseGeoAnnotationResult(result, request.draft, existing);\n if (!parsed.ok) setAnnotationNotice(parsed.error);\n else if (parsed.value.status === \"failed\") setAnnotationNotice(parsed.value.message);\n else {\n setAnnotationNotice(\n `Annotation saved: ${parsed.value.annotation.label}. The list reflects supplied annotations.`,\n );\n setMode(\"inspect\");\n }\n } catch {\n if (isCurrent())\n setAnnotationNotice(\n \"The annotation outcome could not be confirmed. The draft is retained; check the source before trying again.\",\n );\n } finally {\n if (pendingAnnotation.current === request) {\n pendingAnnotation.current = null;\n if (mounted.current) setAnnotationPending(false);\n }\n }\n }\n const meters = measurement.length === 2 ? distanceMeters(measurement[0]!, measurement[1]!) : null;\n const duplicateBookmarks = new Set(\n bookmarks\n .filter(\n (bookmark, index) => bookmarks.findIndex((other) => other.id === bookmark.id) !== index,\n )\n .map((bookmark) => bookmark.id),\n );\n const validAnnotations = useMemo(() => {\n const occurrences = new Map<string, number>();\n for (const annotation of annotations)\n occurrences.set(annotation.id, (occurrences.get(annotation.id) ?? 0) + 1);\n return annotations.filter(\n (annotation) =>\n !getAnnotationDraftError({\n label: annotation.label,\n position: annotation.position,\n sourceId: annotation.sourceId,\n }) &&\n typeof annotation.id === \"string\" &&\n annotation.id.trim() !== \"\" &&\n occurrences.get(annotation.id) === 1,\n );\n }, [annotations]);\n // The map compares what it draws by identity, so this list changes only when its content can.\n const sourceAnnotations = useMemo(\n () => validAnnotations.filter((annotation) => annotation.sourceId === snapshot.id),\n [validAnnotations, snapshot.id],\n );\n return (\n <section\n aria-label=\"Operations map\"\n className=\"not-prose flex min-w-0 flex-col gap-3u font-sans text-oprt-base text-oprt-text\"\n >\n <header className=\"flex min-w-0 flex-wrap items-start justify-between gap-2u border-b border-oprt-border pb-2u\">\n <div className=\"flex min-w-0 flex-col gap-1u\">\n <h2 className=\"m-0 text-oprt-xl font-semibold\">{snapshot.title ?? \"Operations map\"}</h2>\n <p className=\"m-0 text-oprt-text-dim [overflow-wrap:anywhere]\">{snapshot.source}</p>\n </div>\n <div className=\"flex flex-wrap items-center gap-2u\">\n {snapshot.observedAt && (\n <time\n dateTime={snapshot.observedAt}\n className=\"font-mono text-oprt-sm text-oprt-text-dim\"\n >\n {formatUtcInstant(snapshot.observedAt)}\n </time>\n )}\n <Badge\n appearance=\"text\"\n variant={snapshot.availability === \"current\" ? \"success\" : \"warning\"}\n >\n {snapshot.availability}\n </Badge>\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n onClick={() => {\n setMapEnabled(!mapEnabled);\n setFrameReport(null);\n }}\n >\n {mapEnabled ? \"Disable map\" : \"Enable map\"}\n </Button>\n </div>\n </header>\n {snapshot.availability !== \"current\" && (\n <p role=\"status\" className=\"m-0 text-oprt-warning\">\n {snapshot.detail}\n </p>\n )}\n {sourceError ? (\n <p role=\"alert\" className=\"m-0 border border-oprt-error p-2u text-oprt-error\">\n {sourceError}\n </p>\n ) : (\n <>\n {initialView && !isValidGeoView(initialView) && (\n <p role=\"status\" className=\"m-0 text-oprt-warning\">\n The initial map view is invalid. A view fitted to the supplied positions is shown.\n </p>\n )}\n <div className=\"flex min-w-0 flex-wrap items-start gap-3u\">\n <div className=\"flex min-w-0 flex-[2_1_32rem] flex-col gap-2u\">\n <GeoMap\n snapshot={snapshot}\n entities={filtered}\n selectedId={selectedId}\n onSelectedIdChange={selectOnMapStable}\n view={view}\n onViewChange={changeView}\n layers={layers}\n annotations={sourceAnnotations}\n measurement={measurement}\n mode={mode}\n onPoint={acceptPointStable}\n onFrame={reportFrame}\n onInspect={inspectStable}\n {...(tileSource ? { tileSource } : {})}\n disabled={!mapEnabled}\n />\n <MapControls\n zoom={view.zoom}\n bearing={view.bearing}\n disabled={!mapEnabled || snapshot.availability === \"unavailable\"}\n onAction={(action) => setView((old) => moveGeoView(old, action, filtered))}\n />\n <div className=\"flex min-w-0 flex-wrap items-start justify-between gap-2u\">\n <MapScale metersPerPixel={frame?.metersPerPixel ?? null} />\n <p className=\"m-0 font-mono text-oprt-text-dim\">\n Center {formatGeoPosition(view.center)}\n </p>\n </div>\n <p className=\"m-0 text-oprt-text-dim\">\n {mode === \"measure\"\n ? \"Measurement mode: choose two points. A third point begins a new measurement.\"\n : mode === \"annotate\"\n ? \"Annotation mode: choose a point, then review and save it in Annotations.\"\n : \"Choose a marker to preview. Pin it to keep its details beside the map.\"}\n </p>\n {mode !== \"inspect\" && (\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => setMode(\"inspect\")}>\n Return to inspection\n </Button>\n )}\n </div>\n {preview ? (\n <Inspector\n ref={inspectorRef}\n title=\"Selected report\"\n tabIndex={-1}\n className={`${focusStyle} min-w-0 flex-[1_1_21rem]`}\n onClose={() => {\n setFocusRequest({ kind: \"close\" });\n onSelectedIdChange(null);\n }}\n >\n <EntityPreview entity={preview} headingLevel={4} />\n {selected && (\n <div className=\"flex flex-col gap-1u border-t border-oprt-border pt-2u\">\n <h4 className={headingStyle}>Geographic report</h4>\n {fromOtherSource && (\n <p className=\"m-0 text-oprt-warning\">\n This retained report belongs to a different source. Locating it on the\n current source is unavailable.\n </p>\n )}\n <DescriptionList\n layout=\"details\"\n items={[\n ...(reportSource\n ? [\n {\n id: \"source\",\n label: \"Report source\",\n value: (\n <>\n {reportSource.label} ·{\" \"}\n <span className=\"font-mono\">\n {reportSource.id} / {reportSource.revision}\n </span>\n </>\n ),\n },\n ]\n : []),\n { id: \"state\", label: \"Position state\", value: selected.positionState },\n {\n id: \"position\",\n label: \"Position\",\n value: formatGeoPosition(selected.position),\n mono: true,\n },\n {\n id: \"observed\",\n label: \"Position observed\",\n value: <UtcStamp value={selected.observedAt} fallback=\"Not supplied\" />,\n mono: true,\n },\n {\n id: \"uncertainty\",\n label: \"Uncertainty\",\n value:\n typeof selected.uncertaintyMeters === \"number\" &&\n Number.isFinite(selected.uncertaintyMeters) &&\n selected.uncertaintyMeters >= 0\n ? `${selected.uncertaintyMeters} m supplied radius`\n : \"Not supplied\",\n mono: true,\n },\n {\n id: \"coverage\",\n label: \"Coverage\",\n value: selected.coverage?.length\n ? \"Supplied geographic outline\"\n : \"Not supplied\",\n },\n {\n id: \"track\",\n label: \"Track\",\n value: `${selected.track?.length ?? 0} supplied samples`,\n },\n ]}\n />\n {selected.track && selected.track.length > 0 && (\n <details>\n <summary className={summaryStyle}>Reported track samples</summary>\n <ol className=\"m-0 flex flex-col gap-1u pl-3u\">\n {selected.track.map((sample, index) => (\n <li key={index}>\n <span className=\"font-mono\">\n <UtcStamp value={sample.observedAt} fallback=\"Time unknown\" />\n </span>\n <p className=\"m-0 font-mono [overflow-wrap:anywhere]\">\n {formatGeoPosition(sample.position)}\n </p>\n </li>\n ))}\n </ol>\n </details>\n )}\n {selected.coverage && selected.coverage.length > 0 && (\n <details>\n <summary className={summaryStyle}>Coverage outline vertices</summary>\n <ol className=\"m-0 flex flex-col gap-1u pl-3u\">\n {selected.coverage.map((location, index) => (\n <li key={index} className=\"font-mono [overflow-wrap:anywhere]\">\n Vertex {index + 1}: {formatGeoPosition(location)}\n </li>\n ))}\n </ol>\n </details>\n )}\n {currentSelected && !selectedInFilter && (\n <p className=\"m-0 text-oprt-warning\">\n Selected report is outside the current filter.\n </p>\n )}\n {currentSelected &&\n selectedInFilter &&\n frame &&\n frameReport?.query === query &&\n !frame.visibleIds.includes(currentSelected.record.id) && (\n <p className=\"m-0 text-oprt-text-dim\">\n Selected report is outside the map extent or has no plottable position.\n </p>\n )}\n </div>\n )}\n <div className=\"border-t border-oprt-border pt-2u\">\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={\n !mapEnabled ||\n snapshot.availability === \"unavailable\" ||\n !selected ||\n !isGeoPoint(selected.position) ||\n fromOtherSource\n }\n onClick={() => {\n if (selected && !fromOtherSource && isGeoPoint(selected.position))\n setView((old) => ({ ...old, center: selected.position as GeoPoint }));\n }}\n >\n Locate selected report\n </Button>\n </div>\n </Inspector>\n ) : (\n <div className=\"flex flex-col gap-2u border border-oprt-border p-2u\">\n <h3 className={headingStyle}>Selected report</h3>\n <p className=\"m-0 text-oprt-text-dim\">\n Inspect a table record, or choose a map marker and pin its details. Reports with\n unknown positions remains in All records.\n </p>\n <MapLegend items={legendItems} />\n </div>\n )}\n </div>\n <details className=\"min-w-0 border border-oprt-border p-2u\">\n <summary className={summaryStyle}>Layers and legend</summary>\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-3u pt-2u\">\n <LayerControl layers={layers} onLayersChange={(next) => setLayers([...next])} />\n <MapLegend items={legendItems} />\n </div>\n </details>\n <section\n ref={resultsRef}\n role=\"region\"\n aria-label=\"Report results\"\n tabIndex={-1}\n className={`flex min-w-0 flex-col gap-2u border border-oprt-border p-2u ${focusStyle}`}\n >\n <h3 className={headingStyle}>Report results</h3>\n <label htmlFor={`${inputId}-filter`} className={headingStyle}>\n Filter reports\n </label>\n <Input\n id={`${inputId}-filter`}\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder=\"Name, type or ID\"\n />\n <div role=\"group\" aria-label=\"Record scope\" className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant=\"secondary\"\n aria-pressed={scope === \"all\"}\n onClick={() => {\n setScope(\"all\");\n setExtentNotice(\"\");\n }}\n >\n All records\n </Button>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n aria-pressed={scope === \"extent\"}\n disabled={!frame}\n onClick={() => {\n setScope(\"extent\");\n setExtentNotice(\"\");\n }}\n >\n Map extent\n </Button>\n </div>\n <p role=\"status\" className=\"m-0 text-oprt-text-dim\">\n {displayed.length} shown · {filtered.length} matching · {entities.length} supplied.{\" \"}\n {scope === \"extent\" && frame ? \"Scope: map extent.\" : \"Scope: all records.\"} Counts\n include only the supplied collection.\n </p>\n {extentNotice && (\n <p role=\"status\" className=\"m-0 text-oprt-warning\">\n {extentNotice}\n </p>\n )}\n {!frame && (\n <p className=\"m-0 text-oprt-text-dim\">\n Map extent is not available. All records includes unknown and unsupported positions.\n </p>\n )}\n <ReportResults\n entities={displayed}\n onInspect={inspectStable}\n // In the extent scope the map view is the filter: when panning or zooming changes\n // which reports are listed, the table returns to its first page, as for a new query.\n resetKey={`${scope}|${query}|${\n scope === \"extent\" ? `${displayed.length}:${displayed[0]?.record.id ?? \"\"}` : \"\"\n }`}\n />\n {displayed.length === 0 && (\n <p className=\"m-0\">\n {entities.length === 0\n ? \"No reports were supplied.\"\n : \"No reports match this filter and scope.\"}\n </p>\n )}\n </section>\n <details className=\"min-w-0 border border-oprt-border p-2u\">\n <summary className={summaryStyle}>Coordinates and bookmarks</summary>\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-3u pt-2u\">\n <form\n noValidate\n className=\"flex min-w-0 flex-col gap-2u\"\n onSubmit={(event) => {\n event.preventDefault();\n const location = point(goValue);\n if (goInvalid || !location) {\n setGoError(\n \"Enter valid coordinates before moving the map. Supported latitude is ±85.05112878°.\",\n );\n return;\n }\n setGoError(\"\");\n setView((old) => ({ ...old, center: location }));\n }}\n >\n <CoordinateInput\n label=\"Go to coordinates\"\n value={goValue}\n onValueChange={setGoValue}\n onValidationChange={setGoInvalid}\n description=\"WGS84 decimal degrees. Map latitude is limited to ±85.05112878°.\"\n />\n <Button type=\"submit\" size=\"sm\">\n Go to location\n </Button>\n {goError && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n {goError}\n </p>\n )}\n </form>\n <div className=\"flex min-w-0 flex-col gap-2u\">\n <label htmlFor={`${inputId}-bookmark`} className={headingStyle}>\n Bookmark name\n </label>\n <Input\n id={`${inputId}-bookmark`}\n maxLength={80}\n value={bookmarkName}\n onChange={(event) => setBookmarkName(event.target.value)}\n />\n <Button size=\"sm\" onClick={saveBookmark}>\n Save bookmark\n </Button>\n {bookmarkNotice && (\n <p role=\"status\" className=\"m-0\">\n {bookmarkNotice}\n </p>\n )}\n <ul className=\"m-0 flex list-none flex-col gap-2u p-0\">\n {bookmarks.map((bookmark, index) => {\n const parsed = parseGeoBookmark(bookmark);\n const valid = parsed.ok && !duplicateBookmarks.has(bookmark.id);\n const name =\n typeof bookmark.name === \"string\" ? bookmark.name : \"Invalid bookmark\";\n return (\n <li\n key={valid ? `bookmark:${bookmark.id}` : `invalid:${index}`}\n className=\"flex min-w-0 flex-wrap items-start gap-1u\"\n >\n <Button\n size=\"sm\"\n variant=\"secondary\"\n className=\"h-auto! min-h-[var(--oprt-height-sm)] min-w-0 max-w-full whitespace-normal [overflow-wrap:anywhere]\"\n aria-label={`Restore ${name}`}\n disabled={!valid}\n onClick={() => {\n if (parsed.ok && valid) {\n setView(parsed.value.view);\n setLayers([...parsed.value.layers]);\n setBookmarkNotice(`Restored ${parsed.value.name}.`);\n }\n }}\n >\n {name}\n </Button>\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n disabled={duplicateBookmarks.has(bookmark.id)}\n aria-label={`Remove bookmark ${name}`}\n onClick={() => {\n try {\n onBookmarksChange(\n bookmarks.filter((item) => item.id !== bookmark.id),\n );\n setBookmarkNotice(\n \"Bookmark removal requested. The list reflects the supplied views.\",\n );\n } catch {\n setBookmarkNotice(\"The bookmark could not be removed.\");\n }\n }}\n >\n Remove\n </Button>\n {!valid && (\n <p className=\"m-0 text-oprt-error\">\n This bookmark has invalid or ambiguous saved preferences.\n </p>\n )}\n </li>\n );\n })}\n </ul>\n {bookmarks.length === 0 && (\n <p className=\"m-0 text-oprt-text-dim\">No saved views supplied.</p>\n )}\n </div>\n </div>\n </details>\n <details className=\"min-w-0 border border-oprt-border p-2u\">\n <summary className={summaryStyle}>Measurement</summary>\n <div className=\"flex min-w-0 flex-col gap-2u pt-2u\">\n <p className=\"m-0 text-oprt-text-dim\">\n Approximate great-circle distance between two WGS84 points. Terrain, routing and\n height are not included.\n </p>\n <div className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n aria-pressed={mode === \"measure\"}\n onClick={() => setMode(mode === \"measure\" ? \"inspect\" : \"measure\")}\n >\n Measure on map\n </Button>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n onClick={() => {\n setMeasurement([]);\n setEndpoints([blank, blank]);\n setEndpointInvalid([false, false]);\n setMeasurementKey((old) => old + 1);\n setMeasurementError(\"\");\n setMode(\"inspect\");\n }}\n >\n Clear measurement\n </Button>\n </div>\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-2u\">\n {endpoints.map((value, index) => (\n <CoordinateInput\n key={`${measurementKey}-${index}`}\n label={`Measurement point ${index + 1}`}\n value={value}\n onValueChange={(next) =>\n setEndpoints((old) =>\n old.map((item, position) => (position === index ? next : item)),\n )\n }\n onValidationChange={(invalid) =>\n setEndpointInvalid((old) =>\n old[index] === invalid\n ? old\n : old.map((item, position) => (position === index ? invalid : item)),\n )\n }\n />\n ))}\n </div>\n <Button\n size=\"sm\"\n onClick={() => {\n const first = point(endpoints[0]!);\n const second = point(endpoints[1]!);\n if (endpointInvalid.some(Boolean) || !first || !second) {\n setMeasurementError(\n \"Complete both valid measurement coordinates within the supported map latitude range.\",\n );\n return;\n }\n setMeasurement([first, second]);\n setMeasurementError(\"\");\n }}\n >\n Apply measurement coordinates\n </Button>\n {measurementError && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n {measurementError}\n </p>\n )}\n {measurement.map((location, index) => (\n <p key={index} className=\"m-0 font-mono\">\n Point {index + 1}: {formatGeoPosition(location)}\n </p>\n ))}\n <p role=\"status\" className=\"m-0 font-mono\">\n {meters === null\n ? \"Choose two points to measure.\"\n : `Approximate distance: ${meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(2)} km`}`}\n </p>\n </div>\n </details>\n <details className=\"min-w-0 border border-oprt-border p-2u\">\n <summary className={summaryStyle}>Annotations</summary>\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-3u pt-2u\">\n <form\n noValidate\n className=\"flex min-w-0 flex-col gap-2u\"\n onSubmit={(event) => {\n event.preventDefault();\n void saveAnnotation();\n }}\n >\n <p className=\"m-0 text-oprt-text-dim\">\n Source:{\" \"}\n <span className=\"font-mono\">\n {snapshot.id} / {snapshot.revision}\n </span>\n . Review the label and location before saving.\n </p>\n <label htmlFor={`${inputId}-annotation`} className={headingStyle}>\n Annotation label\n </label>\n <Input\n id={`${inputId}-annotation`}\n maxLength={120}\n value={annotationLabel}\n disabled={!onCreateAnnotation || annotationPending}\n onChange={(event) => {\n setAnnotationLabel(event.target.value);\n setAnnotationNotice(\"\");\n }}\n />\n <CoordinateInput\n key={annotationKey}\n label=\"Annotation position\"\n value={annotationValue}\n onValueChange={setAnnotationValue}\n onValidationChange={setAnnotationInvalid}\n disabled={!onCreateAnnotation || annotationPending}\n />\n <div className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={\n !onCreateAnnotation ||\n annotationPending ||\n !currentSelected ||\n !isGeoPoint(currentSelected.position)\n }\n onClick={() => {\n if (currentSelected && isGeoPoint(currentSelected.position)) {\n setAnnotationValue(coordinate(currentSelected.position));\n setAnnotationInvalid(false);\n setAnnotationKey((old) => old + 1);\n }\n }}\n >\n Use selected position\n </Button>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={!onCreateAnnotation || annotationPending}\n aria-pressed={mode === \"annotate\"}\n onClick={() => setMode(mode === \"annotate\" ? \"inspect\" : \"annotate\")}\n >\n Annotate on map\n </Button>\n <Button\n size=\"sm\"\n type=\"submit\"\n disabled={!onCreateAnnotation || annotationPending}\n >\n {annotationPending ? \"Saving annotation…\" : \"Save annotation\"}\n </Button>\n </div>\n {!onCreateAnnotation && (\n <p className=\"m-0 text-oprt-text-dim\">\n Annotation creation is unavailable; no create adapter was supplied.\n </p>\n )}\n {annotationNotice && (\n <p role=\"status\" className=\"m-0\">\n {annotationNotice}\n </p>\n )}\n </form>\n <div className=\"flex min-w-0 flex-col gap-2u\">\n <h3 className={headingStyle}>Supplied annotations</h3>\n {validAnnotations.length !== annotations.length && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n Some annotations have invalid or duplicate identities or positions and cannot be\n shown safely.\n </p>\n )}\n {validAnnotations.length > sourceAnnotations.length && (\n <p className=\"m-0 text-oprt-text-dim\">\n Annotations for another source are outside this view.\n </p>\n )}\n <ul className=\"m-0 flex list-none flex-col gap-2u p-0\">\n {sourceAnnotations.map((annotation) => (\n <li\n key={annotation.id}\n className=\"flex min-w-0 flex-col gap-1u border border-oprt-border p-2u\"\n >\n <p className=\"m-0 font-medium [overflow-wrap:anywhere]\">{annotation.label}</p>\n <p className=\"m-0 font-mono [overflow-wrap:anywhere]\">\n {formatGeoPosition(annotation.position)}\n </p>\n <p className=\"m-0 font-mono [overflow-wrap:anywhere]\">\n <UtcStamp value={annotation.createdAt} fallback=\"Time unknown\" />\n </p>\n {onRemoveAnnotation && (\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n aria-label={`Remove annotation ${annotation.label}`}\n onClick={() => {\n try {\n onRemoveAnnotation(annotation.id);\n setAnnotationNotice(\n \"Annotation removal requested. The list reflects the supplied source.\",\n );\n } catch {\n setAnnotationNotice(\"The annotation could not be removed.\");\n }\n }}\n >\n Remove\n </Button>\n )}\n </li>\n ))}\n </ul>\n {sourceAnnotations.length === 0 && (\n <p className=\"m-0 text-oprt-text-dim\">\n No saved annotations supplied for this source.\n </p>\n )}\n </div>\n </div>\n </details>\n </>\n )}\n </section>\n );\n}\n",
4834
+ "patterns/geospatial-workspace/geospatial-workspace.tsx": "\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport {\n Badge,\n Button,\n CoordinateInput,\n DescriptionList,\n EntityPreview,\n Input,\n Inspector,\n LayerControl,\n MapControls,\n MapLegend,\n MapScale,\n type CoordinateValue,\n type EntityPreviewRecord,\n type MapLegendItem,\n} from \"@oprt/react\";\nimport { formatUtcInstant } from \"../operational-domain\";\nimport { GeoMap } from \"./geo-map\";\nimport { ReportResults } from \"./report-results\";\nimport { UtcStamp } from \"./utc-stamp\";\nimport { distanceMeters, getFitView, isGeoPoint, moveGeoView } from \"./geometry\";\nimport {\n defaultGeoLayers,\n filterGeoEntities,\n formatGeoPosition,\n geoKnownIds,\n getAnnotationDraftError,\n getGeoFrameError,\n getGeoSnapshotError,\n isValidGeoView,\n parseGeoAnnotationResult,\n parseGeoBookmark,\n retainGeoEntity,\n retainedEntityPreview,\n} from \"./workspace-state\";\nimport type {\n GeoAnnotationDraft,\n GeoEntity,\n GeoFrame,\n GeoMapMode,\n GeoPoint,\n GeoView,\n GeospatialWorkspaceProps,\n} from \"./types\";\n\nconst focusStyle =\n \"outline-hidden focus-visible:shadow-oprt-focus forced-colors:focus-visible:outline-solid forced-colors:focus-visible:outline-2 forced-colors:focus-visible:outline-[CanvasText]\";\nconst headingStyle = \"m-0 font-medium uppercase tracking-oprt-sm\";\nconst summaryStyle = `min-h-[var(--oprt-height-sm)] cursor-pointer py-1u text-oprt-base font-medium uppercase tracking-oprt-sm ${focusStyle}`;\nconst legendItems: readonly MapLegendItem[] = [\n {\n id: \"equipment\",\n label: \"Equipment\",\n kind: \"point\",\n variant: \"accent\",\n description: \"A position does not establish condition or data freshness.\",\n },\n {\n id: \"coverage\",\n label: \"Supplied coverage\",\n kind: \"area\",\n variant: \"info\",\n pattern: \"dashed\",\n description: \"A supplied outline does not establish live sensor coverage.\",\n },\n {\n id: \"track\",\n label: \"Reported track\",\n kind: \"line\",\n variant: \"accent\",\n description: \"Only supplied samples; gaps are not reconstructed.\",\n },\n {\n id: \"uncertainty\",\n label: \"Position uncertainty\",\n kind: \"area\",\n variant: \"warning\",\n pattern: \"dashed\",\n description: \"Approximate supplied radius around a report.\",\n },\n {\n id: \"measurement\",\n label: \"Measurement\",\n kind: \"line\",\n variant: \"warning\",\n description: \"Approximate distance between the two reviewed endpoints.\",\n },\n { id: \"annotation\", label: \"Saved annotation\", kind: \"point\", variant: \"warning\" },\n];\nconst blank: CoordinateValue = { latitude: null, longitude: null };\nconst coordinate = (point: GeoPoint): CoordinateValue => ({\n latitude: point[1],\n longitude: point[0],\n});\nfunction point(value: CoordinateValue): GeoPoint | null {\n const candidate = [value.longitude, value.latitude];\n return isGeoPoint(candidate) ? candidate : null;\n}\nfunction missingRecord(id: string): EntityPreviewRecord {\n return {\n id,\n name: \"Unavailable report\",\n facts: [],\n quality: {\n state: \"unavailable\",\n updatedAt: null,\n detail: \"No last-known detail for this identity is available in this session.\",\n },\n };\n}\ntype FocusRequest = { kind: \"inspect\"; id: string; origin: HTMLElement | null } | { kind: \"close\" };\n\n/** Reference workflow. Supplied identities, annotations and bookmarks remain authoritative. */\nexport function GeospatialWorkspace(props: GeospatialWorkspaceProps) {\n const {\n snapshot,\n selectedId,\n onSelectedIdChange,\n bookmarks,\n onBookmarksChange,\n annotations,\n onCreateAnnotation,\n onRemoveAnnotation,\n initialView,\n tileSource,\n } = props;\n const sourceError = useMemo(() => getGeoSnapshotError(snapshot), [snapshot]);\n const entities = useMemo(() => (sourceError ? [] : snapshot.entities), [sourceError, snapshot]);\n const [view, setView] = useState(() =>\n isValidGeoView(initialView) ? initialView : getFitView(entities),\n );\n const [layers, setLayers] = useState(() => defaultGeoLayers.map((layer) => ({ ...layer })));\n const [query, setQuery] = useState(\"\");\n const filtered = useMemo(() => filterGeoEntities(entities, query), [entities, query]);\n const [mapEnabled, setMapEnabled] = useState(true);\n const [frameReport, setFrameReport] = useState<{\n snapshot: typeof snapshot;\n query: string;\n frame: GeoFrame;\n } | null>(null);\n const frame = frameReport?.snapshot === snapshot && mapEnabled ? frameReport.frame : null;\n const [scope, setScope] = useState<\"all\" | \"extent\">(\"all\");\n const [extentNotice, setExtentNotice] = useState(\"\");\n const [mode, setMode] = useState<GeoMapMode>(\"inspect\");\n const [retained, setRetained] = useState<{\n entity: GeoEntity;\n source: { id: string; revision: string; label: string };\n } | null>(null);\n const currentSelected = entities.find((entity) => entity.record.id === selectedId);\n const retainedSelection = retained?.entity.record.id === selectedId ? retained : null;\n const selected = currentSelected ?? retainedSelection?.entity ?? null;\n const reportSource = currentSelected\n ? { id: snapshot.id, revision: snapshot.revision, label: snapshot.source }\n : (retainedSelection?.source ?? null);\n const fromOtherSource = reportSource !== null && reportSource.id !== snapshot.id;\n const selectedInFilter = filtered.some((entity) => entity.record.id === selectedId);\n const preview =\n selectedId === null\n ? null\n : selected\n ? retainedEntityPreview(selected, !currentSelected)\n : missingRecord(selectedId);\n // The extent narrows the list only in that scope, so a map frame leaves \"all records\" alone.\n const extentFrame = scope === \"extent\" ? frame : null;\n const displayed = useMemo(() => {\n if (!extentFrame) return filtered;\n const visible = new Set(extentFrame.visibleIds);\n return filtered.filter((entity) => visible.has(entity.record.id));\n }, [extentFrame, filtered]);\n const knownIds = useMemo(() => geoKnownIds(filtered), [filtered]);\n const resultsRef = useRef<HTMLElement>(null);\n const inspectorRef = useRef<HTMLElement>(null);\n const originRef = useRef<HTMLElement | null>(null);\n const [focusRequest, setFocusRequest] = useState<FocusRequest | null>(null);\n const previousSelection = useRef(selectedId);\n const inputId = useId();\n const [goValue, setGoValue] = useState<CoordinateValue>(() => coordinate(view.center));\n const [goInvalid, setGoInvalid] = useState(false);\n const [goError, setGoError] = useState(\"\");\n const [bookmarkName, setBookmarkName] = useState(\"\");\n const [bookmarkNotice, setBookmarkNotice] = useState(\"\");\n const bookmarkSequence = useRef(0);\n const [measurement, setMeasurement] = useState<readonly GeoPoint[]>([]);\n const [endpoints, setEndpoints] = useState<readonly CoordinateValue[]>([blank, blank]);\n const [endpointInvalid, setEndpointInvalid] = useState([false, false]);\n const [measurementError, setMeasurementError] = useState(\"\");\n const [measurementKey, setMeasurementKey] = useState(0);\n const [annotationValue, setAnnotationValue] = useState<CoordinateValue>(blank);\n const [annotationInvalid, setAnnotationInvalid] = useState(false);\n const [annotationKey, setAnnotationKey] = useState(0);\n const [annotationLabel, setAnnotationLabel] = useState(\"\");\n const [annotationNotice, setAnnotationNotice] = useState(\"\");\n const [annotationPending, setAnnotationPending] = useState(false);\n const pendingAnnotation = useRef<{\n sourceId: string;\n revision: string;\n draft: GeoAnnotationDraft;\n } | null>(null);\n const mounted = useRef(true);\n const latest = useRef({ snapshot, query, sourceError, onCreateAnnotation });\n latest.current = { snapshot, query, sourceError, onCreateAnnotation };\n\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n pendingAnnotation.current = null;\n };\n }, []);\n useEffect(() => {\n if (selectedId === null) setRetained(null);\n else if (currentSelected)\n setRetained({\n entity: retainGeoEntity(currentSelected),\n source: { id: snapshot.id, revision: snapshot.revision, label: snapshot.source },\n });\n else setRetained((old) => (old?.entity.record.id === selectedId ? old : null));\n }, [currentSelected, selectedId, snapshot.id, snapshot.revision, snapshot.source]);\n useLayoutEffect(() => {\n if (focusRequest?.kind === \"inspect\" && selectedId === focusRequest.id) {\n originRef.current = focusRequest.origin;\n inspectorRef.current?.focus({ preventScroll: true });\n } else if (focusRequest?.kind === \"close\" && selectedId === null) {\n const origin = originRef.current;\n (origin?.isConnected && !origin.hasAttribute(\"disabled\")\n ? origin\n : resultsRef.current\n )?.focus({ preventScroll: true });\n originRef.current = null;\n } else if (previousSelection.current !== selectedId) originRef.current = null;\n previousSelection.current = selectedId;\n if (focusRequest) setFocusRequest(null);\n }, [focusRequest, selectedId]);\n useEffect(() => {\n if (scope === \"extent\" && !frame) {\n setScope(\"all\");\n setExtentNotice(\"Map extent is unavailable; showing all records that match the filter.\");\n }\n }, [scope, frame]);\n useEffect(() => {\n const pending = pendingAnnotation.current;\n if (\n pending &&\n (pending.sourceId !== snapshot.id ||\n pending.revision !== snapshot.revision ||\n sourceError ||\n !onCreateAnnotation)\n ) {\n pendingAnnotation.current = null;\n setAnnotationPending(false);\n setAnnotationNotice(\n \"The source or editing capability changed. The draft is retained; the earlier request may still complete at the service.\",\n );\n }\n if (!onCreateAnnotation) setMode((old) => (old === \"annotate\" ? \"inspect\" : old));\n }, [snapshot.id, snapshot.revision, sourceError, onCreateAnnotation]);\n\n const reportFrame = useCallback(\n (next: GeoFrame | null) => {\n if (\n latest.current.snapshot !== snapshot ||\n latest.current.query !== query ||\n latest.current.sourceError\n )\n return;\n const error = next ? getGeoFrameError(next, filtered, knownIds) : null;\n if (error) setExtentNotice(error);\n setFrameReport(next && !error ? { snapshot, query, frame: next } : null);\n },\n [snapshot, query, filtered, knownIds],\n );\n function inspect(id: string) {\n if (sourceError || !entities.some((entity) => entity.record.id === id)) return;\n setFocusRequest({\n kind: \"inspect\",\n id,\n origin: document.activeElement instanceof HTMLElement ? document.activeElement : null,\n });\n onSelectedIdChange(id);\n }\n function selectOnMap(id: string | null) {\n if (!sourceError && (id === null || entities.some((entity) => entity.record.id === id)))\n onSelectedIdChange(id);\n }\n function acceptPoint(next: GeoPoint) {\n if (!isGeoPoint(next) || sourceError) return;\n if (mode === \"measure\") {\n const updated = measurement.length === 1 ? [measurement[0]!, next] : [next];\n setMeasurement(updated);\n setEndpoints([coordinate(updated[0]!), updated[1] ? coordinate(updated[1]) : blank]);\n setEndpointInvalid([false, false]);\n setMeasurementKey((old) => old + 1);\n setMeasurementError(\"\");\n } else if (mode === \"annotate\" && onCreateAnnotation && !annotationPending) {\n setAnnotationValue(coordinate(next));\n setAnnotationInvalid(false);\n setAnnotationKey((old) => old + 1);\n setAnnotationNotice(\"\");\n }\n }\n // The map is given functions that never change, so a frame that re-renders this workspace does\n // not re-render the map as well. They call the latest version of each handler.\n const handlers = useRef({ inspect, selectOnMap, acceptPoint });\n handlers.current = { inspect, selectOnMap, acceptPoint };\n const inspectStable = useCallback((id: string) => handlers.current.inspect(id), []);\n const selectOnMapStable = useCallback(\n (id: string | null) => handlers.current.selectOnMap(id),\n [],\n );\n const acceptPointStable = useCallback((next: GeoPoint) => handlers.current.acceptPoint(next), []);\n const changeView = useCallback((next: GeoView) => {\n if (isValidGeoView(next)) setView(next);\n }, []);\n function saveBookmark() {\n let id = `view-${inputId}-${++bookmarkSequence.current}`;\n while (bookmarks.some((bookmark) => bookmark.id === id))\n id = `view-${inputId}-${++bookmarkSequence.current}`;\n const parsed = parseGeoBookmark({ id, name: bookmarkName.trim(), view, layers });\n if (!parsed.ok) {\n setBookmarkNotice(parsed.error);\n return;\n }\n if (bookmarks.length >= 100) {\n setBookmarkNotice(\n \"Remove a bookmark before adding another; this example supports up to 100 supplied views.\",\n );\n return;\n }\n try {\n onBookmarksChange([...bookmarks, parsed.value]);\n setBookmarkNotice(\n \"Bookmark update requested. The list shows the views accepted by the host.\",\n );\n } catch {\n setBookmarkNotice(\"The bookmark update failed. The current view and name are retained.\");\n }\n }\n async function saveAnnotation() {\n if (pendingAnnotation.current || !onCreateAnnotation || sourceError) return;\n const location = point(annotationValue);\n const draft = { label: annotationLabel.trim(), position: location, sourceId: snapshot.id };\n const error = annotationInvalid\n ? \"Complete valid annotation coordinates before saving.\"\n : getAnnotationDraftError(draft);\n if (error || !location) {\n setAnnotationNotice(error ?? \"Enter a valid annotation position.\");\n return;\n }\n const request = {\n sourceId: snapshot.id,\n revision: snapshot.revision,\n draft: Object.freeze({ ...draft, position: Object.freeze([...location]) as GeoPoint }),\n };\n const existing = [...annotations];\n const adapter = onCreateAnnotation;\n pendingAnnotation.current = request;\n setAnnotationPending(true);\n setAnnotationNotice(\"\");\n const isCurrent = () =>\n mounted.current &&\n pendingAnnotation.current === request &&\n latest.current.snapshot.id === request.sourceId &&\n latest.current.snapshot.revision === request.revision &&\n !latest.current.sourceError &&\n Boolean(latest.current.onCreateAnnotation);\n try {\n const result = await adapter(request.draft);\n if (!isCurrent()) return;\n const parsed = parseGeoAnnotationResult(result, request.draft, existing);\n if (!parsed.ok) setAnnotationNotice(parsed.error);\n else if (parsed.value.status === \"failed\") setAnnotationNotice(parsed.value.message);\n else {\n setAnnotationNotice(\n `Annotation saved: ${parsed.value.annotation.label}. The list reflects supplied annotations.`,\n );\n setMode(\"inspect\");\n }\n } catch {\n if (isCurrent())\n setAnnotationNotice(\n \"The annotation outcome could not be confirmed. The draft is retained; check the source before trying again.\",\n );\n } finally {\n if (pendingAnnotation.current === request) {\n pendingAnnotation.current = null;\n if (mounted.current) setAnnotationPending(false);\n }\n }\n }\n const meters = measurement.length === 2 ? distanceMeters(measurement[0]!, measurement[1]!) : null;\n const duplicateBookmarks = new Set(\n bookmarks\n .filter(\n (bookmark, index) => bookmarks.findIndex((other) => other.id === bookmark.id) !== index,\n )\n .map((bookmark) => bookmark.id),\n );\n const validAnnotations = useMemo(() => {\n const occurrences = new Map<string, number>();\n for (const annotation of annotations)\n occurrences.set(annotation.id, (occurrences.get(annotation.id) ?? 0) + 1);\n return annotations.filter(\n (annotation) =>\n !getAnnotationDraftError({\n label: annotation.label,\n position: annotation.position,\n sourceId: annotation.sourceId,\n }) &&\n typeof annotation.id === \"string\" &&\n annotation.id.trim() !== \"\" &&\n occurrences.get(annotation.id) === 1,\n );\n }, [annotations]);\n // The map compares what it draws by identity, so this list changes only when its content can.\n const sourceAnnotations = useMemo(\n () => validAnnotations.filter((annotation) => annotation.sourceId === snapshot.id),\n [validAnnotations, snapshot.id],\n );\n return (\n <section\n aria-label=\"Operations map\"\n className=\"not-prose flex min-w-0 flex-col gap-3u font-sans text-oprt-base text-oprt-text\"\n >\n <header className=\"flex min-w-0 flex-wrap items-start justify-between gap-2u border-b border-oprt-border pb-2u\">\n <div className=\"flex min-w-0 flex-col gap-1u\">\n <h2 className=\"m-0 text-oprt-xl font-semibold\">{snapshot.title ?? \"Operations map\"}</h2>\n <p className=\"m-0 text-oprt-text-dim [overflow-wrap:anywhere]\">{snapshot.source}</p>\n </div>\n <div className=\"flex flex-wrap items-center gap-2u\">\n {snapshot.observedAt && (\n <time\n dateTime={snapshot.observedAt}\n className=\"font-mono text-oprt-sm text-oprt-text-dim\"\n >\n {formatUtcInstant(snapshot.observedAt)}\n </time>\n )}\n <Badge\n appearance=\"text\"\n variant={snapshot.availability === \"current\" ? \"success\" : \"warning\"}\n >\n {snapshot.availability}\n </Badge>\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n onClick={() => {\n setMapEnabled(!mapEnabled);\n setFrameReport(null);\n }}\n >\n {mapEnabled ? \"Disable map\" : \"Enable map\"}\n </Button>\n </div>\n </header>\n {/* The notices below are in the page from the start, empty and out of the flow, and the text\n is written into them: a screen reader speaks text that arrives in a status it already\n has, and said nothing for one inserted together with its text\n (docs/verification/2026-10-21-screen-readers.md). */}\n <p\n role=\"status\"\n className={snapshot.availability !== \"current\" ? \"m-0 text-oprt-warning\" : \"sr-only\"}\n >\n {snapshot.availability !== \"current\" ? snapshot.detail : \"\"}\n </p>\n {sourceError ? (\n <p role=\"alert\" className=\"m-0 border border-oprt-error p-2u text-oprt-error\">\n {sourceError}\n </p>\n ) : (\n <>\n {initialView && !isValidGeoView(initialView) && (\n <p role=\"status\" className=\"m-0 text-oprt-warning\">\n The initial map view is invalid. A view fitted to the supplied positions is shown.\n </p>\n )}\n <div className=\"flex min-w-0 flex-wrap items-start gap-3u\">\n <div className=\"flex min-w-0 flex-[2_1_32rem] flex-col gap-2u\">\n <GeoMap\n snapshot={snapshot}\n entities={filtered}\n selectedId={selectedId}\n onSelectedIdChange={selectOnMapStable}\n view={view}\n onViewChange={changeView}\n layers={layers}\n annotations={sourceAnnotations}\n measurement={measurement}\n mode={mode}\n onPoint={acceptPointStable}\n onFrame={reportFrame}\n onInspect={inspectStable}\n {...(tileSource ? { tileSource } : {})}\n disabled={!mapEnabled}\n />\n <MapControls\n zoom={view.zoom}\n bearing={view.bearing}\n disabled={!mapEnabled || snapshot.availability === \"unavailable\"}\n onAction={(action) => setView((old) => moveGeoView(old, action, filtered))}\n />\n <div className=\"flex min-w-0 flex-wrap items-start justify-between gap-2u\">\n <MapScale metersPerPixel={frame?.metersPerPixel ?? null} />\n <p className=\"m-0 font-mono text-oprt-text-dim\">\n Center {formatGeoPosition(view.center)}\n </p>\n </div>\n <p className=\"m-0 text-oprt-text-dim\">\n {mode === \"measure\"\n ? \"Measurement mode: choose two points. A third point begins a new measurement.\"\n : mode === \"annotate\"\n ? \"Annotation mode: choose a point, then review and save it in Annotations.\"\n : \"Choose a marker to preview. Pin it to keep its details beside the map.\"}\n </p>\n {mode !== \"inspect\" && (\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => setMode(\"inspect\")}>\n Return to inspection\n </Button>\n )}\n </div>\n {preview ? (\n <Inspector\n ref={inspectorRef}\n title=\"Selected report\"\n tabIndex={-1}\n className={`${focusStyle} min-w-0 flex-[1_1_21rem]`}\n onClose={() => {\n setFocusRequest({ kind: \"close\" });\n onSelectedIdChange(null);\n }}\n >\n <EntityPreview entity={preview} headingLevel={4} />\n {selected && (\n <div className=\"flex flex-col gap-1u border-t border-oprt-border pt-2u\">\n <h4 className={headingStyle}>Geographic report</h4>\n {fromOtherSource && (\n <p className=\"m-0 text-oprt-warning\">\n This retained report belongs to a different source. Locating it on the\n current source is unavailable.\n </p>\n )}\n <DescriptionList\n layout=\"details\"\n items={[\n ...(reportSource\n ? [\n {\n id: \"source\",\n label: \"Report source\",\n value: (\n <>\n {reportSource.label} ·{\" \"}\n <span className=\"font-mono\">\n {reportSource.id} / {reportSource.revision}\n </span>\n </>\n ),\n },\n ]\n : []),\n { id: \"state\", label: \"Position state\", value: selected.positionState },\n {\n id: \"position\",\n label: \"Position\",\n value: formatGeoPosition(selected.position),\n mono: true,\n },\n {\n id: \"observed\",\n label: \"Position observed\",\n value: <UtcStamp value={selected.observedAt} fallback=\"Not supplied\" />,\n mono: true,\n },\n {\n id: \"uncertainty\",\n label: \"Uncertainty\",\n value:\n typeof selected.uncertaintyMeters === \"number\" &&\n Number.isFinite(selected.uncertaintyMeters) &&\n selected.uncertaintyMeters >= 0\n ? `${selected.uncertaintyMeters} m supplied radius`\n : \"Not supplied\",\n mono: true,\n },\n {\n id: \"coverage\",\n label: \"Coverage\",\n value: selected.coverage?.length\n ? \"Supplied geographic outline\"\n : \"Not supplied\",\n },\n {\n id: \"track\",\n label: \"Track\",\n value: `${selected.track?.length ?? 0} supplied samples`,\n },\n ]}\n />\n {selected.track && selected.track.length > 0 && (\n <details>\n <summary className={summaryStyle}>Reported track samples</summary>\n <ol className=\"m-0 flex flex-col gap-1u pl-3u\">\n {selected.track.map((sample, index) => (\n <li key={index}>\n <span className=\"font-mono\">\n <UtcStamp value={sample.observedAt} fallback=\"Time unknown\" />\n </span>\n <p className=\"m-0 font-mono [overflow-wrap:anywhere]\">\n {formatGeoPosition(sample.position)}\n </p>\n </li>\n ))}\n </ol>\n </details>\n )}\n {selected.coverage && selected.coverage.length > 0 && (\n <details>\n <summary className={summaryStyle}>Coverage outline vertices</summary>\n <ol className=\"m-0 flex flex-col gap-1u pl-3u\">\n {selected.coverage.map((location, index) => (\n <li key={index} className=\"font-mono [overflow-wrap:anywhere]\">\n Vertex {index + 1}: {formatGeoPosition(location)}\n </li>\n ))}\n </ol>\n </details>\n )}\n {currentSelected && !selectedInFilter && (\n <p className=\"m-0 text-oprt-warning\">\n Selected report is outside the current filter.\n </p>\n )}\n {currentSelected &&\n selectedInFilter &&\n frame &&\n frameReport?.query === query &&\n !frame.visibleIds.includes(currentSelected.record.id) && (\n <p className=\"m-0 text-oprt-text-dim\">\n Selected report is outside the map extent or has no plottable position.\n </p>\n )}\n </div>\n )}\n <div className=\"border-t border-oprt-border pt-2u\">\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={\n !mapEnabled ||\n snapshot.availability === \"unavailable\" ||\n !selected ||\n !isGeoPoint(selected.position) ||\n fromOtherSource\n }\n onClick={() => {\n if (selected && !fromOtherSource && isGeoPoint(selected.position))\n setView((old) => ({ ...old, center: selected.position as GeoPoint }));\n }}\n >\n Locate selected report\n </Button>\n </div>\n </Inspector>\n ) : (\n <div className=\"flex flex-col gap-2u border border-oprt-border p-2u\">\n <h3 className={headingStyle}>Selected report</h3>\n <p className=\"m-0 text-oprt-text-dim\">\n Inspect a table record, or choose a map marker and pin its details. Reports with\n unknown positions remains in All records.\n </p>\n <MapLegend items={legendItems} />\n </div>\n )}\n </div>\n <details className=\"min-w-0 border border-oprt-border p-2u\">\n <summary className={summaryStyle}>Layers and legend</summary>\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-3u pt-2u\">\n <LayerControl layers={layers} onLayersChange={(next) => setLayers([...next])} />\n <MapLegend items={legendItems} />\n </div>\n </details>\n <section\n ref={resultsRef}\n role=\"region\"\n aria-label=\"Report results\"\n tabIndex={-1}\n className={`flex min-w-0 flex-col gap-2u border border-oprt-border p-2u ${focusStyle}`}\n >\n <h3 className={headingStyle}>Report results</h3>\n <label htmlFor={`${inputId}-filter`} className={headingStyle}>\n Search reports\n </label>\n <Input\n id={`${inputId}-filter`}\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder=\"Name, type or ID\"\n />\n <div role=\"group\" aria-label=\"Record scope\" className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant=\"secondary\"\n aria-pressed={scope === \"all\"}\n onClick={() => {\n setScope(\"all\");\n setExtentNotice(\"\");\n }}\n >\n All records\n </Button>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n aria-pressed={scope === \"extent\"}\n disabled={!frame}\n onClick={() => {\n setScope(\"extent\");\n setExtentNotice(\"\");\n }}\n >\n Map extent\n </Button>\n </div>\n <p role=\"status\" className=\"m-0 text-oprt-text-dim\">\n {displayed.length} shown · {filtered.length} matching · {entities.length} supplied.{\" \"}\n {scope === \"extent\" && frame ? \"Scope: map extent.\" : \"Scope: all records.\"} Counts\n include only the supplied collection.\n </p>\n <p role=\"status\" className={extentNotice ? \"m-0 text-oprt-warning\" : \"sr-only\"}>\n {extentNotice}\n </p>\n {!frame && (\n <p className=\"m-0 text-oprt-text-dim\">\n Map extent is not available. All records includes unknown and unsupported positions.\n </p>\n )}\n <ReportResults\n entities={displayed}\n onInspect={inspectStable}\n // In the extent scope the map view is the filter: when panning or zooming changes\n // which reports are listed, the table returns to its first page, as for a new query.\n resetKey={`${scope}|${query}|${\n scope === \"extent\" ? `${displayed.length}:${displayed[0]?.record.id ?? \"\"}` : \"\"\n }`}\n />\n {displayed.length === 0 && (\n <p className=\"m-0\">\n {entities.length === 0\n ? \"No reports were supplied.\"\n : \"No reports match this filter and scope.\"}\n </p>\n )}\n </section>\n <details className=\"min-w-0 border border-oprt-border p-2u\">\n <summary className={summaryStyle}>Coordinates and bookmarks</summary>\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-3u pt-2u\">\n <form\n noValidate\n className=\"flex min-w-0 flex-col gap-2u\"\n onSubmit={(event) => {\n event.preventDefault();\n const location = point(goValue);\n if (goInvalid || !location) {\n setGoError(\n \"Enter valid coordinates before moving the map. Supported latitude is ±85.05112878°.\",\n );\n return;\n }\n setGoError(\"\");\n setView((old) => ({ ...old, center: location }));\n }}\n >\n <CoordinateInput\n label=\"Go to coordinates\"\n value={goValue}\n onValueChange={setGoValue}\n onValidationChange={setGoInvalid}\n description=\"WGS84 decimal degrees. Map latitude is limited to ±85.05112878°.\"\n />\n <Button type=\"submit\" size=\"sm\">\n Go to location\n </Button>\n {goError && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n {goError}\n </p>\n )}\n </form>\n <div className=\"flex min-w-0 flex-col gap-2u\">\n <label htmlFor={`${inputId}-bookmark`} className={headingStyle}>\n Bookmark name\n </label>\n <Input\n id={`${inputId}-bookmark`}\n maxLength={80}\n value={bookmarkName}\n onChange={(event) => setBookmarkName(event.target.value)}\n />\n <Button size=\"sm\" onClick={saveBookmark}>\n Save bookmark\n </Button>\n <p role=\"status\" className={bookmarkNotice ? \"m-0\" : \"sr-only\"}>\n {bookmarkNotice}\n </p>\n <ul className=\"m-0 flex list-none flex-col gap-2u p-0\">\n {bookmarks.map((bookmark, index) => {\n const parsed = parseGeoBookmark(bookmark);\n const valid = parsed.ok && !duplicateBookmarks.has(bookmark.id);\n const name =\n typeof bookmark.name === \"string\" ? bookmark.name : \"Invalid bookmark\";\n return (\n <li\n key={valid ? `bookmark:${bookmark.id}` : `invalid:${index}`}\n className=\"flex min-w-0 flex-wrap items-start gap-1u\"\n >\n <Button\n size=\"sm\"\n variant=\"secondary\"\n className=\"h-auto! min-h-[var(--oprt-height-sm)] min-w-0 max-w-full whitespace-normal [overflow-wrap:anywhere]\"\n aria-label={`Restore ${name}`}\n disabled={!valid}\n onClick={() => {\n if (parsed.ok && valid) {\n setView(parsed.value.view);\n setLayers([...parsed.value.layers]);\n setBookmarkNotice(`Restored ${parsed.value.name}.`);\n }\n }}\n >\n {name}\n </Button>\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n disabled={duplicateBookmarks.has(bookmark.id)}\n aria-label={`Remove bookmark ${name}`}\n onClick={() => {\n try {\n onBookmarksChange(\n bookmarks.filter((item) => item.id !== bookmark.id),\n );\n setBookmarkNotice(\n \"Bookmark removal requested. The list reflects the supplied views.\",\n );\n } catch {\n setBookmarkNotice(\"The bookmark could not be removed.\");\n }\n }}\n >\n Remove\n </Button>\n {!valid && (\n <p className=\"m-0 text-oprt-error\">\n This bookmark has invalid or ambiguous saved preferences.\n </p>\n )}\n </li>\n );\n })}\n </ul>\n {bookmarks.length === 0 && (\n <p className=\"m-0 text-oprt-text-dim\">No saved views supplied.</p>\n )}\n </div>\n </div>\n </details>\n <details className=\"min-w-0 border border-oprt-border p-2u\">\n <summary className={summaryStyle}>Measurement</summary>\n <div className=\"flex min-w-0 flex-col gap-2u pt-2u\">\n <p className=\"m-0 text-oprt-text-dim\">\n Approximate great-circle distance between two WGS84 points. Terrain, routing and\n height are not included.\n </p>\n <div className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n aria-pressed={mode === \"measure\"}\n onClick={() => setMode(mode === \"measure\" ? \"inspect\" : \"measure\")}\n >\n Measure on map\n </Button>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n onClick={() => {\n setMeasurement([]);\n setEndpoints([blank, blank]);\n setEndpointInvalid([false, false]);\n setMeasurementKey((old) => old + 1);\n setMeasurementError(\"\");\n setMode(\"inspect\");\n }}\n >\n Clear measurement\n </Button>\n </div>\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-2u\">\n {endpoints.map((value, index) => (\n <CoordinateInput\n key={`${measurementKey}-${index}`}\n label={`Measurement point ${index + 1}`}\n value={value}\n onValueChange={(next) =>\n setEndpoints((old) =>\n old.map((item, position) => (position === index ? next : item)),\n )\n }\n onValidationChange={(invalid) =>\n setEndpointInvalid((old) =>\n old[index] === invalid\n ? old\n : old.map((item, position) => (position === index ? invalid : item)),\n )\n }\n />\n ))}\n </div>\n <Button\n size=\"sm\"\n onClick={() => {\n const first = point(endpoints[0]!);\n const second = point(endpoints[1]!);\n if (endpointInvalid.some(Boolean) || !first || !second) {\n setMeasurementError(\n \"Complete both valid measurement coordinates within the supported map latitude range.\",\n );\n return;\n }\n setMeasurement([first, second]);\n setMeasurementError(\"\");\n }}\n >\n Apply measurement coordinates\n </Button>\n {measurementError && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n {measurementError}\n </p>\n )}\n {measurement.map((location, index) => (\n <p key={index} className=\"m-0 font-mono\">\n Point {index + 1}: {formatGeoPosition(location)}\n </p>\n ))}\n <p role=\"status\" className=\"m-0 font-mono\">\n {meters === null\n ? \"Choose two points to measure.\"\n : `Approximate distance: ${meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(2)} km`}`}\n </p>\n </div>\n </details>\n <details className=\"min-w-0 border border-oprt-border p-2u\">\n <summary className={summaryStyle}>Annotations</summary>\n <div className=\"grid min-w-0 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))] gap-3u pt-2u\">\n <form\n noValidate\n className=\"flex min-w-0 flex-col gap-2u\"\n onSubmit={(event) => {\n event.preventDefault();\n void saveAnnotation();\n }}\n >\n <p className=\"m-0 text-oprt-text-dim\">\n Source:{\" \"}\n <span className=\"font-mono\">\n {snapshot.id} / {snapshot.revision}\n </span>\n . Review the label and location before saving.\n </p>\n <label htmlFor={`${inputId}-annotation`} className={headingStyle}>\n Annotation label\n </label>\n <Input\n id={`${inputId}-annotation`}\n maxLength={120}\n value={annotationLabel}\n disabled={!onCreateAnnotation || annotationPending}\n onChange={(event) => {\n setAnnotationLabel(event.target.value);\n setAnnotationNotice(\"\");\n }}\n />\n <CoordinateInput\n key={annotationKey}\n label=\"Annotation position\"\n value={annotationValue}\n onValueChange={setAnnotationValue}\n onValidationChange={setAnnotationInvalid}\n disabled={!onCreateAnnotation || annotationPending}\n />\n <div className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={\n !onCreateAnnotation ||\n annotationPending ||\n !currentSelected ||\n !isGeoPoint(currentSelected.position)\n }\n onClick={() => {\n if (currentSelected && isGeoPoint(currentSelected.position)) {\n setAnnotationValue(coordinate(currentSelected.position));\n setAnnotationInvalid(false);\n setAnnotationKey((old) => old + 1);\n }\n }}\n >\n Use selected position\n </Button>\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={!onCreateAnnotation || annotationPending}\n aria-pressed={mode === \"annotate\"}\n onClick={() => setMode(mode === \"annotate\" ? \"inspect\" : \"annotate\")}\n >\n Annotate on map\n </Button>\n <Button\n size=\"sm\"\n type=\"submit\"\n disabled={!onCreateAnnotation || annotationPending}\n >\n {annotationPending ? \"Saving annotation…\" : \"Save annotation\"}\n </Button>\n </div>\n {!onCreateAnnotation && (\n <p className=\"m-0 text-oprt-text-dim\">\n Annotation creation is unavailable; no create adapter was supplied.\n </p>\n )}\n <p role=\"status\" className={annotationNotice ? \"m-0\" : \"sr-only\"}>\n {annotationNotice}\n </p>\n </form>\n <div className=\"flex min-w-0 flex-col gap-2u\">\n <h3 className={headingStyle}>Supplied annotations</h3>\n {validAnnotations.length !== annotations.length && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n Some annotations have invalid or duplicate identities or positions and cannot be\n shown safely.\n </p>\n )}\n {validAnnotations.length > sourceAnnotations.length && (\n <p className=\"m-0 text-oprt-text-dim\">\n Annotations for another source are outside this view.\n </p>\n )}\n <ul className=\"m-0 flex list-none flex-col gap-2u p-0\">\n {sourceAnnotations.map((annotation) => (\n <li\n key={annotation.id}\n className=\"flex min-w-0 flex-col gap-1u border border-oprt-border p-2u\"\n >\n <p className=\"m-0 font-medium [overflow-wrap:anywhere]\">{annotation.label}</p>\n <p className=\"m-0 font-mono [overflow-wrap:anywhere]\">\n {formatGeoPosition(annotation.position)}\n </p>\n <p className=\"m-0 font-mono [overflow-wrap:anywhere]\">\n <UtcStamp value={annotation.createdAt} fallback=\"Time unknown\" />\n </p>\n {onRemoveAnnotation && (\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n aria-label={`Remove annotation ${annotation.label}`}\n onClick={() => {\n try {\n onRemoveAnnotation(annotation.id);\n setAnnotationNotice(\n \"Annotation removal requested. The list reflects the supplied source.\",\n );\n } catch {\n setAnnotationNotice(\"The annotation could not be removed.\");\n }\n }}\n >\n Remove\n </Button>\n )}\n </li>\n ))}\n </ul>\n {sourceAnnotations.length === 0 && (\n <p className=\"m-0 text-oprt-text-dim\">\n No saved annotations supplied for this source.\n </p>\n )}\n </div>\n </div>\n </details>\n </>\n )}\n </section>\n );\n}\n",
4835
4835
  "patterns/operational-domain.ts": "/** Fixture context used by reusable application recipes. */\nexport type OperationalDomain = \"military\" | \"industrial\";\n\nexport interface UtcInstantOptions {\n /**\n * Whether the milliseconds are shown. \"auto\" (the default) shows them only when the supplied\n * instant carries a non-zero fraction, because sub-second timing matters only where it exists.\n */\n milliseconds?: \"auto\" | \"always\" | \"never\";\n}\n\nconst INSTANT =\n /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,3}))?(Z|[+-]\\d{2}:\\d{2})$/;\n\n/**\n * Reads an ISO 8601 instant as \"YYYY-MM-DD HH:MM:SS UTC\", with \".mmm\" after the seconds where the\n * options ask for it. Text that is not a real instant (an invented date such as 30 February, or\n * free text) is returned as supplied rather than reinterpreted. It never uses Intl, so the server\n * and every browser print the same characters.\n */\nexport function formatUtcInstant(value: string, options: UtcInstantOptions = {}): string {\n const parts = INSTANT.exec(value);\n if (!parts) return value;\n const [year, month, day, hour, minute, second] = parts.slice(1, 7).map(Number) as [\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n const calendar = new Date(Date.UTC(year, month - 1, day));\n // Date rolls an invented day into the next month; compare it back to what was written.\n if (\n calendar.getUTCFullYear() !== year ||\n calendar.getUTCMonth() !== month - 1 ||\n calendar.getUTCDate() !== day ||\n hour > 23 ||\n minute > 59 ||\n second > 59\n )\n return value;\n const fraction = (parts[7] ?? \"\").padEnd(3, \"0\");\n const parsed = new Date(`${value.slice(0, 19)}.${fraction}${parts[8]}`);\n if (Number.isNaN(parsed.getTime())) return value;\n const { milliseconds = \"auto\" } = options;\n const showFraction = milliseconds === \"always\" || (milliseconds === \"auto\" && fraction !== \"000\");\n const iso = parsed.toISOString();\n return `${iso.slice(0, 10)} ${iso.slice(11, showFraction ? 23 : 19)} UTC`;\n}\n\n/**\n * Reads epoch milliseconds as \"YYYY-MM-DD HH:MM:SS UTC\", with the same options as\n * formatUtcInstant. A value that is not a real time reads \"Unknown time\" rather than throwing.\n */\nexport function formatUtcEpoch(ms: number, options: UtcInstantOptions = {}): string {\n const date = new Date(ms);\n if (!Number.isFinite(ms) || Number.isNaN(date.getTime())) return \"Unknown time\";\n return formatUtcInstant(date.toISOString(), options);\n}\n",
4836
- "patterns/geospatial-workspace/geo-map.tsx": "\"use client\";\n\nimport {\n memo,\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type RefObject,\n} from \"react\";\nimport { Button, EntityPreview, Popover } from \"@oprt/react\";\nimport type { EngineCallbacks, EngineState, GeoEngine, TileState } from \"./engine-contract\";\nimport { isGeoPoint, normalizeGeoView, sameGeoAnchor, sameGeoFrame } from \"./geometry\";\nimport { mapDataIssues } from \"./renderer-data\";\nimport type {\n GeoAnnotation,\n GeoEntity,\n GeoFrame,\n GeoMapProps,\n GeoMarkerGroup,\n GeoPoint,\n} from \"./types\";\nimport { UtcStamp } from \"./utc-stamp\";\n\n/** One shared empty collection, so a host that passes a new `[]` each render changes nothing. */\nconst NO_ANNOTATIONS: readonly GeoAnnotation[] = [];\nconst NO_POINTS: readonly GeoPoint[] = [];\nconst useBrowserLayoutEffect = typeof document === \"undefined\" ? useEffect : useLayoutEffect;\nconst finitePixel = (point: readonly number[] | null) =>\n point !== null && point.length === 2 && point.every(Number.isFinite);\n\n/** One engine start. Retiring it (dispose, failure, unmount) silences every callback it issued. */\ninterface EngineInstance {\n live: boolean;\n}\n\ninterface MarkerProps {\n group: GeoMarkerGroup;\n records: ReadonlyMap<string, GeoEntity>;\n isSelected: boolean;\n open: boolean;\n /** The location choice made inside this group while it is open. */\n choice: string | null;\n /** The supplied selection, which a choice waits for; only read while the group is open. */\n selectedId: string | null;\n region: RefObject<HTMLDivElement | null>;\n onOpenChange: (groupId: string, open: boolean, singleId: string | null) => void;\n onChoose: (groupId: string, entityId: string, origin: HTMLElement) => void;\n onPin: (groupId: string, entityId: string) => void;\n onCloseAutoFocus: (groupId: string, event: Event) => void;\n}\n\nconst sameIds = (a: readonly string[], b: readonly string[]) =>\n a === b || (a.length === b.length && a.every((item, index) => item === b[index]));\n/** A marker is drawn again for what it shows, not for where it is: a pan only moves its wrapper. */\nconst sameMarker = (previous: MarkerProps, next: MarkerProps) =>\n previous.group.id === next.group.id &&\n sameIds(previous.group.ids, next.group.ids) &&\n previous.records === next.records &&\n previous.isSelected === next.isSelected &&\n previous.open === next.open &&\n previous.choice === next.choice &&\n previous.selectedId === next.selectedId &&\n previous.region === next.region &&\n previous.onOpenChange === next.onOpenChange &&\n previous.onChoose === next.onChoose &&\n previous.onPin === next.onPin &&\n previous.onCloseAutoFocus === next.onCloseAutoFocus;\n\nconst ClusterMarker = memo(function ClusterMarker({\n group,\n records,\n isSelected,\n open,\n choice,\n selectedId,\n region,\n onOpenChange,\n onChoose,\n onPin,\n onCloseAutoFocus,\n}: MarkerProps) {\n const members = group.ids.flatMap((entityId) =>\n records.has(entityId) ? [records.get(entityId)!] : [],\n );\n const selected =\n choice && choice === selectedId && group.ids.includes(choice) ? records.get(choice) : undefined;\n const single = members.length === 1 ? members[0] : undefined;\n const label = single\n ? `${single.record.name} — ${single.record.id}`\n : `${members.length} equipment at this location`;\n return (\n <Popover\n title={selected ? \"Equipment preview\" : \"Equipment at this location\"}\n open={open}\n onOpenChange={(next) => onOpenChange(group.id, next, single?.record.id ?? null)}\n returnFocusRef={region}\n onCloseAutoFocus={(event) => onCloseAutoFocus(group.id, event)}\n content={\n <div className=\"flex max-w-[calc(var(--oprt-space-8)*5)] flex-col gap-2u\">\n {selected ? (\n <>\n <EntityPreview\n entity={{\n ...selected.record,\n facts: [\n ...selected.record.facts.map((fact) => ({\n ...fact,\n id: `entity:${fact.id}`,\n })),\n {\n id: \"geo:position\",\n label: \"Position\",\n value: selected.positionState,\n },\n {\n id: \"geo:observed\",\n label: \"Observed\",\n value: <UtcStamp value={selected.observedAt} />,\n mono: true,\n },\n ],\n }}\n actions={\n <Button size=\"sm\" onClick={() => onPin(group.id, selected.record.id)}>\n Pin in Inspector\n </Button>\n }\n />\n </>\n ) : (\n <>\n <p className=\"m-0 normal-case\">Choose a supplied record to preview its details.</p>\n {members.map((entity) => (\n <Button\n key={entity.record.id}\n size=\"sm\"\n variant=\"secondary\"\n className=\"h-auto! min-h-[var(--oprt-height-sm)] whitespace-normal! break-words text-left normal-case!\"\n onClick={(event) => onChoose(group.id, entity.record.id, event.currentTarget)}\n >\n {entity.record.name} — {entity.record.id}\n </Button>\n ))}\n {choice && (\n <p role=\"status\" className=\"normal-case\">\n Selection requested; waiting for the supplied selection.\n </p>\n )}\n </>\n )}\n </div>\n }\n >\n <Button\n size=\"sm\"\n variant={isSelected ? \"primary\" : \"secondary\"}\n aria-label={label}\n aria-pressed={isSelected}\n className={`whitespace-nowrap normal-case!${isSelected ? \"\" : \" bg-oprt-surface-1!\"}`}\n >\n {single ? single.record.id : members.length}\n </Button>\n </Popover>\n );\n}, sameMarker);\n\n/**\n * Optional renderer. All source, view and selection authority stays with its host.\n *\n * `snapshot`, `entities`, `annotations`, `measurement` and `layers` are compared by identity: the\n * renderer redraws when the host supplies a new collection, and not when it supplies an equal one\n * (a map of 2,000 reports cannot be compared by value on every pan frame). Keep a collection\n * stable between changes, for example in state or memoized.\n */\nexport const GeoMap = memo(function GeoMap(props: GeoMapProps) {\n const id = useId();\n const target = useRef<HTMLDivElement>(null);\n const region = useRef<HTMLDivElement>(null);\n const adapter = useRef<GeoEngine | null>(null);\n const instance = useRef<EngineInstance | null>(null);\n const anchorFrame = useRef<GeoFrame | null>(null);\n // The marker group whose preview or choices are open, for the engine callbacks, which never change\n // identity and so cannot read the state.\n const openGroupRef = useRef<string | null>(null);\n const pinnedGroup = useRef<string | null>(null);\n const choiceFocus = useRef<{\n id: string;\n groupId: string;\n origin: HTMLElement;\n dialog: HTMLElement;\n source: string;\n } | null>(null);\n const { snapshot, entities, selectedId, layers, mode, tileSource } = props;\n const annotations = props.annotations.length ? props.annotations : NO_ANNOTATIONS;\n const measurement = props.measurement.length ? props.measurement : NO_POINTS;\n // The view is a few numbers, not an object to compare: a pan changes these and nothing else.\n const view = normalizeGeoView(props.view);\n const [longitude, latitude] = view.center;\n const { zoom, bearing } = view;\n const tileUrl = tileSource?.url;\n const tileAttribution = tileSource?.attribution;\n const sourceKey = useMemo(\n () => JSON.stringify([snapshot.id, snapshot.revision]),\n [snapshot.id, snapshot.revision],\n );\n // What the engine draws. Its identity changes exactly when one of these inputs does, and it is\n // the stamp that tells a current engine callback from one the next update superseded.\n const state = useMemo<EngineState>(\n () => ({\n snapshot,\n entities,\n selectedId,\n view: { center: [longitude, latitude], zoom, bearing },\n layers,\n annotations,\n measurement,\n mode,\n ...(tileUrl !== undefined && tileAttribution !== undefined\n ? { tileSource: { url: tileUrl, attribution: tileAttribution } }\n : {}),\n }),\n [\n snapshot,\n entities,\n selectedId,\n longitude,\n latitude,\n zoom,\n bearing,\n layers,\n annotations,\n measurement,\n mode,\n tileUrl,\n tileAttribution,\n ],\n );\n // The supplied data is checked once per change of the data, never for a change of the view.\n const issues = useMemo(\n () => mapDataIssues({ snapshot, entities, annotations, measurement }),\n [snapshot, entities, annotations, measurement],\n );\n const positionedIds = useMemo(\n () =>\n new Set(\n entities.filter((entity) => isGeoPoint(entity.position)).map((entity) => entity.record.id),\n ),\n [entities],\n );\n const records = useMemo(\n () => new Map(entities.map((entity) => [entity.record.id, entity])),\n [entities],\n );\n const enabled = !props.disabled && !issues.duplicates && snapshot.availability !== \"unavailable\";\n const latest = useRef({ props, state, enabled, positionedIds });\n latest.current = { props, state, enabled, positionedIds };\n const [attempt, setAttempt] = useState(0);\n const [status, setStatus] = useState<\"loading\" | \"ready\" | \"error\">(\"loading\");\n const [error, setError] = useState(\"\");\n const [frame, setFrame] = useState<GeoFrame | null>(null);\n const [tileState, setTileState] = useState<TileState>(\"loading\");\n const [openGroup, setOpenGroup] = useState<string | null>(null);\n openGroupRef.current = openGroup;\n const [choice, setChoice] = useState<string | null>(null);\n /** Callbacks for one engine start and one state; they go quiet once either is superseded. */\n const makeCallbacks = useCallback(\n (expected: EngineState, owner: EngineInstance): EngineCallbacks => {\n const current = () =>\n owner.live && latest.current.enabled && latest.current.state === expected;\n return {\n onFrame(next) {\n if (!current()) return;\n const ids = latest.current.positionedIds;\n if (\n next.visibleIds.some((entityId) => !ids.has(entityId)) ||\n new Set(next.visibleIds).size !== next.visibleIds.length ||\n next.groups.some(\n (group) =>\n !isGeoPoint(group.point) ||\n !finitePixel(group.pixel) ||\n !group.ids.length ||\n group.ids.some((entityId) => !ids.has(entityId)) ||\n new Set(group.ids).size !== group.ids.length,\n ) ||\n (next.selectedPixel !== null && !finitePixel(next.selectedPixel)) ||\n (next.metersPerPixel !== null &&\n (!Number.isFinite(next.metersPerPixel) || next.metersPerPixel <= 0))\n )\n return;\n // What is open belongs to its marker group, not to the pixel it is drawn at. A frame that\n // only moves the view (Fit map, a zoom, a pan, or the second frame of the renderer settling)\n // moves the marker and what is open from it; it is dismissed when its group is gone or its\n // equipment moved on the ground.\n const open = openGroupRef.current;\n if (open !== null && !sameGeoAnchor(anchorFrame.current, next, open)) {\n choiceFocus.current = null;\n openGroupRef.current = null;\n setOpenGroup(null);\n setChoice(null);\n }\n anchorFrame.current = next;\n setFrame((previous) => (sameGeoFrame(previous, next) ? previous : next));\n latest.current.props.onFrame(next);\n },\n onViewChange(next) {\n if (current()) latest.current.props.onViewChange(normalizeGeoView(next));\n },\n onPoint(point) {\n if (current() && latest.current.props.mode !== \"inspect\" && isGeoPoint(point))\n latest.current.props.onPoint([point[0], point[1]]);\n },\n onTileState(next) {\n if (current()) setTileState(next);\n },\n };\n },\n [],\n );\n\n useEffect(() => {\n // This start owns the callbacks it issues. Retiring it is a write to its own object, not to a\n // counter held in a ref, so the cleanup reads nothing that React might have moved on.\n const owner: EngineInstance = { live: true };\n instance.current = owner;\n anchorFrame.current = null;\n choiceFocus.current = null;\n setFrame(null);\n setOpenGroup(null);\n setChoice(null);\n latest.current.props.onFrame(null);\n if (!enabled || !target.current) return;\n setStatus(\"loading\");\n setError(\"\");\n setTileState(\"loading\");\n void import(\"./engine\")\n .then(({ createGeoEngine }) => {\n if (!owner.live || !latest.current.enabled || !target.current) return;\n const current = latest.current;\n adapter.current = createGeoEngine(\n target.current,\n current.state,\n makeCallbacks(current.state, owner),\n );\n setStatus(\"ready\");\n })\n .catch((cause: unknown) => {\n if (!owner.live) return;\n owner.live = false;\n adapter.current?.dispose();\n adapter.current = null;\n setError(cause instanceof Error ? cause.message : \"Map renderer could not start.\");\n setStatus(\"error\");\n setFrame(null);\n latest.current.props.onFrame(null);\n });\n return () => {\n owner.live = false;\n adapter.current?.dispose();\n adapter.current = null;\n latest.current.props.onFrame(null);\n };\n }, [enabled, attempt, makeCallbacks]);\n\n useEffect(() => {\n const owner = instance.current;\n if (!adapter.current || !enabled || !owner?.live) return;\n try {\n adapter.current.update(state, makeCallbacks(state, owner));\n } catch (cause: unknown) {\n adapter.current.dispose();\n adapter.current = null;\n owner.live = false;\n setError(cause instanceof Error ? cause.message : \"Map renderer could not update.\");\n setStatus(\"error\");\n setFrame(null);\n latest.current.props.onFrame(null);\n }\n }, [state, enabled, makeCallbacks]);\n\n useEffect(() => {\n const leave = (event: Event) => {\n const pending = choiceFocus.current;\n if (pending && !pending.dialog.contains(event.target as Node | null))\n choiceFocus.current = null;\n };\n const blur = (event: FocusEvent) => {\n const pending = choiceFocus.current;\n // A still-connected choice explicitly blurred to nowhere is a departure;\n // removing the focused choice during acceptance is handled after commit.\n if (\n pending &&\n event.target === pending.origin &&\n pending.origin.isConnected &&\n !event.relatedTarget\n )\n choiceFocus.current = null;\n };\n document.addEventListener(\"focusin\", leave, true);\n document.addEventListener(\"pointerdown\", leave, true);\n document.addEventListener(\"focusout\", blur, true);\n return () => {\n choiceFocus.current = null;\n document.removeEventListener(\"focusin\", leave, true);\n document.removeEventListener(\"pointerdown\", leave, true);\n document.removeEventListener(\"focusout\", blur, true);\n };\n }, []);\n useBrowserLayoutEffect(() => {\n const pending = choiceFocus.current;\n if (!pending) return;\n if (\n !enabled ||\n status !== \"ready\" ||\n pending.groupId !== openGroup ||\n pending.id !== choice ||\n pending.source !== sourceKey ||\n !pending.dialog.isConnected\n ) {\n choiceFocus.current = null;\n return;\n }\n if (selectedId !== pending.id) return;\n choiceFocus.current = null;\n const active = document.activeElement;\n if (\n pending.dialog.contains(active) ||\n (active === document.body && !pending.origin.isConnected)\n )\n pending.dialog.focus({ preventScroll: true });\n }, [choice, selectedId, openGroup, enabled, status, sourceKey]);\n\n // The marker handlers read the latest props and write state, so they never change identity and\n // a marker is not drawn again because the map around it was.\n const onMarkerOpenChange = useCallback(\n (groupId: string, open: boolean, singleId: string | null) => {\n setOpenGroup(open ? groupId : null);\n choiceFocus.current = null;\n if (open) {\n setChoice(singleId);\n if (singleId) latest.current.props.onSelectedIdChange(singleId);\n }\n },\n [],\n );\n const onMarkerChoose = useCallback(\n (groupId: string, entityId: string, origin: HTMLElement) => {\n const dialog = origin.closest<HTMLElement>(\"[role=dialog]\");\n choiceFocus.current = dialog\n ? { id: entityId, groupId, origin, dialog, source: sourceKey }\n : null;\n setChoice(entityId);\n latest.current.props.onSelectedIdChange(entityId);\n },\n [sourceKey],\n );\n const onMarkerPin = useCallback((groupId: string, entityId: string) => {\n pinnedGroup.current = groupId;\n setOpenGroup(null);\n latest.current.props.onInspect(entityId);\n }, []);\n const onMarkerCloseAutoFocus = useCallback((groupId: string, event: Event) => {\n if (pinnedGroup.current === groupId) {\n pinnedGroup.current = null;\n event.preventDefault();\n }\n }, []);\n\n const entityLayer = layers.find((layer) => layer.id === \"entities\");\n const groups =\n enabled &&\n status === \"ready\" &&\n mode === \"inspect\" &&\n entityLayer?.visible &&\n entityLayer.opacity > 0\n ? (frame?.groups ?? [])\n : [];\n return (\n <section\n aria-labelledby={`${id}-title`}\n className={`min-w-0 flex flex-col gap-2u font-sans text-oprt-base text-oprt-text ${props.className ?? \"\"}`}\n >\n <h3\n id={`${id}-title`}\n className=\"m-0 text-oprt-base font-medium uppercase tracking-oprt-base\"\n >\n Equipment map\n </h3>\n <p id={`${id}-help`} className=\"sr-only\">\n Focus the viewport and use arrow keys to pan, plus or minus to zoom. Use equipment markers\n for location choices. Equipment records remain available for inspection, including unknown\n positions.\n </p>\n {issues.invalid > 0 && (\n <p role=\"status\">\n {issues.invalid} invalid geometr{issues.invalid === 1 ? \"y\" : \"ies\"} excluded from the\n map. Review supplied coordinates in the table.{\" \"}\n {issues.uncertainty > 0 &&\n `${issues.uncertainty} uncertainty footprint${issues.uncertainty === 1 ? \" is\" : \"s are\"} excluded because of an invalid radius or unsupported projection bounds.`}\n </p>\n )}\n {issues.unknown > 0 && (\n <p>\n {issues.unknown} record{issues.unknown === 1 ? \" has\" : \"s have\"} an unknown position.\n </p>\n )}\n {issues.duplicates ? (\n <p role=\"alert\">\n Duplicate or empty IDs prevent unambiguous map selection. Correct the supplied source; the\n equipment records remain available.\n </p>\n ) : props.disabled ? (\n <p role=\"status\">Map disabled. Use the table to review equipment.</p>\n ) : snapshot.availability === \"unavailable\" ? (\n <p role=\"status\">Map source unavailable. Use the table and source recovery controls.</p>\n ) : null}\n <div\n ref={region}\n role=\"group\"\n aria-label=\"Map viewport\"\n aria-describedby={`${id}-help`}\n tabIndex={enabled && status === \"ready\" ? 0 : -1}\n className=\"relative h-[calc(var(--oprt-space-8)*8)] min-w-0 overflow-hidden border border-oprt-border bg-oprt-surface-2 outline-hidden focus-visible:border-oprt-border-focus focus-visible:shadow-oprt-focus focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-oprt-border-focus forced-colors:focus-visible:outline-[CanvasText]\"\n >\n <div\n ref={target}\n aria-hidden=\"true\"\n role=\"presentation\"\n className=\"absolute inset-0 [&_.ol-viewport]:touch-none!\"\n />\n {enabled && status === \"loading\" && (\n <p role=\"status\" className=\"absolute inset-x-0 top-0 m-0 bg-oprt-surface-1 p-2u\">\n Loading map renderer…\n </p>\n )}\n {enabled && status === \"error\" && (\n <div className=\"absolute inset-0 flex flex-col items-start gap-2u bg-oprt-surface-1 p-3u\">\n <p role=\"alert\">Map renderer unavailable: {error}</p>\n <Button size=\"sm\" onClick={() => setAttempt((value) => value + 1)}>\n Retry map\n </Button>\n </div>\n )}\n {groups.map((group) => {\n if (!group.ids.every((entityId) => records.has(entityId))) return null;\n const open = openGroup === group.id;\n return (\n <div\n key={group.id}\n className=\"absolute -translate-x-1/2 -translate-y-1/2\"\n style={{ left: group.pixel[0], top: group.pixel[1] }}\n >\n <ClusterMarker\n group={group}\n records={records}\n isSelected={selectedId !== null && group.ids.includes(selectedId)}\n open={open}\n choice={open ? choice : null}\n selectedId={open ? selectedId : null}\n region={region}\n onOpenChange={onMarkerOpenChange}\n onChoose={onMarkerChoose}\n onPin={onMarkerPin}\n onCloseAutoFocus={onMarkerCloseAutoFocus}\n />\n </div>\n );\n })}\n </div>\n {enabled && tileSource && (\n <div className=\"flex flex-wrap items-center gap-2u text-oprt-text-dim\">\n <span className=\"[overflow-wrap:anywhere]\">{tileSource.attribution}</span>\n {tileState === \"loading\" && <span role=\"status\">Loading background tiles…</span>}\n {tileState === \"error\" && (\n <>\n <span role=\"status\">\n Background tiles unavailable. Local vectors and the table remain available.\n </span>\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => setAttempt((value) => value + 1)}>\n Retry tiles\n </Button>\n </>\n )}\n </div>\n )}\n </section>\n );\n});\n",
4836
+ "patterns/geospatial-workspace/geo-map.tsx": "\"use client\";\n\nimport {\n memo,\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type RefObject,\n} from \"react\";\nimport { Button, EntityPreview, Popover } from \"@oprt/react\";\nimport type { EngineCallbacks, EngineState, GeoEngine, TileState } from \"./engine-contract\";\nimport { isGeoPoint, normalizeGeoView, sameGeoAnchor, sameGeoFrame } from \"./geometry\";\nimport { mapDataIssues } from \"./renderer-data\";\nimport type {\n GeoAnnotation,\n GeoEntity,\n GeoFrame,\n GeoMapProps,\n GeoMarkerGroup,\n GeoPoint,\n} from \"./types\";\nimport { UtcStamp } from \"./utc-stamp\";\n\n/** One shared empty collection, so a host that passes a new `[]` each render changes nothing. */\nconst NO_ANNOTATIONS: readonly GeoAnnotation[] = [];\nconst NO_POINTS: readonly GeoPoint[] = [];\nconst useBrowserLayoutEffect = typeof document === \"undefined\" ? useEffect : useLayoutEffect;\nconst finitePixel = (point: readonly number[] | null) =>\n point !== null && point.length === 2 && point.every(Number.isFinite);\n\n/** One engine start. Retiring it (dispose, failure, unmount) silences every callback it issued. */\ninterface EngineInstance {\n live: boolean;\n}\n\ninterface MarkerProps {\n group: GeoMarkerGroup;\n records: ReadonlyMap<string, GeoEntity>;\n isSelected: boolean;\n open: boolean;\n /** The location choice made inside this group while it is open. */\n choice: string | null;\n /** The supplied selection, which a choice waits for; only read while the group is open. */\n selectedId: string | null;\n region: RefObject<HTMLDivElement | null>;\n onOpenChange: (groupId: string, open: boolean, singleId: string | null) => void;\n onChoose: (groupId: string, entityId: string, origin: HTMLElement) => void;\n onPin: (groupId: string, entityId: string) => void;\n onCloseAutoFocus: (groupId: string, event: Event) => void;\n}\n\nconst sameIds = (a: readonly string[], b: readonly string[]) =>\n a === b || (a.length === b.length && a.every((item, index) => item === b[index]));\n/** A marker is drawn again for what it shows, not for where it is: a pan only moves its wrapper. */\nconst sameMarker = (previous: MarkerProps, next: MarkerProps) =>\n previous.group.id === next.group.id &&\n sameIds(previous.group.ids, next.group.ids) &&\n previous.records === next.records &&\n previous.isSelected === next.isSelected &&\n previous.open === next.open &&\n previous.choice === next.choice &&\n previous.selectedId === next.selectedId &&\n previous.region === next.region &&\n previous.onOpenChange === next.onOpenChange &&\n previous.onChoose === next.onChoose &&\n previous.onPin === next.onPin &&\n previous.onCloseAutoFocus === next.onCloseAutoFocus;\n\nconst ClusterMarker = memo(function ClusterMarker({\n group,\n records,\n isSelected,\n open,\n choice,\n selectedId,\n region,\n onOpenChange,\n onChoose,\n onPin,\n onCloseAutoFocus,\n}: MarkerProps) {\n const members = group.ids.flatMap((entityId) =>\n records.has(entityId) ? [records.get(entityId)!] : [],\n );\n const selected =\n choice && choice === selectedId && group.ids.includes(choice) ? records.get(choice) : undefined;\n const single = members.length === 1 ? members[0] : undefined;\n const label = single\n ? `${single.record.name} — ${single.record.id}`\n : `${members.length} equipment at this location`;\n return (\n <Popover\n title={selected ? \"Equipment preview\" : \"Equipment at this location\"}\n open={open}\n onOpenChange={(next) => onOpenChange(group.id, next, single?.record.id ?? null)}\n returnFocusRef={region}\n onCloseAutoFocus={(event) => onCloseAutoFocus(group.id, event)}\n content={\n <div className=\"flex max-w-[calc(var(--oprt-space-8)*5)] flex-col gap-2u\">\n {selected ? (\n <>\n <EntityPreview\n entity={{\n ...selected.record,\n facts: [\n ...selected.record.facts.map((fact) => ({\n ...fact,\n id: `entity:${fact.id}`,\n })),\n {\n id: \"geo:position\",\n label: \"Position\",\n value: selected.positionState,\n },\n {\n id: \"geo:observed\",\n label: \"Observed\",\n value: <UtcStamp value={selected.observedAt} />,\n mono: true,\n },\n ],\n }}\n actions={\n <Button size=\"sm\" onClick={() => onPin(group.id, selected.record.id)}>\n Pin in Inspector\n </Button>\n }\n />\n </>\n ) : (\n <>\n <p className=\"m-0 normal-case\">Choose a supplied record to preview its details.</p>\n {members.map((entity) => (\n <Button\n key={entity.record.id}\n size=\"sm\"\n variant=\"secondary\"\n className=\"h-auto! min-h-[var(--oprt-height-sm)] whitespace-normal! break-words text-left normal-case!\"\n onClick={(event) => onChoose(group.id, entity.record.id, event.currentTarget)}\n >\n {entity.record.name} — {entity.record.id}\n </Button>\n ))}\n {/* In the preview from the start, empty and out of the flow, and the text is\n written into it (docs/verification/2026-10-21-screen-readers.md). */}\n <p role=\"status\" className={choice ? \"normal-case\" : \"sr-only\"}>\n {choice ? \"Selection requested; waiting for the supplied selection.\" : \"\"}\n </p>\n </>\n )}\n </div>\n }\n >\n <Button\n size=\"sm\"\n variant={isSelected ? \"primary\" : \"secondary\"}\n aria-label={label}\n aria-pressed={isSelected}\n className={`whitespace-nowrap normal-case!${isSelected ? \"\" : \" bg-oprt-surface-1!\"}`}\n >\n {single ? single.record.id : members.length}\n </Button>\n </Popover>\n );\n}, sameMarker);\n\n/**\n * Optional renderer. All source, view and selection authority stays with its host.\n *\n * `snapshot`, `entities`, `annotations`, `measurement` and `layers` are compared by identity: the\n * renderer redraws when the host supplies a new collection, and not when it supplies an equal one\n * (a map of 2,000 reports cannot be compared by value on every pan frame). Keep a collection\n * stable between changes, for example in state or memoized.\n */\nexport const GeoMap = memo(function GeoMap(props: GeoMapProps) {\n const id = useId();\n const target = useRef<HTMLDivElement>(null);\n const region = useRef<HTMLDivElement>(null);\n const adapter = useRef<GeoEngine | null>(null);\n const instance = useRef<EngineInstance | null>(null);\n const anchorFrame = useRef<GeoFrame | null>(null);\n // The marker group whose preview or choices are open, for the engine callbacks, which never change\n // identity and so cannot read the state.\n const openGroupRef = useRef<string | null>(null);\n const pinnedGroup = useRef<string | null>(null);\n const choiceFocus = useRef<{\n id: string;\n groupId: string;\n origin: HTMLElement;\n dialog: HTMLElement;\n source: string;\n } | null>(null);\n const { snapshot, entities, selectedId, layers, mode, tileSource } = props;\n const annotations = props.annotations.length ? props.annotations : NO_ANNOTATIONS;\n const measurement = props.measurement.length ? props.measurement : NO_POINTS;\n // The view is a few numbers, not an object to compare: a pan changes these and nothing else.\n const view = normalizeGeoView(props.view);\n const [longitude, latitude] = view.center;\n const { zoom, bearing } = view;\n const tileUrl = tileSource?.url;\n const tileAttribution = tileSource?.attribution;\n const sourceKey = useMemo(\n () => JSON.stringify([snapshot.id, snapshot.revision]),\n [snapshot.id, snapshot.revision],\n );\n // What the engine draws. Its identity changes exactly when one of these inputs does, and it is\n // the stamp that tells a current engine callback from one the next update superseded.\n const state = useMemo<EngineState>(\n () => ({\n snapshot,\n entities,\n selectedId,\n view: { center: [longitude, latitude], zoom, bearing },\n layers,\n annotations,\n measurement,\n mode,\n ...(tileUrl !== undefined && tileAttribution !== undefined\n ? { tileSource: { url: tileUrl, attribution: tileAttribution } }\n : {}),\n }),\n [\n snapshot,\n entities,\n selectedId,\n longitude,\n latitude,\n zoom,\n bearing,\n layers,\n annotations,\n measurement,\n mode,\n tileUrl,\n tileAttribution,\n ],\n );\n // The supplied data is checked once per change of the data, never for a change of the view.\n const issues = useMemo(\n () => mapDataIssues({ snapshot, entities, annotations, measurement }),\n [snapshot, entities, annotations, measurement],\n );\n const positionedIds = useMemo(\n () =>\n new Set(\n entities.filter((entity) => isGeoPoint(entity.position)).map((entity) => entity.record.id),\n ),\n [entities],\n );\n const records = useMemo(\n () => new Map(entities.map((entity) => [entity.record.id, entity])),\n [entities],\n );\n const enabled = !props.disabled && !issues.duplicates && snapshot.availability !== \"unavailable\";\n const latest = useRef({ props, state, enabled, positionedIds });\n latest.current = { props, state, enabled, positionedIds };\n const [attempt, setAttempt] = useState(0);\n const [status, setStatus] = useState<\"loading\" | \"ready\" | \"error\">(\"loading\");\n const [error, setError] = useState(\"\");\n const [frame, setFrame] = useState<GeoFrame | null>(null);\n const [tileState, setTileState] = useState<TileState>(\"loading\");\n const [openGroup, setOpenGroup] = useState<string | null>(null);\n openGroupRef.current = openGroup;\n const [choice, setChoice] = useState<string | null>(null);\n /** Callbacks for one engine start and one state; they go quiet once either is superseded. */\n const makeCallbacks = useCallback(\n (expected: EngineState, owner: EngineInstance): EngineCallbacks => {\n const current = () =>\n owner.live && latest.current.enabled && latest.current.state === expected;\n return {\n onFrame(next) {\n if (!current()) return;\n const ids = latest.current.positionedIds;\n if (\n next.visibleIds.some((entityId) => !ids.has(entityId)) ||\n new Set(next.visibleIds).size !== next.visibleIds.length ||\n next.groups.some(\n (group) =>\n !isGeoPoint(group.point) ||\n !finitePixel(group.pixel) ||\n !group.ids.length ||\n group.ids.some((entityId) => !ids.has(entityId)) ||\n new Set(group.ids).size !== group.ids.length,\n ) ||\n (next.selectedPixel !== null && !finitePixel(next.selectedPixel)) ||\n (next.metersPerPixel !== null &&\n (!Number.isFinite(next.metersPerPixel) || next.metersPerPixel <= 0))\n )\n return;\n // What is open belongs to its marker group, not to the pixel it is drawn at. A frame that\n // only moves the view (Fit map, a zoom, a pan, or the second frame of the renderer settling)\n // moves the marker and what is open from it; it is dismissed when its group is gone or its\n // equipment moved on the ground.\n const open = openGroupRef.current;\n if (open !== null && !sameGeoAnchor(anchorFrame.current, next, open)) {\n choiceFocus.current = null;\n openGroupRef.current = null;\n setOpenGroup(null);\n setChoice(null);\n }\n anchorFrame.current = next;\n setFrame((previous) => (sameGeoFrame(previous, next) ? previous : next));\n latest.current.props.onFrame(next);\n },\n onViewChange(next) {\n if (current()) latest.current.props.onViewChange(normalizeGeoView(next));\n },\n onPoint(point) {\n if (current() && latest.current.props.mode !== \"inspect\" && isGeoPoint(point))\n latest.current.props.onPoint([point[0], point[1]]);\n },\n onTileState(next) {\n if (current()) setTileState(next);\n },\n };\n },\n [],\n );\n\n useEffect(() => {\n // This start owns the callbacks it issues. Retiring it is a write to its own object, not to a\n // counter held in a ref, so the cleanup reads nothing that React might have moved on.\n const owner: EngineInstance = { live: true };\n instance.current = owner;\n anchorFrame.current = null;\n choiceFocus.current = null;\n setFrame(null);\n setOpenGroup(null);\n setChoice(null);\n latest.current.props.onFrame(null);\n if (!enabled || !target.current) return;\n setStatus(\"loading\");\n setError(\"\");\n setTileState(\"loading\");\n void import(\"./engine\")\n .then(({ createGeoEngine }) => {\n if (!owner.live || !latest.current.enabled || !target.current) return;\n const current = latest.current;\n adapter.current = createGeoEngine(\n target.current,\n current.state,\n makeCallbacks(current.state, owner),\n );\n setStatus(\"ready\");\n })\n .catch((cause: unknown) => {\n if (!owner.live) return;\n owner.live = false;\n adapter.current?.dispose();\n adapter.current = null;\n setError(cause instanceof Error ? cause.message : \"Map renderer could not start.\");\n setStatus(\"error\");\n setFrame(null);\n latest.current.props.onFrame(null);\n });\n return () => {\n owner.live = false;\n adapter.current?.dispose();\n adapter.current = null;\n latest.current.props.onFrame(null);\n };\n }, [enabled, attempt, makeCallbacks]);\n\n useEffect(() => {\n const owner = instance.current;\n if (!adapter.current || !enabled || !owner?.live) return;\n try {\n adapter.current.update(state, makeCallbacks(state, owner));\n } catch (cause: unknown) {\n adapter.current.dispose();\n adapter.current = null;\n owner.live = false;\n setError(cause instanceof Error ? cause.message : \"Map renderer could not update.\");\n setStatus(\"error\");\n setFrame(null);\n latest.current.props.onFrame(null);\n }\n }, [state, enabled, makeCallbacks]);\n\n useEffect(() => {\n const leave = (event: Event) => {\n const pending = choiceFocus.current;\n if (pending && !pending.dialog.contains(event.target as Node | null))\n choiceFocus.current = null;\n };\n const blur = (event: FocusEvent) => {\n const pending = choiceFocus.current;\n // A still-connected choice explicitly blurred to nowhere is a departure;\n // removing the focused choice during acceptance is handled after commit.\n if (\n pending &&\n event.target === pending.origin &&\n pending.origin.isConnected &&\n !event.relatedTarget\n )\n choiceFocus.current = null;\n };\n document.addEventListener(\"focusin\", leave, true);\n document.addEventListener(\"pointerdown\", leave, true);\n document.addEventListener(\"focusout\", blur, true);\n return () => {\n choiceFocus.current = null;\n document.removeEventListener(\"focusin\", leave, true);\n document.removeEventListener(\"pointerdown\", leave, true);\n document.removeEventListener(\"focusout\", blur, true);\n };\n }, []);\n useBrowserLayoutEffect(() => {\n const pending = choiceFocus.current;\n if (!pending) return;\n if (\n !enabled ||\n status !== \"ready\" ||\n pending.groupId !== openGroup ||\n pending.id !== choice ||\n pending.source !== sourceKey ||\n !pending.dialog.isConnected\n ) {\n choiceFocus.current = null;\n return;\n }\n if (selectedId !== pending.id) return;\n choiceFocus.current = null;\n const active = document.activeElement;\n if (\n pending.dialog.contains(active) ||\n (active === document.body && !pending.origin.isConnected)\n )\n pending.dialog.focus({ preventScroll: true });\n }, [choice, selectedId, openGroup, enabled, status, sourceKey]);\n\n // The marker handlers read the latest props and write state, so they never change identity and\n // a marker is not drawn again because the map around it was.\n const onMarkerOpenChange = useCallback(\n (groupId: string, open: boolean, singleId: string | null) => {\n setOpenGroup(open ? groupId : null);\n choiceFocus.current = null;\n if (open) {\n setChoice(singleId);\n if (singleId) latest.current.props.onSelectedIdChange(singleId);\n }\n },\n [],\n );\n const onMarkerChoose = useCallback(\n (groupId: string, entityId: string, origin: HTMLElement) => {\n const dialog = origin.closest<HTMLElement>(\"[role=dialog]\");\n choiceFocus.current = dialog\n ? { id: entityId, groupId, origin, dialog, source: sourceKey }\n : null;\n setChoice(entityId);\n latest.current.props.onSelectedIdChange(entityId);\n },\n [sourceKey],\n );\n const onMarkerPin = useCallback((groupId: string, entityId: string) => {\n pinnedGroup.current = groupId;\n setOpenGroup(null);\n latest.current.props.onInspect(entityId);\n }, []);\n const onMarkerCloseAutoFocus = useCallback((groupId: string, event: Event) => {\n if (pinnedGroup.current === groupId) {\n pinnedGroup.current = null;\n event.preventDefault();\n }\n }, []);\n\n // Only the first of these three applies: duplicates are an alert of their own.\n const mapNotice = issues.duplicates\n ? \"\"\n : props.disabled\n ? \"Map disabled. Use the table to review equipment.\"\n : snapshot.availability === \"unavailable\"\n ? \"Map source unavailable. Use the table and source recovery controls.\"\n : \"\";\n const tileMessage =\n tileState === \"loading\"\n ? \"Loading background tiles…\"\n : tileState === \"error\"\n ? \"Background tiles unavailable. Local vectors and the table remain available.\"\n : \"\";\n const entityLayer = layers.find((layer) => layer.id === \"entities\");\n const groups =\n enabled &&\n status === \"ready\" &&\n mode === \"inspect\" &&\n entityLayer?.visible &&\n entityLayer.opacity > 0\n ? (frame?.groups ?? [])\n : [];\n return (\n <section\n aria-labelledby={`${id}-title`}\n className={`min-w-0 flex flex-col gap-2u font-sans text-oprt-base text-oprt-text ${props.className ?? \"\"}`}\n >\n <h3\n id={`${id}-title`}\n className=\"m-0 text-oprt-base font-medium uppercase tracking-oprt-base\"\n >\n Equipment map\n </h3>\n <p id={`${id}-help`} className=\"sr-only\">\n Focus the viewport and use arrow keys to pan, plus or minus to zoom. Use equipment markers\n for location choices. Equipment records remain available for inspection, including unknown\n positions.\n </p>\n {/* The notices that come and go with the data and the map's state are in the page from the\n start, empty and out of the flow, and their text is written into them: a screen reader\n speaks text that arrives in a status it already has, and said nothing for one inserted\n together with its text (docs/verification/2026-10-21-screen-readers.md). */}\n <p role=\"status\" className={issues.invalid > 0 ? undefined : \"sr-only\"}>\n {issues.invalid > 0 && (\n <>\n {issues.invalid} invalid geometr{issues.invalid === 1 ? \"y\" : \"ies\"} excluded from the\n map. Review supplied coordinates in the table.{\" \"}\n {issues.uncertainty > 0 &&\n `${issues.uncertainty} uncertainty footprint${issues.uncertainty === 1 ? \" is\" : \"s are\"} excluded because of an invalid radius or unsupported projection bounds.`}\n </>\n )}\n </p>\n {issues.unknown > 0 && (\n <p>\n {issues.unknown} record{issues.unknown === 1 ? \" has\" : \"s have\"} an unknown position.\n </p>\n )}\n {issues.duplicates && (\n <p role=\"alert\">\n Duplicate or empty IDs prevent unambiguous map selection. Correct the supplied source; the\n equipment records remain available.\n </p>\n )}\n <p role=\"status\" className={mapNotice ? undefined : \"sr-only\"}>\n {mapNotice}\n </p>\n <div\n ref={region}\n role=\"group\"\n aria-label=\"Map viewport\"\n aria-describedby={`${id}-help`}\n tabIndex={enabled && status === \"ready\" ? 0 : -1}\n className=\"relative h-[calc(var(--oprt-space-8)*8)] min-w-0 overflow-hidden border border-oprt-border bg-oprt-surface-2 outline-hidden focus-visible:border-oprt-border-focus focus-visible:shadow-oprt-focus focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-oprt-border-focus forced-colors:focus-visible:outline-[CanvasText]\"\n >\n <div\n ref={target}\n aria-hidden=\"true\"\n role=\"presentation\"\n className=\"absolute inset-0 [&_.ol-viewport]:touch-none!\"\n />\n <p\n role=\"status\"\n className={\n enabled && status === \"loading\"\n ? \"absolute inset-x-0 top-0 m-0 bg-oprt-surface-1 p-2u\"\n : \"sr-only\"\n }\n >\n {enabled && status === \"loading\" ? \"Loading map renderer…\" : \"\"}\n </p>\n {enabled && status === \"error\" && (\n <div className=\"absolute inset-0 flex flex-col items-start gap-2u bg-oprt-surface-1 p-3u\">\n <p role=\"alert\">Map renderer unavailable: {error}</p>\n <Button size=\"sm\" onClick={() => setAttempt((value) => value + 1)}>\n Retry map\n </Button>\n </div>\n )}\n {groups.map((group) => {\n if (!group.ids.every((entityId) => records.has(entityId))) return null;\n const open = openGroup === group.id;\n return (\n <div\n key={group.id}\n className=\"absolute -translate-x-1/2 -translate-y-1/2\"\n style={{ left: group.pixel[0], top: group.pixel[1] }}\n >\n <ClusterMarker\n group={group}\n records={records}\n isSelected={selectedId !== null && group.ids.includes(selectedId)}\n open={open}\n choice={open ? choice : null}\n selectedId={open ? selectedId : null}\n region={region}\n onOpenChange={onMarkerOpenChange}\n onChoose={onMarkerChoose}\n onPin={onMarkerPin}\n onCloseAutoFocus={onMarkerCloseAutoFocus}\n />\n </div>\n );\n })}\n </div>\n {enabled && tileSource && (\n <div className=\"flex flex-wrap items-center gap-2u text-oprt-text-dim\">\n <span className=\"[overflow-wrap:anywhere]\">{tileSource.attribution}</span>\n <span role=\"status\" className={tileMessage ? undefined : \"sr-only\"}>\n {tileMessage}\n </span>\n {tileState === \"error\" && (\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => setAttempt((value) => value + 1)}>\n Retry tiles\n </Button>\n )}\n </div>\n )}\n </section>\n );\n});\n",
4837
4837
  "patterns/geospatial-workspace/engine-contract.ts": "import type { GeoFrame, GeoMapProps, GeoPoint, GeoView } from \"./types\";\n\nexport type EngineState = Pick<\n GeoMapProps,\n | \"snapshot\"\n | \"entities\"\n | \"selectedId\"\n | \"view\"\n | \"layers\"\n | \"annotations\"\n | \"measurement\"\n | \"mode\"\n | \"tileSource\"\n>;\nexport type TileState = \"loading\" | \"ready\" | \"error\";\nexport interface EngineCallbacks {\n onFrame: (frame: GeoFrame) => void;\n onViewChange: (view: GeoView) => void;\n onPoint: (point: GeoPoint) => void;\n onTileState: (state: TileState) => void;\n}\nexport interface GeoEngine {\n update: (state: EngineState, callbacks: EngineCallbacks) => void;\n dispose: () => void;\n}\n",
4838
4838
  "patterns/geospatial-workspace/types.ts": "import type { EntityPreviewRecord, LayerControlItem } from \"@oprt/react\";\n\n/** WGS84 longitude, latitude in decimal degrees. */\nexport type GeoPoint = readonly [number, number];\nexport interface GeoView {\n center: GeoPoint;\n zoom: number;\n bearing: number;\n}\nexport interface GeoEntity {\n record: EntityPreviewRecord;\n position: GeoPoint | null;\n positionState: \"reported\" | \"last-known\" | \"unknown\";\n observedAt: string | null;\n uncertaintyMeters?: number;\n coverage?: readonly GeoPoint[];\n track?: readonly { position: GeoPoint; observedAt: string }[];\n}\nexport interface GeoSiteFeature {\n id: string;\n label: string;\n kind: \"building\" | \"road\" | \"water\" | \"boundary\";\n points: readonly GeoPoint[];\n}\nexport interface GeoSnapshot {\n id: string;\n title?: string;\n revision: string;\n source: string;\n observedAt: string | null;\n availability: \"current\" | \"partial\" | \"offline\" | \"unavailable\";\n detail?: string;\n entities: readonly GeoEntity[];\n site: readonly GeoSiteFeature[];\n}\nexport interface GeoAnnotation {\n id: string;\n label: string;\n position: GeoPoint;\n sourceId: string;\n createdAt: string;\n}\nexport interface GeoAnnotationDraft {\n label: string;\n position: GeoPoint;\n sourceId: string;\n}\nexport type GeoAnnotationResult =\n | { status: \"saved\"; annotation: GeoAnnotation }\n | { status: \"failed\"; message: string };\nexport interface GeoBookmark {\n id: string;\n name: string;\n view: GeoView;\n layers: readonly LayerControlItem[];\n}\nexport interface GeoMarkerGroup {\n id: string;\n ids: readonly string[];\n point: GeoPoint;\n pixel: readonly [number, number];\n}\nexport interface GeoFrame {\n view: GeoView;\n visibleIds: readonly string[];\n groups: readonly GeoMarkerGroup[];\n selectedPixel: readonly [number, number] | null;\n metersPerPixel: number | null;\n}\nexport interface GeoTileSource {\n url: string;\n attribution: string;\n}\nexport type GeoMapMode = \"inspect\" | \"measure\" | \"annotate\";\nexport interface GeoMapProps {\n snapshot: GeoSnapshot;\n entities: readonly GeoEntity[];\n selectedId: string | null;\n onSelectedIdChange: (id: string | null) => void;\n view: GeoView;\n onViewChange: (view: GeoView) => void;\n layers: readonly LayerControlItem[];\n annotations: readonly GeoAnnotation[];\n measurement: readonly GeoPoint[];\n mode: GeoMapMode;\n onPoint: (point: GeoPoint) => void;\n onFrame: (frame: GeoFrame | null) => void;\n onInspect: (id: string) => void;\n tileSource?: GeoTileSource;\n disabled?: boolean;\n className?: string;\n}\nexport interface GeospatialWorkspaceProps {\n snapshot: GeoSnapshot;\n selectedId: string | null;\n onSelectedIdChange: (id: string | null) => void;\n annotations: readonly GeoAnnotation[];\n onCreateAnnotation?: (draft: GeoAnnotationDraft) => Promise<GeoAnnotationResult>;\n onRemoveAnnotation?: (id: string) => void;\n bookmarks: readonly GeoBookmark[];\n onBookmarksChange: (bookmarks: readonly GeoBookmark[]) => void;\n initialView?: GeoView;\n tileSource?: GeoTileSource;\n}\n",
4839
4839
  "patterns/geospatial-workspace/geometry.ts": "import type { MapAction } from \"@oprt/react\";\nimport type { GeoEntity, GeoFrame, GeoPoint, GeoView } from \"./types\";\n\nexport const MERCATOR_LATITUDE = 85.05112878;\nconst radians = Math.PI / 180;\nconst wrap = (value: number, period: number) => ((value % period) + period) % period;\nexport const normalizeLongitude = (longitude: number) => wrap(longitude + 180, 360) - 180;\nconst finite = (value: unknown, fallback: number): number =>\n typeof value === \"number\" && Number.isFinite(value) ? value : fallback;\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\nconst mercatorY = (latitude: number) =>\n Math.log(Math.tan(Math.PI / 4 + (latitude * radians) / 2)) / (2 * Math.PI);\nconst latitudeFromY = (y: number) =>\n (2 * Math.atan(Math.exp(y * 2 * Math.PI)) - Math.PI / 2) / radians;\n\nexport function isGeoPoint(value: unknown): value is GeoPoint {\n return (\n Array.isArray(value) &&\n value.length === 2 &&\n value.every((axis) => typeof axis === \"number\" && Number.isFinite(axis)) &&\n Math.abs(value[0]) <= 180 &&\n Math.abs(value[1]) <= MERCATOR_LATITUDE\n );\n}\nexport function normalizeGeoView(view: GeoView): GeoView {\n return {\n center: [\n normalizeLongitude(finite(view?.center?.[0], 0)),\n clamp(finite(view?.center?.[1], 0), -MERCATOR_LATITUDE, MERCATOR_LATITUDE),\n ],\n zoom: clamp(finite(view?.zoom, 2), 2, 20),\n bearing: wrap(finite(view?.bearing, 0), 360),\n };\n}\nexport function distanceMeters(a: GeoPoint, b: GeoPoint): number | null {\n if (!isGeoPoint(a) || !isGeoPoint(b)) return null;\n const dLat = (b[1] - a[1]) * radians,\n dLon = normalizeLongitude(b[0] - a[0]) * radians;\n const arc =\n Math.sin(dLat / 2) ** 2 +\n Math.cos(a[1] * radians) * Math.cos(b[1] * radians) * Math.sin(dLon / 2) ** 2;\n return 2 * 6371008.8 * Math.asin(Math.sqrt(clamp(arc, 0, 1)));\n}\nexport function getFitView(entities: readonly GeoEntity[]): GeoView {\n const points = entities.flatMap((entity) =>\n isGeoPoint(entity.position) ? [entity.position] : [],\n );\n if (!points.length) return { center: [0, 0], zoom: 2, bearing: 0 };\n const longitudes = points.map((point) => wrap(point[0], 360)).sort((a, b) => a - b);\n let largestGap = -1,\n start = longitudes[0]!;\n for (let index = 0; index < longitudes.length; index++) {\n const next =\n longitudes[(index + 1) % longitudes.length]! + (index === longitudes.length - 1 ? 360 : 0);\n const gap = next - longitudes[index]!;\n if (gap > largestGap) {\n largestGap = gap;\n start = next;\n }\n }\n const span = 360 - largestGap;\n const ys = points.map((point) => mercatorY(point[1]));\n const low = Math.min(...ys),\n high = Math.max(...ys);\n const fraction = Math.max(span / 360, high - low);\n return normalizeGeoView({\n center: [start + span / 2, latitudeFromY((low + high) / 2)],\n zoom: fraction > 0 ? Math.floor(Math.log2(0.7 / fraction)) : 16,\n bearing: 0,\n });\n}\nexport function moveGeoView(\n view: GeoView,\n action: MapAction,\n entities: readonly GeoEntity[],\n): GeoView {\n const next = normalizeGeoView(view);\n if (action === \"fit\") return getFitView(entities);\n const step = 0.25 / 2 ** next.zoom;\n return normalizeGeoView({\n center: [\n next.center[0] + (action === \"pan-east\" ? 1 : action === \"pan-west\" ? -1 : 0) * step * 360,\n latitudeFromY(\n mercatorY(next.center[1]) +\n (action === \"pan-north\" ? 1 : action === \"pan-south\" ? -1 : 0) * step,\n ),\n ],\n zoom: next.zoom + (action === \"zoom-in\" ? 1 : action === \"zoom-out\" ? -1 : 0),\n bearing: action === \"reset-north\" ? 0 : next.bearing,\n });\n}\n\n/** Approximate spherical footprint. A rejected cap is omitted and counted by the host. */\nexport function getUncertaintyFootprint(\n center: GeoPoint,\n radius: number,\n): readonly GeoPoint[] | null {\n if (!isGeoPoint(center) || !Number.isFinite(radius) || radius < 0) return null;\n if (radius === 0) return [];\n const angularRadius = radius / 6371008.8;\n // Check the complete cap, including its interior, before sampling its boundary.\n if (Math.abs(center[1]) + angularRadius / radians > MERCATOR_LATITUDE) return null;\n const latitude = center[1] * radians;\n const sinLatitude = Math.sin(latitude),\n cosLatitude = Math.cos(latitude);\n const sinRadius = Math.sin(angularRadius),\n cosRadius = Math.cos(angularRadius);\n const points: GeoPoint[] = Array.from({ length: 64 }, (_, index) => {\n const bearing = (2 * Math.PI * index) / 64;\n const nextLatitude = Math.asin(\n clamp(sinLatitude * cosRadius + cosLatitude * sinRadius * Math.cos(bearing), -1, 1),\n );\n const deltaLongitude = Math.atan2(\n Math.sin(bearing) * sinRadius * cosLatitude,\n cosRadius - sinLatitude * Math.sin(nextLatitude),\n );\n return [normalizeLongitude(center[0] + deltaLongitude / radians), nextLatitude / radians];\n });\n return points.every(isGeoPoint) ? points : null;\n}\n\nconst same = (a: number, b: number) => a === b || (Number.isNaN(a) && Number.isNaN(b));\nconst samePair = (a: readonly number[], b: readonly number[]) =>\n same(a[0]!, b[0]!) && same(a[1]!, b[1]!);\nconst sameList = (a: readonly string[], b: readonly string[]) =>\n a === b || (a.length === b.length && a.every((item, index) => item === b[index]));\n\n/**\n * Whether two map frames report the same view, extent, markers and scale. Frames come about every\n * animation frame, so this walks them once instead of serializing them to compare the text.\n */\nexport function sameGeoFrame(a: GeoFrame | null, b: GeoFrame | null): boolean {\n if (a === b) return true;\n if (!a || !b) return false;\n if (\n !samePair(a.view.center, b.view.center) ||\n !same(a.view.zoom, b.view.zoom) ||\n !same(a.view.bearing, b.view.bearing) ||\n !sameList(a.visibleIds, b.visibleIds) ||\n a.groups.length !== b.groups.length ||\n (a.selectedPixel === null) !== (b.selectedPixel === null) ||\n (a.selectedPixel !== null && !samePair(a.selectedPixel, b.selectedPixel!)) ||\n (a.metersPerPixel === null) !== (b.metersPerPixel === null) ||\n (a.metersPerPixel !== null && !same(a.metersPerPixel, b.metersPerPixel!))\n )\n return false;\n return a.groups.every((group, index) => {\n const other = b.groups[index]!;\n return (\n group.id === other.id &&\n sameList(group.ids, other.ids) &&\n samePair(group.point, other.point) &&\n samePair(group.pixel, other.pixel)\n );\n });\n}\n\n/**\n * Whether the marker group `groupId` is still anchored in `after`: the frame draws a group with that\n * id (made from its members) and, where `before` had it too, at the same point on the ground. Its\n * pixel and the view around it are left out on purpose: a marker moves with the map, so whatever is\n * open from the marker (a preview, a list of choices) moves with it. An open marker is dismissed when\n * its group is gone (out of the viewport, regrouped) or when its equipment has moved on the ground.\n */\nexport function sameGeoAnchor(before: GeoFrame | null, after: GeoFrame, groupId: string): boolean {\n const next = after.groups.find((group) => group.id === groupId);\n if (!next) return false;\n const previous = before?.groups.find((group) => group.id === groupId);\n return !previous || samePair(previous.point, next.point);\n}\n",
@@ -4874,7 +4874,7 @@
4874
4874
  "layout": "import { AssetManagementDemo } from \"./patterns/resource-management\";\n\n<AssetManagementDemo />",
4875
4875
  "files": {
4876
4876
  "patterns/resource-management/index.ts": "export {\n AssetManagement,\n type AssetManagementProps,\n ResourceManagement,\n type ResourceManagementProps,\n} from \"./resource-management\";\nexport { AssetManagementDemo, ResourceManagementDemo } from \"./demo\";\nexport {\n createResourceRecords,\n type ManagedResource,\n type ResourceCondition,\n type AssetServiceRecord,\n} from \"./fixtures\";\nexport {\n applyResourceSave,\n validateResource,\n type ResourceSaveRequest,\n type ResourceSaveResult,\n type ResourceValues,\n} from \"./state\";\n",
4877
- "patterns/resource-management/resource-management.tsx": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport {\n Badge,\n Button,\n CollectionState,\n CopyButton,\n DataGrid,\n EntityPreview,\n Input,\n Inspector,\n Select,\n SelectOption,\n type DataGridColumn,\n} from \"@oprt/react\";\nimport { formatUtcInstant, type OperationalDomain } from \"../operational-domain\";\nimport { requestArtifactDownload, transferCsvCell } from \"../resource-transfer/export\";\nimport {\n conditionLabels,\n resourceScenarios,\n type ManagedResource,\n type ResourceCondition,\n} from \"./fixtures\";\nimport { ResourceForm } from \"./resource-form\";\nimport type { ResourceSaveRequest, ResourceSaveResult } from \"./state\";\n\nexport type ResourceManagementProps = {\n domain: OperationalDomain;\n records: readonly ManagedResource[];\n /** Resolve only when the application has accepted or declined the exact revision. */\n onSave: (request: ResourceSaveRequest) => Promise<ResourceSaveResult>;\n};\nexport type AssetManagementProps = Omit<ResourceManagementProps, \"domain\">;\n\n/** Dedicated industrial register; the host owns the equipment snapshot and save adapter. */\nexport function AssetManagement(props: AssetManagementProps) {\n return <ResourceRegister {...props} domain=\"industrial\" asset />;\n}\n\n/** Compatibility recipe for internal contexts and existing consumers. */\nexport function ResourceManagement(props: ResourceManagementProps) {\n return <ResourceRegister {...props} />;\n}\n\ntype Scope = \"all\" | \"attention\" | \"ready\";\nconst variants: Record<ResourceCondition, \"success\" | \"warning\" | \"neutral\"> = {\n ready: \"success\",\n restricted: \"warning\",\n maintenance: \"neutral\",\n};\n/** The open asset is shown by a tint and a 3 px bar (Highlight in forced colors), never by color alone. */\nconst selectedRowStyle =\n \"[&_tr:has(button[aria-pressed=true])]:bg-oprt-surface-3 [&_tr:has(button[aria-pressed=true])_td:first-child]:relative [&_tr:has(button[aria-pressed=true])_td:first-child]:before:absolute [&_tr:has(button[aria-pressed=true])_td:first-child]:before:inset-y-0 [&_tr:has(button[aria-pressed=true])_td:first-child]:before:left-0 [&_tr:has(button[aria-pressed=true])_td:first-child]:before:w-[3px] [&_tr:has(button[aria-pressed=true])_td:first-child]:before:bg-oprt-accent [&_tr:has(button[aria-pressed=true])_td:first-child]:forced-colors:before:bg-[Highlight]\";\nconst focusStyle =\n \"outline-hidden focus-visible:shadow-oprt-focus focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-oprt-border-focus\";\n\n/** Mount with a domain key when switching application contexts. */\nfunction ResourceRegister({\n domain,\n records,\n onSave,\n asset = false,\n}: ResourceManagementProps & { asset?: boolean }) {\n const scenario = resourceScenarios[domain];\n const subject = asset ? \"asset\" : \"resource\";\n const label = asset ? \"Asset\" : \"Resource\";\n const [query, setQuery] = useState(\"\");\n const [scope, setScope] = useState<Scope>(\"all\");\n const [location, setLocation] = useState(\"all\");\n const [owner, setOwner] = useState(\"all\");\n const [selectedId, setSelectedId] = useState<string | null>(() =>\n records.some((record) => record.id === scenario.selectedId)\n ? scenario.selectedId\n : (records[0]?.id ?? null),\n );\n const [editor, setEditor] = useState<{ record: ManagedResource | null } | null>(null);\n const [notice, setNotice] = useState(\"\");\n const [exportError, setExportError] = useState(\"\");\n const inspector = useRef<HTMLElement>(null);\n const register = useRef<HTMLElement>(null);\n const selectionOrigin = useRef<HTMLElement | null>(null);\n const focusRequested = useRef(false);\n const selected = records.find((record) => record.id === selectedId);\n const search = query.trim().toLocaleLowerCase();\n const visible = records.filter(\n (record) =>\n (scope === \"all\" ||\n (scope === \"attention\" ? record.condition !== \"ready\" : record.condition === \"ready\")) &&\n (location === \"all\" || record.location === location) &&\n (owner === \"all\" || record.owner === owner) &&\n (!search ||\n [record.id, record.name, record.category, record.owner, record.location].some((value) =>\n value.toLocaleLowerCase().includes(search),\n )),\n );\n const attentionCount = records.filter((record) => record.condition !== \"ready\").length;\n\n useLayoutEffect(() => {\n if (focusRequested.current) {\n inspector.current?.focus();\n focusRequested.current = false;\n }\n }, [selectedId]);\n\n /** A saved or exported confirmation describes the moment it happened, so it ends when the view moves on. */\n function dropConfirmations() {\n setNotice(\"\");\n setExportError(\"\");\n }\n function select(record: ManagedResource, origin?: HTMLElement) {\n selectionOrigin.current =\n origin ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n if (record.id === selectedId) {\n inspector.current?.focus();\n return;\n }\n focusRequested.current = true;\n dropConfirmations();\n setSelectedId(record.id);\n }\n function closeInspection() {\n setSelectedId(null);\n (selectionOrigin.current?.isConnected ? selectionOrigin.current : register.current)?.focus();\n }\n function clear() {\n dropConfirmations();\n setQuery(\"\");\n setScope(\"all\");\n setLocation(\"all\");\n setOwner(\"all\");\n }\n function exportRecords() {\n setExportError(\"\");\n try {\n const header = [\n \"id\",\n \"revision\",\n \"name\",\n \"type\",\n \"owner\",\n \"location\",\n \"condition\",\n \"service_due\",\n \"service_note\",\n ];\n const rows = visible.map((record) => [\n record.id,\n record.revision,\n record.name,\n record.category,\n record.owner,\n record.location,\n conditionLabels[record.condition],\n record.serviceDue,\n record.note,\n ]);\n requestArtifactDownload({\n filename: asset ? \"northbank-asset-register.csv\" : `${domain}-resource-register.csv`,\n mediaType: \"text/csv\",\n content:\n [header, ...rows].map((row) => row.map(transferCsvCell).join(\",\")).join(\"\\r\\n\") + \"\\r\\n\",\n });\n setNotice(\n `CSV prepared for ${visible.length} ${subject}${visible.length === 1 ? \"\" : \"s\"}. Download requested.`,\n );\n } catch {\n setExportError(\"The CSV could not be prepared. Try exporting the current results again.\");\n }\n }\n const columns: DataGridColumn[] = [\n {\n key: \"name\",\n label: asset ? \"Equipment\" : \"Resource\",\n render: (_value, row) => {\n const record = row.record as ManagedResource;\n return (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n aria-label={`Inspect ${record.name}, ${record.id}`}\n aria-pressed={record.id === selectedId}\n className=\"h-auto! min-h-5u w-full justify-start! px-0! text-left normal-case! tracking-normal!\"\n onClick={(event) => select(record, event.currentTarget)}\n >\n <span className=\"flex flex-col gap-0.5u\">\n <span className=\"whitespace-nowrap font-medium\">{record.name}</span>\n <span className=\"font-mono\">{record.id}</span>\n </span>\n </Button>\n );\n },\n },\n {\n key: \"location\",\n label: \"Location\",\n render: (value) => <span className=\"whitespace-nowrap\">{String(value)}</span>,\n },\n {\n key: \"owner\",\n label: \"Owner\",\n render: (value) => <span className=\"whitespace-nowrap\">{String(value)}</span>,\n },\n {\n key: \"condition\",\n label: \"Condition\",\n render: (value) => (\n <Badge appearance=\"text\" indicator=\"none\" variant={variants[value as ResourceCondition]}>\n {conditionLabels[value as ResourceCondition]}\n </Badge>\n ),\n },\n {\n key: \"serviceDue\",\n label: \"Service due\",\n render: (value) => <span className=\"whitespace-nowrap font-mono\">{String(value)}</span>,\n },\n ];\n\n return (\n <section\n ref={register}\n aria-label={`${scenario.title} ${subject} register`}\n tabIndex={-1}\n className={`not-prose flex w-full min-w-0 flex-col gap-3u font-sans text-oprt-base text-oprt-text ${focusStyle}`}\n >\n <header className=\"flex flex-wrap items-start justify-between gap-2u border-b border-oprt-border pb-3u\">\n <div className=\"min-w-0\">\n <h2 className=\"m-0 text-oprt-xl font-semibold normal-case tracking-normal\">\n {label} register\n </h2>\n {asset && (\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n Equipment ownership, inspections and service records\n </p>\n )}\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n {scenario.title} <span aria-hidden=\"true\">/</span> {scenario.department}\n </p>\n </div>\n <div className=\"flex flex-wrap gap-1u\">\n <Button size=\"sm\" variant=\"tertiary\" disabled={!visible.length} onClick={exportRecords}>\n Export CSV\n </Button>\n <Button\n size=\"sm\"\n onClick={() => {\n setEditor({ record: null });\n setNotice(\"\");\n }}\n >\n Add {subject}\n </Button>\n </div>\n </header>\n <div className=\"flex flex-wrap items-center justify-between gap-2u\">\n <div role=\"group\" aria-label={`${label} scopes`} className=\"flex flex-wrap gap-1u\">\n {(\n [\n [\"all\", `All ${subject}s`, records.length],\n [\"attention\", \"Needs attention\", attentionCount],\n [\"ready\", \"Ready\", records.length - attentionCount],\n ] as const\n ).map(([key, label, count]) => (\n <Button\n key={key}\n size=\"sm\"\n variant={scope === key ? \"secondary\" : \"ghost\"}\n aria-pressed={scope === key}\n onClick={() => {\n dropConfirmations();\n setScope(key);\n }}\n >\n {label}\n <span className=\"ml-1u font-mono text-oprt-text-dim\">{count}</span>\n </Button>\n ))}\n </div>\n <p className=\"m-0 text-oprt-text-dim\">{scenario.role}</p>\n </div>\n <div className=\"flex flex-wrap items-center gap-1u\">\n <Input\n type=\"search\"\n aria-label={`Search ${subject}s`}\n placeholder=\"Search ID, name or owner\"\n value={query}\n onChange={(event) => {\n dropConfirmations();\n setQuery(event.target.value);\n }}\n className=\"min-w-0 flex-1 basis-[16rem]\"\n />\n <Select\n aria-label={`${label} location`}\n value={location}\n onValueChange={(value) => {\n dropConfirmations();\n setLocation(value);\n }}\n size=\"sm\"\n >\n <SelectOption value=\"all\">All locations</SelectOption>\n {scenario.locations.map((item) => (\n <SelectOption key={item} value={item}>\n {item}\n </SelectOption>\n ))}\n </Select>\n <Select\n aria-label={`${label} owner`}\n value={owner}\n onValueChange={(value) => {\n dropConfirmations();\n setOwner(value);\n }}\n size=\"sm\"\n >\n <SelectOption value=\"all\">All owners</SelectOption>\n {scenario.owners.map((item) => (\n <SelectOption key={item} value={item}>\n {item}\n </SelectOption>\n ))}\n </Select>\n </div>\n {notice && (\n <p role=\"status\" className=\"m-0 border border-oprt-border bg-oprt-surface-2 p-2u\">\n {notice}\n </p>\n )}\n {exportError && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n {exportError}\n </p>\n )}\n <div className=\"flex min-w-0 flex-wrap items-start gap-3u\">\n <div className=\"min-w-0 flex-[999_1_35rem]\">\n <p role=\"status\" aria-label={`${label} count`} className=\"m-0 mb-2u text-oprt-text-dim\">\n {visible.length} of {records.length} {subject}s <span aria-hidden=\"true\">·</span>{\" \"}\n {scenario.date}, {scenario.time}\n </p>\n {!records.length ? (\n <CollectionState\n variant=\"empty\"\n title={`Register your first ${subject}`}\n description=\"Add equipment with an owner, a location and its current condition.\"\n actions={<Button onClick={() => setEditor({ record: null })}>Add {subject}</Button>}\n />\n ) : visible.length === 0 ? (\n <CollectionState\n variant=\"no-results\"\n title={`No ${subject}s match`}\n description=\"Try another identifier or clear the current search, scope and filters.\"\n actions={<Button onClick={clear}>Clear search and scope</Button>}\n />\n ) : (\n <DataGrid\n caption={`${label} register`}\n columns={columns}\n data={visible.map((record) => ({ ...record, record }))}\n getRowId={(row) => String(row.id)}\n pageSize={12}\n dense\n className={selectedRowStyle}\n />\n )}\n </div>\n {selectedId && (\n <Inspector\n ref={inspector}\n tabIndex={-1}\n title={`${label} details`}\n onClose={closeInspection}\n className={`min-w-0 flex-[1_1_24rem] ${focusStyle}`}\n >\n {selected ? (\n <>\n {!visible.some((record) => record.id === selectedId) && (\n <p className=\"m-0 text-oprt-text-dim\">\n Selected {subject} is outside the current results.\n </p>\n )}\n <EntityPreview\n entity={{\n id: selected.id,\n name: selected.name,\n typeLabel: selected.category,\n facts: [\n { id: \"owner\", label: \"Owner\", value: selected.owner },\n { id: \"location\", label: \"Location\", value: selected.location },\n {\n id: \"service\",\n label: \"Service due\",\n value: selected.serviceDue,\n mono: true,\n },\n {\n id: \"revision\",\n label: \"Revision\",\n value: String(selected.revision).padStart(2, \"0\"),\n mono: true,\n },\n ],\n condition: {\n label: conditionLabels[selected.condition],\n variant: variants[selected.condition],\n },\n quality: {\n state: \"current\",\n source: scenario.source,\n updatedAt: Date.parse(selected.updatedAt),\n },\n }}\n actions={\n <>\n <Button\n size=\"sm\"\n onClick={() => {\n setEditor({ record: selected });\n setNotice(\"\");\n }}\n >\n Edit {subject}\n </Button>\n <CopyButton size=\"sm\" value={selected.id} label={`Copy ${subject} ID`} />\n </>\n }\n />\n <div className=\"border-t border-oprt-border pt-2u\">\n <h4 className=\"m-0 font-medium\">Service note</h4>\n <p className=\"m-0 mt-1u leading-relaxed text-oprt-text-dim\">{selected.note}</p>\n </div>\n {asset && (\n <section\n aria-label=\"Inspection and service records\"\n className=\"border-t border-oprt-border pt-2u\"\n >\n <h4 className=\"m-0 font-medium\">Inspection &amp; service records</h4>\n {selected.serviceRecords?.length ? (\n <ol className=\"m-0 mt-2u list-none flex flex-col gap-3u p-0\">\n {selected.serviceRecords.map((record) => (\n <li key={record.id} className=\"flex flex-col gap-1u\">\n <div className=\"flex flex-wrap items-center justify-between gap-1u\">\n <span className=\"font-mono\">{record.id}</span>\n <Badge\n appearance=\"text\"\n indicator=\"none\"\n variant={record.result === \"Complete\" ? \"success\" : \"warning\"}\n >\n {record.result}\n </Badge>\n </div>\n <p className=\"m-0 font-medium\">{record.title}</p>\n <p className=\"m-0 leading-relaxed text-oprt-text-dim\">\n {record.detail}\n </p>\n <p className=\"m-0 text-oprt-text-dim\">\n Recorded by {record.recordedBy}\n </p>\n <time\n dateTime={record.recordedAt}\n className=\"block font-mono text-oprt-text-dim\"\n >\n {formatUtcInstant(record.recordedAt, { milliseconds: \"never\" })}\n </time>\n </li>\n ))}\n </ol>\n ) : (\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n No inspection or service evidence has been supplied for this asset.\n </p>\n )}\n </section>\n )}\n <div className=\"border-t border-oprt-border pt-2u\">\n <h4 className=\"m-0 font-medium\">\n {asset ? \"Register history\" : \"Recent activity\"}\n </h4>\n <ol className=\"m-0 mt-2u list-none flex flex-col gap-2u p-0\">\n {selected.activity.slice(0, 3).map((event) => (\n <li key={event.id}>\n <p className=\"m-0 text-oprt-text-dim\">{event.detail}</p>\n <time\n dateTime={event.at}\n className=\"font-mono text-oprt-sm text-oprt-text-dim\"\n >\n {formatUtcInstant(event.at, { milliseconds: \"never\" })}\n </time>\n </li>\n ))}\n </ol>\n </div>\n </>\n ) : (\n <CollectionState\n variant=\"empty\"\n title={`${label} no longer available`}\n description={`This ${subject} has left the current register. Choose another ${subject} to inspect its details.`}\n />\n )}\n </Inspector>\n )}\n </div>\n {asset && (\n <p className=\"m-0 text-oprt-text-dim\">\n Fictional Northbank Works register · changes are stored in this session.\n </p>\n )}\n {editor && (\n <ResourceForm\n asset={asset}\n domain={domain}\n record={editor.record}\n onSave={onSave}\n onClose={() => setEditor(null)}\n onSaved={(record) => {\n setEditor(null);\n setSelectedId(record.id);\n setNotice(\n `${record.id} saved. ${record.name} is ${conditionLabels[record.condition].toLowerCase()}.`,\n );\n }}\n />\n )}\n </section>\n );\n}\n",
4877
+ "patterns/resource-management/resource-management.tsx": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport {\n Badge,\n Button,\n CollectionState,\n CopyButton,\n DataGrid,\n EntityPreview,\n Input,\n Inspector,\n Select,\n SelectOption,\n type DataGridColumn,\n} from \"@oprt/react\";\nimport { formatUtcInstant, type OperationalDomain } from \"../operational-domain\";\nimport { requestArtifactDownload, transferCsvCell } from \"../resource-transfer/export\";\nimport {\n conditionLabels,\n resourceScenarios,\n type ManagedResource,\n type ResourceCondition,\n} from \"./fixtures\";\nimport { ResourceForm } from \"./resource-form\";\nimport type { ResourceSaveRequest, ResourceSaveResult } from \"./state\";\n\nexport type ResourceManagementProps = {\n domain: OperationalDomain;\n records: readonly ManagedResource[];\n /** Resolve only when the application has accepted or declined the exact revision. */\n onSave: (request: ResourceSaveRequest) => Promise<ResourceSaveResult>;\n};\nexport type AssetManagementProps = Omit<ResourceManagementProps, \"domain\">;\n\n/** Dedicated industrial register; the host owns the equipment snapshot and save adapter. */\nexport function AssetManagement(props: AssetManagementProps) {\n return <ResourceRegister {...props} domain=\"industrial\" asset />;\n}\n\n/** Compatibility recipe for internal contexts and existing consumers. */\nexport function ResourceManagement(props: ResourceManagementProps) {\n return <ResourceRegister {...props} />;\n}\n\ntype Scope = \"all\" | \"attention\" | \"ready\";\nconst variants: Record<ResourceCondition, \"success\" | \"warning\" | \"neutral\"> = {\n ready: \"success\",\n restricted: \"warning\",\n maintenance: \"neutral\",\n};\n/** The open asset is shown by a tint and a 3 px bar (Highlight in forced colors), never by color alone. */\nconst selectedRowStyle =\n \"[&_tr:has(button[aria-pressed=true])]:bg-oprt-surface-3 [&_tr:has(button[aria-pressed=true])_td:first-child]:relative [&_tr:has(button[aria-pressed=true])_td:first-child]:before:absolute [&_tr:has(button[aria-pressed=true])_td:first-child]:before:inset-y-0 [&_tr:has(button[aria-pressed=true])_td:first-child]:before:left-0 [&_tr:has(button[aria-pressed=true])_td:first-child]:before:w-[3px] [&_tr:has(button[aria-pressed=true])_td:first-child]:before:bg-oprt-accent [&_tr:has(button[aria-pressed=true])_td:first-child]:forced-colors:before:bg-[Highlight]\";\nconst focusStyle =\n \"outline-hidden focus-visible:shadow-oprt-focus focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-oprt-border-focus\";\n\n/** Mount with a domain key when switching application contexts. */\nfunction ResourceRegister({\n domain,\n records,\n onSave,\n asset = false,\n}: ResourceManagementProps & { asset?: boolean }) {\n const scenario = resourceScenarios[domain];\n const subject = asset ? \"asset\" : \"resource\";\n const label = asset ? \"Asset\" : \"Resource\";\n const [query, setQuery] = useState(\"\");\n const [scope, setScope] = useState<Scope>(\"all\");\n const [location, setLocation] = useState(\"all\");\n const [owner, setOwner] = useState(\"all\");\n const [selectedId, setSelectedId] = useState<string | null>(() =>\n records.some((record) => record.id === scenario.selectedId)\n ? scenario.selectedId\n : (records[0]?.id ?? null),\n );\n const [editor, setEditor] = useState<{ record: ManagedResource | null } | null>(null);\n const [notice, setNotice] = useState(\"\");\n const [exportError, setExportError] = useState(\"\");\n const inspector = useRef<HTMLElement>(null);\n const register = useRef<HTMLElement>(null);\n const selectionOrigin = useRef<HTMLElement | null>(null);\n const focusRequested = useRef(false);\n const selected = records.find((record) => record.id === selectedId);\n const search = query.trim().toLocaleLowerCase();\n const visible = records.filter(\n (record) =>\n (scope === \"all\" ||\n (scope === \"attention\" ? record.condition !== \"ready\" : record.condition === \"ready\")) &&\n (location === \"all\" || record.location === location) &&\n (owner === \"all\" || record.owner === owner) &&\n (!search ||\n [record.id, record.name, record.category, record.owner, record.location].some((value) =>\n value.toLocaleLowerCase().includes(search),\n )),\n );\n const attentionCount = records.filter((record) => record.condition !== \"ready\").length;\n\n useLayoutEffect(() => {\n if (focusRequested.current) {\n inspector.current?.focus();\n focusRequested.current = false;\n }\n }, [selectedId]);\n\n /** A saved or exported confirmation describes the moment it happened, so it ends when the view moves on. */\n function dropConfirmations() {\n setNotice(\"\");\n setExportError(\"\");\n }\n function select(record: ManagedResource, origin?: HTMLElement) {\n selectionOrigin.current =\n origin ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);\n if (record.id === selectedId) {\n inspector.current?.focus();\n return;\n }\n focusRequested.current = true;\n dropConfirmations();\n setSelectedId(record.id);\n }\n function closeInspection() {\n setSelectedId(null);\n (selectionOrigin.current?.isConnected ? selectionOrigin.current : register.current)?.focus();\n }\n function clear() {\n dropConfirmations();\n setQuery(\"\");\n setScope(\"all\");\n setLocation(\"all\");\n setOwner(\"all\");\n }\n function exportRecords() {\n setExportError(\"\");\n try {\n const header = [\n \"id\",\n \"revision\",\n \"name\",\n \"type\",\n \"owner\",\n \"location\",\n \"condition\",\n \"service_due\",\n \"service_note\",\n ];\n const rows = visible.map((record) => [\n record.id,\n record.revision,\n record.name,\n record.category,\n record.owner,\n record.location,\n conditionLabels[record.condition],\n record.serviceDue,\n record.note,\n ]);\n requestArtifactDownload({\n filename: asset ? \"northbank-asset-register.csv\" : `${domain}-resource-register.csv`,\n mediaType: \"text/csv\",\n content:\n [header, ...rows].map((row) => row.map(transferCsvCell).join(\",\")).join(\"\\r\\n\") + \"\\r\\n\",\n });\n setNotice(\n `CSV prepared for ${visible.length} ${subject}${visible.length === 1 ? \"\" : \"s\"}. Download requested.`,\n );\n } catch {\n setExportError(\"The CSV could not be prepared. Try exporting the current results again.\");\n }\n }\n const columns: DataGridColumn[] = [\n {\n key: \"name\",\n label: asset ? \"Equipment\" : \"Resource\",\n render: (_value, row) => {\n const record = row.record as ManagedResource;\n return (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n aria-label={`Inspect ${record.name}, ${record.id}`}\n aria-pressed={record.id === selectedId}\n className=\"h-auto! min-h-5u w-full justify-start! px-0! text-left normal-case! tracking-normal!\"\n onClick={(event) => select(record, event.currentTarget)}\n >\n <span className=\"flex flex-col gap-0.5u\">\n <span className=\"whitespace-nowrap font-medium\">{record.name}</span>\n <span className=\"font-mono\">{record.id}</span>\n </span>\n </Button>\n );\n },\n },\n {\n key: \"location\",\n label: \"Location\",\n render: (value) => <span className=\"whitespace-nowrap\">{String(value)}</span>,\n },\n {\n key: \"owner\",\n label: \"Owner\",\n render: (value) => <span className=\"whitespace-nowrap\">{String(value)}</span>,\n },\n {\n key: \"condition\",\n label: \"Condition\",\n render: (value) => (\n <Badge appearance=\"text\" indicator=\"none\" variant={variants[value as ResourceCondition]}>\n {conditionLabels[value as ResourceCondition]}\n </Badge>\n ),\n },\n {\n key: \"serviceDue\",\n label: \"Service due\",\n render: (value) => <span className=\"whitespace-nowrap font-mono\">{String(value)}</span>,\n },\n ];\n\n return (\n <section\n ref={register}\n aria-label={`${scenario.title} ${subject} register`}\n tabIndex={-1}\n className={`not-prose flex w-full min-w-0 flex-col gap-3u font-sans text-oprt-base text-oprt-text ${focusStyle}`}\n >\n <header className=\"flex flex-wrap items-start justify-between gap-2u border-b border-oprt-border pb-3u\">\n <div className=\"min-w-0\">\n <h2 className=\"m-0 text-oprt-xl font-semibold normal-case tracking-normal\">\n {label} register\n </h2>\n {asset && (\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n Equipment ownership, inspections and service records\n </p>\n )}\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n {scenario.title} <span aria-hidden=\"true\">/</span> {scenario.department}\n </p>\n </div>\n <div className=\"flex flex-wrap gap-1u\">\n <Button size=\"sm\" variant=\"tertiary\" disabled={!visible.length} onClick={exportRecords}>\n Export CSV\n </Button>\n <Button\n size=\"sm\"\n onClick={() => {\n setEditor({ record: null });\n setNotice(\"\");\n }}\n >\n Add {subject}\n </Button>\n </div>\n </header>\n <div className=\"flex flex-wrap items-center justify-between gap-2u\">\n <div role=\"group\" aria-label={`${label} scopes`} className=\"flex flex-wrap gap-1u\">\n {(\n [\n [\"all\", `All ${subject}s`, records.length],\n [\"attention\", \"Needs attention\", attentionCount],\n [\"ready\", \"Ready\", records.length - attentionCount],\n ] as const\n ).map(([key, label, count]) => (\n <Button\n key={key}\n size=\"sm\"\n variant={scope === key ? \"secondary\" : \"ghost\"}\n aria-pressed={scope === key}\n onClick={() => {\n dropConfirmations();\n setScope(key);\n }}\n >\n {label}\n <span className=\"ml-1u font-mono text-oprt-text-dim\">{count}</span>\n </Button>\n ))}\n </div>\n <p className=\"m-0 text-oprt-text-dim\">{scenario.role}</p>\n </div>\n <div className=\"flex flex-wrap items-center gap-1u\">\n <Input\n type=\"search\"\n aria-label={`Search ${subject}s`}\n placeholder=\"Search ID, name or owner\"\n value={query}\n onChange={(event) => {\n dropConfirmations();\n setQuery(event.target.value);\n }}\n className=\"min-w-0 flex-1 basis-[16rem]\"\n />\n <Select\n aria-label={`${label} location`}\n value={location}\n onValueChange={(value) => {\n dropConfirmations();\n setLocation(value);\n }}\n size=\"sm\"\n >\n <SelectOption value=\"all\">All locations</SelectOption>\n {scenario.locations.map((item) => (\n <SelectOption key={item} value={item}>\n {item}\n </SelectOption>\n ))}\n </Select>\n <Select\n aria-label={`${label} owner`}\n value={owner}\n onValueChange={(value) => {\n dropConfirmations();\n setOwner(value);\n }}\n size=\"sm\"\n >\n <SelectOption value=\"all\">All owners</SelectOption>\n {scenario.owners.map((item) => (\n <SelectOption key={item} value={item}>\n {item}\n </SelectOption>\n ))}\n </Select>\n </div>\n {/* In the page from the start, empty and out of the flow, and the outcome is written into it:\n a screen reader speaks text that arrives in a status it already has, and said nothing for\n one inserted together with its text (docs/verification/2026-10-21-screen-readers.md). */}\n <p\n role=\"status\"\n className={notice ? \"m-0 border border-oprt-border bg-oprt-surface-2 p-2u\" : \"sr-only\"}\n >\n {notice}\n </p>\n {exportError && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n {exportError}\n </p>\n )}\n <div className=\"flex min-w-0 flex-wrap items-start gap-3u\">\n <div className=\"min-w-0 flex-[999_1_35rem]\">\n <p role=\"status\" aria-label={`${label} count`} className=\"m-0 mb-2u text-oprt-text-dim\">\n {visible.length} of {records.length} {subject}s <span aria-hidden=\"true\">·</span>{\" \"}\n {scenario.date}, {scenario.time}\n </p>\n {!records.length ? (\n <CollectionState\n variant=\"empty\"\n title={`Register your first ${subject}`}\n description=\"Add equipment with an owner, a location and its current condition.\"\n actions={<Button onClick={() => setEditor({ record: null })}>Add {subject}</Button>}\n />\n ) : visible.length === 0 ? (\n <CollectionState\n variant=\"no-results\"\n title={`No ${subject}s match`}\n description=\"Try another identifier or clear the current search, scope and filters.\"\n actions={<Button onClick={clear}>Clear search and scope</Button>}\n />\n ) : (\n <DataGrid\n caption={`${label} register`}\n columns={columns}\n data={visible.map((record) => ({ ...record, record }))}\n getRowId={(row) => String(row.id)}\n pageSize={12}\n dense\n className={selectedRowStyle}\n />\n )}\n </div>\n {selectedId && (\n <Inspector\n ref={inspector}\n tabIndex={-1}\n title={`${label} details`}\n onClose={closeInspection}\n className={`min-w-0 flex-[1_1_24rem] ${focusStyle}`}\n >\n {selected ? (\n <>\n {!visible.some((record) => record.id === selectedId) && (\n <p className=\"m-0 text-oprt-text-dim\">\n Selected {subject} is outside the current results.\n </p>\n )}\n <EntityPreview\n entity={{\n id: selected.id,\n name: selected.name,\n typeLabel: selected.category,\n facts: [\n { id: \"owner\", label: \"Owner\", value: selected.owner },\n { id: \"location\", label: \"Location\", value: selected.location },\n {\n id: \"service\",\n label: \"Service due\",\n value: selected.serviceDue,\n mono: true,\n },\n {\n id: \"revision\",\n label: \"Revision\",\n value: String(selected.revision).padStart(2, \"0\"),\n mono: true,\n },\n ],\n condition: {\n label: conditionLabels[selected.condition],\n variant: variants[selected.condition],\n },\n quality: {\n state: \"current\",\n source: scenario.source,\n updatedAt: Date.parse(selected.updatedAt),\n },\n }}\n actions={\n <>\n <Button\n size=\"sm\"\n onClick={() => {\n setEditor({ record: selected });\n setNotice(\"\");\n }}\n >\n Edit {subject}\n </Button>\n <CopyButton size=\"sm\" value={selected.id} label={`Copy ${subject} ID`} />\n </>\n }\n />\n <div className=\"border-t border-oprt-border pt-2u\">\n <h4 className=\"m-0 font-medium\">Service note</h4>\n <p className=\"m-0 mt-1u leading-relaxed text-oprt-text-dim\">{selected.note}</p>\n </div>\n {asset && (\n <section\n aria-label=\"Inspection and service records\"\n className=\"border-t border-oprt-border pt-2u\"\n >\n <h4 className=\"m-0 font-medium\">Inspection &amp; service records</h4>\n {selected.serviceRecords?.length ? (\n <ol className=\"m-0 mt-2u list-none flex flex-col gap-3u p-0\">\n {selected.serviceRecords.map((record) => (\n <li key={record.id} className=\"flex flex-col gap-1u\">\n <div className=\"flex flex-wrap items-center justify-between gap-1u\">\n <span className=\"font-mono\">{record.id}</span>\n <Badge\n appearance=\"text\"\n indicator=\"none\"\n variant={record.result === \"Complete\" ? \"success\" : \"warning\"}\n >\n {record.result}\n </Badge>\n </div>\n <p className=\"m-0 font-medium\">{record.title}</p>\n <p className=\"m-0 leading-relaxed text-oprt-text-dim\">\n {record.detail}\n </p>\n <p className=\"m-0 text-oprt-text-dim\">\n Recorded by {record.recordedBy}\n </p>\n <time\n dateTime={record.recordedAt}\n className=\"block font-mono text-oprt-text-dim\"\n >\n {formatUtcInstant(record.recordedAt, { milliseconds: \"never\" })}\n </time>\n </li>\n ))}\n </ol>\n ) : (\n <p className=\"m-0 mt-1u text-oprt-text-dim\">\n No inspection or service evidence has been supplied for this asset.\n </p>\n )}\n </section>\n )}\n <div className=\"border-t border-oprt-border pt-2u\">\n <h4 className=\"m-0 font-medium\">\n {asset ? \"Register history\" : \"Recent activity\"}\n </h4>\n <ol className=\"m-0 mt-2u list-none flex flex-col gap-2u p-0\">\n {selected.activity.slice(0, 3).map((event) => (\n <li key={event.id}>\n <p className=\"m-0 text-oprt-text-dim\">{event.detail}</p>\n <time\n dateTime={event.at}\n className=\"font-mono text-oprt-sm text-oprt-text-dim\"\n >\n {formatUtcInstant(event.at, { milliseconds: \"never\" })}\n </time>\n </li>\n ))}\n </ol>\n </div>\n </>\n ) : (\n <CollectionState\n variant=\"empty\"\n title={`${label} no longer available`}\n description={`This ${subject} has left the current register. Choose another ${subject} to inspect its details.`}\n />\n )}\n </Inspector>\n )}\n </div>\n {asset && (\n <p className=\"m-0 text-oprt-text-dim\">\n Fictional Northbank Works register · changes are stored in this session.\n </p>\n )}\n {editor && (\n <ResourceForm\n asset={asset}\n domain={domain}\n record={editor.record}\n onSave={onSave}\n onClose={() => setEditor(null)}\n onSaved={(record) => {\n setEditor(null);\n setSelectedId(record.id);\n setNotice(\n `${record.id} saved. ${record.name} is ${conditionLabels[record.condition].toLowerCase()}.`,\n );\n }}\n />\n )}\n </section>\n );\n}\n",
4878
4878
  "patterns/operational-domain.ts": "/** Fixture context used by reusable application recipes. */\nexport type OperationalDomain = \"military\" | \"industrial\";\n\nexport interface UtcInstantOptions {\n /**\n * Whether the milliseconds are shown. \"auto\" (the default) shows them only when the supplied\n * instant carries a non-zero fraction, because sub-second timing matters only where it exists.\n */\n milliseconds?: \"auto\" | \"always\" | \"never\";\n}\n\nconst INSTANT =\n /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,3}))?(Z|[+-]\\d{2}:\\d{2})$/;\n\n/**\n * Reads an ISO 8601 instant as \"YYYY-MM-DD HH:MM:SS UTC\", with \".mmm\" after the seconds where the\n * options ask for it. Text that is not a real instant (an invented date such as 30 February, or\n * free text) is returned as supplied rather than reinterpreted. It never uses Intl, so the server\n * and every browser print the same characters.\n */\nexport function formatUtcInstant(value: string, options: UtcInstantOptions = {}): string {\n const parts = INSTANT.exec(value);\n if (!parts) return value;\n const [year, month, day, hour, minute, second] = parts.slice(1, 7).map(Number) as [\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n const calendar = new Date(Date.UTC(year, month - 1, day));\n // Date rolls an invented day into the next month; compare it back to what was written.\n if (\n calendar.getUTCFullYear() !== year ||\n calendar.getUTCMonth() !== month - 1 ||\n calendar.getUTCDate() !== day ||\n hour > 23 ||\n minute > 59 ||\n second > 59\n )\n return value;\n const fraction = (parts[7] ?? \"\").padEnd(3, \"0\");\n const parsed = new Date(`${value.slice(0, 19)}.${fraction}${parts[8]}`);\n if (Number.isNaN(parsed.getTime())) return value;\n const { milliseconds = \"auto\" } = options;\n const showFraction = milliseconds === \"always\" || (milliseconds === \"auto\" && fraction !== \"000\");\n const iso = parsed.toISOString();\n return `${iso.slice(0, 10)} ${iso.slice(11, showFraction ? 23 : 19)} UTC`;\n}\n\n/**\n * Reads epoch milliseconds as \"YYYY-MM-DD HH:MM:SS UTC\", with the same options as\n * formatUtcInstant. A value that is not a real time reads \"Unknown time\" rather than throwing.\n */\nexport function formatUtcEpoch(ms: number, options: UtcInstantOptions = {}): string {\n const date = new Date(ms);\n if (!Number.isFinite(ms) || Number.isNaN(date.getTime())) return \"Unknown time\";\n return formatUtcInstant(date.toISOString(), options);\n}\n",
4879
4879
  "patterns/resource-transfer/export.ts": "import type { TransferArtifact, TransferRequest } from \"./transfer-state\";\nimport type { ImportValidationRow } from \"./csv\";\n\n/** Quote every cell and neutralize spreadsheet formula prefixes, including leading whitespace. */\nexport function transferCsvCell(value: string | number | boolean): string {\n let text = String(value);\n const firstMeaningful = [...text].find(\n (character) => character.charCodeAt(0) > 32 && character.trim() !== \"\",\n );\n if ((firstMeaningful && \"=+@-\".includes(firstMeaningful)) || /^[\\t\\r\\n]/u.test(text))\n text = `'${text}`;\n return `\"${text.replaceAll('\"', '\"\"')}\"`;\n}\n\nexport function prepareTransferExport(\n request: Extract<TransferRequest, { operation: \"export\" }>,\n): TransferArtifact {\n const ids = new Set(request.selection.ids);\n if (\n ids.size === 0 ||\n ids.size !== request.selection.ids.length ||\n request.records.length !== ids.size ||\n new Set(request.records.map((record) => record.id)).size !== ids.size ||\n request.records.some((record) => !ids.has(record.id))\n )\n throw new Error(\"Export records do not match the reviewed selection scope.\");\n const { context, selection } = request;\n const capturedAt = new Date(context.capturedAt).toISOString();\n const asOf =\n context.source.asOf === null ? \"Unknown\" : new Date(context.source.asOf).toISOString();\n const header = [\n \"id\",\n \"record_revision\",\n \"name\",\n \"kind\",\n \"site\",\n \"inspection_required\",\n \"source_id\",\n \"source_revision\",\n \"source_label\",\n \"source_as_of\",\n \"exported_at\",\n \"time_zone\",\n \"selection_scope\",\n \"filter\",\n \"filter_description\",\n ];\n const rows = request.records.map((record) => [\n record.id,\n record.revision,\n record.name,\n record.kind,\n record.site,\n record.inspectionRequired,\n context.source.id,\n context.source.revision,\n context.source.label,\n asOf,\n capturedAt,\n context.timeZone,\n selection.scope,\n JSON.stringify(context.filter),\n context.filterDescription,\n ]);\n return {\n filename: `equipment-export-${capturedAt.replace(/[.:]/g, \"-\")}.csv`,\n mediaType: \"text/csv\",\n content:\n [header, ...rows].map((row) => row.map(transferCsvCell).join(\",\")).join(\"\\r\\n\") + \"\\r\\n\",\n };\n}\n\nexport function prepareImportErrorReport(rows: readonly ImportValidationRow[]): TransferArtifact {\n const output: (string | number)[][] = [\n [\"row_key\", \"source_line\", \"field\", \"message\", \"source_cells\"],\n ];\n for (const row of rows) {\n for (const error of row.errors)\n output.push([row.key, error.line, error.field, error.message, JSON.stringify(row.cells)]);\n }\n return {\n filename: \"equipment-import-errors.csv\",\n mediaType: \"text/csv\",\n content: output.map((row) => row.map(transferCsvCell).join(\",\")).join(\"\\r\\n\") + \"\\r\\n\",\n };\n}\n\n/** Requests a browser download. The browser does not report whether the file reached disk. */\nexport function requestArtifactDownload(artifact: TransferArtifact): void {\n if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,160}\\.csv$/.test(artifact.filename))\n throw new Error(\"Invalid download filename.\");\n if (artifact.mediaType !== \"text/csv\" || typeof artifact.content !== \"string\")\n throw new Error(\"Unsupported download artifact.\");\n const blob = new Blob([artifact.content], { type: \"text/csv;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const revoke = URL.revokeObjectURL.bind(URL);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = artifact.filename;\n link.hidden = true;\n try {\n document.body.appendChild(link);\n link.click();\n } catch (error) {\n revoke(url);\n throw error;\n } finally {\n link.remove();\n }\n setTimeout(() => revoke(url), 1000);\n}\n",
4880
4880
  "patterns/resource-transfer/transfer-state.ts": "import type { BulkSelectionSnapshot } from \"@oprt/react\";\n\nexport interface TransferRecord {\n id: string;\n revision: string;\n name: string;\n kind: \"pump\" | \"fan\" | \"valve\" | \"gauge\";\n site: \"west\" | \"east\";\n inspectionRequired: boolean;\n}\nexport interface TransferSource {\n id: string;\n revision: string;\n label: string;\n asOf: number | null;\n complete: boolean;\n records: readonly TransferRecord[];\n}\nexport interface TransferFilter {\n query: string;\n kind: TransferRecord[\"kind\"] | \"all\";\n site: TransferRecord[\"site\"] | \"all\";\n}\nexport interface TransferContext {\n source: Omit<TransferSource, \"records\">;\n capturedAt: number;\n timeZone: string;\n filter: TransferFilter;\n filterDescription: string;\n}\nexport type TransferImportValues = Pick<TransferRecord, \"id\" | \"name\" | \"kind\" | \"site\">;\ninterface TransferRequestBase {\n requestId: string;\n sessionId: string;\n context: TransferContext;\n selection: BulkSelectionSnapshot;\n}\nexport type TransferRequest = TransferRequestBase &\n (\n | { operation: \"import\"; items: readonly { key: string; values: TransferImportValues }[] }\n | { operation: \"flag-inspection\"; records: readonly TransferRecord[] }\n | { operation: \"export\"; records: readonly TransferRecord[] }\n );\nexport interface TransferArtifact {\n filename: string;\n mediaType: \"text/csv\";\n content: string;\n}\nexport type TransferItemResult =\n | { key: string; status: \"applied\"; record: TransferRecord }\n | { key: string; status: \"failed\"; message: string; retryable: boolean }\n | { key: string; status: \"canceled\" | \"skipped\"; message: string };\nexport type TransferResult =\n | {\n requestId: string;\n operation: \"import\" | \"flag-inspection\";\n status: \"complete\" | \"partial\" | \"failed\" | \"canceled\";\n items: readonly TransferItemResult[];\n message: string;\n sourceRevision: string | null;\n }\n | { requestId: string; operation: \"export\"; status: \"complete\"; artifact: TransferArtifact }\n | { requestId: string; operation: \"export\"; status: \"failed\" | \"canceled\"; message: string };\nexport interface TransferProgress {\n requestId: string;\n state: \"queued\" | \"running\" | \"cancel-requested\";\n completed: number | null;\n total: number | null;\n message?: string;\n}\nexport type TransferCancelResult =\n | { requestId: string; status: \"requested\" }\n | { requestId: string; status: \"rejected\"; message: string };\nexport interface TransferRunOptions {\n signal: AbortSignal;\n onProgress(update: TransferProgress): void;\n}\nexport type TransferParseResult<T> = { ok: true; value: T } | { ok: false; error: string };\n\nconst kinds = [\"pump\", \"fan\", \"valve\", \"gauge\"];\nconst sites = [\"west\", \"east\"];\nfunction hasControlCharacters(value: string): boolean {\n return Array.from(value).some((character) => {\n const code = character.charCodeAt(0);\n return code < 32 || (code >= 127 && code <= 159);\n });\n}\nconst invalid = (error: string): { ok: false; error: string } => ({ ok: false, error });\nconst text = (value: unknown, max = 256): value is string =>\n typeof value === \"string\" &&\n value.trim().length > 0 &&\n value.length <= max &&\n !hasControlCharacters(value);\nconst time = (value: unknown): value is number =>\n typeof value === \"number\" &&\n Number.isSafeInteger(value) &&\n value >= 0 &&\n value <= 8640000000000000;\nfunction object(value: unknown): value is Record<string, unknown> {\n return (\n typeof value === \"object\" &&\n value !== null &&\n !Array.isArray(value) &&\n (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)\n );\n}\nfunction keys(\n value: Record<string, unknown>,\n expected: readonly string[],\n optional: readonly string[] = [],\n): boolean {\n return (\n expected.every((key) => Object.hasOwn(value, key)) &&\n Reflect.ownKeys(value).every(\n (key) => typeof key === \"string\" && (expected.includes(key) || optional.includes(key)),\n )\n );\n}\nfunction freezeCopy<T>(value: T): T {\n if (Array.isArray(value))\n return Object.freeze(value.map((item: unknown) => freezeCopy(item))) as T;\n if (value !== null && typeof value === \"object\")\n return Object.freeze(\n Object.fromEntries(Object.entries(value).map(([key, item]) => [key, freezeCopy(item)])),\n ) as T;\n return value;\n}\nfunction values(value: unknown): value is TransferImportValues {\n return (\n object(value) &&\n keys(value, [\"id\", \"name\", \"kind\", \"site\"]) &&\n text(value.id, 80) &&\n text(value.name, 120) &&\n typeof value.kind === \"string\" &&\n kinds.includes(value.kind) &&\n typeof value.site === \"string\" &&\n sites.includes(value.site)\n );\n}\nfunction record(value: unknown): value is TransferRecord {\n if (\n !object(value) ||\n !keys(value, [\"id\", \"revision\", \"name\", \"kind\", \"site\", \"inspectionRequired\"])\n )\n return false;\n return (\n values({ id: value.id, name: value.name, kind: value.kind, site: value.site }) &&\n text(value.revision) &&\n typeof value.inspectionRequired === \"boolean\"\n );\n}\nfunction equalRecord(a: TransferRecord, b: TransferRecord): boolean {\n return (\n a.id === b.id &&\n a.revision === b.revision &&\n a.name === b.name &&\n a.kind === b.kind &&\n a.site === b.site &&\n a.inspectionRequired === b.inspectionRequired\n );\n}\nfunction validFilter(value: unknown): value is TransferFilter {\n return (\n object(value) &&\n keys(value, [\"query\", \"kind\", \"site\"]) &&\n typeof value.query === \"string\" &&\n value.query.length <= 120 &&\n !hasControlCharacters(value.query) &&\n typeof value.kind === \"string\" &&\n [\"all\", ...kinds].includes(value.kind) &&\n typeof value.site === \"string\" &&\n [\"all\", ...sites].includes(value.site)\n );\n}\nfunction sameIds(a: readonly string[], b: readonly string[]): boolean {\n return a.length === b.length && a.every((id) => b.includes(id));\n}\nfunction sameFilter(a: TransferFilter, b: TransferFilter): boolean {\n return a.query === b.query && a.kind === b.kind && a.site === b.site;\n}\nfunction validZone(value: unknown): value is string {\n if (!text(value, 128) || /^[+-]/.test(value)) return false;\n try {\n new Intl.DateTimeFormat(\"en\", { timeZone: value }).format(0);\n return true;\n } catch {\n return false;\n }\n}\n\nexport function parseTransferSource(input: unknown): TransferParseResult<TransferSource> {\n try {\n if (\n !object(input) ||\n !keys(input, [\"id\", \"revision\", \"label\", \"asOf\", \"complete\", \"records\"]) ||\n !text(input.id) ||\n !text(input.revision) ||\n !text(input.label, 200) ||\n (input.asOf !== null && !time(input.asOf)) ||\n typeof input.complete !== \"boolean\" ||\n !Array.isArray(input.records) ||\n input.records.length > 1000 ||\n !input.records.every(record)\n )\n return invalid(\n \"Inventory source or equipment records are malformed or exceed the 1,000-record limit.\",\n );\n const ids = input.records.map((item) => (item as TransferRecord).id);\n if (new Set(ids).size !== ids.length) return invalid(\"Inventory record IDs must be unique.\");\n return { ok: true, value: freezeCopy(input as unknown as TransferSource) };\n } catch {\n return invalid(\"Inventory source could not be read.\");\n }\n}\n\n/** Captures a complete reviewed request; no caller object is retained by reference. */\nexport function createTransferRequest(\n input: TransferRequest,\n source: TransferSource,\n canWrite: boolean,\n): TransferParseResult<TransferRequest> {\n try {\n const sourceResult = parseTransferSource(source);\n if (!sourceResult.ok) return sourceResult;\n if (\n !object(input) ||\n !text(input.requestId) ||\n !text(input.sessionId) ||\n ![\"import\", \"flag-inspection\", \"export\"].includes(input.operation)\n )\n return invalid(\"Transfer request identity or operation is invalid.\");\n if (\n !keys(input, [\n \"requestId\",\n \"sessionId\",\n \"context\",\n \"selection\",\n \"operation\",\n input.operation === \"import\" ? \"items\" : \"records\",\n ])\n )\n return invalid(\"Transfer request contains unexpected or missing fields.\");\n const context = input.context;\n if (\n !object(context) ||\n !keys(context, [\"source\", \"capturedAt\", \"timeZone\", \"filter\", \"filterDescription\"]) ||\n !time(context.capturedAt) ||\n !validZone(context.timeZone) ||\n !validFilter(context.filter) ||\n context.filterDescription !== describeTransferFilter(context.filter) ||\n !object(context.source) ||\n !keys(context.source, [\"id\", \"revision\", \"label\", \"asOf\", \"complete\"])\n )\n return invalid(\"Transfer source, time or filter context is invalid.\");\n const metadata = context.source;\n if (\n metadata.id !== source.id ||\n metadata.revision !== source.revision ||\n metadata.label !== source.label ||\n metadata.asOf !== source.asOf ||\n metadata.complete !== source.complete\n )\n return invalid(\"The source changed. Review the current inventory before continuing.\");\n const selection = input.selection;\n if (\n !object(selection) ||\n !keys(selection, [\n \"id\",\n \"scope\",\n \"ids\",\n \"sourceId\",\n \"sourceRevision\",\n \"filterDescription\",\n \"capturedAt\",\n ]) ||\n !text(selection.id) ||\n ![\"visible\", \"selected\", \"all-matching\"].includes(selection.scope) ||\n !Array.isArray(selection.ids) ||\n selection.ids.length === 0 ||\n selection.ids.length > 1000 ||\n !selection.ids.every((id) => text(id)) ||\n new Set(selection.ids).size !== selection.ids.length ||\n selection.sourceId !== source.id ||\n selection.sourceRevision !== source.revision ||\n selection.filterDescription !== context.filterDescription ||\n selection.capturedAt !== context.capturedAt\n )\n return invalid(\n \"The selection snapshot is empty, malformed or belongs to another source or filter.\",\n );\n if (input.operation !== \"export\" && !canWrite)\n return invalid(\"Write permission is required for this transfer.\");\n if (selection.scope === \"all-matching\" && !source.complete)\n return invalid(\"All matching requires a complete supplied inventory.\");\n if (input.operation === \"import\") {\n if (\n selection.scope !== \"selected\" ||\n !Array.isArray(input.items) ||\n input.items.length !== selection.ids.length ||\n !input.items.every(\n (item) =>\n object(item) && keys(item, [\"key\", \"values\"]) && text(item.key) && values(item.values),\n )\n )\n return invalid(\"Import candidates or row selection are invalid.\");\n if (\n new Set(input.items.map((item) => item.key)).size !== input.items.length ||\n !sameIds(\n input.items.map((item) => item.key),\n selection.ids,\n ) ||\n new Set(input.items.map((item) => item.values.id)).size !== input.items.length ||\n input.items.some((item) => source.records.some((current) => current.id === item.values.id))\n )\n return invalid(\n \"Import contains duplicate, existing or incorrectly selected equipment IDs.\",\n );\n } else {\n if (\n !Array.isArray(input.records) ||\n input.records.length !== selection.ids.length ||\n !input.records.every(record) ||\n new Set(input.records.map((item) => item.id)).size !== input.records.length ||\n !sameIds(\n input.records.map((item) => item.id),\n selection.ids,\n )\n )\n return invalid(\"Selected records do not match the reviewed IDs.\");\n if (\n input.records.some((item) => !source.records.some((current) => equalRecord(item, current)))\n )\n return invalid(\"A selected record changed or is no longer in the source. Review again.\");\n if (\n selection.scope === \"all-matching\" &&\n !sameIds(\n selection.ids,\n filterTransferRecords(source.records, context.filter).map((item) => item.id),\n )\n )\n return invalid(\n \"All matching must contain every matching record in the supplied inventory.\",\n );\n }\n return { ok: true, value: freezeCopy(input) };\n } catch {\n return invalid(\"Transfer request could not be read.\");\n }\n}\n\nexport function getTransferAuthorityError(\n request: TransferRequest,\n source: TransferSource,\n filter: TransferFilter,\n canWrite: boolean,\n): string | null {\n if (!validFilter(filter) || !sameFilter(request.context.filter, filter))\n return \"The filters changed. Review the selection again.\";\n const result = createTransferRequest(request, source, canWrite);\n return result.ok ? null : result.error;\n}\n\n/** Missing/unknown results are protocol errors, never implied successful or safe-to-retry items. */\nexport function parseTransferResult(\n input: unknown,\n request: TransferRequest,\n): TransferParseResult<TransferResult> {\n try {\n if (\n !object(input) ||\n input.requestId !== request.requestId ||\n input.operation !== request.operation\n )\n return invalid(\n \"The service returned an unrelated or malformed result; the remote outcome is unknown.\",\n );\n if (request.operation === \"export\") {\n if (input.status === \"complete\") {\n const artifact = input.artifact;\n if (\n !keys(input, [\"requestId\", \"operation\", \"status\", \"artifact\"]) ||\n !object(artifact) ||\n !keys(artifact, [\"filename\", \"mediaType\", \"content\"]) ||\n !text(artifact.filename, 160) ||\n !/^[a-z0-9][a-z0-9_.-]*\\.csv$/i.test(artifact.filename) ||\n artifact.mediaType !== \"text/csv\" ||\n typeof artifact.content !== \"string\" ||\n artifact.content.length > 8388608\n )\n return invalid(\"The service returned an invalid or oversized CSV export artifact.\");\n } else if (\n !keys(input, [\"requestId\", \"operation\", \"status\", \"message\"]) ||\n (input.status !== \"failed\" && input.status !== \"canceled\") ||\n !text(input.message, 2000)\n )\n return invalid(\"The service returned an invalid export outcome.\");\n return { ok: true, value: freezeCopy(input as unknown as TransferResult) };\n }\n if (\n !keys(input, [\"requestId\", \"operation\", \"status\", \"items\", \"message\", \"sourceRevision\"]) ||\n typeof input.status !== \"string\" ||\n ![\"complete\", \"partial\", \"failed\", \"canceled\"].includes(input.status) ||\n !text(input.message, 2000) ||\n !Array.isArray(input.items) ||\n input.items.length !== request.selection.ids.length\n )\n return invalid(\n \"The service did not report an explicit valid outcome for every requested item.\",\n );\n const seen = new Set<string>();\n let accepted = 0,\n canceled = 0;\n for (const item of input.items) {\n if (\n !object(item) ||\n !text(item.key) ||\n seen.has(item.key) ||\n !request.selection.ids.includes(item.key)\n )\n return invalid(\"Service outcomes contain duplicate or unknown item identities.\");\n seen.add(item.key);\n if (item.status === \"applied\") {\n if (\n !keys(item, [\"key\", \"status\", \"record\"]) ||\n !record(item.record) ||\n !item.record.inspectionRequired\n )\n return invalid(\"An applied item is missing a valid flagged equipment record.\");\n const expected =\n request.operation === \"import\"\n ? request.items.find((row) => row.key === item.key)?.values\n : request.records.find((row) => row.id === item.key);\n if (\n !expected ||\n item.record.id !== expected.id ||\n item.record.name !== expected.name ||\n item.record.kind !== expected.kind ||\n item.record.site !== expected.site ||\n (\"revision\" in expected && item.record.revision === expected.revision)\n )\n return invalid(\n \"An applied record does not match its requested item or has an unchanged revision.\",\n );\n accepted++;\n } else if (item.status === \"failed\") {\n if (\n !keys(item, [\"key\", \"status\", \"message\", \"retryable\"]) ||\n !text(item.message, 2000) ||\n typeof item.retryable !== \"boolean\"\n )\n return invalid(\"A failed item needs an explicit message and retryability.\");\n } else if (item.status === \"canceled\" || item.status === \"skipped\") {\n if (!keys(item, [\"key\", \"status\", \"message\"]) || !text(item.message, 2000))\n return invalid(\"An unprocessed item needs an explicit explanation.\");\n if (item.status === \"canceled\") canceled++;\n } else return invalid(\"The service returned an unknown item outcome.\");\n }\n if (\n (accepted > 0 &&\n (!text(input.sourceRevision) ||\n input.sourceRevision === request.context.source.revision)) ||\n (accepted === 0 && input.sourceRevision !== null)\n )\n return invalid(\n \"Applied records require a new source revision; unsuccessful runs cannot advance the source.\",\n );\n if (\n (input.status === \"complete\" && accepted !== input.items.length) ||\n (input.status === \"partial\" && (accepted === 0 || accepted === input.items.length)) ||\n (input.status === \"failed\" && accepted !== 0) ||\n (input.status === \"canceled\" && (canceled === 0 || accepted === input.items.length))\n )\n return invalid(\"The terminal summary does not agree with its item outcomes.\");\n return { ok: true, value: freezeCopy(input as unknown as TransferResult) };\n } catch {\n return invalid(\"The service result could not be read; the remote outcome is unknown.\");\n }\n}\n\nexport function parseTransferProgress(\n input: unknown,\n request: TransferRequest,\n): TransferParseResult<TransferProgress> {\n try {\n if (\n !object(input) ||\n !keys(input, [\"requestId\", \"state\", \"completed\", \"total\"], [\"message\"]) ||\n input.requestId !== request.requestId ||\n typeof input.state !== \"string\" ||\n ![\"queued\", \"running\", \"cancel-requested\"].includes(input.state) ||\n (input.completed !== null &&\n (typeof input.completed !== \"number\" ||\n !Number.isSafeInteger(input.completed) ||\n input.completed < 0)) ||\n (input.total !== null &&\n (typeof input.total !== \"number\" ||\n !Number.isSafeInteger(input.total) ||\n input.total < 0)) ||\n (typeof input.completed === \"number\" &&\n typeof input.total === \"number\" &&\n input.completed > input.total) ||\n (input.message !== undefined && !text(input.message, 2000))\n )\n return invalid(\"Job progress is invalid or belongs to another request.\");\n return { ok: true, value: freezeCopy(input as unknown as TransferProgress) };\n } catch {\n return invalid(\"Job progress could not be read.\");\n }\n}\n\nexport function parseTransferCancelResult(\n input: unknown,\n requestId: string,\n): TransferParseResult<TransferCancelResult> {\n try {\n if (!object(input) || input.requestId !== requestId)\n return invalid(\"The cancellation acknowledgment belongs to another request.\");\n const requested = input.status === \"requested\" && keys(input, [\"requestId\", \"status\"]);\n const rejected =\n input.status === \"rejected\" &&\n keys(input, [\"requestId\", \"status\", \"message\"]) &&\n text(input.message, 2000);\n if (!requested && !rejected)\n return invalid(\n \"The service did not acknowledge this cancellation request. The job outcome is still pending.\",\n );\n return { ok: true, value: freezeCopy(input as unknown as TransferCancelResult) };\n } catch {\n return invalid(\n \"The cancellation response could not be read. The job outcome is still pending.\",\n );\n }\n}\n\nexport function filterTransferRecords(\n records: readonly TransferRecord[],\n filter: TransferFilter,\n): TransferRecord[] {\n if (!validFilter(filter)) return [];\n const query = filter.query.toLocaleLowerCase(\"en-US\");\n return records.filter(\n (item) =>\n (filter.kind === \"all\" || filter.kind === item.kind) &&\n (filter.site === \"all\" || filter.site === item.site) &&\n (item.name.toLocaleLowerCase(\"en-US\").includes(query) ||\n item.id.toLocaleLowerCase(\"en-US\").includes(query)),\n );\n}\nexport function describeTransferFilter(filter: TransferFilter): string {\n if (!validFilter(filter)) return \"Invalid filters\";\n return (\n [\n filter.query ? `Name or ID contains “${filter.query}”` : \"\",\n filter.kind !== \"all\" ? `Kind: ${filter.kind}` : \"\",\n filter.site !== \"all\" ? `Site: ${filter.site}` : \"\",\n ]\n .filter(Boolean)\n .join(\"; \") || \"All equipment\"\n );\n}\nexport function getRetryableTransferKeys(result: TransferResult): string[] {\n return result.operation === \"export\"\n ? []\n : result.items\n .filter((item) => item.status === \"failed\" && item.retryable)\n .map((item) => item.key);\n}\n",
@@ -4904,7 +4904,7 @@
4904
4904
  "layout": "import { IncidentResponseDemo } from \"./patterns/incident-response\";\n\n<IncidentResponseDemo />",
4905
4905
  "files": {
4906
4906
  "patterns/incident-response/index.ts": "export { IncidentResponse } from \"./incident-response\";\nexport { IncidentResponseDemo } from \"./demo\";\nexport { createIncidentSnapshot } from \"./fixtures\";\nexport { applyIncidentAction, prioritizeIncidents, validateIncidentText } from \"./incident-state\";\nexport type {\n IncidentStatus,\n IncidentAction,\n IncidentCase,\n IncidentSnapshot,\n IncidentActionRequest,\n IncidentActionResult,\n IncidentResponseProps,\n} from \"./types\";\n",
4907
- "patterns/incident-response/incident-response.tsx": "\"use client\";\n\nimport { useId, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n Badge,\n Button,\n EntityPreview,\n Field,\n Input,\n Select,\n SelectOption,\n Textarea,\n} from \"@oprt/react\";\nimport { formatUtcInstant } from \"../operational-domain\";\nimport { prioritizeIncidents, validateIncidentText } from \"./incident-state\";\nimport type {\n IncidentAction,\n IncidentActionResult,\n IncidentCase,\n IncidentResponseProps,\n IncidentSnapshot,\n} from \"./types\";\n\nconst focus =\n \"outline-hidden focus-visible:border-oprt-border-focus focus-visible:shadow-oprt-focus forced-colors:focus-visible:outline-solid forced-colors:focus-visible:outline-2\";\nconst sectionTitle = \"m-0 text-oprt-base font-semibold uppercase tracking-oprt-caps\";\nconst statusLabel = { new: \"New\", acknowledged: \"Acknowledged\", resolved: \"Resolved\" };\nconst priorityLabel = { 1: \"High\", 2: \"Medium\", 3: \"Low\" };\nconst isoOf = (at: number) => new Date(at).toISOString();\n/** \"YYYY-MM-DD HH:MM:SS UTC\". Case times are minutes, so milliseconds are never shown. */\nconst stamp = (at: number) => formatUtcInstant(isoOf(at), { milliseconds: \"never\" });\n\n/** A time with its date and zone. A narrow column breaks between the date and the time, never inside either. */\nfunction Stamp({ at, className }: { at: number; className?: string }) {\n const text = stamp(at);\n return (\n <time dateTime={isoOf(at)} {...(className ? { className } : {})}>\n <span className=\"inline-block\">{text.slice(0, 10)}</span>{\" \"}\n <span className=\"inline-block\">{text.slice(11)}</span>\n </time>\n );\n}\n\n/**\n * The queue is tight, so a case opened on the day of the snapshot shows only \"HH:MM UTC\". An\n * earlier one carries its date above the time, so a handover across midnight is not mistaken for today.\n */\nfunction QueueTime({ at, today }: { at: number; today: string }) {\n const text = stamp(at);\n const date = text.slice(0, 10);\n return (\n <time dateTime={isoOf(at)} className=\"text-right font-mono text-oprt-text-dim\">\n {date !== today && <span className=\"block\">{date}</span>}\n <span className=\"inline-block\">{text.slice(11, 16)} UTC</span>\n </time>\n );\n}\n\ninterface CaseSession {\n editor: \"assign\" | \"note\" | \"resolve\" | null;\n revision: number;\n text: string;\n ownerId: string;\n fieldError: string | undefined;\n result: IncidentActionResult | null;\n pending: boolean;\n}\n\nfunction createCaseSession(item: IncidentCase, operatorId: string): CaseSession {\n return {\n editor: null,\n revision: item.revision,\n text: \"\",\n ownerId: item.ownerId ?? operatorId,\n fieldError: undefined,\n result: null,\n pending: false,\n };\n}\n\n/** Queue selection and canonical case data belong to the application. */\nexport function IncidentResponse(props: IncidentResponseProps) {\n // A replaced source remounts local filters and drafts, including in-flight feedback.\n return <IncidentWorkspace key={props.snapshot.id} {...props} />;\n}\n\nfunction IncidentWorkspace({\n snapshot,\n selectedId,\n onSelectedIdChange,\n onAction,\n}: IncidentResponseProps) {\n const [scope, setScope] = useState<\"open\" | \"all\">(\"open\");\n const [query, setQuery] = useState(\"\");\n const [sessions, setSessions] = useState(() => new Map<string, CaseSession>());\n const [selectionRequest, setSelectionRequest] = useState<string | null>(null);\n const detailsRef = useRef<HTMLElement>(null);\n const filterId = useId();\n const selected = snapshot.cases.find((item) => item.id === selectedId);\n const open = snapshot.cases.filter((item) => item.status !== \"resolved\").length;\n const visible = prioritizeIncidents(snapshot.cases).filter(\n (item) =>\n (scope === \"all\" || item.status !== \"resolved\") &&\n `${item.id} ${item.title} ${item.location}`\n .toLowerCase()\n .includes(query.trim().toLowerCase()),\n );\n const operator = snapshot.owners.find((owner) => owner.id === snapshot.operatorId);\n const today = isoOf(snapshot.observedAt).slice(0, 10);\n\n function updateSession(item: IncidentCase, patch: Partial<CaseSession>) {\n // Each callback retains its case identity, including after selection changes.\n setSessions((current) =>\n new Map(current).set(item.id, {\n ...(current.get(item.id) ?? createCaseSession(item, snapshot.operatorId)),\n ...patch,\n }),\n );\n }\n\n useLayoutEffect(() => {\n if (selectionRequest && selectedId === selectionRequest) detailsRef.current?.focus();\n if (selectionRequest) setSelectionRequest(null);\n }, [selectedId, selectionRequest]);\n\n return (\n <section\n aria-label=\"Incident response workspace\"\n className=\"not-prose w-full min-w-0 border border-oprt-border bg-oprt-surface-1 font-sans text-oprt-base text-oprt-text\"\n >\n <header className=\"flex flex-wrap items-start justify-between gap-2u border-b border-oprt-border px-3u py-2u\">\n <div className=\"min-w-0\">\n <h2 className=\"m-0 text-oprt-lg font-semibold normal-case tracking-normal\">\n {snapshot.title}\n </h2>\n <p className=\"m-0 mt-0.5u text-oprt-text-dim\">{snapshot.location}</p>\n </div>\n <div className=\"text-right text-oprt-text-dim\">\n <p className=\"m-0\">{operator?.name ?? \"Operator unavailable\"}</p>\n <p className=\"m-0 mt-0.5u font-mono\">\n Snapshot · <Stamp at={snapshot.observedAt} />\n </p>\n </div>\n </header>\n <div className=\"flex min-w-0 flex-wrap\">\n <section\n aria-label=\"Incident queue\"\n className=\"min-w-0 flex-[1_1_14rem] border-b border-oprt-border\"\n >\n <div className=\"flex flex-col gap-2u border-b border-oprt-border p-2u\">\n <div className=\"flex items-baseline justify-between gap-2u\">\n <h3 className={sectionTitle}>Incident queue</h3>\n <span className=\"font-mono text-oprt-text-dim\">{open} open</span>\n </div>\n <div role=\"group\" aria-label=\"Queue scope\" className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant={scope === \"open\" ? \"secondary\" : \"ghost\"}\n aria-pressed={scope === \"open\"}\n onClick={() => setScope(\"open\")}\n >\n Open cases\n </Button>\n <Button\n size=\"sm\"\n variant={scope === \"all\" ? \"secondary\" : \"ghost\"}\n aria-pressed={scope === \"all\"}\n onClick={() => setScope(\"all\")}\n >\n All cases · {snapshot.cases.length}\n </Button>\n </div>\n <label className=\"sr-only\" htmlFor={filterId}>\n Find a case\n </label>\n <Input\n id={filterId}\n placeholder=\"Find a case or location\"\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n />\n </div>\n <ol\n className=\"m-0 max-h-[min(40rem,70dvh)] list-none overflow-y-auto p-0\"\n aria-label=\"Cases by priority\"\n >\n {visible.map((item) => {\n const ownerName = item.ownerId\n ? (snapshot.owners.find((owner) => owner.id === item.ownerId)?.name ??\n \"Owner unavailable\")\n : \"Unassigned\";\n const isSelected = item.id === selectedId;\n return (\n <li key={item.id} className=\"border-b border-oprt-border last:border-b-0\">\n <button\n type=\"button\"\n aria-pressed={isSelected}\n // The name opens with the ID and title and then carries every other visible\n // part, so it holds what the button shows (WCAG 2.5.3) in the same order.\n aria-label={`${item.id} · ${item.title}, P${item.priority} · ${priorityLabel[item.priority]}, ${item.location}, ${statusLabel[item.status]} · ${ownerName}, opened ${stamp(item.openedAt)}`}\n onClick={() => {\n setSelectionRequest(item.id);\n onSelectedIdChange(item.id);\n }}\n // The selected case also gets a bar down its edge. The tint alone is about 1.1:1\n // against the panel, and a bar survives the focus ring, which is a box-shadow.\n className={`relative flex w-full min-w-0 flex-col gap-1u border border-transparent p-2u text-left text-oprt-base hover:bg-oprt-surface-2 ${focus} ${isSelected ? \"bg-oprt-surface-3 before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-oprt-accent forced-colors:before:bg-[Highlight]\" : \"bg-transparent\"}`}\n >\n <span className=\"flex w-full flex-wrap items-baseline justify-between gap-1u\">\n <span className=\"font-mono text-oprt-text-dim\">{item.id}</span>\n <Badge\n appearance=\"text\"\n variant={\n item.priority === 1 && item.status !== \"resolved\" ? \"warning\" : \"neutral\"\n }\n >\n P{item.priority} · {priorityLabel[item.priority]}\n </Badge>\n </span>\n <span className=\"text-oprt-md font-semibold normal-case leading-snug\">\n {item.title}\n </span>\n <span className=\"text-oprt-text-dim\">{item.location}</span>\n <span className=\"flex w-full flex-wrap items-center justify-between gap-1u\">\n <span className=\"text-oprt-text-dim\">\n {statusLabel[item.status]} · {ownerName}\n </span>\n <QueueTime at={item.openedAt} today={today} />\n </span>\n </button>\n </li>\n );\n })}\n </ol>\n {visible.length === 0 && (\n <p className=\"m-0 p-3u text-oprt-text-dim\">\n No cases match this queue. Change the filter or choose All cases.\n </p>\n )}\n {selected && !visible.some((item) => item.id === selectedId) && (\n <p className=\"m-0 border-t border-oprt-border p-2u text-oprt-text-dim\">\n The selected case is outside this queue.\n </p>\n )}\n <p className=\"m-0 border-t border-oprt-border p-2u text-oprt-text-dim\">\n Highest priority first · oldest report first\n </p>\n </section>\n <section\n ref={detailsRef}\n aria-label=\"Selected case\"\n tabIndex={-1}\n className={`min-w-0 flex-[2.4_1_25rem] border border-transparent border-l-oprt-border p-3u ${focus}`}\n >\n {selected ? (\n <CaseWorkspace\n key={selected.id}\n snapshot={snapshot}\n item={selected}\n session={\n sessions.get(selected.id) ?? createCaseSession(selected, snapshot.operatorId)\n }\n onSessionChange={(patch) => updateSession(selected, patch)}\n {...(onAction ? { onAction } : {})}\n />\n ) : (\n <div className=\"flex flex-col gap-1u\">\n <h3 className=\"m-0 text-oprt-md font-semibold\">\n {selectedId ? \"Case unavailable\" : \"Select a case\"}\n </h3>\n <p className=\"m-0 text-oprt-text-dim\">\n {selectedId\n ? \"This case is no longer in the supplied queue. Choose another case to continue.\"\n : \"Choose a case from the queue to review its evidence and next action.\"}\n </p>\n </div>\n )}\n </section>\n </div>\n </section>\n );\n}\n\nfunction CaseWorkspace({\n snapshot,\n item,\n session,\n onSessionChange,\n onAction,\n}: {\n snapshot: IncidentSnapshot;\n item: IncidentCase;\n session: CaseSession;\n onSessionChange: (patch: Partial<CaseSession>) => void;\n onAction?: IncidentResponseProps[\"onAction\"];\n}) {\n const { editor, revision, text, ownerId, fieldError, result, pending } = session;\n const actionRef = useRef<HTMLButtonElement>(null);\n const assignOpenerRef = useRef<HTMLButtonElement>(null);\n const noteOpenerRef = useRef<HTMLButtonElement>(null);\n const assignmentRef = useRef<HTMLButtonElement>(null);\n // The control that opened the editor, so closing it puts focus back where the operator was.\n const opener = useRef<\"primary\" | \"assign\" | \"note\">(\"primary\");\n const returnFocus = useRef(false);\n const owner = snapshot.owners.find((person) => person.id === item.ownerId);\n const actionsUnavailable = !onAction\n ? \"Case updates are unavailable in this workspace.\"\n : (item.unavailableReason ??\n (Object.values(item.permissions).some((value) => !value)\n ? \"Your access does not include all case actions.\"\n : undefined));\n const disabled = (action: IncidentAction[\"type\"]) =>\n pending || !onAction || !item.permissions[action] || result?.kind === \"conflict\";\n const primaryAction =\n item.status === \"new\" ? \"acknowledge\" : item.status === \"acknowledged\" ? \"resolve\" : \"note\";\n const primaryLabel =\n item.status === \"new\"\n ? \"Acknowledge case\"\n : item.status === \"acknowledged\"\n ? \"Resolve case\"\n : \"Add handover\";\n\n useLayoutEffect(() => {\n if (editor === \"assign\") assignmentRef.current?.focus();\n if (editor === null && returnFocus.current) {\n const target = { primary: actionRef, assign: assignOpenerRef, note: noteOpenerRef }[\n opener.current\n ].current;\n // A control the case no longer offers (a resolved case has no Assign owner) gives way to\n // the primary action.\n (target?.isConnected && !target.disabled ? target : actionRef.current)?.focus();\n returnFocus.current = false;\n }\n }, [editor, item.status]);\n\n function edit(next: \"assign\" | \"note\" | \"resolve\", from: \"primary\" | \"assign\" | \"note\") {\n opener.current = from;\n onSessionChange({ ...createCaseSession(item, snapshot.operatorId), editor: next });\n }\n\n async function act(action: IncidentAction, expectedRevision: number) {\n if (!onAction || pending) return;\n onSessionChange({ pending: true, result: null });\n try {\n const outcome = await onAction({ caseId: item.id, expectedRevision, action });\n if (outcome.kind === \"accepted\") {\n returnFocus.current = true;\n onSessionChange({ result: outcome, editor: null, text: \"\", fieldError: undefined });\n } else {\n onSessionChange({ result: outcome });\n }\n } catch {\n onSessionChange({\n result: {\n kind: \"rejected\",\n message:\n \"The action outcome could not be confirmed. Refresh the case from the service before retrying.\",\n },\n });\n } finally {\n onSessionChange({ pending: false });\n }\n }\n\n return (\n <div className=\"flex min-w-0 flex-col gap-3u\">\n <EntityPreview\n headingLevel={3}\n entity={{\n id: item.id,\n name: item.title,\n typeLabel: \"Incident case\",\n summary: item.summary,\n facts: [\n {\n id: \"status\",\n label: \"Status\",\n value: (\n <Badge\n appearance=\"text\"\n size=\"md\"\n variant={item.status === \"resolved\" ? \"success\" : \"neutral\"}\n >\n {statusLabel[item.status]}\n </Badge>\n ),\n },\n {\n id: \"priority\",\n label: \"Priority\",\n value: (\n <Badge\n appearance=\"text\"\n size=\"md\"\n variant={\n item.priority === 1 && item.status !== \"resolved\" ? \"warning\" : \"neutral\"\n }\n >\n P{item.priority} · {priorityLabel[item.priority]}\n </Badge>\n ),\n },\n {\n id: \"owner\",\n label: \"Owner\",\n value: owner?.name ?? (item.ownerId ? \"Owner unavailable\" : \"Unassigned\"),\n },\n { id: \"location\", label: \"Location\", value: item.location },\n {\n id: \"opened\",\n label: \"Opened\",\n value: <Stamp at={item.openedAt} />,\n mono: true,\n },\n { id: \"revision\", label: \"Revision\", value: `r${item.revision}`, mono: true },\n ],\n quality: { state: item.dataState, source: item.source, updatedAt: item.updatedAt },\n }}\n />\n\n <div className=\"flex flex-col gap-2u border-y border-oprt-border py-2u\">\n {editor === null ? (\n <div className=\"flex flex-wrap items-center gap-1u\">\n <Button\n ref={actionRef}\n size=\"sm\"\n disabled={disabled(primaryAction)}\n onClick={() => {\n if (primaryAction === \"acknowledge\")\n void act({ type: \"acknowledge\" }, item.revision);\n else edit(primaryAction, \"primary\");\n }}\n >\n {pending ? \"Submitting…\" : primaryLabel}\n </Button>\n {item.status !== \"resolved\" && (\n <Button\n ref={assignOpenerRef}\n size=\"sm\"\n variant=\"tertiary\"\n disabled={disabled(\"assign\")}\n onClick={() => edit(\"assign\", \"assign\")}\n >\n Assign owner\n </Button>\n )}\n {item.status !== \"resolved\" && (\n <Button\n ref={noteOpenerRef}\n size=\"sm\"\n variant=\"ghost\"\n disabled={disabled(\"note\")}\n onClick={() => edit(\"note\", \"note\")}\n >\n Add handover\n </Button>\n )}\n </div>\n ) : (\n <form\n noValidate\n className=\"flex min-w-0 flex-col gap-2u\"\n aria-label={\n editor === \"assign\"\n ? \"Assign case\"\n : editor === \"note\"\n ? \"Add handover\"\n : \"Resolve case\"\n }\n onSubmit={(event) => {\n event.preventDefault();\n if (editor === \"assign\") {\n void act({ type: \"assign\", ownerId }, revision);\n return;\n }\n const error = validateIncidentText(\n text,\n editor === \"note\" ? \"Handover note\" : \"Resolution reason\",\n );\n onSessionChange({ fieldError: error });\n if (!error)\n void act(\n editor === \"note\"\n ? { type: \"note\", note: text }\n : { type: \"resolve\", reason: text },\n revision,\n );\n }}\n >\n {editor === \"assign\" ? (\n <Field label=\"Case owner\" required>\n <Select\n ref={assignmentRef}\n value={ownerId}\n onValueChange={(ownerId) => onSessionChange({ ownerId })}\n disabled={pending}\n size=\"sm\"\n className=\"w-full\"\n required\n >\n {snapshot.owners.map((person) => (\n <SelectOption\n key={person.id}\n value={person.id}\n >{`${person.name} · ${person.role}`}</SelectOption>\n ))}\n </Select>\n </Field>\n ) : (\n <Field\n label={editor === \"note\" ? \"Handover note\" : \"Resolution reason\"}\n required\n help={\n editor === \"note\"\n ? `Record the current condition, outstanding check, and what the next ${snapshot.domain === \"military\" ? \"watch\" : \"shift\"} must follow up.`\n : \"Record what changed and how recovery was verified.\"\n }\n {...(fieldError ? { error: fieldError } : {})}\n >\n <Textarea\n autoFocus\n required\n rows={3}\n maxLength={1000}\n value={text}\n disabled={pending}\n onChange={(event) => {\n onSessionChange({ text: event.target.value, fieldError: undefined });\n }}\n />\n </Field>\n )}\n <div className=\"flex flex-wrap gap-1u\">\n <Button\n type=\"submit\"\n size=\"sm\"\n disabled={\n disabled(editor) ||\n result?.kind === \"conflict\" ||\n (editor === \"assign\" && ownerId === item.ownerId)\n }\n >\n {pending\n ? \"Submitting…\"\n : editor === \"assign\"\n ? \"Save assignment\"\n : editor === \"note\"\n ? \"Save handover\"\n : \"Confirm resolution\"}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={pending}\n onClick={() => {\n returnFocus.current = true;\n onSessionChange({ editor: null, result: null, text: \"\", fieldError: undefined });\n }}\n >\n Cancel\n </Button>\n </div>\n </form>\n )}\n {actionsUnavailable && <p className=\"m-0 text-oprt-text-dim\">{actionsUnavailable}</p>}\n {result && (\n <div className=\"flex flex-col gap-1u\">\n <p\n role={result.kind === \"accepted\" ? \"status\" : \"alert\"}\n className={`m-0 ${result.kind === \"accepted\" ? \"text-oprt-text-dim\" : \"text-oprt-error\"}`}\n >\n {result.message}\n </p>\n {result.kind === \"conflict\" && (\n <Button\n className=\"self-start\"\n size=\"sm\"\n variant=\"tertiary\"\n onClick={() => {\n onSessionChange({ revision: item.revision, result: null });\n }}\n >\n Review latest case\n </Button>\n )}\n </div>\n )}\n </div>\n\n {item.resolution && (\n <section aria-label=\"Resolution\" className=\"flex flex-col gap-1u\">\n <h4 className={sectionTitle}>Resolution</h4>\n <p className=\"m-0 leading-relaxed\">{item.resolution}</p>\n </section>\n )}\n\n <section aria-label=\"Case evidence\" className=\"flex flex-col gap-2u\">\n <h4 className={sectionTitle}>Evidence</h4>\n <dl className=\"m-0 flex min-w-0 flex-col gap-2u\">\n {item.evidence.map((entry) => (\n <div\n key={entry.id}\n className=\"grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-x-2u gap-y-0.5u\"\n >\n <dt className=\"font-medium\">{entry.label}</dt>\n <dd className=\"m-0 text-right font-mono\">{entry.value}</dd>\n <dd className=\"col-span-2 m-0 text-oprt-text-dim\">\n {entry.detail} <Stamp at={entry.at} className=\"font-mono\" />\n </dd>\n </div>\n ))}\n </dl>\n {item.evidence.length === 0 && (\n <p className=\"m-0 text-oprt-text-dim\">No evidence has been supplied for this case.</p>\n )}\n </section>\n\n <section\n aria-label=\"Case history\"\n className=\"flex flex-col gap-2u border-t border-oprt-border pt-3u\"\n >\n <div className=\"flex items-baseline justify-between gap-2u\">\n <h4 className={sectionTitle}>Case history</h4>\n <span className=\"text-oprt-text-dim\">Oldest first</span>\n </div>\n <ol className=\"m-0 flex list-none flex-col gap-3u p-0\">\n {[...item.history]\n .sort((a, b) => a.at - b.at)\n .map((event) => (\n <li key={event.id} className=\"flex min-w-0 flex-wrap gap-x-2u gap-y-0.5u\">\n <Stamp at={event.at} className=\"font-mono text-oprt-text-dim\" />\n <div className=\"min-w-0 flex-[1_1_12rem]\">\n <p className=\"m-0 font-semibold\">{event.label}</p>\n <p className=\"m-0 mt-0.5u [overflow-wrap:anywhere]\">{event.detail}</p>\n <p className=\"m-0 mt-0.5u text-oprt-text-dim\">{event.actor}</p>\n </div>\n </li>\n ))}\n </ol>\n {item.history.length === 0 && (\n <p className=\"m-0 text-oprt-text-dim\">No history has been supplied for this case.</p>\n )}\n </section>\n </div>\n );\n}\n",
4907
+ "patterns/incident-response/incident-response.tsx": "\"use client\";\n\nimport { useId, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n Badge,\n Button,\n EntityPreview,\n Field,\n Input,\n Select,\n SelectOption,\n Textarea,\n} from \"@oprt/react\";\nimport { formatUtcInstant } from \"../operational-domain\";\nimport { prioritizeIncidents, validateIncidentText } from \"./incident-state\";\nimport type {\n IncidentAction,\n IncidentActionResult,\n IncidentCase,\n IncidentResponseProps,\n IncidentSnapshot,\n} from \"./types\";\n\nconst focus =\n \"outline-hidden focus-visible:border-oprt-border-focus focus-visible:shadow-oprt-focus forced-colors:focus-visible:outline-solid forced-colors:focus-visible:outline-2\";\nconst sectionTitle = \"m-0 text-oprt-base font-semibold uppercase tracking-oprt-caps\";\nconst statusLabel = { new: \"New\", acknowledged: \"Acknowledged\", resolved: \"Resolved\" };\nconst priorityLabel = { 1: \"High\", 2: \"Medium\", 3: \"Low\" };\nconst isoOf = (at: number) => new Date(at).toISOString();\n/** \"YYYY-MM-DD HH:MM:SS UTC\". Case times are minutes, so milliseconds are never shown. */\nconst stamp = (at: number) => formatUtcInstant(isoOf(at), { milliseconds: \"never\" });\n\n/** A time with its date and zone. A narrow column breaks between the date and the time, never inside either. */\nfunction Stamp({ at, className }: { at: number; className?: string }) {\n const text = stamp(at);\n return (\n <time dateTime={isoOf(at)} {...(className ? { className } : {})}>\n <span className=\"inline-block\">{text.slice(0, 10)}</span>{\" \"}\n <span className=\"inline-block\">{text.slice(11)}</span>\n </time>\n );\n}\n\n/**\n * The queue is tight, so a case opened on the day of the snapshot shows only \"HH:MM UTC\". An\n * earlier one carries its date above the time, so a handover across midnight is not mistaken for today.\n */\n/** What QueueTime shows, as text: the date when it is not today, then the time. */\nfunction queueTimeText(at: number, today: string) {\n const text = stamp(at);\n const date = text.slice(0, 10);\n return `${date !== today ? `${date} ` : \"\"}${text.slice(11, 16)} UTC`;\n}\n\nfunction QueueTime({ at, today }: { at: number; today: string }) {\n const text = stamp(at);\n const date = text.slice(0, 10);\n return (\n <time dateTime={isoOf(at)} className=\"text-right font-mono text-oprt-text-dim\">\n {date !== today && <span className=\"block\">{date}</span>}\n <span className=\"inline-block\">{text.slice(11, 16)} UTC</span>\n </time>\n );\n}\n\ninterface CaseSession {\n editor: \"assign\" | \"note\" | \"resolve\" | null;\n revision: number;\n text: string;\n ownerId: string;\n fieldError: string | undefined;\n result: IncidentActionResult | null;\n pending: boolean;\n}\n\nfunction createCaseSession(item: IncidentCase, operatorId: string): CaseSession {\n return {\n editor: null,\n revision: item.revision,\n text: \"\",\n ownerId: item.ownerId ?? operatorId,\n fieldError: undefined,\n result: null,\n pending: false,\n };\n}\n\n/** Queue selection and canonical case data belong to the application. */\nexport function IncidentResponse(props: IncidentResponseProps) {\n // A replaced source remounts local filters and drafts, including in-flight feedback.\n return <IncidentWorkspace key={props.snapshot.id} {...props} />;\n}\n\nfunction IncidentWorkspace({\n snapshot,\n selectedId,\n onSelectedIdChange,\n onAction,\n}: IncidentResponseProps) {\n const [scope, setScope] = useState<\"open\" | \"all\">(\"open\");\n const [query, setQuery] = useState(\"\");\n const [sessions, setSessions] = useState(() => new Map<string, CaseSession>());\n const [selectionRequest, setSelectionRequest] = useState<string | null>(null);\n const detailsRef = useRef<HTMLElement>(null);\n const filterId = useId();\n const selected = snapshot.cases.find((item) => item.id === selectedId);\n const open = snapshot.cases.filter((item) => item.status !== \"resolved\").length;\n const visible = prioritizeIncidents(snapshot.cases).filter(\n (item) =>\n (scope === \"all\" || item.status !== \"resolved\") &&\n `${item.id} ${item.title} ${item.location}`\n .toLowerCase()\n .includes(query.trim().toLowerCase()),\n );\n const operator = snapshot.owners.find((owner) => owner.id === snapshot.operatorId);\n const today = isoOf(snapshot.observedAt).slice(0, 10);\n\n function updateSession(item: IncidentCase, patch: Partial<CaseSession>) {\n // Each callback retains its case identity, including after selection changes.\n setSessions((current) =>\n new Map(current).set(item.id, {\n ...(current.get(item.id) ?? createCaseSession(item, snapshot.operatorId)),\n ...patch,\n }),\n );\n }\n\n useLayoutEffect(() => {\n if (selectionRequest && selectedId === selectionRequest) detailsRef.current?.focus();\n if (selectionRequest) setSelectionRequest(null);\n }, [selectedId, selectionRequest]);\n\n return (\n <section\n aria-label=\"Incident response workspace\"\n className=\"not-prose w-full min-w-0 border border-oprt-border bg-oprt-surface-1 font-sans text-oprt-base text-oprt-text\"\n >\n <header className=\"flex flex-wrap items-start justify-between gap-2u border-b border-oprt-border px-3u py-2u\">\n <div className=\"min-w-0\">\n <h2 className=\"m-0 text-oprt-lg font-semibold normal-case tracking-normal\">\n {snapshot.title}\n </h2>\n <p className=\"m-0 mt-0.5u text-oprt-text-dim\">{snapshot.location}</p>\n </div>\n <div className=\"text-right text-oprt-text-dim\">\n <p className=\"m-0\">{operator?.name ?? \"Operator unavailable\"}</p>\n <p className=\"m-0 mt-0.5u font-mono\">\n Snapshot · <Stamp at={snapshot.observedAt} />\n </p>\n </div>\n </header>\n <div className=\"flex min-w-0 flex-wrap\">\n <section\n aria-label=\"Incident queue\"\n className=\"min-w-0 flex-[1_1_14rem] border-b border-oprt-border\"\n >\n <div className=\"flex flex-col gap-2u border-b border-oprt-border p-2u\">\n <div className=\"flex items-baseline justify-between gap-2u\">\n <h3 className={sectionTitle}>Incident queue</h3>\n <span className=\"font-mono text-oprt-text-dim\">{open} open</span>\n </div>\n <div role=\"group\" aria-label=\"Queue scope\" className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant={scope === \"open\" ? \"secondary\" : \"ghost\"}\n aria-pressed={scope === \"open\"}\n onClick={() => setScope(\"open\")}\n >\n Open cases\n </Button>\n <Button\n size=\"sm\"\n variant={scope === \"all\" ? \"secondary\" : \"ghost\"}\n aria-pressed={scope === \"all\"}\n onClick={() => setScope(\"all\")}\n >\n All cases · {snapshot.cases.length}\n </Button>\n </div>\n <label className=\"sr-only\" htmlFor={filterId}>\n Search cases or locations\n </label>\n <Input\n id={filterId}\n placeholder=\"Search cases or locations\"\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n />\n </div>\n <ol\n className=\"m-0 max-h-[min(40rem,70dvh)] list-none overflow-y-auto p-0\"\n aria-label=\"Cases by priority\"\n >\n {visible.map((item) => {\n const ownerName = item.ownerId\n ? (snapshot.owners.find((owner) => owner.id === item.ownerId)?.name ??\n \"Owner unavailable\")\n : \"Unassigned\";\n const isSelected = item.id === selectedId;\n return (\n <li key={item.id} className=\"border-b border-oprt-border last:border-b-0\">\n <button\n type=\"button\"\n aria-pressed={isSelected}\n // The name carries every visible part in the order the button shows it (WCAG\n // 2.5.3): the ID, the priority, the title, the place, the status and owner and\n // the time. The full date and time follow.\n aria-label={`${item.id} · P${item.priority} · ${priorityLabel[item.priority]}, ${item.title}, ${item.location}, ${statusLabel[item.status]} · ${ownerName}, ${queueTimeText(item.openedAt, today)}, opened ${stamp(item.openedAt)}`}\n onClick={() => {\n setSelectionRequest(item.id);\n onSelectedIdChange(item.id);\n }}\n // The selected case also gets a bar down its edge. The tint alone is about 1.1:1\n // against the panel, and a bar survives the focus ring, which is a box-shadow.\n className={`relative flex w-full min-w-0 flex-col gap-1u border border-transparent p-2u text-left text-oprt-base hover:bg-oprt-surface-2 ${focus} ${isSelected ? \"bg-oprt-surface-3 before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-oprt-accent forced-colors:before:bg-[Highlight]\" : \"bg-transparent\"}`}\n >\n <span className=\"flex w-full flex-wrap items-baseline justify-between gap-1u\">\n <span className=\"font-mono text-oprt-text-dim\">{item.id}</span>\n <Badge\n appearance=\"text\"\n variant={\n item.priority === 1 && item.status !== \"resolved\" ? \"warning\" : \"neutral\"\n }\n >\n P{item.priority} · {priorityLabel[item.priority]}\n </Badge>\n </span>\n <span className=\"text-oprt-md font-semibold normal-case leading-snug\">\n {item.title}\n </span>\n <span className=\"text-oprt-text-dim\">{item.location}</span>\n <span className=\"flex w-full flex-wrap items-center justify-between gap-1u\">\n <span className=\"text-oprt-text-dim\">\n {statusLabel[item.status]} · {ownerName}\n </span>\n <QueueTime at={item.openedAt} today={today} />\n </span>\n </button>\n </li>\n );\n })}\n </ol>\n {visible.length === 0 && (\n <p className=\"m-0 p-3u text-oprt-text-dim\">\n No cases match this queue. Change the filter or choose All cases.\n </p>\n )}\n {selected && !visible.some((item) => item.id === selectedId) && (\n <p className=\"m-0 border-t border-oprt-border p-2u text-oprt-text-dim\">\n The selected case is outside this queue.\n </p>\n )}\n <p className=\"m-0 border-t border-oprt-border p-2u text-oprt-text-dim\">\n Highest priority first · oldest report first\n </p>\n </section>\n <section\n ref={detailsRef}\n aria-label=\"Selected case\"\n tabIndex={-1}\n className={`min-w-0 flex-[2.4_1_25rem] border border-transparent border-l-oprt-border p-3u ${focus}`}\n >\n {selected ? (\n <CaseWorkspace\n key={selected.id}\n snapshot={snapshot}\n item={selected}\n session={\n sessions.get(selected.id) ?? createCaseSession(selected, snapshot.operatorId)\n }\n onSessionChange={(patch) => updateSession(selected, patch)}\n {...(onAction ? { onAction } : {})}\n />\n ) : (\n <div className=\"flex flex-col gap-1u\">\n <h3 className=\"m-0 text-oprt-md font-semibold\">\n {selectedId ? \"Case unavailable\" : \"Select a case\"}\n </h3>\n <p className=\"m-0 text-oprt-text-dim\">\n {selectedId\n ? \"This case is no longer in the supplied queue. Choose another case to continue.\"\n : \"Choose a case from the queue to review its evidence and next action.\"}\n </p>\n </div>\n )}\n </section>\n </div>\n </section>\n );\n}\n\nfunction CaseWorkspace({\n snapshot,\n item,\n session,\n onSessionChange,\n onAction,\n}: {\n snapshot: IncidentSnapshot;\n item: IncidentCase;\n session: CaseSession;\n onSessionChange: (patch: Partial<CaseSession>) => void;\n onAction?: IncidentResponseProps[\"onAction\"];\n}) {\n const { editor, revision, text, ownerId, fieldError, result, pending } = session;\n const actionRef = useRef<HTMLButtonElement>(null);\n const assignOpenerRef = useRef<HTMLButtonElement>(null);\n const noteOpenerRef = useRef<HTMLButtonElement>(null);\n const assignmentRef = useRef<HTMLButtonElement>(null);\n // The control that opened the editor, so closing it puts focus back where the operator was.\n const opener = useRef<\"primary\" | \"assign\" | \"note\">(\"primary\");\n const returnFocus = useRef(false);\n const owner = snapshot.owners.find((person) => person.id === item.ownerId);\n const actionsUnavailable = !onAction\n ? \"Case updates are unavailable in this workspace.\"\n : (item.unavailableReason ??\n (Object.values(item.permissions).some((value) => !value)\n ? \"Your access does not include all case actions.\"\n : undefined));\n const disabled = (action: IncidentAction[\"type\"]) =>\n pending || !onAction || !item.permissions[action] || result?.kind === \"conflict\";\n const primaryAction =\n item.status === \"new\" ? \"acknowledge\" : item.status === \"acknowledged\" ? \"resolve\" : \"note\";\n const primaryLabel =\n item.status === \"new\"\n ? \"Acknowledge case\"\n : item.status === \"acknowledged\"\n ? \"Resolve case\"\n : \"Add handover\";\n\n useLayoutEffect(() => {\n if (editor === \"assign\") assignmentRef.current?.focus();\n if (editor === null && returnFocus.current) {\n const target = { primary: actionRef, assign: assignOpenerRef, note: noteOpenerRef }[\n opener.current\n ].current;\n // A control the case no longer offers (a resolved case has no Assign owner) gives way to\n // the primary action.\n (target?.isConnected && !target.disabled ? target : actionRef.current)?.focus();\n returnFocus.current = false;\n }\n }, [editor, item.status]);\n\n function edit(next: \"assign\" | \"note\" | \"resolve\", from: \"primary\" | \"assign\" | \"note\") {\n opener.current = from;\n onSessionChange({ ...createCaseSession(item, snapshot.operatorId), editor: next });\n }\n\n async function act(action: IncidentAction, expectedRevision: number) {\n if (!onAction || pending) return;\n onSessionChange({ pending: true, result: null });\n try {\n const outcome = await onAction({ caseId: item.id, expectedRevision, action });\n if (outcome.kind === \"accepted\") {\n returnFocus.current = true;\n onSessionChange({ result: outcome, editor: null, text: \"\", fieldError: undefined });\n } else {\n onSessionChange({ result: outcome });\n }\n } catch {\n onSessionChange({\n result: {\n kind: \"rejected\",\n message:\n \"The action outcome could not be confirmed. Refresh the case from the service before retrying.\",\n },\n });\n } finally {\n onSessionChange({ pending: false });\n }\n }\n\n return (\n <div className=\"flex min-w-0 flex-col gap-3u\">\n <EntityPreview\n headingLevel={3}\n entity={{\n id: item.id,\n name: item.title,\n typeLabel: \"Incident case\",\n summary: item.summary,\n facts: [\n {\n id: \"status\",\n label: \"Status\",\n value: (\n <Badge\n appearance=\"text\"\n size=\"md\"\n variant={item.status === \"resolved\" ? \"success\" : \"neutral\"}\n >\n {statusLabel[item.status]}\n </Badge>\n ),\n },\n {\n id: \"priority\",\n label: \"Priority\",\n value: (\n <Badge\n appearance=\"text\"\n size=\"md\"\n variant={\n item.priority === 1 && item.status !== \"resolved\" ? \"warning\" : \"neutral\"\n }\n >\n P{item.priority} · {priorityLabel[item.priority]}\n </Badge>\n ),\n },\n {\n id: \"owner\",\n label: \"Owner\",\n value: owner?.name ?? (item.ownerId ? \"Owner unavailable\" : \"Unassigned\"),\n },\n { id: \"location\", label: \"Location\", value: item.location },\n {\n id: \"opened\",\n label: \"Opened\",\n value: <Stamp at={item.openedAt} />,\n mono: true,\n },\n { id: \"revision\", label: \"Revision\", value: `r${item.revision}`, mono: true },\n ],\n quality: { state: item.dataState, source: item.source, updatedAt: item.updatedAt },\n }}\n />\n\n <div className=\"flex flex-col gap-2u border-y border-oprt-border py-2u\">\n {editor === null ? (\n <div className=\"flex flex-wrap items-center gap-1u\">\n <Button\n ref={actionRef}\n size=\"sm\"\n disabled={disabled(primaryAction)}\n onClick={() => {\n if (primaryAction === \"acknowledge\")\n void act({ type: \"acknowledge\" }, item.revision);\n else edit(primaryAction, \"primary\");\n }}\n >\n {pending ? \"Submitting…\" : primaryLabel}\n </Button>\n {item.status !== \"resolved\" && (\n <Button\n ref={assignOpenerRef}\n size=\"sm\"\n variant=\"tertiary\"\n disabled={disabled(\"assign\")}\n onClick={() => edit(\"assign\", \"assign\")}\n >\n Assign owner\n </Button>\n )}\n {item.status !== \"resolved\" && (\n <Button\n ref={noteOpenerRef}\n size=\"sm\"\n variant=\"ghost\"\n disabled={disabled(\"note\")}\n onClick={() => edit(\"note\", \"note\")}\n >\n Add handover\n </Button>\n )}\n </div>\n ) : (\n <form\n noValidate\n className=\"flex min-w-0 flex-col gap-2u\"\n aria-label={\n editor === \"assign\"\n ? \"Assign case\"\n : editor === \"note\"\n ? \"Add handover\"\n : \"Resolve case\"\n }\n onSubmit={(event) => {\n event.preventDefault();\n if (editor === \"assign\") {\n void act({ type: \"assign\", ownerId }, revision);\n return;\n }\n const error = validateIncidentText(\n text,\n editor === \"note\" ? \"Handover note\" : \"Resolution reason\",\n );\n onSessionChange({ fieldError: error });\n if (!error)\n void act(\n editor === \"note\"\n ? { type: \"note\", note: text }\n : { type: \"resolve\", reason: text },\n revision,\n );\n }}\n >\n {editor === \"assign\" ? (\n <Field label=\"Case owner\" required>\n <Select\n ref={assignmentRef}\n value={ownerId}\n onValueChange={(ownerId) => onSessionChange({ ownerId })}\n disabled={pending}\n size=\"sm\"\n className=\"w-full\"\n required\n >\n {snapshot.owners.map((person) => (\n <SelectOption\n key={person.id}\n value={person.id}\n >{`${person.name} · ${person.role}`}</SelectOption>\n ))}\n </Select>\n </Field>\n ) : (\n <Field\n label={editor === \"note\" ? \"Handover note\" : \"Resolution reason\"}\n required\n help={\n editor === \"note\"\n ? `Record the current condition, outstanding check, and what the next ${snapshot.domain === \"military\" ? \"watch\" : \"shift\"} must follow up.`\n : \"Record what changed and how recovery was verified.\"\n }\n {...(fieldError ? { error: fieldError } : {})}\n >\n <Textarea\n autoFocus\n required\n rows={3}\n maxLength={1000}\n value={text}\n disabled={pending}\n onChange={(event) => {\n onSessionChange({ text: event.target.value, fieldError: undefined });\n }}\n />\n </Field>\n )}\n <div className=\"flex flex-wrap gap-1u\">\n <Button\n type=\"submit\"\n size=\"sm\"\n disabled={\n disabled(editor) ||\n result?.kind === \"conflict\" ||\n (editor === \"assign\" && ownerId === item.ownerId)\n }\n >\n {pending\n ? \"Submitting…\"\n : editor === \"assign\"\n ? \"Save assignment\"\n : editor === \"note\"\n ? \"Save handover\"\n : \"Confirm resolution\"}\n </Button>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={pending}\n onClick={() => {\n returnFocus.current = true;\n onSessionChange({ editor: null, result: null, text: \"\", fieldError: undefined });\n }}\n >\n Cancel\n </Button>\n </div>\n </form>\n )}\n {actionsUnavailable && <p className=\"m-0 text-oprt-text-dim\">{actionsUnavailable}</p>}\n {/* The status stays in the page, empty, and the outcome is written into it: a screen\n reader speaks text that arrives in a status element it already knows, and said nothing\n for one that was inserted with its text (measured with NVDA in Chrome). A refusal is a\n separate alert, which is spoken as it appears. */}\n <p\n role=\"status\"\n className={result?.kind === \"accepted\" ? \"m-0 text-oprt-text-dim\" : \"sr-only\"}\n >\n {result?.kind === \"accepted\" ? result.message : \"\"}\n </p>\n {result && result.kind !== \"accepted\" && (\n <div className=\"flex flex-col gap-1u\">\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n {result.message}\n </p>\n {result.kind === \"conflict\" && (\n <Button\n className=\"self-start\"\n size=\"sm\"\n variant=\"tertiary\"\n onClick={() => {\n onSessionChange({ revision: item.revision, result: null });\n }}\n >\n Review latest case\n </Button>\n )}\n </div>\n )}\n </div>\n\n {item.resolution && (\n <section aria-label=\"Resolution\" className=\"flex flex-col gap-1u\">\n <h4 className={sectionTitle}>Resolution</h4>\n <p className=\"m-0 leading-relaxed\">{item.resolution}</p>\n </section>\n )}\n\n <section aria-label=\"Case evidence\" className=\"flex flex-col gap-2u\">\n <h4 className={sectionTitle}>Evidence</h4>\n <dl className=\"m-0 flex min-w-0 flex-col gap-2u\">\n {item.evidence.map((entry) => (\n <div\n key={entry.id}\n className=\"grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-x-2u gap-y-0.5u\"\n >\n <dt className=\"font-medium\">{entry.label}</dt>\n <dd className=\"m-0 text-right font-mono\">{entry.value}</dd>\n <dd className=\"col-span-2 m-0 text-oprt-text-dim\">\n {entry.detail} <Stamp at={entry.at} className=\"font-mono\" />\n </dd>\n </div>\n ))}\n </dl>\n {item.evidence.length === 0 && (\n <p className=\"m-0 text-oprt-text-dim\">No evidence has been supplied for this case.</p>\n )}\n </section>\n\n <section\n aria-label=\"Case history\"\n className=\"flex flex-col gap-2u border-t border-oprt-border pt-3u\"\n >\n <div className=\"flex items-baseline justify-between gap-2u\">\n <h4 className={sectionTitle}>Case history</h4>\n <span className=\"text-oprt-text-dim\">Oldest first</span>\n </div>\n <ol className=\"m-0 flex list-none flex-col gap-3u p-0\">\n {[...item.history]\n .sort((a, b) => a.at - b.at)\n .map((event) => (\n <li key={event.id} className=\"flex min-w-0 flex-wrap gap-x-2u gap-y-0.5u\">\n <Stamp at={event.at} className=\"font-mono text-oprt-text-dim\" />\n <div className=\"min-w-0 flex-[1_1_12rem]\">\n <p className=\"m-0 font-semibold\">{event.label}</p>\n <p className=\"m-0 mt-0.5u [overflow-wrap:anywhere]\">{event.detail}</p>\n <p className=\"m-0 mt-0.5u text-oprt-text-dim\">{event.actor}</p>\n </div>\n </li>\n ))}\n </ol>\n {item.history.length === 0 && (\n <p className=\"m-0 text-oprt-text-dim\">No history has been supplied for this case.</p>\n )}\n </section>\n </div>\n );\n}\n",
4908
4908
  "patterns/operational-domain.ts": "/** Fixture context used by reusable application recipes. */\nexport type OperationalDomain = \"military\" | \"industrial\";\n\nexport interface UtcInstantOptions {\n /**\n * Whether the milliseconds are shown. \"auto\" (the default) shows them only when the supplied\n * instant carries a non-zero fraction, because sub-second timing matters only where it exists.\n */\n milliseconds?: \"auto\" | \"always\" | \"never\";\n}\n\nconst INSTANT =\n /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,3}))?(Z|[+-]\\d{2}:\\d{2})$/;\n\n/**\n * Reads an ISO 8601 instant as \"YYYY-MM-DD HH:MM:SS UTC\", with \".mmm\" after the seconds where the\n * options ask for it. Text that is not a real instant (an invented date such as 30 February, or\n * free text) is returned as supplied rather than reinterpreted. It never uses Intl, so the server\n * and every browser print the same characters.\n */\nexport function formatUtcInstant(value: string, options: UtcInstantOptions = {}): string {\n const parts = INSTANT.exec(value);\n if (!parts) return value;\n const [year, month, day, hour, minute, second] = parts.slice(1, 7).map(Number) as [\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n const calendar = new Date(Date.UTC(year, month - 1, day));\n // Date rolls an invented day into the next month; compare it back to what was written.\n if (\n calendar.getUTCFullYear() !== year ||\n calendar.getUTCMonth() !== month - 1 ||\n calendar.getUTCDate() !== day ||\n hour > 23 ||\n minute > 59 ||\n second > 59\n )\n return value;\n const fraction = (parts[7] ?? \"\").padEnd(3, \"0\");\n const parsed = new Date(`${value.slice(0, 19)}.${fraction}${parts[8]}`);\n if (Number.isNaN(parsed.getTime())) return value;\n const { milliseconds = \"auto\" } = options;\n const showFraction = milliseconds === \"always\" || (milliseconds === \"auto\" && fraction !== \"000\");\n const iso = parsed.toISOString();\n return `${iso.slice(0, 10)} ${iso.slice(11, showFraction ? 23 : 19)} UTC`;\n}\n\n/**\n * Reads epoch milliseconds as \"YYYY-MM-DD HH:MM:SS UTC\", with the same options as\n * formatUtcInstant. A value that is not a real time reads \"Unknown time\" rather than throwing.\n */\nexport function formatUtcEpoch(ms: number, options: UtcInstantOptions = {}): string {\n const date = new Date(ms);\n if (!Number.isFinite(ms) || Number.isNaN(date.getTime())) return \"Unknown time\";\n return formatUtcInstant(date.toISOString(), options);\n}\n",
4909
4909
  "patterns/incident-response/incident-state.ts": "import type {\n IncidentActionRequest,\n IncidentActionResult,\n IncidentCase,\n IncidentSnapshot,\n} from \"./types\";\n\nexport function prioritizeIncidents(cases: readonly IncidentCase[]): IncidentCase[] {\n return [...cases].sort(\n (a, b) =>\n Number(a.status === \"resolved\") - Number(b.status === \"resolved\") ||\n a.priority - b.priority ||\n a.openedAt - b.openedAt,\n );\n}\n\nexport function validateIncidentText(value: string, label: string): string | undefined {\n if (value.trim().length < 12) return `Add a ${label.toLowerCase()} of at least 12 characters.`;\n if (value.trim().length > 1000) return `${label} must be 1,000 characters or fewer.`;\n return undefined;\n}\n\n/** Local reference host. Services must enforce these rules transactionally at their own boundary. */\nexport function applyIncidentAction(\n snapshot: IncidentSnapshot,\n request: IncidentActionRequest,\n at: number,\n): { snapshot: IncidentSnapshot; result: IncidentActionResult } {\n const refuse = (kind: \"rejected\" | \"conflict\", message: string) => ({\n snapshot,\n result: { kind, message },\n });\n const current = snapshot.cases.find((item) => item.id === request.caseId);\n if (!current) return refuse(\"rejected\", \"This case is no longer available. Select another case.\");\n if (current.revision !== request.expectedRevision)\n return refuse(\n \"conflict\",\n \"This case changed while you were working. Review the latest details before retrying.\",\n );\n if (!current.permissions[request.action.type])\n return refuse(\n \"rejected\",\n current.unavailableReason ?? \"You do not have permission for this action.\",\n );\n if (!Number.isFinite(at) || !Number.isFinite(new Date(at).getTime()) || at < current.updatedAt)\n return refuse(\n \"rejected\",\n \"The action time precedes the latest case update. Refresh the source time.\",\n );\n const actor = snapshot.owners.find((owner) => owner.id === snapshot.operatorId);\n if (!actor)\n return refuse(\n \"rejected\",\n \"Your operator identity is unavailable. Reconnect before updating this case.\",\n );\n if (current.status === \"resolved\" && request.action.type !== \"note\")\n return refuse(\"rejected\", \"This case is already resolved. You can still add a handover note.\");\n\n const next = { ...current, revision: current.revision + 1, updatedAt: at };\n const action = request.action;\n let label = \"\";\n let detail = \"\";\n switch (action.type) {\n case \"acknowledge\":\n if (current.status !== \"new\")\n return refuse(\"rejected\", \"This case has already been acknowledged.\");\n next.status = \"acknowledged\";\n next.ownerId = current.ownerId ?? actor.id;\n label = \"Case acknowledged\";\n detail = `${actor.name} accepted the case for review.`;\n break;\n case \"assign\": {\n const owner = snapshot.owners.find((item) => item.id === action.ownerId);\n if (!owner)\n return refuse(\"rejected\", \"That owner is no longer available. Choose another owner.\");\n if (current.ownerId === owner.id)\n return refuse(\"rejected\", \"This case is already assigned to that owner.\");\n next.ownerId = owner.id;\n label = \"Owner assigned\";\n detail = `${owner.name} · ${owner.role}`;\n break;\n }\n case \"resolve\": {\n if (current.status !== \"acknowledged\")\n return refuse(\"rejected\", \"Acknowledge this case before resolving it.\");\n const error = validateIncidentText(action.reason, \"Resolution reason\");\n if (error) return refuse(\"rejected\", error);\n next.status = \"resolved\";\n next.resolution = action.reason.trim();\n label = \"Case resolved\";\n detail = next.resolution;\n break;\n }\n case \"note\": {\n const error = validateIncidentText(action.note, \"Handover note\");\n if (error) return refuse(\"rejected\", error);\n label = \"Handover note\";\n detail = action.note.trim();\n break;\n }\n }\n next.history = [\n ...current.history,\n { id: `${current.id}-r${next.revision}`, label, detail, at, actor: actor.name },\n ];\n return {\n snapshot: {\n ...snapshot,\n observedAt: Math.max(snapshot.observedAt, at),\n cases: snapshot.cases.map((item) => (item.id === next.id ? next : item)),\n },\n result: { kind: \"accepted\", message: `${label}. ${current.id} updated.` },\n };\n}\n",
4910
4910
  "patterns/incident-response/types.ts": "import type { OperationalState } from \"@oprt/react\";\nimport type { OperationalDomain } from \"../operational-domain\";\n\nexport type IncidentStatus = \"new\" | \"acknowledged\" | \"resolved\";\nexport type IncidentAction =\n | { type: \"acknowledge\" }\n | { type: \"assign\"; ownerId: string }\n | { type: \"resolve\"; reason: string }\n | { type: \"note\"; note: string };\n\nexport interface IncidentCase {\n id: string;\n revision: number;\n title: string;\n summary: string;\n priority: 1 | 2 | 3;\n status: IncidentStatus;\n ownerId: string | null;\n location: string;\n openedAt: number;\n updatedAt: number;\n source: string;\n dataState: OperationalState;\n evidence: readonly { id: string; label: string; value: string; detail: string; at: number }[];\n history: readonly { id: string; label: string; detail: string; at: number; actor: string }[];\n permissions: Record<IncidentAction[\"type\"], boolean>;\n unavailableReason?: string;\n resolution?: string;\n}\n\nexport interface IncidentSnapshot {\n /** Changes when the application replaces the entire workspace source. */\n id: string;\n domain: OperationalDomain;\n title: string;\n location: string;\n observedAt: number;\n operatorId: string;\n owners: readonly { id: string; name: string; role: string }[];\n cases: readonly IncidentCase[];\n}\n\nexport interface IncidentActionRequest {\n caseId: string;\n expectedRevision: number;\n action: IncidentAction;\n}\n\nexport type IncidentActionResult = {\n kind: \"accepted\" | \"rejected\" | \"conflict\";\n message: string;\n};\n\nexport interface IncidentResponseProps {\n snapshot: IncidentSnapshot;\n selectedId: string | null;\n onSelectedIdChange: (id: string) => void;\n /** Publish the canonical snapshot separately; this view never applies optimistic case changes. */\n onAction?: (request: IncidentActionRequest) => Promise<IncidentActionResult>;\n}\n",
@@ -4931,7 +4931,7 @@
4931
4931
  "layout": "import { MaintenancePlanningDemo } from \"./patterns/planning-scheduling\";\n\n<MaintenancePlanningDemo />",
4932
4932
  "files": {
4933
4933
  "patterns/planning-scheduling/index.ts": "export {\n PlanningScheduling,\n PlanningScheduling as MaintenancePlanning,\n type PlanningSchedulingProps,\n type PlanningSchedulingProps as MaintenancePlanningProps,\n} from \"./planning-scheduling\";\nexport { MaintenancePlanningDemo, PlanningSchedulingDemo } from \"./demo\";\nexport { createScheduleFixture } from \"./fixtures\";\nexport {\n conflictsFor,\n layoutLane,\n parseUtcInput,\n utcInput,\n utcTime,\n utcDate,\n utcWindow,\n validateChange,\n} from \"./schedule-state\";\nexport type {\n ScheduleResource,\n ScheduleJob,\n ScheduleSnapshot,\n ScheduleChange,\n ScheduleApplyRequest,\n ScheduleApplyResult,\n ScheduleIssue,\n} from \"./schedule-state\";\n",
4934
- "patterns/planning-scheduling/planning-scheduling.tsx": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n Badge,\n Button,\n EntityPreview,\n Field,\n Input,\n Inspector,\n Select,\n SelectOption,\n} from \"@oprt/react\";\nimport {\n conflictsFor,\n layoutLane,\n parseUtcInput,\n utcInput,\n utcTime,\n utcWindow,\n validateChange,\n type ScheduleApplyRequest,\n type ScheduleApplyResult,\n type ScheduleChange,\n type ScheduleIssue,\n type ScheduleJob,\n type ScheduleSnapshot,\n} from \"./schedule-state\";\n\nexport type PlanningSchedulingProps = {\n snapshot: ScheduleSnapshot;\n initialSelectedJobId?: string;\n onApply?: (request: ScheduleApplyRequest) => Promise<ScheduleApplyResult>;\n onAccepted?: (snapshot: ScheduleSnapshot) => void;\n acceptanceMessage?: string;\n};\ntype Draft = {\n scheduleId: string;\n jobId: string;\n revision: string;\n resourceId: string;\n start: string;\n end: string;\n};\nconst focusStyle = \"outline-hidden focus-visible:shadow-oprt-focus focus-visible:z-10\";\n/** The selected work order is shown by a 3 px bar (Highlight in forced colors) as well as a tint. */\nconst selectedBar =\n \"before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-oprt-accent forced-colors:before:bg-[Highlight]\";\n/** Layout is measured before paint where there is a document, so a phone never shows the timeline first. */\nconst useBrowserLayoutEffect = typeof document === \"undefined\" ? useEffect : useLayoutEffect;\n/** A number stays with its unit. */\nconst hours = (value: number) => `${value}\\u00A0h`;\n\n/** A controlled schedule. The host owns source revisions and accepted persistence. */\nexport function PlanningScheduling({\n snapshot,\n initialSelectedJobId,\n onApply,\n onAccepted,\n acceptanceMessage = \"Schedule change accepted by the supplied service.\",\n}: PlanningSchedulingProps) {\n const instance = useId();\n const [selectedId, setSelectedId] = useState(\n () =>\n snapshot.jobs.find((job) => job.id === initialSelectedJobId)?.id ??\n snapshot.jobs[0]?.id ??\n \"\",\n );\n // No fallback to another order: a revision that drops the selection says so (see the inspector).\n const selected = snapshot.jobs.find((job) => job.id === selectedId);\n const [viewChoice, setView] = useState<\"schedule\" | \"list\" | null>(null);\n const [narrow, setNarrow] = useState(false);\n const workspace = useRef<HTMLElement>(null);\n const view = viewChoice ?? (narrow ? \"list\" : \"schedule\");\n useBrowserLayoutEffect(() => {\n const element = workspace.current;\n if (!element) return;\n // Measured now, before paint; a box with no width yet (hidden, or no layout) says nothing.\n if (element.clientWidth > 0) setNarrow(element.clientWidth < 640);\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(([entry]) => {\n if (entry) setNarrow(entry.contentRect.width < 640);\n });\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const [draft, setDraft] = useState<Draft | null>(null);\n const [review, setReview] = useState<ScheduleChange | null>(null);\n const [issues, setIssues] = useState<ScheduleIssue[]>([]);\n const [failure, setFailure] = useState(\"\");\n const [notice, setNotice] = useState(\"\");\n const [pending, setPending] = useState(false);\n const [requestedSelection, setRequestedSelection] = useState<string | null>(null);\n const inspector = useRef<HTMLElement>(null);\n const keepEditing = useRef<HTMLButtonElement>(null);\n const selectionRequested = useRef(false);\n useLayoutEffect(() => {\n if (selectionRequested.current) {\n inspector.current?.focus();\n selectionRequested.current = false;\n }\n }, [selectedId]);\n const source = useRef(snapshot);\n source.current = snapshot;\n const mounted = useRef(true);\n const sequence = useRef(0);\n const editButton = useRef<HTMLButtonElement>(null);\n const formRef = useRef<HTMLFormElement>(null);\n const reviewRef = useRef<HTMLElement>(null);\n const returnToEdit = useRef(false);\n const stale =\n draft !== null && (draft.revision !== snapshot.revision || draft.scheduleId !== snapshot.id);\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n sequence.current += 1;\n };\n }, []);\n useEffect(() => {\n sequence.current += 1;\n setPending(false);\n }, [snapshot.id, snapshot.revision]);\n // The form takes focus when an edit starts (or a different order is edited) and when review\n // returns to editing, not on every keystroke: the effect reads two primitives, not the draft.\n const editingJobId = draft?.jobId ?? null;\n const editing = draft !== null;\n useEffect(() => {\n if (editing && !review) formRef.current?.querySelector<HTMLButtonElement>(\"button\")?.focus();\n }, [editing, editingJobId, review]);\n useEffect(() => {\n if (review) reviewRef.current?.focus();\n }, [review]);\n // A tap on another order while a draft is open is answered where the operator can see it.\n useEffect(() => {\n if (!requestedSelection) return;\n keepEditing.current?.focus({ preventScroll: true });\n keepEditing.current?.scrollIntoView?.({ block: \"nearest\" });\n }, [requestedSelection]);\n useEffect(() => {\n if (!draft && returnToEdit.current) {\n editButton.current?.focus();\n returnToEdit.current = false;\n }\n }, [draft]);\n\n const conflictedJobs = snapshot.jobs.filter((job) => conflictsFor(snapshot.jobs, job).length > 0);\n const conflictPairs =\n conflictedJobs.reduce((total, job) => total + conflictsFor(snapshot.jobs, job).length, 0) / 2;\n const firstConflict = conflictedJobs[0];\n const firstConflictPeer = firstConflict\n ? snapshot.jobs.find((job) => job.id === conflictsFor(snapshot.jobs, firstConflict)[0])\n : undefined;\n const resourceName = (id: string) =>\n snapshot.resources.find((resource) => resource.id === id)?.name ?? id;\n const jobLabel = (job: ScheduleJob) =>\n `Select ${job.id}, ${job.name}, ${utcWindow(job)}, ${resourceName(job.resourceId)}${\n conflictsFor(snapshot.jobs, job).length ? \", resource conflict\" : \"\"\n }`;\n const choose = (id: string) => {\n if (draft && id !== selected?.id) {\n setRequestedSelection(id);\n return;\n }\n selectionRequested.current = id !== selectedId;\n if (id === selectedId) inspector.current?.focus();\n setSelectedId(id);\n setNotice(\"\");\n setFailure(\"\");\n };\n const closeEditor = () => {\n sequence.current += 1;\n returnToEdit.current = true;\n setPending(false);\n setDraft(null);\n setReview(null);\n setIssues([]);\n setFailure(\"\");\n setRequestedSelection(null);\n };\n const beginEdit = () => {\n if (!selected) return;\n setDraft({\n scheduleId: snapshot.id,\n jobId: selected.id,\n revision: snapshot.revision,\n resourceId: selected.resourceId,\n start: utcInput(selected.start),\n end: utcInput(selected.end),\n });\n setReview(null);\n setIssues([]);\n setFailure(\"\");\n setNotice(\"\");\n };\n const reviewChange = () => {\n if (!draft || stale) return;\n const proposed = {\n jobId: draft.jobId,\n resourceId: draft.resourceId,\n start: parseUtcInput(draft.start) ?? NaN,\n end: parseUtcInput(draft.end) ?? NaN,\n };\n const nextIssues = validateChange(snapshot, proposed);\n setIssues(nextIssues);\n setFailure(\"\");\n if (nextIssues.length === 0) setReview(proposed);\n };\n const apply = async () => {\n if (!review || !draft || stale || pending || !onApply) return;\n const nextIssues = validateChange(snapshot, review);\n if (nextIssues.length) {\n setReview(null);\n setIssues(nextIssues);\n return;\n }\n const requestSequence = ++sequence.current;\n const request: ScheduleApplyRequest = {\n ...review,\n scheduleId: snapshot.id,\n expectedRevision: draft.revision,\n requestId: `${instance}-${requestSequence}`,\n };\n setPending(true);\n setFailure(\"\");\n try {\n const result = await onApply(request);\n if (\n !mounted.current ||\n sequence.current !== requestSequence ||\n source.current.id !== request.scheduleId ||\n source.current.revision !== request.expectedRevision\n )\n return;\n if (!result || result.requestId !== request.requestId) {\n setFailure(\n \"The service response did not match this request. Your proposed change is retained.\",\n );\n return;\n }\n if (result.status === \"rejected\" || result.status === \"conflict\") {\n setFailure(\n result.message || \"The change was not accepted. Review the source and try again.\",\n );\n return;\n }\n if (result.status !== \"accepted\") {\n setFailure(\"The service returned an unknown outcome. Your proposed change is retained.\");\n return;\n }\n const accepted = result.snapshot;\n const job = accepted?.jobs?.find((item) => item.id === request.jobId);\n if (\n !accepted ||\n accepted.id !== request.scheduleId ||\n typeof accepted.revision !== \"string\" ||\n !accepted.revision ||\n accepted.revision === request.expectedRevision ||\n !Array.isArray(accepted.resources) ||\n !job ||\n job.resourceId !== request.resourceId ||\n job.start !== request.start ||\n job.end !== request.end ||\n validateChange(accepted, request).length > 0\n ) {\n setFailure(\n \"The service returned an inconsistent schedule. Your proposed change is retained.\",\n );\n return;\n }\n onAccepted?.(accepted);\n returnToEdit.current = true;\n setDraft(null);\n setReview(null);\n setNotice(acceptanceMessage);\n } catch {\n if (mounted.current && sequence.current === requestSequence)\n setFailure(\n \"The schedule service could not be reached. Your proposed change is retained; retry or return to editing.\",\n );\n } finally {\n if (mounted.current && sequence.current === requestSequence) setPending(false);\n }\n };\n\n return (\n <section\n ref={workspace}\n aria-label={snapshot.title}\n className=\"not-prose min-w-0 border border-oprt-border bg-oprt-surface-1 font-sans text-oprt-base normal-case text-oprt-text\"\n >\n <header className=\"flex flex-wrap items-start justify-between gap-3u border-b border-oprt-border p-3u\">\n <div className=\"min-w-0 flex flex-col gap-1u\">\n <h2 className=\"m-0 text-oprt-xl font-semibold normal-case tracking-normal\">\n {snapshot.title}\n </h2>\n <p className=\"m-0 text-oprt-text-dim\">{snapshot.location}</p>\n <p className=\"m-0 text-oprt-text-dim\">{snapshot.summary}</p>\n </div>\n <div className=\"flex flex-col gap-1u text-oprt-text-dim\">\n <p className=\"m-0 text-oprt-sm font-medium uppercase tracking-oprt-sm\">Planning window</p>\n <p className=\"m-0 font-mono text-oprt-text\">{utcWindow(snapshot)}</p>\n <p className=\"m-0\">\n {snapshot.jobs.length} work orders · {snapshot.resources.length} resources ·{\" \"}\n <span className=\"font-mono\">{snapshot.revision}</span>\n </p>\n </div>\n </header>\n {/* Beside or under the schedule by the container's width, like the list/timeline switch. A 1 px gap over\n the border color is the divider, so it is right in both arrangements. */}\n <div className=\"flex min-w-0 flex-wrap gap-px bg-oprt-border forced-colors:bg-[CanvasText]\">\n <div className=\"min-w-0 flex-[999_1_36rem] bg-oprt-surface-1\">\n <div className=\"flex flex-wrap items-center justify-between gap-2u border-b border-oprt-border px-3u py-2u\">\n <div className=\"flex flex-wrap items-center gap-2u\">\n <h3 className=\"m-0 text-oprt-md font-medium\">Resource allocation</h3>\n <Badge appearance=\"text\" variant={conflictPairs ? \"warning\" : \"success\"}>\n {conflictPairs\n ? `${conflictPairs} resource conflict${conflictPairs === 1 ? \"\" : \"s\"}`\n : \"No resource conflicts\"}\n </Badge>\n </div>\n {requestedSelection && selected && draft && (\n <p className=\"m-0 text-oprt-warning\">\n Draft open on {selected.id}: keep or discard it to open {requestedSelection}.\n </p>\n )}\n <div role=\"group\" aria-label=\"Schedule view\" className=\"flex gap-1u\">\n <Button\n size=\"sm\"\n variant={view === \"schedule\" ? \"secondary\" : \"ghost\"}\n aria-pressed={view === \"schedule\"}\n onClick={() => setView(\"schedule\")}\n >\n Schedule\n </Button>\n <Button\n size=\"sm\"\n variant={view === \"list\" ? \"secondary\" : \"ghost\"}\n aria-pressed={view === \"list\"}\n onClick={() => setView(\"list\")}\n >\n List\n </Button>\n </div>\n </div>\n <section\n aria-label=\"Conflict review\"\n className=\"flex flex-col gap-1u border-b border-oprt-border px-3u py-2u\"\n >\n <h4 className=\"m-0 text-oprt-base font-medium\">Conflict review</h4>\n {firstConflict && firstConflictPeer ? (\n <div className=\"flex flex-wrap items-center justify-between gap-2u\">\n <p className=\"m-0 max-w-[65ch] text-oprt-text-dim\">\n <span className=\"font-mono text-oprt-text\">{firstConflict.id}</span> and{\" \"}\n <span className=\"font-mono text-oprt-text\">{firstConflictPeer.id}</span> need{\" \"}\n {resourceName(firstConflict.resourceId)} at the same time. Reassign a compatible\n resource or adjust the service window.\n </p>\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => choose(firstConflictPeer.id)}>\n Inspect conflict\n </Button>\n </div>\n ) : (\n <p className=\"m-0 text-oprt-text-dim\">\n Resource windows are clear. Prerequisites and downstream work are checked before\n each change is applied.\n </p>\n )}\n </section>\n {view === \"schedule\" ? (\n <div\n role=\"region\"\n aria-label=\"Resource timeline, scroll horizontally for later hours\"\n tabIndex={0}\n className={`overflow-x-auto ${focusStyle}`}\n >\n <div className=\"min-w-[860px]\">\n <div className=\"grid grid-cols-[150px_minmax(0,1fr)] border-b border-oprt-border bg-oprt-surface-2\">\n <div className=\"px-3u py-2u font-medium text-oprt-text-dim\">\n Resource / assignment\n </div>\n <div className=\"relative h-5u font-mono text-oprt-text-dim\">\n {Array.from({ length: 7 }, (_, index) => (\n <span\n key={index}\n style={{\n left: `${(index / 6) * 100}%`,\n transform: index === 6 ? \"translateX(-100%)\" : \"none\",\n }}\n className=\"absolute top-2u px-1u\"\n >\n {utcTime(snapshot.start + ((snapshot.end - snapshot.start) * index) / 6)}\n {index === 0 ? \"\\u00A0UTC\" : \"\"}\n </span>\n ))}\n </div>\n </div>\n {snapshot.resources.map((resource) => {\n const lane = layoutLane(snapshot, resource.id);\n const height = lane.trackCount * 78 + 16;\n const reservedHours = lane.items.reduce(\n (total, { job }) => total + (job.end - job.start) / 3_600_000,\n 0,\n );\n return (\n <div\n key={resource.id}\n className=\"grid grid-cols-[150px_minmax(0,1fr)] border-b border-oprt-border last:border-b-0\"\n >\n <div className=\"border-r border-oprt-border px-3u py-3u\">\n <p className=\"m-0 font-medium\">{resource.name}</p>\n <p className=\"m-0 mt-1u text-oprt-text-dim\">{resource.detail}</p>\n <p className=\"m-0 mt-1u font-mono text-oprt-text-dim\">\n {hours(reservedHours)} reserved\n </p>\n </div>\n <div className=\"relative\" style={{ height }}>\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-0 grid grid-cols-6\"\n >\n {Array.from({ length: 6 }, (_, index) => (\n <div\n key={index}\n className=\"border-r border-oprt-border last:border-r-0\"\n />\n ))}\n </div>\n {lane.items.map(({ job, track, left, width }) => (\n <button\n type=\"button\"\n key={job.id}\n aria-label={jobLabel(job)}\n aria-pressed={selected?.id === job.id}\n onClick={() => choose(job.id)}\n style={{\n left: `calc(${left}% + 4px)`,\n width: `calc(${width}% - 8px)`,\n top: track * 78 + 8,\n height: 70,\n }}\n className={`absolute flex min-w-0 flex-col items-start justify-center gap-0.5u border px-1u text-left text-oprt-base font-medium transition-colors hover:bg-oprt-surface-3 ${focusStyle} ${selected?.id === job.id ? `border-oprt-accent bg-oprt-surface-3 ${selectedBar}` : \"border-oprt-border bg-oprt-surface-2\"}`}\n >\n <span className=\"flex w-full justify-between gap-1u font-mono text-oprt-text-dim\">\n <span>{job.id}</span>\n {conflictsFor(snapshot.jobs, job).length > 0 && (\n <span className=\"font-sans text-oprt-warning\">Conflict</span>\n )}\n </span>\n <span className=\"w-full leading-tight\">{job.name}</span>\n <span className=\"font-mono text-oprt-text-dim\">\n {utcTime(job.start)}–{utcTime(job.end)}&nbsp;UTC\n </span>\n </button>\n ))}\n </div>\n </div>\n );\n })}\n </div>\n </div>\n ) : narrow ? (\n <ul aria-label=\"Work order list\" className=\"m-0 list-none p-0\">\n {[...snapshot.jobs]\n .sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))\n .map((job) => (\n <li key={job.id} className=\"border-b border-oprt-border last:border-b-0\">\n <button\n type=\"button\"\n aria-label={jobLabel(job)}\n aria-pressed={selected?.id === job.id}\n onClick={() => choose(job.id)}\n className={`relative flex w-full flex-col gap-1u p-2u text-left text-oprt-base font-medium hover:bg-oprt-surface-3 ${focusStyle} ${selected?.id === job.id ? `bg-oprt-surface-3 ${selectedBar}` : \"\"}`}\n >\n <span className=\"flex w-full flex-wrap justify-between gap-1u\">\n <span className=\"font-mono text-oprt-text-dim\">{job.id}</span>\n <Badge\n appearance=\"text\"\n variant={conflictsFor(snapshot.jobs, job).length ? \"warning\" : \"neutral\"}\n >\n {conflictsFor(snapshot.jobs, job).length ? \"Conflict\" : \"Scheduled\"}\n </Badge>\n </span>\n <span>{job.name}</span>\n <span className=\"text-oprt-text-dim\">{job.asset}</span>\n <span className=\"text-oprt-text-dim\">{resourceName(job.resourceId)}</span>\n <span className=\"font-mono\">\n {utcTime(job.start)}–{utcTime(job.end)}&nbsp;UTC\n </span>\n </button>\n </li>\n ))}\n </ul>\n ) : (\n <div className=\"overflow-x-auto\">\n <table\n aria-label=\"Work order list\"\n className=\"w-full min-w-[640px] border-collapse text-left text-oprt-base\"\n >\n <thead className=\"bg-oprt-surface-2\">\n <tr>\n {[\"Work order\", \"Resource\", \"Window · UTC\", \"Status\"].map((heading) => (\n <th\n key={heading}\n scope=\"col\"\n className=\"border-b border-oprt-border px-2u py-2u font-medium text-oprt-text-dim\"\n >\n {heading}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {[...snapshot.jobs]\n .sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))\n .map((job) => (\n <tr\n key={job.id}\n className={selected?.id === job.id ? \"bg-oprt-surface-3\" : \"\"}\n >\n <td\n className={`border-b border-oprt-border p-2u ${selected?.id === job.id ? `relative ${selectedBar}` : \"\"}`}\n >\n <button\n type=\"button\"\n aria-label={jobLabel(job)}\n aria-pressed={selected?.id === job.id}\n onClick={() => choose(job.id)}\n className={`flex flex-col gap-0.5u text-left font-medium ${focusStyle}`}\n >\n <span>{job.name}</span>\n <span className=\"font-mono text-oprt-text-dim\">{job.id}</span>\n </button>\n </td>\n <td className=\"border-b border-oprt-border p-2u\">\n {resourceName(job.resourceId)}\n </td>\n <td className=\"border-b border-oprt-border p-2u font-mono\">\n {utcTime(job.start)}–{utcTime(job.end)}\n </td>\n <td className=\"border-b border-oprt-border p-2u\">\n <Badge\n appearance=\"text\"\n variant={\n conflictsFor(snapshot.jobs, job).length ? \"warning\" : \"neutral\"\n }\n >\n {conflictsFor(snapshot.jobs, job).length ? \"Conflict\" : \"Scheduled\"}\n </Badge>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n <div className=\"border-t border-oprt-border px-3u py-2u\">\n <p className=\"m-0 text-oprt-text-dim\">\n All times are UTC. A resource may start its next work order exactly when the previous\n order ends.\n </p>\n </div>\n </div>\n <Inspector\n ref={inspector}\n tabIndex={-1}\n title=\"Selected work order\"\n className={`min-w-0 flex-[1_1_20rem] border-0 p-3u ${focusStyle}`}\n >\n {selected ? (\n <>\n <EntityPreview\n entity={{\n id: selected.id,\n name: selected.name,\n typeLabel: selected.asset,\n summary: selected.summary,\n condition: {\n label:\n conflictsFor(snapshot.jobs, selected).length > 0\n ? \"Resource conflict\"\n : \"Scheduled\",\n variant:\n conflictsFor(snapshot.jobs, selected).length > 0 ? \"warning\" : \"neutral\",\n },\n facts: [\n { id: \"resource\", label: \"Resource\", value: resourceName(selected.resourceId) },\n { id: \"owner\", label: \"Work lead\", value: selected.owner },\n {\n id: \"window\",\n label: \"Service window\",\n value: <span className=\"font-mono\">{utcWindow(selected)}</span>,\n },\n {\n id: \"duration\",\n label: \"Duration\",\n value: (\n <span className=\"font-mono\">\n {hours((selected.end - selected.start) / 3_600_000)}\n </span>\n ),\n },\n ],\n quality: {\n state: \"current\",\n source: `Supplied plan · ${snapshot.revision}`,\n detail: \"Freshness reflects the supplied snapshot.\",\n },\n }}\n />\n <Dependencies selected={selected} snapshot={snapshot} />\n {requestedSelection && (\n <div className=\"flex flex-col gap-1u border-t border-oprt-border pt-2u\">\n <p role=\"alert\" className=\"m-0\">\n {pending\n ? \"The service is processing this change. Wait for its outcome before opening another work order.\"\n : `Keep this draft or discard it to open ${requestedSelection}.`}\n </p>\n <div className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n disabled={pending}\n onClick={() => {\n const next = requestedSelection;\n closeEditor();\n setSelectedId(next);\n }}\n >\n Discard change and open work order\n </Button>\n <Button\n ref={keepEditing}\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setRequestedSelection(null)}\n >\n Keep editing\n </Button>\n </div>\n </div>\n )}\n {notice && (\n <p role=\"status\" className=\"m-0 border-t border-oprt-border pt-2u\">\n {notice}\n </p>\n )}\n {draft ? (\n <div className=\"flex flex-col gap-2u border-t border-oprt-border pt-2u\">\n {stale && (\n <div role=\"alert\" className=\"flex flex-col gap-1u\">\n <p className=\"m-0 text-oprt-warning\">\n The source revision changed. Load the latest revision before editing again.\n </p>\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n className=\"self-start\"\n onClick={closeEditor}\n >\n Load latest revision\n </Button>\n </div>\n )}\n {failure && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n {failure}\n </p>\n )}\n {review ? (\n <section\n ref={reviewRef}\n tabIndex={-1}\n aria-label=\"Review schedule change\"\n className={`flex flex-col gap-2u ${focusStyle}`}\n >\n <h4 className=\"m-0 text-oprt-md font-medium\">Review schedule change</h4>\n <p className=\"m-0 font-mono\">\n {selected.id} · {draft.revision}\n </p>\n <div className=\"flex flex-col gap-1u\">\n <p className=\"m-0 font-medium\">Current</p>\n <p className=\"m-0\">{resourceName(selected.resourceId)}</p>\n <p className=\"m-0 font-mono\">{utcWindow(selected)}</p>\n </div>\n <div className=\"flex flex-col gap-1u\">\n <p className=\"m-0 font-medium\">Proposed</p>\n <p className=\"m-0\">{resourceName(review.resourceId)}</p>\n <p className=\"m-0 font-mono\">{utcWindow(review)}</p>\n </div>\n <p className=\"m-0 text-oprt-text-dim\">\n No resource overlap. Prerequisites and downstream work remain in sequence.\n </p>\n {!onApply && (\n <p className=\"m-0 text-oprt-text-dim\">\n Review only: connect a schedule service to apply changes.\n </p>\n )}\n <div className=\"flex flex-wrap gap-1u\">\n {onApply && (\n <Button disabled={pending || stale} onClick={() => void apply()}>\n {pending ? \"Applying change…\" : \"Apply schedule change\"}\n </Button>\n )}\n <Button\n variant=\"tertiary\"\n disabled={pending}\n onClick={() => {\n setReview(null);\n setFailure(\"\");\n }}\n >\n Back to editing\n </Button>\n </div>\n </section>\n ) : (\n <form\n ref={formRef}\n aria-label=\"Edit work window\"\n className=\"flex flex-col gap-2u\"\n onSubmit={(event) => {\n event.preventDefault();\n reviewChange();\n }}\n noValidate\n >\n <h4 className=\"m-0 text-oprt-md font-medium\">Edit work window</h4>\n <Field label=\"Resource\" controlId={`${instance}-resource`}>\n <Select\n value={draft.resourceId}\n onValueChange={(resourceId) => setDraft({ ...draft, resourceId })}\n disabled={stale}\n className=\"w-full\"\n >\n <>\n {snapshot.resources\n .filter(\n (resource) =>\n !selected.eligibleResourceIds ||\n selected.eligibleResourceIds.includes(resource.id),\n )\n .map((resource) => (\n <SelectOption key={resource.id} value={resource.id}>\n {resource.name}\n </SelectOption>\n ))}\n </>\n </Select>\n </Field>\n <Field label=\"Start · UTC\" controlId={`${instance}-start`}>\n <Input\n type=\"datetime-local\"\n step={60}\n value={draft.start}\n disabled={stale}\n onChange={(event) => setDraft({ ...draft, start: event.target.value })}\n className=\"w-full\"\n />\n </Field>\n <Field label=\"End · UTC\" controlId={`${instance}-end`}>\n <Input\n type=\"datetime-local\"\n step={60}\n value={draft.end}\n disabled={stale}\n onChange={(event) => setDraft({ ...draft, end: event.target.value })}\n className=\"w-full\"\n />\n </Field>\n <p className=\"m-0 text-oprt-text-dim\">\n Enter UTC values within {utcWindow(snapshot)}. Local device time is not\n used.\n </p>\n {issues.length > 0 && (\n <div role=\"alert\" className=\"flex flex-col gap-1u text-oprt-error\">\n <p className=\"m-0 font-medium\">Resolve before review</p>\n <ul className=\"m-0 list-disc space-y-1u pl-2u\">\n {issues.map((issue, index) => (\n <li key={index}>{issue.message}</li>\n ))}\n </ul>\n </div>\n )}\n <div className=\"flex flex-wrap gap-1u\">\n <Button type=\"submit\" disabled={stale}>\n Review change\n </Button>\n <Button variant=\"tertiary\" onClick={closeEditor}>\n Cancel edit\n </Button>\n </div>\n </form>\n )}\n </div>\n ) : (\n <div className=\"border-t border-oprt-border pt-2u\">\n <Button ref={editButton} onClick={beginEdit}>\n Edit work window\n </Button>\n </div>\n )}\n </>\n ) : snapshot.jobs.length && selectedId ? (\n <p role=\"status\" className=\"m-0 text-oprt-text-dim\">\n {selectedId} is no longer in this revision. Choose a work order.\n </p>\n ) : (\n <p className=\"m-0 text-oprt-text-dim\">No work orders in this planning window.</p>\n )}\n </Inspector>\n </div>\n </section>\n );\n}\n\nfunction Dependencies({\n selected,\n snapshot,\n}: {\n selected: ScheduleJob;\n snapshot: ScheduleSnapshot;\n}) {\n const prerequisites = selected.prerequisites.map((id) =>\n snapshot.jobs.find((job) => job.id === id),\n );\n const downstream = snapshot.jobs.filter((job) => job.prerequisites.includes(selected.id));\n return (\n <section\n aria-label=\"Dependencies and impact\"\n className=\"flex flex-col gap-2u border-t border-oprt-border pt-2u\"\n >\n <h4 className=\"m-0 text-oprt-md font-medium\">Dependencies & impact</h4>\n <div className=\"flex flex-col gap-1u\">\n <p className=\"m-0 font-medium text-oprt-text-dim\">Before this work</p>\n {prerequisites.length ? (\n prerequisites.map((job, index) => (\n <p key={selected.prerequisites[index]} className=\"m-0\">\n {job ? (\n <>\n {job.name}\n <span className=\"mt-0.5u block font-mono text-oprt-text-dim\">\n {job.id} · ends {utcTime(job.end)} UTC\n </span>\n </>\n ) : (\n `Missing prerequisite: ${selected.prerequisites[index]}`\n )}\n </p>\n ))\n ) : (\n <p className=\"m-0\">No prerequisites assigned.</p>\n )}\n </div>\n <div className=\"flex flex-col gap-1u\">\n <p className=\"m-0 font-medium text-oprt-text-dim\">Work that depends on this</p>\n {downstream.length ? (\n downstream.map((job) => (\n <p key={job.id} className=\"m-0\">\n {job.name}\n <span className=\"mt-0.5u block font-mono text-oprt-text-dim\">\n {job.id} · starts {utcTime(job.start)} UTC\n </span>\n </p>\n ))\n ) : (\n <p className=\"m-0\">No downstream work assigned.</p>\n )}\n </div>\n </section>\n );\n}\n",
4934
+ "patterns/planning-scheduling/planning-scheduling.tsx": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n Badge,\n Button,\n EntityPreview,\n Field,\n Input,\n Inspector,\n Select,\n SelectOption,\n} from \"@oprt/react\";\nimport {\n conflictsFor,\n layoutLane,\n parseUtcInput,\n utcInput,\n utcTime,\n utcWindow,\n validateChange,\n type ScheduleApplyRequest,\n type ScheduleApplyResult,\n type ScheduleChange,\n type ScheduleIssue,\n type ScheduleJob,\n type ScheduleSnapshot,\n} from \"./schedule-state\";\n\nexport type PlanningSchedulingProps = {\n snapshot: ScheduleSnapshot;\n initialSelectedJobId?: string;\n onApply?: (request: ScheduleApplyRequest) => Promise<ScheduleApplyResult>;\n onAccepted?: (snapshot: ScheduleSnapshot) => void;\n acceptanceMessage?: string;\n};\ntype Draft = {\n scheduleId: string;\n jobId: string;\n revision: string;\n resourceId: string;\n start: string;\n end: string;\n};\nconst focusStyle = \"outline-hidden focus-visible:shadow-oprt-focus focus-visible:z-10\";\n/** The selected work order is shown by a 3 px bar (Highlight in forced colors) as well as a tint. */\nconst selectedBar =\n \"before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-oprt-accent forced-colors:before:bg-[Highlight]\";\n/** Layout is measured before paint where there is a document, so a phone never shows the timeline first. */\nconst useBrowserLayoutEffect = typeof document === \"undefined\" ? useEffect : useLayoutEffect;\n/** A number stays with its unit. */\nconst hours = (value: number) => `${value}\\u00A0h`;\n\n/** A controlled schedule. The host owns source revisions and accepted persistence. */\nexport function PlanningScheduling({\n snapshot,\n initialSelectedJobId,\n onApply,\n onAccepted,\n acceptanceMessage = \"Schedule change accepted by the supplied service.\",\n}: PlanningSchedulingProps) {\n const instance = useId();\n const [selectedId, setSelectedId] = useState(\n () =>\n snapshot.jobs.find((job) => job.id === initialSelectedJobId)?.id ??\n snapshot.jobs[0]?.id ??\n \"\",\n );\n // No fallback to another order: a revision that drops the selection says so (see the inspector).\n const selected = snapshot.jobs.find((job) => job.id === selectedId);\n // The chosen work order is not in this revision (the source moved on).\n const gone = !selected && snapshot.jobs.length > 0 && selectedId !== null;\n const [viewChoice, setView] = useState<\"schedule\" | \"list\" | null>(null);\n const [narrow, setNarrow] = useState(false);\n const workspace = useRef<HTMLElement>(null);\n const view = viewChoice ?? (narrow ? \"list\" : \"schedule\");\n useBrowserLayoutEffect(() => {\n const element = workspace.current;\n if (!element) return;\n // Measured now, before paint; a box with no width yet (hidden, or no layout) says nothing.\n if (element.clientWidth > 0) setNarrow(element.clientWidth < 640);\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(([entry]) => {\n if (entry) setNarrow(entry.contentRect.width < 640);\n });\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const [draft, setDraft] = useState<Draft | null>(null);\n const [review, setReview] = useState<ScheduleChange | null>(null);\n const [issues, setIssues] = useState<ScheduleIssue[]>([]);\n const [failure, setFailure] = useState(\"\");\n const [notice, setNotice] = useState(\"\");\n const [pending, setPending] = useState(false);\n const [requestedSelection, setRequestedSelection] = useState<string | null>(null);\n const inspector = useRef<HTMLElement>(null);\n const keepEditing = useRef<HTMLButtonElement>(null);\n const selectionRequested = useRef(false);\n useLayoutEffect(() => {\n if (selectionRequested.current) {\n inspector.current?.focus();\n selectionRequested.current = false;\n }\n }, [selectedId]);\n const source = useRef(snapshot);\n source.current = snapshot;\n const mounted = useRef(true);\n const sequence = useRef(0);\n const editButton = useRef<HTMLButtonElement>(null);\n const formRef = useRef<HTMLFormElement>(null);\n const reviewRef = useRef<HTMLElement>(null);\n const returnToEdit = useRef(false);\n const stale =\n draft !== null && (draft.revision !== snapshot.revision || draft.scheduleId !== snapshot.id);\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n sequence.current += 1;\n };\n }, []);\n useEffect(() => {\n sequence.current += 1;\n setPending(false);\n }, [snapshot.id, snapshot.revision]);\n // The form takes focus when an edit starts (or a different order is edited) and when review\n // returns to editing, not on every keystroke: the effect reads two primitives, not the draft.\n const editingJobId = draft?.jobId ?? null;\n const editing = draft !== null;\n useEffect(() => {\n if (editing && !review) formRef.current?.querySelector<HTMLButtonElement>(\"button\")?.focus();\n }, [editing, editingJobId, review]);\n useEffect(() => {\n if (review) reviewRef.current?.focus();\n }, [review]);\n // A tap on another order while a draft is open is answered where the operator can see it.\n useEffect(() => {\n if (!requestedSelection) return;\n keepEditing.current?.focus({ preventScroll: true });\n keepEditing.current?.scrollIntoView?.({ block: \"nearest\" });\n }, [requestedSelection]);\n useEffect(() => {\n if (!draft && returnToEdit.current) {\n editButton.current?.focus();\n returnToEdit.current = false;\n }\n }, [draft]);\n\n const conflictedJobs = snapshot.jobs.filter((job) => conflictsFor(snapshot.jobs, job).length > 0);\n const conflictPairs =\n conflictedJobs.reduce((total, job) => total + conflictsFor(snapshot.jobs, job).length, 0) / 2;\n const firstConflict = conflictedJobs[0];\n const firstConflictPeer = firstConflict\n ? snapshot.jobs.find((job) => job.id === conflictsFor(snapshot.jobs, firstConflict)[0])\n : undefined;\n const resourceName = (id: string) =>\n snapshot.resources.find((resource) => resource.id === id)?.name ?? id;\n // The name holds what the button shows, in the order it shows it (WCAG 2.5.3), and each\n // layout shows something different. The timeline bar: the ID, the Conflict badge, the name and\n // the time window. The phone list: the ID, the status, the name, the asset, the resource and\n // the window. The table: the name and the ID. What a layout does not show follows.\n const jobLabel = (job: ScheduleJob, layout: \"timeline\" | \"list\" | \"table\") => {\n const window = utcWindow(job);\n const time = window.slice(11);\n const date = window.slice(0, 10);\n const conflict = conflictsFor(snapshot.jobs, job).length > 0;\n const resource = resourceName(job.resourceId);\n if (layout === \"list\")\n return `Inspect ${job.id}, ${conflict ? \"Conflict\" : \"Scheduled\"}, ${job.name}, ${job.asset}, ${resource}, ${time}, ${date}`;\n if (layout === \"table\")\n return `Inspect ${job.name}, ${job.id}, ${conflict ? \"Conflict, \" : \"\"}${time}, ${date}, ${resource}`;\n return `Inspect ${job.id}, ${conflict ? \"Conflict, \" : \"\"}${job.name}, ${time}, ${date}, ${resource}`;\n };\n const choose = (id: string) => {\n if (draft && id !== selected?.id) {\n setRequestedSelection(id);\n return;\n }\n selectionRequested.current = id !== selectedId;\n if (id === selectedId) inspector.current?.focus();\n setSelectedId(id);\n setNotice(\"\");\n setFailure(\"\");\n };\n const closeEditor = () => {\n sequence.current += 1;\n returnToEdit.current = true;\n setPending(false);\n setDraft(null);\n setReview(null);\n setIssues([]);\n setFailure(\"\");\n setRequestedSelection(null);\n };\n const beginEdit = () => {\n if (!selected) return;\n setDraft({\n scheduleId: snapshot.id,\n jobId: selected.id,\n revision: snapshot.revision,\n resourceId: selected.resourceId,\n start: utcInput(selected.start),\n end: utcInput(selected.end),\n });\n setReview(null);\n setIssues([]);\n setFailure(\"\");\n setNotice(\"\");\n };\n const reviewChange = () => {\n if (!draft || stale) return;\n const proposed = {\n jobId: draft.jobId,\n resourceId: draft.resourceId,\n start: parseUtcInput(draft.start) ?? NaN,\n end: parseUtcInput(draft.end) ?? NaN,\n };\n const nextIssues = validateChange(snapshot, proposed);\n setIssues(nextIssues);\n setFailure(\"\");\n if (nextIssues.length === 0) setReview(proposed);\n };\n const apply = async () => {\n if (!review || !draft || stale || pending || !onApply) return;\n const nextIssues = validateChange(snapshot, review);\n if (nextIssues.length) {\n setReview(null);\n setIssues(nextIssues);\n return;\n }\n const requestSequence = ++sequence.current;\n const request: ScheduleApplyRequest = {\n ...review,\n scheduleId: snapshot.id,\n expectedRevision: draft.revision,\n requestId: `${instance}-${requestSequence}`,\n };\n setPending(true);\n setFailure(\"\");\n try {\n const result = await onApply(request);\n if (\n !mounted.current ||\n sequence.current !== requestSequence ||\n source.current.id !== request.scheduleId ||\n source.current.revision !== request.expectedRevision\n )\n return;\n if (!result || result.requestId !== request.requestId) {\n setFailure(\n \"The service response did not match this request. Your proposed change is retained.\",\n );\n return;\n }\n if (result.status === \"rejected\" || result.status === \"conflict\") {\n setFailure(\n result.message || \"The change was not accepted. Review the source and try again.\",\n );\n return;\n }\n if (result.status !== \"accepted\") {\n setFailure(\"The service returned an unknown outcome. Your proposed change is retained.\");\n return;\n }\n const accepted = result.snapshot;\n const job = accepted?.jobs?.find((item) => item.id === request.jobId);\n if (\n !accepted ||\n accepted.id !== request.scheduleId ||\n typeof accepted.revision !== \"string\" ||\n !accepted.revision ||\n accepted.revision === request.expectedRevision ||\n !Array.isArray(accepted.resources) ||\n !job ||\n job.resourceId !== request.resourceId ||\n job.start !== request.start ||\n job.end !== request.end ||\n validateChange(accepted, request).length > 0\n ) {\n setFailure(\n \"The service returned an inconsistent schedule. Your proposed change is retained.\",\n );\n return;\n }\n onAccepted?.(accepted);\n returnToEdit.current = true;\n setDraft(null);\n setReview(null);\n setNotice(acceptanceMessage);\n } catch {\n if (mounted.current && sequence.current === requestSequence)\n setFailure(\n \"The schedule service could not be reached. Your proposed change is retained; retry or return to editing.\",\n );\n } finally {\n if (mounted.current && sequence.current === requestSequence) setPending(false);\n }\n };\n\n return (\n <section\n ref={workspace}\n aria-label={snapshot.title}\n className=\"not-prose min-w-0 border border-oprt-border bg-oprt-surface-1 font-sans text-oprt-base normal-case text-oprt-text\"\n >\n <header className=\"flex flex-wrap items-start justify-between gap-3u border-b border-oprt-border p-3u\">\n <div className=\"min-w-0 flex flex-col gap-1u\">\n <h2 className=\"m-0 text-oprt-xl font-semibold normal-case tracking-normal\">\n {snapshot.title}\n </h2>\n <p className=\"m-0 text-oprt-text-dim\">{snapshot.location}</p>\n <p className=\"m-0 text-oprt-text-dim\">{snapshot.summary}</p>\n </div>\n <div className=\"flex flex-col gap-1u text-oprt-text-dim\">\n <p className=\"m-0 text-oprt-sm font-medium uppercase tracking-oprt-sm\">Planning window</p>\n <p className=\"m-0 font-mono text-oprt-text\">{utcWindow(snapshot)}</p>\n <p className=\"m-0\">\n {snapshot.jobs.length} work orders · {snapshot.resources.length} resources ·{\" \"}\n <span className=\"font-mono\">{snapshot.revision}</span>\n </p>\n </div>\n </header>\n {/* Beside or under the schedule by the container's width, like the list/timeline switch. A 1 px gap over\n the border color is the divider, so it is right in both arrangements. */}\n <div className=\"flex min-w-0 flex-wrap gap-px bg-oprt-border forced-colors:bg-[CanvasText]\">\n <div className=\"min-w-0 flex-[999_1_36rem] bg-oprt-surface-1\">\n <div className=\"flex flex-wrap items-center justify-between gap-2u border-b border-oprt-border px-3u py-2u\">\n <div className=\"flex flex-wrap items-center gap-2u\">\n <h3 className=\"m-0 text-oprt-md font-medium\">Resource allocation</h3>\n <Badge appearance=\"text\" variant={conflictPairs ? \"warning\" : \"success\"}>\n {conflictPairs\n ? `${conflictPairs} resource conflict${conflictPairs === 1 ? \"\" : \"s\"}`\n : \"No resource conflicts\"}\n </Badge>\n </div>\n {requestedSelection && selected && draft && (\n <p className=\"m-0 text-oprt-warning\">\n Draft open on {selected.id}: keep or discard it to open {requestedSelection}.\n </p>\n )}\n <div role=\"group\" aria-label=\"Schedule view\" className=\"flex gap-1u\">\n <Button\n size=\"sm\"\n variant={view === \"schedule\" ? \"secondary\" : \"ghost\"}\n aria-pressed={view === \"schedule\"}\n onClick={() => setView(\"schedule\")}\n >\n Schedule\n </Button>\n <Button\n size=\"sm\"\n variant={view === \"list\" ? \"secondary\" : \"ghost\"}\n aria-pressed={view === \"list\"}\n onClick={() => setView(\"list\")}\n >\n List\n </Button>\n </div>\n </div>\n <section\n aria-label=\"Conflict review\"\n className=\"flex flex-col gap-1u border-b border-oprt-border px-3u py-2u\"\n >\n <h4 className=\"m-0 text-oprt-base font-medium\">Conflict review</h4>\n {firstConflict && firstConflictPeer ? (\n <div className=\"flex flex-wrap items-center justify-between gap-2u\">\n <p className=\"m-0 max-w-[65ch] text-oprt-text-dim\">\n <span className=\"font-mono text-oprt-text\">{firstConflict.id}</span> and{\" \"}\n <span className=\"font-mono text-oprt-text\">{firstConflictPeer.id}</span> need{\" \"}\n {resourceName(firstConflict.resourceId)} at the same time. Reassign a compatible\n resource or adjust the service window.\n </p>\n <Button size=\"sm\" variant=\"tertiary\" onClick={() => choose(firstConflictPeer.id)}>\n Inspect conflict\n </Button>\n </div>\n ) : (\n <p className=\"m-0 text-oprt-text-dim\">\n Resource windows are clear. Prerequisites and downstream work are checked before\n each change is applied.\n </p>\n )}\n </section>\n {view === \"schedule\" ? (\n <div\n role=\"region\"\n aria-label=\"Resource timeline, scroll horizontally for later hours\"\n tabIndex={0}\n className={`overflow-x-auto ${focusStyle}`}\n >\n <div className=\"min-w-[860px]\">\n <div className=\"grid grid-cols-[150px_minmax(0,1fr)] border-b border-oprt-border bg-oprt-surface-2\">\n <div className=\"px-3u py-2u font-medium text-oprt-text-dim\">\n Resource / assignment\n </div>\n <div className=\"relative h-5u font-mono text-oprt-text-dim\">\n {Array.from({ length: 7 }, (_, index) => (\n <span\n key={index}\n style={{\n left: `${(index / 6) * 100}%`,\n transform: index === 6 ? \"translateX(-100%)\" : \"none\",\n }}\n className=\"absolute top-2u px-1u\"\n >\n {utcTime(snapshot.start + ((snapshot.end - snapshot.start) * index) / 6)}\n {index === 0 ? \"\\u00A0UTC\" : \"\"}\n </span>\n ))}\n </div>\n </div>\n {snapshot.resources.map((resource) => {\n const lane = layoutLane(snapshot, resource.id);\n const height = lane.trackCount * 78 + 16;\n const reservedHours = lane.items.reduce(\n (total, { job }) => total + (job.end - job.start) / 3_600_000,\n 0,\n );\n return (\n <div\n key={resource.id}\n className=\"grid grid-cols-[150px_minmax(0,1fr)] border-b border-oprt-border last:border-b-0\"\n >\n <div className=\"border-r border-oprt-border px-3u py-3u\">\n <p className=\"m-0 font-medium\">{resource.name}</p>\n <p className=\"m-0 mt-1u text-oprt-text-dim\">{resource.detail}</p>\n <p className=\"m-0 mt-1u font-mono text-oprt-text-dim\">\n {hours(reservedHours)} reserved\n </p>\n </div>\n <div className=\"relative\" style={{ height }}>\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-0 grid grid-cols-6\"\n >\n {Array.from({ length: 6 }, (_, index) => (\n <div\n key={index}\n className=\"border-r border-oprt-border last:border-r-0\"\n />\n ))}\n </div>\n {lane.items.map(({ job, track, left, width }) => (\n <button\n type=\"button\"\n key={job.id}\n aria-label={jobLabel(job, \"timeline\")}\n aria-pressed={selected?.id === job.id}\n onClick={() => choose(job.id)}\n style={{\n left: `calc(${left}% + 4px)`,\n width: `calc(${width}% - 8px)`,\n top: track * 78 + 8,\n height: 70,\n }}\n className={`absolute flex min-w-0 flex-col items-start justify-center gap-0.5u border px-1u text-left text-oprt-base font-medium transition-colors hover:bg-oprt-surface-3 ${focusStyle} ${selected?.id === job.id ? `border-oprt-accent bg-oprt-surface-3 ${selectedBar}` : \"border-oprt-border bg-oprt-surface-2\"}`}\n >\n <span className=\"flex w-full justify-between gap-1u font-mono text-oprt-text-dim\">\n <span>{job.id}</span>\n {conflictsFor(snapshot.jobs, job).length > 0 && (\n <span className=\"font-sans text-oprt-warning\">Conflict</span>\n )}\n </span>\n <span className=\"w-full leading-tight\">{job.name}</span>\n <span className=\"font-mono text-oprt-text-dim\">\n {utcTime(job.start)}–{utcTime(job.end)}&nbsp;UTC\n </span>\n </button>\n ))}\n </div>\n </div>\n );\n })}\n </div>\n </div>\n ) : narrow ? (\n <ul aria-label=\"Work order list\" className=\"m-0 list-none p-0\">\n {[...snapshot.jobs]\n .sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))\n .map((job) => (\n <li key={job.id} className=\"border-b border-oprt-border last:border-b-0\">\n <button\n type=\"button\"\n aria-label={jobLabel(job, \"list\")}\n aria-pressed={selected?.id === job.id}\n onClick={() => choose(job.id)}\n className={`relative flex w-full flex-col gap-1u p-2u text-left text-oprt-base font-medium hover:bg-oprt-surface-3 ${focusStyle} ${selected?.id === job.id ? `bg-oprt-surface-3 ${selectedBar}` : \"\"}`}\n >\n <span className=\"flex w-full flex-wrap justify-between gap-1u\">\n <span className=\"font-mono text-oprt-text-dim\">{job.id}</span>\n <Badge\n appearance=\"text\"\n variant={conflictsFor(snapshot.jobs, job).length ? \"warning\" : \"neutral\"}\n >\n {conflictsFor(snapshot.jobs, job).length ? \"Conflict\" : \"Scheduled\"}\n </Badge>\n </span>\n <span>{job.name}</span>\n <span className=\"text-oprt-text-dim\">{job.asset}</span>\n <span className=\"text-oprt-text-dim\">{resourceName(job.resourceId)}</span>\n <span className=\"font-mono\">\n {utcTime(job.start)}–{utcTime(job.end)}&nbsp;UTC\n </span>\n </button>\n </li>\n ))}\n </ul>\n ) : (\n <div className=\"overflow-x-auto\">\n <table\n aria-label=\"Work order list\"\n className=\"w-full min-w-[640px] border-collapse text-left text-oprt-base\"\n >\n <thead className=\"bg-oprt-surface-2\">\n <tr>\n {[\"Work order\", \"Resource\", \"Window · UTC\", \"Status\"].map((heading) => (\n <th\n key={heading}\n scope=\"col\"\n className=\"border-b border-oprt-border px-2u py-2u font-medium text-oprt-text-dim\"\n >\n {heading}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {[...snapshot.jobs]\n .sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))\n .map((job) => (\n <tr\n key={job.id}\n className={selected?.id === job.id ? \"bg-oprt-surface-3\" : \"\"}\n >\n <td\n className={`border-b border-oprt-border p-2u ${selected?.id === job.id ? `relative ${selectedBar}` : \"\"}`}\n >\n <button\n type=\"button\"\n aria-label={jobLabel(job, \"table\")}\n aria-pressed={selected?.id === job.id}\n onClick={() => choose(job.id)}\n className={`flex flex-col gap-0.5u text-left font-medium ${focusStyle}`}\n >\n <span>{job.name}</span>\n <span className=\"font-mono text-oprt-text-dim\">{job.id}</span>\n </button>\n </td>\n <td className=\"border-b border-oprt-border p-2u\">\n {resourceName(job.resourceId)}\n </td>\n <td className=\"border-b border-oprt-border p-2u font-mono\">\n {utcTime(job.start)}–{utcTime(job.end)}\n </td>\n <td className=\"border-b border-oprt-border p-2u\">\n <Badge\n appearance=\"text\"\n variant={\n conflictsFor(snapshot.jobs, job).length ? \"warning\" : \"neutral\"\n }\n >\n {conflictsFor(snapshot.jobs, job).length ? \"Conflict\" : \"Scheduled\"}\n </Badge>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n <div className=\"border-t border-oprt-border px-3u py-2u\">\n <p className=\"m-0 text-oprt-text-dim\">\n All times are UTC. A resource may start its next work order exactly when the previous\n order ends.\n </p>\n </div>\n </div>\n <Inspector\n ref={inspector}\n tabIndex={-1}\n title=\"Selected work order\"\n className={`min-w-0 flex-[1_1_20rem] border-0 p-3u ${focusStyle}`}\n >\n {/* In the inspector from the start, empty and out of the flow: the message that the work\n order left the revision is written into it, because a screen reader speaks text that\n arrives in a status it already has, and said nothing for one inserted together with\n its text (docs/verification/2026-10-21-screen-readers.md). */}\n <p role=\"status\" className={gone ? \"m-0 text-oprt-text-dim\" : \"sr-only\"}>\n {gone ? `${selectedId} is no longer in this revision. Choose a work order.` : \"\"}\n </p>\n {selected ? (\n <>\n <EntityPreview\n entity={{\n id: selected.id,\n name: selected.name,\n typeLabel: selected.asset,\n summary: selected.summary,\n condition: {\n label:\n conflictsFor(snapshot.jobs, selected).length > 0\n ? \"Resource conflict\"\n : \"Scheduled\",\n variant:\n conflictsFor(snapshot.jobs, selected).length > 0 ? \"warning\" : \"neutral\",\n },\n facts: [\n { id: \"resource\", label: \"Resource\", value: resourceName(selected.resourceId) },\n { id: \"owner\", label: \"Work lead\", value: selected.owner },\n {\n id: \"window\",\n label: \"Service window\",\n value: <span className=\"font-mono\">{utcWindow(selected)}</span>,\n },\n {\n id: \"duration\",\n label: \"Duration\",\n value: (\n <span className=\"font-mono\">\n {hours((selected.end - selected.start) / 3_600_000)}\n </span>\n ),\n },\n ],\n quality: {\n state: \"current\",\n source: `Supplied plan · ${snapshot.revision}`,\n detail: \"Freshness reflects the supplied snapshot.\",\n },\n }}\n />\n <Dependencies selected={selected} snapshot={snapshot} />\n {requestedSelection && (\n <div className=\"flex flex-col gap-1u border-t border-oprt-border pt-2u\">\n <p role=\"alert\" className=\"m-0\">\n {pending\n ? \"The service is processing this change. Wait for its outcome before opening another work order.\"\n : `Keep this draft or discard it to open ${requestedSelection}.`}\n </p>\n <div className=\"flex flex-wrap gap-1u\">\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n disabled={pending}\n onClick={() => {\n const next = requestedSelection;\n closeEditor();\n setSelectedId(next);\n }}\n >\n Discard change and open work order\n </Button>\n <Button\n ref={keepEditing}\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setRequestedSelection(null)}\n >\n Keep editing\n </Button>\n </div>\n </div>\n )}\n {/* Always in the page, empty until there is an outcome: a screen reader speaks text\n that arrives in a status element it already knows, and said nothing for one that\n was inserted with its text (measured with NVDA in Chrome). */}\n <p\n role=\"status\"\n className={notice ? \"m-0 border-t border-oprt-border pt-2u\" : \"sr-only\"}\n >\n {notice}\n </p>\n {draft ? (\n <div className=\"flex flex-col gap-2u border-t border-oprt-border pt-2u\">\n {stale && (\n <div role=\"alert\" className=\"flex flex-col gap-1u\">\n <p className=\"m-0 text-oprt-warning\">\n The source revision changed. Load the latest revision before editing again.\n </p>\n <Button\n size=\"sm\"\n variant=\"tertiary\"\n className=\"self-start\"\n onClick={closeEditor}\n >\n Load latest revision\n </Button>\n </div>\n )}\n {failure && (\n <p role=\"alert\" className=\"m-0 text-oprt-error\">\n {failure}\n </p>\n )}\n {review ? (\n <section\n ref={reviewRef}\n tabIndex={-1}\n aria-label=\"Review schedule change\"\n className={`flex flex-col gap-2u ${focusStyle}`}\n >\n <h4 className=\"m-0 text-oprt-md font-medium\">Review schedule change</h4>\n <p className=\"m-0 font-mono\">\n {selected.id} · {draft.revision}\n </p>\n <div className=\"flex flex-col gap-1u\">\n <p className=\"m-0 font-medium\">Current</p>\n <p className=\"m-0\">{resourceName(selected.resourceId)}</p>\n <p className=\"m-0 font-mono\">{utcWindow(selected)}</p>\n </div>\n <div className=\"flex flex-col gap-1u\">\n <p className=\"m-0 font-medium\">Proposed</p>\n <p className=\"m-0\">{resourceName(review.resourceId)}</p>\n <p className=\"m-0 font-mono\">{utcWindow(review)}</p>\n </div>\n <p className=\"m-0 text-oprt-text-dim\">\n No resource overlap. Prerequisites and downstream work remain in sequence.\n </p>\n {!onApply && (\n <p className=\"m-0 text-oprt-text-dim\">\n Review only: connect a schedule service to apply changes.\n </p>\n )}\n <div className=\"flex flex-wrap gap-1u\">\n {onApply && (\n <Button disabled={pending || stale} onClick={() => void apply()}>\n {pending ? \"Applying change…\" : \"Apply schedule change\"}\n </Button>\n )}\n <Button\n variant=\"tertiary\"\n disabled={pending}\n onClick={() => {\n setReview(null);\n setFailure(\"\");\n }}\n >\n Back to editing\n </Button>\n </div>\n </section>\n ) : (\n <form\n ref={formRef}\n aria-label=\"Edit work window\"\n className=\"flex flex-col gap-2u\"\n onSubmit={(event) => {\n event.preventDefault();\n reviewChange();\n }}\n noValidate\n >\n <h4 className=\"m-0 text-oprt-md font-medium\">Edit work window</h4>\n <Field label=\"Resource\" controlId={`${instance}-resource`}>\n <Select\n value={draft.resourceId}\n onValueChange={(resourceId) => setDraft({ ...draft, resourceId })}\n disabled={stale}\n className=\"w-full\"\n >\n <>\n {snapshot.resources\n .filter(\n (resource) =>\n !selected.eligibleResourceIds ||\n selected.eligibleResourceIds.includes(resource.id),\n )\n .map((resource) => (\n <SelectOption key={resource.id} value={resource.id}>\n {resource.name}\n </SelectOption>\n ))}\n </>\n </Select>\n </Field>\n <Field label=\"Start · UTC\" controlId={`${instance}-start`}>\n <Input\n type=\"datetime-local\"\n step={60}\n value={draft.start}\n disabled={stale}\n onChange={(event) => setDraft({ ...draft, start: event.target.value })}\n className=\"w-full\"\n />\n </Field>\n <Field label=\"End · UTC\" controlId={`${instance}-end`}>\n <Input\n type=\"datetime-local\"\n step={60}\n value={draft.end}\n disabled={stale}\n onChange={(event) => setDraft({ ...draft, end: event.target.value })}\n className=\"w-full\"\n />\n </Field>\n <p className=\"m-0 text-oprt-text-dim\">\n Enter UTC values within {utcWindow(snapshot)}. Local device time is not\n used.\n </p>\n {/* Paragraphs, not a list: NVDA speaks the text of an alert when it appears but\n leaves out a list inside it (\"alert, Resolve before review\", with the\n reasons missing). */}\n {issues.length > 0 && (\n <div role=\"alert\" className=\"flex flex-col gap-1u text-oprt-error\">\n <p className=\"m-0 font-medium\">Resolve before review</p>\n {issues.map((issue, index) => (\n <p key={index} className=\"m-0\">\n {issue.message}\n </p>\n ))}\n </div>\n )}\n <div className=\"flex flex-wrap gap-1u\">\n <Button type=\"submit\" disabled={stale}>\n Review change\n </Button>\n <Button variant=\"tertiary\" onClick={closeEditor}>\n Cancel edit\n </Button>\n </div>\n </form>\n )}\n </div>\n ) : (\n <div className=\"border-t border-oprt-border pt-2u\">\n <Button ref={editButton} onClick={beginEdit}>\n Edit work window\n </Button>\n </div>\n )}\n </>\n ) : gone ? null : (\n <p className=\"m-0 text-oprt-text-dim\">No work orders in this planning window.</p>\n )}\n </Inspector>\n </div>\n </section>\n );\n}\n\nfunction Dependencies({\n selected,\n snapshot,\n}: {\n selected: ScheduleJob;\n snapshot: ScheduleSnapshot;\n}) {\n const prerequisites = selected.prerequisites.map((id) =>\n snapshot.jobs.find((job) => job.id === id),\n );\n const downstream = snapshot.jobs.filter((job) => job.prerequisites.includes(selected.id));\n return (\n <section\n aria-label=\"Dependencies and impact\"\n className=\"flex flex-col gap-2u border-t border-oprt-border pt-2u\"\n >\n <h4 className=\"m-0 text-oprt-md font-medium\">Dependencies & impact</h4>\n <div className=\"flex flex-col gap-1u\">\n <p className=\"m-0 font-medium text-oprt-text-dim\">Before this work</p>\n {prerequisites.length ? (\n prerequisites.map((job, index) => (\n <p key={selected.prerequisites[index]} className=\"m-0\">\n {job ? (\n <>\n {job.name}\n <span className=\"mt-0.5u block font-mono text-oprt-text-dim\">\n {job.id} · ends {utcTime(job.end)} UTC\n </span>\n </>\n ) : (\n `Missing prerequisite: ${selected.prerequisites[index]}`\n )}\n </p>\n ))\n ) : (\n <p className=\"m-0\">No prerequisites assigned.</p>\n )}\n </div>\n <div className=\"flex flex-col gap-1u\">\n <p className=\"m-0 font-medium text-oprt-text-dim\">Work that depends on this</p>\n {downstream.length ? (\n downstream.map((job) => (\n <p key={job.id} className=\"m-0\">\n {job.name}\n <span className=\"mt-0.5u block font-mono text-oprt-text-dim\">\n {job.id} · starts {utcTime(job.start)} UTC\n </span>\n </p>\n ))\n ) : (\n <p className=\"m-0\">No downstream work assigned.</p>\n )}\n </div>\n </section>\n );\n}\n",
4935
4935
  "patterns/planning-scheduling/schedule-state.ts": "export type ScheduleResource = { id: string; name: string; detail: string };\nexport type ScheduleJob = {\n id: string;\n name: string;\n asset: string;\n owner: string;\n resourceId: string;\n eligibleResourceIds?: readonly string[];\n start: number;\n end: number;\n prerequisites: readonly string[];\n summary: string;\n};\nexport type ScheduleSnapshot = {\n id: string;\n revision: string;\n title: string;\n location: string;\n summary: string;\n start: number;\n end: number;\n resources: readonly ScheduleResource[];\n jobs: readonly ScheduleJob[];\n};\nexport type ScheduleChange = { jobId: string; resourceId: string; start: number; end: number };\nexport type ScheduleApplyRequest = ScheduleChange & {\n scheduleId: string;\n expectedRevision: string;\n requestId: string;\n};\nexport type ScheduleApplyResult =\n | { status: \"accepted\"; requestId: string; snapshot: ScheduleSnapshot }\n | { status: \"rejected\" | \"conflict\"; requestId: string; message: string };\nexport type ScheduleIssue = {\n kind: \"window\" | \"resource\" | \"dependency\";\n message: string;\n jobId?: string;\n};\n\n/** Resource reservations are half-open intervals: [start, end). */\nexport function conflictsFor(jobs: readonly ScheduleJob[], job: ScheduleJob): string[] {\n return jobs\n .filter(\n (other) =>\n other.id !== job.id &&\n other.resourceId === job.resourceId &&\n other.start < job.end &&\n job.start < other.end,\n )\n .map((other) => other.id);\n}\n\nexport function validateChange(\n snapshot: ScheduleSnapshot,\n change: ScheduleChange,\n): ScheduleIssue[] {\n const original = snapshot.jobs.find((job) => job.id === change.jobId);\n if (!original)\n return [\n {\n kind: \"window\",\n message: \"This work order is no longer in the schedule. Load the latest revision.\",\n },\n ];\n if (!Number.isFinite(change.start) || !Number.isFinite(change.end) || change.end <= change.start)\n return [{ kind: \"window\", message: \"Enter a complete window with the end after the start.\" }];\n if (change.start < snapshot.start || change.end > snapshot.end)\n return [{ kind: \"window\", message: \"Keep both times within the displayed planning window.\" }];\n if (!snapshot.resources.some((resource) => resource.id === change.resourceId))\n return [{ kind: \"resource\", message: \"Choose an available resource.\" }];\n if (original.eligibleResourceIds && !original.eligibleResourceIds.includes(change.resourceId))\n return [\n {\n kind: \"resource\",\n message: \"This resource cannot perform the work. Choose a compatible resource.\",\n },\n ];\n const proposed = { ...original, ...change, id: original.id };\n const issues: ScheduleIssue[] = conflictsFor(snapshot.jobs, proposed).map((id) => ({\n kind: \"resource\",\n jobId: id,\n message: `Resource overlaps ${id} · ${snapshot.jobs.find((job) => job.id === id)!.name}. Choose another resource or window.`,\n }));\n for (const id of original.prerequisites) {\n const prerequisite = snapshot.jobs.find((job) => job.id === id);\n if (!prerequisite || prerequisite.end > change.start)\n issues.push({\n kind: \"dependency\",\n jobId: id,\n message: prerequisite\n ? `${id} · ${prerequisite.name} must finish before this work starts.`\n : `Prerequisite ${id} is missing. Restore the source record before applying.`,\n });\n }\n for (const dependent of snapshot.jobs.filter((job) => job.prerequisites.includes(original.id))) {\n if (dependent.start < change.end)\n issues.push({\n kind: \"dependency\",\n jobId: dependent.id,\n message: `${dependent.id} · ${dependent.name} starts before this work would finish. Keep its prerequisite available.`,\n });\n }\n return issues;\n}\n\n/** Track packing preserves exact time positions while making overlaps selectable. */\nexport function layoutLane(snapshot: ScheduleSnapshot, resourceId: string) {\n const ends: number[] = [];\n const duration = snapshot.end - snapshot.start;\n const items = snapshot.jobs\n .filter((job) => job.resourceId === resourceId)\n .sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))\n .map((job) => {\n let track = ends.findIndex((end) => end <= job.start);\n if (track === -1) track = ends.length;\n ends[track] = job.end;\n return {\n job,\n track,\n left: ((job.start - snapshot.start) / duration) * 100,\n width: ((job.end - job.start) / duration) * 100,\n };\n });\n return { items, trackCount: Math.max(1, ends.length) };\n}\n\nexport function parseUtcInput(value: string): number | null {\n if (!/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/.test(value)) return null;\n const instant = Date.parse(`${value}:00Z`);\n return Number.isFinite(instant) && new Date(instant).toISOString().slice(0, 16) === value\n ? instant\n : null;\n}\nexport const utcInput = (instant: number) => new Date(instant).toISOString().slice(0, 16);\nexport const utcTime = (instant: number) => new Date(instant).toISOString().slice(11, 16);\nexport const utcDate = (instant: number) => new Date(instant).toISOString().slice(0, 10);\nexport const utcWindow = (job: { start: number; end: number }) =>\n `${utcDate(job.start)} ${utcTime(job.start)}–${utcDate(job.start) === utcDate(job.end) ? \"\" : `${utcDate(job.end)} `}${utcTime(job.end)} UTC`;\n",
4936
4936
  "patterns/planning-scheduling/demo.tsx": "\"use client\";\n\nimport { useState } from \"react\";\nimport type { OperationalDomain } from \"../operational-domain\";\nimport { createScheduleFixture } from \"./fixtures\";\nimport { PlanningScheduling } from \"./planning-scheduling\";\nimport {\n validateChange,\n type ScheduleApplyRequest,\n type ScheduleApplyResult,\n} from \"./schedule-state\";\n\nexport function PlanningSchedulingDemo({\n initialDomain = \"industrial\",\n}: { initialDomain?: OperationalDomain } = {}) {\n return (\n <div className=\"not-prose min-w-0 flex flex-col gap-2u\">\n <LocalSchedule key={initialDomain} domain={initialDomain} />\n <p className=\"m-0 font-sans text-oprt-base text-oprt-text-dim\">\n Fictional maintenance plan. Changes stay in this page session.\n </p>\n </div>\n );\n}\n\n/** Public industrial example; legacy fixtures remain available to regression tests. */\nexport function MaintenancePlanningDemo() {\n return <PlanningSchedulingDemo initialDomain=\"industrial\" />;\n}\n\nfunction LocalSchedule({ domain }: { domain: OperationalDomain }) {\n const [snapshot, setSnapshot] = useState(() => createScheduleFixture(domain));\n const apply = async (request: ScheduleApplyRequest): Promise<ScheduleApplyResult> => {\n if (request.scheduleId !== snapshot.id || request.expectedRevision !== snapshot.revision)\n return {\n status: \"conflict\",\n requestId: request.requestId,\n message: \"The source revision changed. Load the latest revision before applying.\",\n };\n const issues = validateChange(snapshot, request);\n if (issues.length)\n return {\n status: \"rejected\",\n requestId: request.requestId,\n message: issues.map((issue) => issue.message).join(\" \"),\n };\n return {\n status: \"accepted\",\n requestId: request.requestId,\n snapshot: {\n ...snapshot,\n revision: `plan-${String(Number(snapshot.revision.slice(5)) + 1).padStart(2, \"0\")}`,\n jobs: snapshot.jobs.map((job) =>\n job.id === request.jobId\n ? { ...job, resourceId: request.resourceId, start: request.start, end: request.end }\n : job,\n ),\n },\n };\n };\n return (\n <PlanningScheduling\n snapshot={snapshot}\n initialSelectedJobId={domain === \"military\" ? \"WO-1842\" : \"PM-2402\"}\n onApply={apply}\n onAccepted={setSnapshot}\n acceptanceMessage=\"Schedule updated locally. The resource conflict is resolved for this work order.\"\n />\n );\n}\n",
4937
4937
  "patterns/operational-domain.ts": "/** Fixture context used by reusable application recipes. */\nexport type OperationalDomain = \"military\" | \"industrial\";\n\nexport interface UtcInstantOptions {\n /**\n * Whether the milliseconds are shown. \"auto\" (the default) shows them only when the supplied\n * instant carries a non-zero fraction, because sub-second timing matters only where it exists.\n */\n milliseconds?: \"auto\" | \"always\" | \"never\";\n}\n\nconst INSTANT =\n /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.(\\d{1,3}))?(Z|[+-]\\d{2}:\\d{2})$/;\n\n/**\n * Reads an ISO 8601 instant as \"YYYY-MM-DD HH:MM:SS UTC\", with \".mmm\" after the seconds where the\n * options ask for it. Text that is not a real instant (an invented date such as 30 February, or\n * free text) is returned as supplied rather than reinterpreted. It never uses Intl, so the server\n * and every browser print the same characters.\n */\nexport function formatUtcInstant(value: string, options: UtcInstantOptions = {}): string {\n const parts = INSTANT.exec(value);\n if (!parts) return value;\n const [year, month, day, hour, minute, second] = parts.slice(1, 7).map(Number) as [\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n const calendar = new Date(Date.UTC(year, month - 1, day));\n // Date rolls an invented day into the next month; compare it back to what was written.\n if (\n calendar.getUTCFullYear() !== year ||\n calendar.getUTCMonth() !== month - 1 ||\n calendar.getUTCDate() !== day ||\n hour > 23 ||\n minute > 59 ||\n second > 59\n )\n return value;\n const fraction = (parts[7] ?? \"\").padEnd(3, \"0\");\n const parsed = new Date(`${value.slice(0, 19)}.${fraction}${parts[8]}`);\n if (Number.isNaN(parsed.getTime())) return value;\n const { milliseconds = \"auto\" } = options;\n const showFraction = milliseconds === \"always\" || (milliseconds === \"auto\" && fraction !== \"000\");\n const iso = parsed.toISOString();\n return `${iso.slice(0, 10)} ${iso.slice(11, showFraction ? 23 : 19)} UTC`;\n}\n\n/**\n * Reads epoch milliseconds as \"YYYY-MM-DD HH:MM:SS UTC\", with the same options as\n * formatUtcInstant. A value that is not a real time reads \"Unknown time\" rather than throwing.\n */\nexport function formatUtcEpoch(ms: number, options: UtcInstantOptions = {}): string {\n const date = new Date(ms);\n if (!Number.isFinite(ms) || Number.isNaN(date.getTime())) return \"Unknown time\";\n return formatUtcInstant(date.toISOString(), options);\n}\n",