@12-apps/notifications 4.10.2 → 4.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ADOPTING.md +17 -4
- package/README.md +7 -5
- package/dist/{chunk-I5QUMTCN.js → chunk-H55A4LHG.js} +2 -2
- package/dist/{chunk-ZFIYBNZ7.js → chunk-KEYRE245.js} +3 -6
- package/dist/chunk-KEYRE245.js.map +1 -0
- package/dist/{chunk-ZY32PC34.js → chunk-MPUOVQVX.js} +2 -4
- package/dist/chunk-MPUOVQVX.js.map +1 -0
- package/dist/email/previews/react/index.js +1 -1
- package/dist/manifest/web.js +2 -2
- package/dist/{panel-OPB3DBLJ.js → panel-V2ULFC4Y.js} +2 -2
- package/dist/react/index.d.ts +25 -5
- package/dist/react/index.js +2 -2
- package/package.json +1 -1
- package/src/email/previews/react/loadable.tsx +4 -1
- package/src/email/previews/react/preview-screen.tsx +4 -4
- package/src/react/live-section.tsx +32 -14
- package/dist/chunk-ZFIYBNZ7.js.map +0 -1
- package/dist/chunk-ZY32PC34.js.map +0 -1
- /package/dist/{chunk-I5QUMTCN.js.map → chunk-H55A4LHG.js.map} +0 -0
- /package/dist/{panel-OPB3DBLJ.js.map → panel-V2ULFC4Y.js.map} +0 -0
package/ADOPTING.md
CHANGED
|
@@ -472,8 +472,11 @@ createWebNotifications({
|
|
|
472
472
|
your session, your query client), which is the same reason
|
|
473
473
|
`NotificationsSignalHook` exists one seam over. `active` is `false` while the
|
|
474
474
|
panel is shut; pass it to your query's `enabled`. Ignoring it is correct and
|
|
475
|
-
merely costs money —
|
|
476
|
-
|
|
475
|
+
merely costs money — but that money is now real: the BELL calls this hook
|
|
476
|
+
too, and the bell is mounted for as long as your app is. There is no
|
|
477
|
+
"unopened inbox is free" any more. A host answering with a poll polls for
|
|
478
|
+
every signed-in reader on every screen; answer from a pushed cache if you
|
|
479
|
+
can.
|
|
477
480
|
2. **Three sentences**, in their own pack rather than on
|
|
478
481
|
`NotificationMessages` — live activities are opt-in, and requiring copy for a
|
|
479
482
|
section you never render is the tax that gets a required-config change
|
|
@@ -481,8 +484,18 @@ createWebNotifications({
|
|
|
481
484
|
|
|
482
485
|
**Two rules that are ours, not yours:**
|
|
483
486
|
|
|
484
|
-
- A live activity
|
|
485
|
-
|
|
487
|
+
- A live activity DOES count on the bell, and it is not counted as unread. The
|
|
488
|
+
badge's number is `unread + live`, and its TONE says which: `primary` when
|
|
489
|
+
something has happened, `neutral` when the only thing there has already been
|
|
490
|
+
looked at. That distinction is the answer to the original objection — a number
|
|
491
|
+
no amount of reading can clear — which is why the tone exists at all.
|
|
492
|
+
- **An inbox notification about a subject you also report as live is counted
|
|
493
|
+
TWICE on the bell** — once as the unread row, once as the activity. That is
|
|
494
|
+
not a reason to stop sending them: the section below tells you to tag them
|
|
495
|
+
with `LIVE_SUBJECT_KEY` precisely so the phone collapses them, and the row is
|
|
496
|
+
still the record the reader re-reads later. It is a reason to decide which
|
|
497
|
+
surface owns the COUNT before you turn live activities on, because this
|
|
498
|
+
package does not decide it for you.
|
|
486
499
|
- It leaves when your hook stops returning it. There is no dismiss, no read and
|
|
487
500
|
no delete — the subject finishing is the only exit, which is what stops the
|
|
488
501
|
section becoming a second inbox.
|
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ Mounted under whatever prefix the host chooses (the origin host: `/api/account`)
|
|
|
84
84
|
| | |
|
|
85
85
|
|---|---|
|
|
86
86
|
| `GET /notifications` | the owner's inbox — newest first, cursor-paginated, `filter=unread` |
|
|
87
|
-
| `GET /notifications/unread-count` | the badge number, a single indexed COUNT |
|
|
87
|
+
| `GET /notifications/unread-count` | the inbox half of the badge number, a single indexed COUNT |
|
|
88
88
|
| `POST /notifications/mark-read` | `{ ids }` or `{ all: true }`; idempotent, reports what moved |
|
|
89
89
|
| `POST /notifications/delete` | soft delete, 1..100 ids; the delivery trail survives |
|
|
90
90
|
| `GET` / `PUT /notification-preferences` | the category × channel matrix, plus per-channel AVAILABILITY (destination on file + channel declared) so dead toggles render disabled with a hint |
|
|
@@ -215,10 +215,12 @@ createWebNotifications({
|
|
|
215
215
|
```
|
|
216
216
|
|
|
217
217
|
`active` is whether anyone is looking — `false` while the panel is shut. Pass it
|
|
218
|
-
to your query's `enabled`. It is a hint about NEED, never about correctness
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
218
|
+
to your query's `enabled`. It is a hint about NEED, never about correctness.
|
|
219
|
+
|
|
220
|
+
It is NOT a promise that a shut panel costs nothing: the bell calls the same
|
|
221
|
+
hook, and the bell is always mounted. `active` earns its keep on the closing
|
|
222
|
+
transition, where a query told to stand down does not fire one last time on the
|
|
223
|
+
way out.
|
|
222
224
|
|
|
223
225
|
One activity is `{ id, kind, title, body, link, steps, activeStepId, updatedAt }`
|
|
224
226
|
— `LiveActivity`, importable from either entry (`@12-apps/notifications` or
|
|
@@ -278,7 +278,7 @@ import { Suspense, lazy, useEffect, useState } from "react";
|
|
|
278
278
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
279
279
|
function lazyNotificationsPanel(parts) {
|
|
280
280
|
const Bound = lazy(async () => {
|
|
281
|
-
const { NotificationsPanel } = await import("./panel-
|
|
281
|
+
const { NotificationsPanel } = await import("./panel-V2ULFC4Y.js");
|
|
282
282
|
return {
|
|
283
283
|
default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx2(NotificationsPanel, { ...props, ...parts }), "default")
|
|
284
284
|
};
|
|
@@ -384,4 +384,4 @@ export {
|
|
|
384
384
|
httpNotificationsTransport,
|
|
385
385
|
createWebNotifications
|
|
386
386
|
};
|
|
387
|
-
//# sourceMappingURL=chunk-
|
|
387
|
+
//# sourceMappingURL=chunk-H55A4LHG.js.map
|
|
@@ -147,7 +147,7 @@ function useLoadable(load, options = {}) {
|
|
|
147
147
|
return () => {
|
|
148
148
|
live = false;
|
|
149
149
|
};
|
|
150
|
-
}, [load, nonce]);
|
|
150
|
+
}, [load, nonce, keepPrevious]);
|
|
151
151
|
return { ...state, reload: /* @__PURE__ */ __name(() => setNonce((n) => n + 1), "reload") };
|
|
152
152
|
}
|
|
153
153
|
__name(useLoadable, "useLoadable");
|
|
@@ -462,10 +462,7 @@ function createEmailPreviewScreen(config) {
|
|
|
462
462
|
const [, setUrlNonce] = useState2(0);
|
|
463
463
|
const locale = searchParam("locale") ?? "";
|
|
464
464
|
const selectedId = searchParam("id");
|
|
465
|
-
const load = useCallback(
|
|
466
|
-
() => fetchEmailPreviewIndex(apiBase, locale),
|
|
467
|
-
[apiBase, locale]
|
|
468
|
-
);
|
|
465
|
+
const load = useCallback(() => fetchEmailPreviewIndex(apiBase, locale), [locale]);
|
|
469
466
|
const index = useLoadable(load, { keepPrevious: true });
|
|
470
467
|
const patch = /* @__PURE__ */ __name((next) => {
|
|
471
468
|
patchSearch(next);
|
|
@@ -499,4 +496,4 @@ __name(createEmailPreviewScreen, "createEmailPreviewScreen");
|
|
|
499
496
|
export {
|
|
500
497
|
createEmailPreviewScreen
|
|
501
498
|
};
|
|
502
|
-
//# sourceMappingURL=chunk-
|
|
499
|
+
//# sourceMappingURL=chunk-KEYRE245.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/email/previews/react/message-view.tsx","../src/email/previews/react/preview-screen.tsx","../src/email/previews/react/loadable.tsx","../src/email/previews/react/message-list.tsx","../src/email/previews/react/transport.ts"],"sourcesContent":["import type { JSX } from 'react';\n\nimport { ToggleGroup } from '@12-apps/ui/form/ToggleGroup';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewDetail } from '../catalog';\n\nimport type { EmailPreviewScreenCopy } from './copy';\n\n/**\n * One rendered message, in the three ways it is worth looking at.\n *\n * - **HTML** — what most people will see, in a sandboxed frame at either of the\n * two widths that matter. A phone is not a nice-to-have: more than half of\n * transactional mail is opened on one, and the 600px card is exactly the\n * thing that either survives that or does not.\n * - **Text** — the plain-text twin. Worth its own tab because it is what a spam\n * filter scores, what a watch shows, and what a screen reader in plain-text\n * mode reads — and because it is the half nobody ever looks at, which is how\n * it drifts out of step with the HTML.\n * - **Source** — the markup itself, for the moment somebody is debugging why a\n * client rendered it oddly.\n *\n * ## Why an iframe, and why sandboxed\n *\n * The mail is a whole document with its own `<body>` background, and rendering\n * that inside the console's DOM would both break the mail (the console's CSS\n * reaches it) and break the console (the mail's body styles reach the page). A\n * frame is the only honest preview.\n *\n * `sandbox=\"\"` — no scripts, no forms, no top-level navigation. These documents\n * come from the host's own renderer and carry no script, so this is less a\n * containment measure than a statement that the preview is INERT: a click on a\n * CTA inside a previewed mail must never navigate the operator anywhere, least\n * of all to a sample verification link.\n */\n\n/** The two widths the HTML view renders at. */\nexport type PreviewWidth = 'desktop' | 'mobile';\n\n/** Which of the three views is showing. */\nexport type PreviewTab = 'html' | 'text' | 'source';\n\n/** A phone. 390px is a common iPhone CSS width, and among the narrowest. */\nconst MOBILE_WIDTH = 390;\n\nfunction Monospace({ children, testId }: { children: string; testId: string }): JSX.Element {\n return (\n <Box\n component=\"pre\"\n data-testid={testId}\n sx={{\n m: 0,\n p: 2,\n borderRadius: 1.5,\n border: '1px solid',\n borderColor: 'divider',\n background: 'background.default',\n fontSize: 13,\n lineHeight: 1.6,\n whiteSpace: 'pre-wrap',\n wordBreak: 'break-word',\n maxHeight: 720,\n overflow: 'auto',\n }}\n >\n {children}\n </Box>\n );\n}\n\nfunction HtmlFrame({\n detail,\n width,\n title,\n}: {\n detail: EmailPreviewDetail;\n width: PreviewWidth;\n title: string;\n}): JSX.Element {\n return (\n <Box\n sx={{\n display: 'flex',\n justifyContent: 'center',\n borderRadius: 1.5,\n border: '1px solid',\n borderColor: 'divider',\n overflow: 'hidden',\n }}\n >\n <Box\n component=\"iframe\"\n data-testid=\"email-preview-frame\"\n title={title}\n srcDoc={detail.html}\n sandbox=\"\"\n sx={{\n border: 0,\n width: width === 'mobile' ? MOBILE_WIDTH : '100%',\n height: 760,\n background: '#fff',\n }}\n />\n </Box>\n );\n}\n\ninterface MessageViewProps {\n detail: EmailPreviewDetail;\n copy: EmailPreviewScreenCopy;\n tab: PreviewTab;\n width: PreviewWidth;\n onTabChange: (tab: PreviewTab) => void;\n onWidthChange: (width: PreviewWidth) => void;\n}\n\nexport function MessageView(props: MessageViewProps): JSX.Element {\n const { detail, copy, tab, width, onTabChange, onWidthChange } = props;\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }} data-testid=\"email-preview-view\">\n <Box>\n <Text as=\"p\" size=\"xs\" color=\"secondary\">\n {copy.subjectLabel}\n </Text>\n <Text as=\"p\" size=\"md\" weight=\"medium\" data-testid=\"email-preview-subject\">\n {detail.subject}\n </Text>\n </Box>\n <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>\n <ToggleGroup\n dataTestId=\"email-preview-tabs\"\n exclusive\n value={tab}\n size=\"sm\"\n options={[\n { value: 'html', label: copy.tabHtml },\n { value: 'text', label: copy.tabText },\n { value: 'source', label: copy.tabSource },\n ]}\n onChange={(_event, value) => {\n if (value) onTabChange(value as PreviewTab);\n }}\n />\n {tab === 'html' ? (\n <ToggleGroup\n dataTestId=\"email-preview-width\"\n exclusive\n value={width}\n size=\"sm\"\n options={[\n { value: 'desktop', label: copy.widthDesktop },\n { value: 'mobile', label: copy.widthMobile },\n ]}\n onChange={(_event, value) => {\n if (value) onWidthChange(value as PreviewWidth);\n }}\n />\n ) : null}\n </Box>\n {tab === 'html' ? <HtmlFrame detail={detail} width={width} title={copy.frameTitle} /> : null}\n {tab === 'text' ? <Monospace testId=\"email-preview-text\">{detail.text}</Monospace> : null}\n {tab === 'source' ? <Monospace testId=\"email-preview-source\">{detail.html}</Monospace> : null}\n </Box>\n );\n}\n","import { useCallback, useMemo, useState, type JSX } from 'react';\n\nimport { Alert } from '@12-apps/ui/data-display/Alert';\nimport { Input } from '@12-apps/ui/form/Input';\nimport { ToggleGroup } from '@12-apps/ui/form/ToggleGroup';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Heading } from '@12-apps/ui/typography/Heading';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewCoverage, EmailPreviewDetail, EmailPreviewIndex } from '../catalog';\n\nimport type { EmailPreviewScreenCopy } from './copy';\nimport { Failure, useLoadable } from './loadable';\nimport { MessageList, matchesFilter } from './message-list';\nimport { MessageView, type PreviewTab, type PreviewWidth } from './message-view';\nimport { fetchEmailPreview, fetchEmailPreviewIndex } from './transport';\n\n/**\n * The operator screen over the `./email/previews` catalogue.\n *\n * ## What a host supplies, and what it does not\n *\n * `apiBase` — where the routes are mounted — and `copy`, by name. Nothing else:\n * the owners, the languages, the messages and their subjects all arrive from\n * the surface, because they are facts about the host's own mail that no prop\n * could usefully restate.\n *\n * ## The selection lives in the URL, without a router\n *\n * `?id=` and `?locale=`, read and written through `history.replaceState`. A\n * link to one mail in one language is the actual workflow this screen serves —\n * \"look at what the reset mail says now\" — and local state would make every\n * such conversation a set of instructions instead of a link.\n *\n * Deliberately NOT a router integration: this package cannot know whether a\n * host runs react-router, TanStack Router or a framework's own, and a screen\n * that imported one would be unmountable in the other two. `replaceState` is\n * the one API all of them are built on, and `replace` rather than `push` so\n * browsing twenty mails is not twenty back-button steps.\n */\n\nexport interface EmailPreviewScreenConfig {\n /** Where the routes are mounted, e.g. `/api/platform/email-previews`. */\n readonly apiBase: string;\n /** The screen's words. REQUIRED — see `./copy`. */\n readonly copy: EmailPreviewScreenCopy;\n}\n\n/** Read one search param without assuming a router owns the URL. */\nfunction searchParam(name: string): string | null {\n if (typeof window === 'undefined') return null;\n return new URLSearchParams(window.location.search).get(name);\n}\n\n/**\n * Patch the query string in place, keeping everything else about the URL.\n *\n * The HASH is carried over deliberately, and it is not defensive coding: a host\n * that routes on `location.hash` — the consumer harness does, and so does any\n * SPA served from a static file — would otherwise be navigated off this screen\n * by its own locale switch, because rebuilding the URL from `pathname` alone\n * silently drops the fragment that says which page this is.\n *\n * `replaceState` rather than `pushState` for the reason the screen exists:\n * browsing twenty messages is one place to come back from, not twenty\n * back-button steps.\n */\nfunction patchSearch(patch: Record<string, string>): void {\n if (typeof window === 'undefined') return;\n const next = new URLSearchParams(window.location.search);\n for (const [key, value] of Object.entries(patch)) next.set(key, value);\n const { pathname, hash } = window.location;\n window.history.replaceState({}, '', `${pathname}?${next.toString()}${hash}`);\n}\n\n/** The surface's honest report about what it cannot show. */\nfunction CoverageNotice({\n coverage,\n copy,\n}: {\n coverage: EmailPreviewCoverage;\n copy: EmailPreviewScreenCopy;\n}): JSX.Element | null {\n if (coverage.missing.length === 0 && coverage.orphan.length === 0) return null;\n return (\n <Alert severity=\"warning\" data-testid=\"email-preview-coverage\">\n <Text as=\"p\" size=\"sm\" weight=\"medium\">\n {copy.coverageTitle}\n </Text>\n {coverage.missing.length > 0 ? (\n <Text as=\"p\" size=\"sm\">\n {copy.missingSamples(coverage.missing.join(', '))}\n </Text>\n ) : null}\n {coverage.orphan.length > 0 ? (\n <Text as=\"p\" size=\"sm\">\n {copy.orphanSamples(coverage.orphan.join(', '))}\n </Text>\n ) : null}\n </Alert>\n );\n}\n\n/** The right-hand pane: the selected message, or an invitation to pick one. */\nfunction PreviewPane({\n apiBase,\n id,\n locale,\n copy,\n tab,\n width,\n onTabChange,\n onWidthChange,\n}: {\n apiBase: string;\n id: string | null;\n locale: string;\n copy: EmailPreviewScreenCopy;\n tab: PreviewTab;\n width: PreviewWidth;\n onTabChange: (tab: PreviewTab) => void;\n onWidthChange: (width: PreviewWidth) => void;\n}): JSX.Element {\n const load = useCallback(\n () =>\n id === null\n ? Promise.resolve(null as EmailPreviewDetail | null)\n : fetchEmailPreview(apiBase, id, locale),\n [apiBase, id, locale],\n );\n const detail = useLoadable(load);\n\n if (id === null) {\n return (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-empty\">\n {copy.pickOne}\n </Text>\n );\n }\n if (detail.error !== null) {\n return <Failure message={detail.error} copy={copy} onRetry={detail.reload} />;\n }\n if (detail.data === null) {\n return (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-detail-loading\">\n {copy.loading}\n </Text>\n );\n }\n return (\n <MessageView\n detail={detail.data}\n copy={copy}\n tab={tab}\n width={width}\n onTabChange={onTabChange}\n onWidthChange={onWidthChange}\n />\n );\n}\n\n/** The left column: the filter, and the rows under their owners. */\nfunction CatalogueColumn({\n index,\n copy,\n selectedId,\n onSelect,\n}: {\n index: EmailPreviewIndex;\n copy: EmailPreviewScreenCopy;\n selectedId: string | null;\n onSelect: (id: string) => void;\n}): JSX.Element {\n const [filter, setFilter] = useState('');\n const visible = useMemo(\n () => index.items.filter((row) => matchesFilter(row, filter)),\n [index.items, filter],\n );\n return (\n /*\n The list owns its OWN scroll rather than growing the page.\n\n A host's console is typically a fixed-viewport shell whose centre column\n scrolls, and a twenty-mail catalogue then scrolls the preview frame off\n the screen — the two things this screen exists to show side by side\n cannot both be on it. `sticky` keeps the list put while the frame is\n read; the height is the viewport minus the chrome above it, so the column\n ends where the window does rather than at an arbitrary pixel count.\n */\n <Box\n sx={{\n width: 320,\n flexShrink: 0,\n display: 'flex',\n flexDirection: 'column',\n gap: 1.5,\n position: 'sticky',\n top: 0,\n maxHeight: 'calc(100dvh - 220px)',\n minHeight: 240,\n }}\n >\n <Input\n label={copy.searchLabel}\n placeholder={copy.searchPlaceholder}\n value={filter}\n data-testid=\"email-preview-filter\"\n onChange={(event) => setFilter(event.target.value)}\n fullWidth\n />\n {/* Only the ROWS scroll — the filter field stays reachable. */}\n <Box sx={{ overflowY: 'auto', flex: 1, pr: 0.5 }}>\n <MessageList rows={visible} selectedId={selectedId} copy={copy} onSelect={onSelect} />\n </Box>\n </Box>\n );\n}\n\n/** The catalogue and the preview, once the index has loaded. */\nfunction Browser({\n apiBase,\n index,\n copy,\n locale,\n selectedId,\n onPatch,\n}: {\n apiBase: string;\n index: EmailPreviewIndex;\n copy: EmailPreviewScreenCopy;\n locale: string;\n selectedId: string | null;\n onPatch: (patch: Record<string, string>) => void;\n}): JSX.Element {\n const [tab, setTab] = useState<PreviewTab>('html');\n const [width, setWidth] = useState<PreviewWidth>('desktop');\n\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n <CoverageNotice coverage={index.coverage} copy={copy} />\n <ToggleGroup\n dataTestId=\"email-preview-locale\"\n exclusive\n value={locale}\n size=\"sm\"\n options={index.locales.map((tag) => ({ value: tag, label: tag }))}\n onChange={(_event, value) => {\n if (value) onPatch({ locale: String(value) });\n }}\n />\n <Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', flexWrap: 'wrap' }}>\n <CatalogueColumn\n index={index}\n copy={copy}\n selectedId={selectedId}\n onSelect={(id) => onPatch({ id })}\n />\n <Box sx={{ flex: 1, minWidth: 360 }}>\n <PreviewPane\n apiBase={apiBase}\n id={selectedId}\n locale={locale}\n copy={copy}\n tab={tab}\n width={width}\n onTabChange={setTab}\n onWidthChange={setWidth}\n />\n </Box>\n </Box>\n </Box>\n );\n}\n\n/**\n * Build the screen. One call, one config object — the shape every factory in\n * this estate has.\n */\nexport function createEmailPreviewScreen(config: EmailPreviewScreenConfig): {\n page: () => JSX.Element;\n} {\n const { apiBase, copy } = config;\n\n function EmailPreviewsPage(): JSX.Element {\n // The VALUE is deliberately discarded: nothing reads the counter, and the\n // only thing it has to do is change, so React re-renders and the URL is\n // re-read below. It used to be a dependency of the catalogue fetch, which\n // is what made every row click refetch the list.\n const [, setUrlNonce] = useState(0);\n const locale = searchParam('locale') ?? '';\n const selectedId = searchParam('id');\n // The catalogue depends on the LANGUAGE and on nothing else. `urlNonce` is\n // deliberately absent: it counts every URL patch, selection included, and\n // including it here refetched the whole catalogue on each row click — for a\n // list whose contents cannot have changed. What the click actually needs is\n // a re-RENDER, so `selectedId` is re-read, and `setUrlNonce` already does\n // that on its own.\n // Not `apiBase`: it comes from the FACTORY's config, not from this\n // component, so it is constant for the component's whole life and listing\n // it says this callback can change when it cannot.\n const load = useCallback(() => fetchEmailPreviewIndex(apiBase, locale), [locale]);\n const index = useLoadable(load, { keepPrevious: true });\n\n const patch = (next: Record<string, string>): void => {\n patchSearch(next);\n // `replaceState` does not notify React, so the screen re-reads the URL\n // through this counter rather than through a router's own subscription.\n setUrlNonce((n) => n + 1);\n };\n\n return (\n <Box data-testid=\"page-email-previews\" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n <Box>\n <Heading level=\"h2\">{copy.title}</Heading>\n <Text as=\"p\" size=\"sm\" color=\"secondary\">\n {copy.description}\n </Text>\n </Box>\n {index.error !== null ? (\n <Failure message={index.error} copy={copy} onRetry={index.reload} />\n ) : null}\n {index.error === null && index.data === null ? (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-index-loading\">\n {copy.loading}\n </Text>\n ) : null}\n {index.data !== null ? (\n <Browser\n apiBase={apiBase}\n index={index.data}\n copy={copy}\n locale={index.data.locale}\n selectedId={selectedId}\n onPatch={patch}\n />\n ) : null}\n </Box>\n );\n }\n\n return { page: EmailPreviewsPage };\n}\n","import type { JSX } from 'react';\nimport { useEffect, useState } from 'react';\n\nimport { Alert } from '@12-apps/ui/data-display/Alert';\nimport { Button } from '@12-apps/ui/form/Button';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewScreenCopy } from './copy';\n\n/**\n * The screen's async plumbing: one hook, and the state it cannot render itself.\n *\n * Its own module because it is the half with no opinion about e-mail. What is\n * left in `preview-screen.tsx` is composition — which columns, which panes,\n * what the URL says — and this is the machinery underneath all of it.\n *\n * `keepPrevious` is the load-bearing option and its docblock says why: a\n * consumer that renders children only while `data` is non-null gets those\n * children UNMOUNTED by a blanking reload, which silently discards whatever\n * state they held.\n */\n\n/** A load that can fail, in the two states a screen has to render. */\ninterface Loadable<T> {\n data: T | null;\n error: string | null;\n}\n\nexport function useLoadable<T>(\n load: () => Promise<T>,\n options: { keepPrevious?: boolean } = {},\n): Loadable<T> & { reload: () => void } {\n const [state, setState] = useState<Loadable<T>>({ data: null, error: null });\n const [nonce, setNonce] = useState(0);\n const { keepPrevious = false } = options;\n useEffect(() => {\n let live = true;\n // `keepPrevious` holds the last good answer on screen while the next one is\n // in flight, and it is not a nicety. The consumer of this hook renders its\n // children only while `data` is non-null, so blanking here UNMOUNTS them —\n // taking the filter text, the open tab and the chosen width with it. On a\n // fast connection the refetch lands before anyone notices; on a slow one\n // the operator watches what they just typed disappear.\n setState((previous) => (keepPrevious ? { ...previous, error: null } : { data: null, error: null }));\n load()\n .then((data) => live && setState({ data, error: null }))\n .catch(\n (error: unknown) =>\n live &&\n setState({ data: null, error: error instanceof Error ? error.message : String(error) }),\n );\n return () => {\n // A language switched twice in a second must not let the FIRST answer\n // land last — the screen would show a document the operator did not ask\n // for, with the toggle disagreeing.\n live = false;\n };\n // `keepPrevious` belongs here: it decides whether this effect blanks the\n // children, so a caller that flips it must get the new behaviour rather\n // than the one captured on first render.\n }, [load, nonce, keepPrevious]);\n return { ...state, reload: () => setNonce((n) => n + 1) };\n}\n\nexport function Failure({\n message,\n copy,\n onRetry,\n}: {\n message: string;\n copy: EmailPreviewScreenCopy;\n onRetry: () => void;\n}): JSX.Element {\n return (\n <Box data-testid=\"email-preview-error\" sx={{ display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'flex-start' }}>\n <Alert severity=\"error\">\n <Text as=\"p\" size=\"sm\">{copy.loadError}</Text>\n <Text as=\"p\" size=\"sm\">{message}</Text>\n </Alert>\n <Button size=\"sm\" variant=\"outline\" onClick={onRetry}>\n {copy.retry}\n </Button>\n </Box>\n );\n}\n","import type { JSX } from 'react';\n\nimport { Chip } from '@12-apps/ui/data-display/Chip';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewRow } from '../catalog';\n\nimport type { EmailPreviewScreenCopy } from './copy';\n\n/**\n * The catalogue, grouped by the PACKAGE that owns each message.\n *\n * Grouping by owner rather than by family is the whole answer to \"which parts\n * of this system send mail\": the section headers ARE that list, derived from\n * the rows the surface sent rather than written down anywhere, so a package\n * that starts sending mail appears the day it does.\n *\n * The family stays visible as a chip on each row, because it is the other\n * question an operator asks — and one owner can span two families.\n */\n\ninterface OwnerGroup {\n owner: string;\n rows: EmailPreviewRow[];\n}\n\n/** Group in FIRST-SEEN order, so the list does not reshuffle between renders. */\nfunction groupByOwner(rows: readonly EmailPreviewRow[]): OwnerGroup[] {\n const groups = new Map<string, EmailPreviewRow[]>();\n for (const row of rows) {\n const bucket = groups.get(row.owner);\n if (bucket) bucket.push(row);\n else groups.set(row.owner, [row]);\n }\n return [...groups].map(([owner, ownerRows]) => ({ owner, rows: ownerRows }));\n}\n\n/** Does this row match what was typed? Subject, key, owner and family all count. */\nexport function matchesFilter(row: EmailPreviewRow, filter: string): boolean {\n const needle = filter.trim().toLowerCase();\n if (needle === '') return true;\n return [row.subject, row.key, row.owner, row.family].some((field) =>\n field.toLowerCase().includes(needle),\n );\n}\n\nfunction MessageRow({\n row,\n selected,\n onSelect,\n}: {\n row: EmailPreviewRow;\n selected: boolean;\n onSelect: (id: string) => void;\n}): JSX.Element {\n return (\n <Box\n component=\"button\"\n type=\"button\"\n data-testid={`email-preview-row-${row.id}`}\n aria-current={selected}\n onClick={() => onSelect(row.id)}\n sx={{\n appearance: 'none',\n textAlign: 'left',\n width: '100%',\n cursor: 'pointer',\n border: '1px solid',\n borderColor: selected ? 'primary.main' : 'divider',\n background: selected ? 'action.selected' : 'background.paper',\n borderRadius: 1.5,\n p: 1.25,\n display: 'flex',\n flexDirection: 'column',\n gap: 0.5,\n }}\n >\n <Text as=\"span\" size=\"sm\" weight=\"medium\">\n {row.subject}\n </Text>\n <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>\n <Chip label={row.family} size=\"sm\" variant=\"outlined\" color=\"neutral\" />\n <Text as=\"span\" size=\"xs\" color=\"secondary\">\n {row.key}\n </Text>\n </Box>\n </Box>\n );\n}\n\nexport function MessageList({\n rows,\n selectedId,\n copy,\n onSelect,\n}: {\n rows: readonly EmailPreviewRow[];\n selectedId: string | null;\n copy: EmailPreviewScreenCopy;\n onSelect: (id: string) => void;\n}): JSX.Element {\n if (rows.length === 0) {\n return (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-no-matches\">\n {copy.noMatches}\n </Text>\n );\n }\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n {groupByOwner(rows).map((group) => (\n <Box\n key={group.owner}\n data-testid={`email-preview-owner-${group.owner}`}\n sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}\n >\n <Text as=\"p\" size=\"xs\" weight=\"medium\" color=\"secondary\">\n {group.owner}\n </Text>\n {group.rows.map((row) => (\n <MessageRow\n key={row.id}\n row={row}\n selected={row.id === selectedId}\n onSelect={onSelect}\n />\n ))}\n </Box>\n ))}\n </Box>\n );\n}\n","import type { EmailPreviewDetail, EmailPreviewIndex } from '../catalog';\n\n/**\n * How the screen reaches its own endpoints.\n *\n * Plain `fetch` rather than a data library: this package cannot know whether a\n * host runs react-query, SWR or nothing at all, and a screen that dragged one\n * in would put a second cache beside whichever the host already has. The two\n * calls here are a list and a document — neither needs invalidation, retries or\n * shared state, which is most of what a data library is for.\n *\n * Both unwrap the `{ data }` envelope the routes write, and both surface a\n * non-2xx as a thrown `Error` carrying whatever the surface said, so the screen\n * can show the operator the real refusal (an unknown locale, a 403 from the\n * host's own gate) rather than a generic failure.\n */\n\n/** The envelope every route in this package answers with. */\ninterface Envelope<T> {\n data?: T;\n error?: string;\n}\n\nasync function get<T>(url: string): Promise<T> {\n const response = await fetch(url, { headers: { Accept: 'application/json' } });\n let body: Envelope<T> = {};\n try {\n body = (await response.json()) as Envelope<T>;\n } catch {\n // A gate that refuses before the router runs may answer HTML, not JSON.\n // Falling through to the status line below is more useful than a parse\n // error naming a character offset.\n }\n if (!response.ok || body.data === undefined) {\n throw new Error(body.error ?? `The request failed (${response.status}).`);\n }\n return body.data;\n}\n\nconst withLocale = (base: string, locale: string): string =>\n `${base}?locale=${encodeURIComponent(locale)}`;\n\n/** The catalogue, with every subject rendered in `locale`. */\nexport function fetchEmailPreviewIndex(\n apiBase: string,\n locale: string,\n): Promise<EmailPreviewIndex> {\n return get<EmailPreviewIndex>(withLocale(apiBase, locale));\n}\n\n/** One rendered message. */\nexport function fetchEmailPreview(\n apiBase: string,\n id: string,\n locale: string,\n): Promise<EmailPreviewDetail> {\n return get<EmailPreviewDetail>(\n withLocale(`${apiBase}/${encodeURIComponent(id)}`, locale),\n );\n}\n"],"mappings":"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","names":["useState","Alert","ToggleGroup","Box","Text","Box","Text","jsx","jsxs","Box","Text","Box","Text","jsx","jsxs","Box","Text","jsx","jsxs","Alert","Text","useState","Box","ToggleGroup"]}
|
|
@@ -181,10 +181,8 @@ function LiveSection({
|
|
|
181
181
|
useEffect(() => {
|
|
182
182
|
if (active && liveCount > 0) seen?.mark(activities);
|
|
183
183
|
}, [active, liveCount, activities, seen]);
|
|
184
|
-
if (liveCount === 0) return /* @__PURE__ */ jsx2(Fragment2, { children: children?.(0) });
|
|
185
184
|
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
186
|
-
|
|
187
|
-
/* @__PURE__ */ jsxs2(
|
|
185
|
+
liveCount === 0 ? null : /* @__PURE__ */ jsxs2(
|
|
188
186
|
Box2,
|
|
189
187
|
{
|
|
190
188
|
component: "section",
|
|
@@ -228,4 +226,4 @@ export {
|
|
|
228
226
|
relativeTime,
|
|
229
227
|
LiveSection
|
|
230
228
|
};
|
|
231
|
-
//# sourceMappingURL=chunk-
|
|
229
|
+
//# sourceMappingURL=chunk-MPUOVQVX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/react/relative-time.ts","../src/react/live-section.tsx","../src/react/live-card.tsx"],"sourcesContent":["import type { NotificationMessages } from '../messages';\n\n/**\n * \"há 5 min\"-style relative timestamp, falling back to an absolute date for\n * anything older than a week. Every word comes from the messages table, so a\n * host in another locale changes the copy and the locale together.\n *\n * `now` is a parameter rather than a read, and the live section is why. A\n * relative phrase is only true for the instant it was computed, so something\n * has to CAUSE the render that recomputes it — and the live entries' own data\n * cannot: a host backed by react-query gets the previous object back whenever a\n * poll is deep-equal (`structuralSharing`, on by default), which within one\n * stage it always is. The section therefore ticks a clock and hands it down.\n * Defaulted, so every existing caller reads the wall clock exactly as before.\n */\nexport function relativeTime(\n iso: string,\n messages: NotificationMessages,\n now: number = Date.now(),\n): string {\n const elapsedMs = now - new Date(iso).getTime();\n const minutes = Math.round(elapsedMs / 60_000);\n if (minutes < 1) return messages.justNow;\n if (minutes < 60) return messages.minutesAgo(minutes);\n const hours = Math.round(minutes / 60);\n if (hours < 24) return messages.hoursAgo(hours);\n const days = Math.round(hours / 24);\n if (days < 7) return messages.daysAgo(days);\n return new Date(iso).toLocaleDateString(messages.dateLocale);\n}\n","/**\n * The pinned block at the top of the panel: everything that is happening NOW,\n * above everything that has already happened.\n *\n * ## Why it is here and not a second surface\n *\n * The notification centre is where a person goes to find out what they missed.\n * Splitting \"happening\" into its own bell would make them check two places to\n * answer one question, and the half they would stop checking is the one that\n * only has something in it occasionally — which is this one. Above the list,\n * inside the same drawer, it is on the path they already walk.\n *\n * ## What it deliberately does NOT do\n *\n * - It does not mark anything READ. A live entry counts on the bell, but as\n * itself rather than as unread — the tone, not the number, is what says\n * whether it is news. (This once read \"it does not touch `unread`\", on the\n * argument that counting it would put a number on the bell no amount of\n * reading can clear. The argument stands; the tone is what answers it.)\n * - It renders no heading, no empty state and no reserved space when there is\n * nothing live — but it still renders its SLOT, so the inbox below keeps its\n * position and is not torn down and rebuilt every time a subject starts or\n * finishes.\n * - It does not fetch. `useActivities` is the host's, and `active` tells it\n * whether anyone is looking.\n */\nimport { useEffect, useId, useState, type JSX, type ReactNode } from 'react';\n\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { LiveActivity } from '../live';\nimport type { NotificationMessages } from '../messages';\n\nimport { LiveActivityCard } from './live-card';\nimport type { LiveActivitiesConfig } from './live-config';\nimport type { LiveSeenStore } from './live-seen';\n\n/**\n * How often the section re-reads the clock.\n *\n * Every minute, because the timestamps under the cards are in minutes and a\n * tick that cannot change what is on screen is a wasted render — which is why\n * it is gated on there being something to tick as well as on the panel being\n * open. An open panel with nothing live schedules nothing at all; the earlier\n * gate was `active` alone, and it re-rendered a section that renders `null`\n * once a minute for as long as somebody left the inbox open.\n */\nconst TICK_MS = 60_000;\n\n/**\n * The current minute, re-read on a timer while there is something to tick.\n *\n * The caller passes `active && there are activities` — see {@link TICK_MS} for\n * why both halves are in it.\n */\nfunction useMinuteTick(active: boolean): number {\n const [now, setNow] = useState(() => Date.now());\n useEffect(() => {\n if (!active) return;\n // Re-read once on becoming active too: a panel reopened after ten minutes\n // would otherwise show the minute it was closed at until the first tick.\n setNow(Date.now());\n const timer = setInterval(() => setNow(Date.now()), TICK_MS);\n return () => clearInterval(timer);\n }, [active]);\n return now;\n}\n\nexport interface LiveSectionProps {\n config: LiveActivitiesConfig;\n messages: NotificationMessages;\n /** Whether the panel is open — passed straight through to the host's hook. */\n active: boolean;\n /**\n * Follow a card's link.\n *\n * Optional, and the panel omits it for a host with no router: a card that\n * cannot go anywhere renders as text rather than as a named control that\n * does nothing.\n */\n onOpen?: (activity: LiveActivity) => void;\n /**\n * The rest of the panel, given how many entries are live.\n *\n * A render prop rather than a sibling, because the count is knowable only\n * where the host's hook is CALLED, and it cannot be called anywhere else:\n * `live` is optional on the panel, so reading it there would mean calling a\n * hook conditionally — the failure React reports as a crash in some unrelated\n * component.\n *\n * The inbox needs the number for exactly one decision, and it is the decision\n * this section exists to inform: whether \"no notifications\" is true. A live\n * entry IS a notification, so a panel showing one under that sentence is\n * contradicting itself.\n */\n children?: (liveCount: number) => ReactNode;\n /**\n * Where \"the reader has seen these\" is recorded, for the bell to read.\n *\n * Written HERE because this is the component that puts them on screen, and\n * being on screen is what seen means. Optional so the section stays usable by\n * a host that mounts it outside the panel.\n */\n seen?: LiveSeenStore;\n}\n\n\n\n/**\n * ## Why this always renders its slot, even with nothing live\n *\n * React reconciles a fragment's children POSITIONALLY. The section and the\n * inbox are siblings in one fragment, and the empty branch used to render the\n * inbox ALONE — one child rather than two — so the inbox moved to a position\n * previously held by a different element type, which React handles by\n * unmounting the old subtree and mounting a new one. Every `NotificationRow`\n * would be torn down and rebuilt the moment a pedido started or finished,\n * throwing keyboard focus to `<body>` inside a focus-trapped drawer, for a\n * reader who was only scrolling their inbox.\n *\n * So the empty case renders `null` INTO the slot rather than returning early.\n * Pinned by comparing the row's DOM NODE across the transition: a test on the\n * test id alone passes either way, because a remounted row has the same id.\n */\nexport function LiveSection({\n config,\n messages,\n active,\n onOpen,\n children,\n seen,\n}: LiveSectionProps): JSX.Element {\n // Unconditional, because it is a hook. `active` is how it is told nobody is\n // looking — the same arrangement `useSignal` has one seam over.\n const activities = config.useActivities({ active });\n const now = useMinuteTick(active && activities.length > 0);\n // Per MOUNT, not per module: `LiveSection` is exported, and a host with a\n // desktop and a mobile panel would otherwise emit one id twice and have both\n // regions resolve their label to whichever came first.\n const headingId = useId();\n\n const liveCount = activities.length;\n\n // Only while somebody is looking. The panel keeps this mounted through the\n // closing transition, and marking there would swallow an update that arrived\n // in the frames after the reader turned away.\n useEffect(() => {\n if (active && liveCount > 0) seen?.mark(activities);\n }, [active, liveCount, activities, seen]);\n\n return (\n <>\n {/* A NAMED region, and always a SLOT — see the docblock above. */}\n {liveCount === 0 ? null : (\n <Box\n component=\"section\"\n aria-labelledby={headingId}\n data-testid=\"live-activities\"\n sx={{ pb: 1.5 }}\n >\n {/*\n A SPAN, not a heading. `aria-labelledby` names the region perfectly well\n from one, and an `<h2>` here would sit under the drawer's own `<h6>`\n title and ABOVE the inbox's `<h3>` empty state — an outline in which the\n inbox's states read as part of the live block, which is the opposite of\n what the two blocks are.\n */}\n <Text\n id={headingId}\n variant=\"caption\"\n size=\"xs\"\n color=\"secondary\"\n weight=\"semibold\"\n as=\"span\"\n >\n {config.messages.sectionTitle}\n </Text>\n <Box sx={{ pt: 0.75 }}>\n {activities.map((activity) => (\n <LiveActivityCard\n key={activity.id}\n activity={activity}\n messages={messages}\n live={config.messages}\n now={now}\n {...(onOpen ? { onOpen } : {})}\n {...(config.renderIcon ? { renderIcon: config.renderIcon } : {})}\n />\n ))}\n </Box>\n </Box>\n )}\n {children?.(liveCount)}\n </>\n );\n}\n","/**\n * ONE pinned live entry: a mark, what is happening, its lane, and when it last\n * moved.\n *\n * Visually a WASH rather than a fill — a tinted card with a brand-tinted border\n * — for the reason the inbox's unread row uses the same treatment: this sits at\n * the top of a list of other people's news, and a saturated block there\n * out-shouts everything it is supposed to be introducing.\n *\n * ## The card is a DIV, and the button is inside it\n *\n * The obvious shape — one `<button>` wrapping the whole card — is not\n * available, because `Stepper` draws every stop as a real `<button>`\n * (`@12-apps/ui`'s `StepButton` is `styled(Button)`), and `clickable={false}`\n * only sets `pointer-events: none`. A button inside a button is invalid HTML:\n * the parser auto-closes the outer one at the first nested one, so any host\n * that server-renders the panel open hydrates against a tree the browser\n * rewrote, and every adopter's dev console carries a React error besides.\n *\n * `aria-hidden` and `inert` on the lane fix the tab stops and the accessible\n * name — they do NOT fix the nesting, and an earlier draft of this file claimed\n * they did. So the tap target is the TEXT block, and the lane and the timestamp\n * are its siblings: valid markup, and a target that still covers everything a\n * reader would aim at.\n */\nimport { useId, type JSX, type ReactNode } from 'react';\n\nimport { Stepper } from '@12-apps/ui/data-display/Stepper';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { alpha, type Theme } from '@12-apps/ui/mui/styles';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport { liveActivityLane, type LiveActivity } from '../live';\nimport type { NotificationMessages } from '../messages';\n\nimport type { LiveActivitiesConfig, LiveActivityMessages } from './live-config';\nimport { relativeTime } from './relative-time';\n\nconst cardSx = {\n // `relative`, so the button below can stretch a hit area over the whole card\n // — see `targetSx`.\n position: 'relative',\n border: '1px solid',\n borderColor: (t: Theme) => alpha(t.palette.primary.main, 0.35),\n bgcolor: (t: Theme) => alpha(t.palette.primary.main, 0.06),\n borderRadius: 1.5,\n p: 1.25,\n mb: 1,\n} as const;\n\n/** The text block: the mark, the heading and the sentence under it. */\nconst targetSx = {\n display: 'flex',\n alignItems: 'center',\n gap: 1,\n width: '100%',\n textAlign: 'left',\n font: 'inherit',\n color: 'inherit',\n border: 'none',\n background: 'none',\n p: 0,\n} as const;\n\n/**\n * The button, stretched over the WHOLE card.\n *\n * Taking the lane out of the link fixed the markup and left the card looking\n * like one target while only its top half was one — the lane is the most\n * visually distinctive part of it, and aiming at the obvious thing did nothing.\n *\n * A stretched pseudo-element is the remedy that keeps the structure: the\n * `<button>` stays a sibling of the lane in the tree, so nothing nests, and its\n * `::after` covers the card. All three declarations are load-bearing — a\n * pseudo-element with no `content` generates no box at all, and an absolutely\n * positioned box with auto offsets is 0×0.\n *\n * ## What actually lets a click on the LANE reach it\n *\n * Not paint order. `@12-apps/ui` gives each `StepItem` `position: relative`, and\n * this overlay is positioned too — so the two sit in the SAME painting layer\n * (positioned, `z-index: auto`), where tree order decides, and the lane comes\n * after the button. Every stop, and its label, therefore sits over this\n * overlay. `clickable={false}` does not save it either: the package puts\n * `pointer-events: none` on the step CIRCLE and not on the label beside it.\n *\n * It is `inert` on {@link ActivityLane} that does it: an inert subtree is\n * skipped by hit-testing, so a click on a stop falls through to the overlay\n * underneath. That makes the attribute load-bearing for the TARGET as well as\n * for the tab order it was added for — remove it and the lane silently swallows\n * clicks again, which is why the two are pinned by one test.\n */\nconst stretchedSx = {\n ...targetSx,\n cursor: 'pointer',\n '&::after': { content: '\"\"', position: 'absolute', inset: 0 },\n} as const;\n\n/**\n * Make a four-stop lane fit the panel.\n *\n * The drawer is 400px on a desktop and the full viewport on a phone, so the\n * narrow case is ~320px of card minus its padding. `Stepper` renders its labels\n * at `body2` for every size but `sm` and reserves 24px of connector plus 8px of\n * margin on each side, which is more row than four short words have — measured\n * on a 320px viewport, the last stop hung off the edge and the DRAWER scrolled\n * sideways.\n *\n * Three overrides, each buying back a specific number of pixels: 11px labels, a\n * step column allowed to shrink below the package's 44px floor (so the row's\n * min-content width is the longest WORD rather than the longest phrase), and\n * thinner connectors. `overflow: hidden` is the backstop and not the mechanism\n * — a locale with longer words than any of this anticipates clips its own card\n * instead of making the panel scroll.\n */\nconst laneSx = {\n pt: 1.25,\n px: 0.5,\n overflow: 'hidden',\n '& .MuiTypography-root': { fontSize: 11, lineHeight: 1.25 },\n '& [data-testid^=\"stepper-step-content-\"]': { minWidth: 0 },\n '& [data-testid^=\"stepper-connector-\"]': { minWidth: 6, mx: 0.75 },\n} as const;\n\n/**\n * The lane, or nothing.\n *\n * `aria-hidden` AND `inert`, and each earns its place twice over. The stops are\n * real buttons, so leaving four focusable, named controls per entry in front of\n * an inbox would cost a keyboard user the list they opened the panel for —\n * that is what the pair was added for. `inert` then turns out to be what makes\n * the card's own hit area work as well, because an inert subtree is skipped by\n * hit-testing: see {@link stretchedSx}.\n *\n * Nothing is lost by hiding it — the stop the subject is at is already the\n * card's heading, and the row of dots restates it visually.\n */\nfunction ActivityLane({ activity }: { activity: LiveActivity }): JSX.Element | null {\n const lane = liveActivityLane(activity);\n if (lane === null) return null;\n return (\n <Box sx={laneSx} aria-hidden inert>\n <Stepper\n steps={lane.steps.map((step) => ({ id: step.id, label: step.label }))}\n activeId={lane.activeStepId}\n completed={new Set(lane.completed)}\n orientation=\"horizontal\"\n size=\"xs\"\n clickable={false}\n data-testid={`live-activity-steps-${activity.id}`}\n />\n </Box>\n );\n}\n\n/** The card's props. Not part of the package's surface — see `./index`. */\ninterface LiveActivityCardProps {\n activity: LiveActivity;\n messages: NotificationMessages;\n live: LiveActivityMessages;\n renderIcon?: LiveActivitiesConfig['renderIcon'];\n /** The clock this render reads, so the \"last moved\" line can be ticked. */\n now: number;\n /**\n * Follow the card's link.\n *\n * Absent — as it is for a host with no router — renders the text as text. A\n * named, focusable control that does nothing is worse than no control.\n */\n onOpen?: (activity: LiveActivity) => void;\n}\n\n/**\n * The mark on the left, when the host draws one.\n *\n * PRESENTATIONAL ONLY. It is rendered inside the card's `<button>` and inside\n * an `aria-hidden` wrapper, so a host returning anything focusable — an\n * icon-button, a link — puts a button inside a button (invalid HTML, and the\n * defect this card was restructured to remove) and hides a focusable node from\n * the accessibility tree. An icon, an emoji, an `<svg>`: yes. A control: no.\n */\nfunction ActivityIcon({ icon }: { icon: ReactNode }): JSX.Element | null {\n if (icon === undefined || icon === null) return null;\n return (\n <Box aria-hidden sx={{ display: 'flex', flex: '0 0 auto', color: 'primary.main' }}>\n {icon}\n </Box>\n );\n}\n\n/** The mark, the heading and the line under it. */\nfunction ActivityTarget({\n activity,\n renderIcon,\n bodyId,\n}: Pick<LiveActivityCardProps, 'activity' | 'renderIcon'> & {\n /** Ties the sentence to the button, so a label does not swallow it. */\n bodyId: string;\n}): JSX.Element {\n return (\n <>\n <ActivityIcon icon={renderIcon?.(activity)} />\n {/* A COLUMN, not a bare block: `Text` sets no `display`, so two adjacent\n spans in an ordinary div run together on one line with not even a\n space between them — which is how the heading and the sentence under\n it ended up as one word in an earlier draft. `row.tsx` gets this right\n the same way. */}\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 0 }}>\n {/*\n The live region is THIS LINE and nothing else. The card also carries a\n relative timestamp that moves every minute for as long as the subject\n lasts, and announcing that is a polite interruption per minute for\n news the reader did not ask to be read. What is worth interrupting for\n is the subject MOVING, which is what the heading says.\n */}\n <Text\n variant=\"body\"\n size=\"sm\"\n weight=\"semibold\"\n as=\"span\"\n aria-live=\"polite\"\n data-testid={`live-activity-title-${activity.id}`}\n >\n {activity.title}\n </Text>\n {activity.body === null ? null : (\n <Text id={bodyId} variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"span\">\n {activity.body}\n </Text>\n )}\n </Box>\n </>\n );\n}\n\nexport function LiveActivityCard({\n activity,\n messages,\n live,\n renderIcon,\n now,\n onOpen,\n}: LiveActivityCardProps): JSX.Element {\n const followable = activity.link !== null && onOpen !== undefined;\n const bodyId = useId();\n const target = (\n <ActivityTarget\n activity={activity}\n bodyId={bodyId}\n {...(renderIcon ? { renderIcon } : {})}\n />\n );\n return (\n <Box data-testid={`live-activity-${activity.id}`} sx={cardSx}>\n {followable ? (\n <Box\n component=\"button\"\n type=\"button\"\n onClick={() => onOpen(activity)}\n // `aria-label` REPLACES the contents, so the sentence under the\n // heading — the detail that makes the heading actionable — would be\n // announced to nobody. `aria-describedby` puts it back.\n aria-label={live.openActivity(activity.title)}\n {...(activity.body === null ? {} : { 'aria-describedby': bodyId })}\n data-testid={`live-activity-open-${activity.id}`}\n sx={stretchedSx}\n >\n {target}\n </Box>\n ) : (\n <Box sx={targetSx}>{target}</Box>\n )}\n <ActivityLane activity={activity} />\n <Text variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"span\" italic>\n {live.updated(relativeTime(activity.updatedAt, messages, now))}\n </Text>\n </Box>\n );\n}\n"],"mappings":"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","names":["useId","Box","Text","Fragment","jsx","jsxs","useId","Box","Text"]}
|
package/dist/manifest/web.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createWebNotifications
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-H55A4LHG.js";
|
|
4
4
|
import "../chunk-BW723CX2.js";
|
|
5
5
|
import {
|
|
6
6
|
createEmailPreviewScreen
|
|
7
|
-
} from "../chunk-
|
|
7
|
+
} from "../chunk-KEYRE245.js";
|
|
8
8
|
import "../chunk-M2TVBVH2.js";
|
|
9
9
|
import "../chunk-7QVYU63E.js";
|
|
10
10
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
LiveSection,
|
|
3
3
|
relativeTime
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-MPUOVQVX.js";
|
|
5
5
|
import {
|
|
6
6
|
BellIcon,
|
|
7
7
|
useInboxList
|
|
@@ -295,4 +295,4 @@ __name(NotificationsPanel, "NotificationsPanel");
|
|
|
295
295
|
export {
|
|
296
296
|
NotificationsPanel
|
|
297
297
|
};
|
|
298
|
-
//# sourceMappingURL=panel-
|
|
298
|
+
//# sourceMappingURL=panel-V2ULFC4Y.js.map
|
package/dist/react/index.d.ts
CHANGED
|
@@ -67,11 +67,15 @@ declare function BellIcon({ size, dim, }: {
|
|
|
67
67
|
*
|
|
68
68
|
* ## What it deliberately does NOT do
|
|
69
69
|
*
|
|
70
|
-
* - It does not
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
70
|
+
* - It does not mark anything READ. A live entry counts on the bell, but as
|
|
71
|
+
* itself rather than as unread — the tone, not the number, is what says
|
|
72
|
+
* whether it is news. (This once read "it does not touch `unread`", on the
|
|
73
|
+
* argument that counting it would put a number on the bell no amount of
|
|
74
|
+
* reading can clear. The argument stands; the tone is what answers it.)
|
|
75
|
+
* - It renders no heading, no empty state and no reserved space when there is
|
|
76
|
+
* nothing live — but it still renders its SLOT, so the inbox below keeps its
|
|
77
|
+
* position and is not torn down and rebuilt every time a subject starts or
|
|
78
|
+
* finishes.
|
|
75
79
|
* - It does not fetch. `useActivities` is the host's, and `active` tells it
|
|
76
80
|
* whether anyone is looking.
|
|
77
81
|
*/
|
|
@@ -113,6 +117,22 @@ interface LiveSectionProps {
|
|
|
113
117
|
*/
|
|
114
118
|
seen?: LiveSeenStore;
|
|
115
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* ## Why this always renders its slot, even with nothing live
|
|
122
|
+
*
|
|
123
|
+
* React reconciles a fragment's children POSITIONALLY. The section and the
|
|
124
|
+
* inbox are siblings in one fragment, and the empty branch used to render the
|
|
125
|
+
* inbox ALONE — one child rather than two — so the inbox moved to a position
|
|
126
|
+
* previously held by a different element type, which React handles by
|
|
127
|
+
* unmounting the old subtree and mounting a new one. Every `NotificationRow`
|
|
128
|
+
* would be torn down and rebuilt the moment a pedido started or finished,
|
|
129
|
+
* throwing keyboard focus to `<body>` inside a focus-trapped drawer, for a
|
|
130
|
+
* reader who was only scrolling their inbox.
|
|
131
|
+
*
|
|
132
|
+
* So the empty case renders `null` INTO the slot rather than returning early.
|
|
133
|
+
* Pinned by comparing the row's DOM NODE across the transition: a test on the
|
|
134
|
+
* test id alone passes either way, because a remounted row has the same id.
|
|
135
|
+
*/
|
|
116
136
|
declare function LiveSection({ config, messages, active, onOpen, children, seen, }: LiveSectionProps): JSX.Element;
|
|
117
137
|
|
|
118
138
|
/**
|
package/dist/react/index.js
CHANGED
|
@@ -3,11 +3,11 @@ import {
|
|
|
3
3
|
createNotificationsApiClient,
|
|
4
4
|
createWebNotifications,
|
|
5
5
|
httpNotificationsTransport
|
|
6
|
-
} from "../chunk-
|
|
6
|
+
} from "../chunk-H55A4LHG.js";
|
|
7
7
|
import {
|
|
8
8
|
LiveSection,
|
|
9
9
|
relativeTime
|
|
10
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-MPUOVQVX.js";
|
|
11
11
|
import {
|
|
12
12
|
BADGE_POLL_MS,
|
|
13
13
|
BADGE_RECONCILE_MS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/notifications",
|
|
3
|
-
"version": "4.10.
|
|
3
|
+
"version": "4.10.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Plug-and-play notification system (12-15): an always-on in-app inbox, per-user × per-category channel preferences, and email / SMS / WhatsApp / web-push transports behind vendor DRIVERS so a second provider is a config entry. Framework-free core (.), host-mounted backend surface (./server: inbox / preferences / push-subscription endpoints, the channel router with delivery records + retry sweep, the permission fan-out, duck-typed Prisma seam), Hono adapter (./hono), React surface (./react: bell + badge, inbox drawer, preferences screen), VAPID sender (./web-push) and the package-owned Prisma partial + migrations. Standardized adoption contract in ADOPTING.md.",
|
|
@@ -56,7 +56,10 @@ export function useLoadable<T>(
|
|
|
56
56
|
// for, with the toggle disagreeing.
|
|
57
57
|
live = false;
|
|
58
58
|
};
|
|
59
|
-
|
|
59
|
+
// `keepPrevious` belongs here: it decides whether this effect blanks the
|
|
60
|
+
// children, so a caller that flips it must get the new behaviour rather
|
|
61
|
+
// than the one captured on first render.
|
|
62
|
+
}, [load, nonce, keepPrevious]);
|
|
60
63
|
return { ...state, reload: () => setNonce((n) => n + 1) };
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -295,10 +295,10 @@ export function createEmailPreviewScreen(config: EmailPreviewScreenConfig): {
|
|
|
295
295
|
// list whose contents cannot have changed. What the click actually needs is
|
|
296
296
|
// a re-RENDER, so `selectedId` is re-read, and `setUrlNonce` already does
|
|
297
297
|
// that on its own.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
);
|
|
298
|
+
// Not `apiBase`: it comes from the FACTORY's config, not from this
|
|
299
|
+
// component, so it is constant for the component's whole life and listing
|
|
300
|
+
// it says this callback can change when it cannot.
|
|
301
|
+
const load = useCallback(() => fetchEmailPreviewIndex(apiBase, locale), [locale]);
|
|
302
302
|
const index = useLoadable(load, { keepPrevious: true });
|
|
303
303
|
|
|
304
304
|
const patch = (next: Record<string, string>): void => {
|
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ## What it deliberately does NOT do
|
|
14
14
|
*
|
|
15
|
-
* - It does not
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* - It does not mark anything READ. A live entry counts on the bell, but as
|
|
16
|
+
* itself rather than as unread — the tone, not the number, is what says
|
|
17
|
+
* whether it is news. (This once read "it does not touch `unread`", on the
|
|
18
|
+
* argument that counting it would put a number on the bell no amount of
|
|
19
|
+
* reading can clear. The argument stands; the tone is what answers it.)
|
|
20
|
+
* - It renders no heading, no empty state and no reserved space when there is
|
|
21
|
+
* nothing live — but it still renders its SLOT, so the inbox below keeps its
|
|
22
|
+
* position and is not torn down and rebuilt every time a subject starts or
|
|
23
|
+
* finishes.
|
|
20
24
|
* - It does not fetch. `useActivities` is the host's, and `active` tells it
|
|
21
25
|
* whether anyone is looking.
|
|
22
26
|
*/
|
|
@@ -103,6 +107,22 @@ export interface LiveSectionProps {
|
|
|
103
107
|
|
|
104
108
|
|
|
105
109
|
|
|
110
|
+
/**
|
|
111
|
+
* ## Why this always renders its slot, even with nothing live
|
|
112
|
+
*
|
|
113
|
+
* React reconciles a fragment's children POSITIONALLY. The section and the
|
|
114
|
+
* inbox are siblings in one fragment, and the empty branch used to render the
|
|
115
|
+
* inbox ALONE — one child rather than two — so the inbox moved to a position
|
|
116
|
+
* previously held by a different element type, which React handles by
|
|
117
|
+
* unmounting the old subtree and mounting a new one. Every `NotificationRow`
|
|
118
|
+
* would be torn down and rebuilt the moment a pedido started or finished,
|
|
119
|
+
* throwing keyboard focus to `<body>` inside a focus-trapped drawer, for a
|
|
120
|
+
* reader who was only scrolling their inbox.
|
|
121
|
+
*
|
|
122
|
+
* So the empty case renders `null` INTO the slot rather than returning early.
|
|
123
|
+
* Pinned by comparing the row's DOM NODE across the transition: a test on the
|
|
124
|
+
* test id alone passes either way, because a remounted row has the same id.
|
|
125
|
+
*/
|
|
106
126
|
export function LiveSection({
|
|
107
127
|
config,
|
|
108
128
|
messages,
|
|
@@ -129,14 +149,11 @@ export function LiveSection({
|
|
|
129
149
|
if (active && liveCount > 0) seen?.mark(activities);
|
|
130
150
|
}, [active, liveCount, activities, seen]);
|
|
131
151
|
|
|
132
|
-
if (liveCount === 0) return <>{children?.(0)}</>;
|
|
133
|
-
|
|
134
152
|
return (
|
|
135
153
|
<>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<Box
|
|
154
|
+
{/* A NAMED region, and always a SLOT — see the docblock above. */}
|
|
155
|
+
{liveCount === 0 ? null : (
|
|
156
|
+
<Box
|
|
140
157
|
component="section"
|
|
141
158
|
aria-labelledby={headingId}
|
|
142
159
|
data-testid="live-activities"
|
|
@@ -170,9 +187,10 @@ export function LiveSection({
|
|
|
170
187
|
{...(onOpen ? { onOpen } : {})}
|
|
171
188
|
{...(config.renderIcon ? { renderIcon: config.renderIcon } : {})}
|
|
172
189
|
/>
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
190
|
+
))}
|
|
191
|
+
</Box>
|
|
192
|
+
</Box>
|
|
193
|
+
)}
|
|
176
194
|
{children?.(liveCount)}
|
|
177
195
|
</>
|
|
178
196
|
);
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/email/previews/react/message-view.tsx","../src/email/previews/react/preview-screen.tsx","../src/email/previews/react/loadable.tsx","../src/email/previews/react/message-list.tsx","../src/email/previews/react/transport.ts"],"sourcesContent":["import type { JSX } from 'react';\n\nimport { ToggleGroup } from '@12-apps/ui/form/ToggleGroup';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewDetail } from '../catalog';\n\nimport type { EmailPreviewScreenCopy } from './copy';\n\n/**\n * One rendered message, in the three ways it is worth looking at.\n *\n * - **HTML** — what most people will see, in a sandboxed frame at either of the\n * two widths that matter. A phone is not a nice-to-have: more than half of\n * transactional mail is opened on one, and the 600px card is exactly the\n * thing that either survives that or does not.\n * - **Text** — the plain-text twin. Worth its own tab because it is what a spam\n * filter scores, what a watch shows, and what a screen reader in plain-text\n * mode reads — and because it is the half nobody ever looks at, which is how\n * it drifts out of step with the HTML.\n * - **Source** — the markup itself, for the moment somebody is debugging why a\n * client rendered it oddly.\n *\n * ## Why an iframe, and why sandboxed\n *\n * The mail is a whole document with its own `<body>` background, and rendering\n * that inside the console's DOM would both break the mail (the console's CSS\n * reaches it) and break the console (the mail's body styles reach the page). A\n * frame is the only honest preview.\n *\n * `sandbox=\"\"` — no scripts, no forms, no top-level navigation. These documents\n * come from the host's own renderer and carry no script, so this is less a\n * containment measure than a statement that the preview is INERT: a click on a\n * CTA inside a previewed mail must never navigate the operator anywhere, least\n * of all to a sample verification link.\n */\n\n/** The two widths the HTML view renders at. */\nexport type PreviewWidth = 'desktop' | 'mobile';\n\n/** Which of the three views is showing. */\nexport type PreviewTab = 'html' | 'text' | 'source';\n\n/** A phone. 390px is a common iPhone CSS width, and among the narrowest. */\nconst MOBILE_WIDTH = 390;\n\nfunction Monospace({ children, testId }: { children: string; testId: string }): JSX.Element {\n return (\n <Box\n component=\"pre\"\n data-testid={testId}\n sx={{\n m: 0,\n p: 2,\n borderRadius: 1.5,\n border: '1px solid',\n borderColor: 'divider',\n background: 'background.default',\n fontSize: 13,\n lineHeight: 1.6,\n whiteSpace: 'pre-wrap',\n wordBreak: 'break-word',\n maxHeight: 720,\n overflow: 'auto',\n }}\n >\n {children}\n </Box>\n );\n}\n\nfunction HtmlFrame({\n detail,\n width,\n title,\n}: {\n detail: EmailPreviewDetail;\n width: PreviewWidth;\n title: string;\n}): JSX.Element {\n return (\n <Box\n sx={{\n display: 'flex',\n justifyContent: 'center',\n borderRadius: 1.5,\n border: '1px solid',\n borderColor: 'divider',\n overflow: 'hidden',\n }}\n >\n <Box\n component=\"iframe\"\n data-testid=\"email-preview-frame\"\n title={title}\n srcDoc={detail.html}\n sandbox=\"\"\n sx={{\n border: 0,\n width: width === 'mobile' ? MOBILE_WIDTH : '100%',\n height: 760,\n background: '#fff',\n }}\n />\n </Box>\n );\n}\n\ninterface MessageViewProps {\n detail: EmailPreviewDetail;\n copy: EmailPreviewScreenCopy;\n tab: PreviewTab;\n width: PreviewWidth;\n onTabChange: (tab: PreviewTab) => void;\n onWidthChange: (width: PreviewWidth) => void;\n}\n\nexport function MessageView(props: MessageViewProps): JSX.Element {\n const { detail, copy, tab, width, onTabChange, onWidthChange } = props;\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }} data-testid=\"email-preview-view\">\n <Box>\n <Text as=\"p\" size=\"xs\" color=\"secondary\">\n {copy.subjectLabel}\n </Text>\n <Text as=\"p\" size=\"md\" weight=\"medium\" data-testid=\"email-preview-subject\">\n {detail.subject}\n </Text>\n </Box>\n <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>\n <ToggleGroup\n dataTestId=\"email-preview-tabs\"\n exclusive\n value={tab}\n size=\"sm\"\n options={[\n { value: 'html', label: copy.tabHtml },\n { value: 'text', label: copy.tabText },\n { value: 'source', label: copy.tabSource },\n ]}\n onChange={(_event, value) => {\n if (value) onTabChange(value as PreviewTab);\n }}\n />\n {tab === 'html' ? (\n <ToggleGroup\n dataTestId=\"email-preview-width\"\n exclusive\n value={width}\n size=\"sm\"\n options={[\n { value: 'desktop', label: copy.widthDesktop },\n { value: 'mobile', label: copy.widthMobile },\n ]}\n onChange={(_event, value) => {\n if (value) onWidthChange(value as PreviewWidth);\n }}\n />\n ) : null}\n </Box>\n {tab === 'html' ? <HtmlFrame detail={detail} width={width} title={copy.frameTitle} /> : null}\n {tab === 'text' ? <Monospace testId=\"email-preview-text\">{detail.text}</Monospace> : null}\n {tab === 'source' ? <Monospace testId=\"email-preview-source\">{detail.html}</Monospace> : null}\n </Box>\n );\n}\n","import { useCallback, useMemo, useState, type JSX } from 'react';\n\nimport { Alert } from '@12-apps/ui/data-display/Alert';\nimport { Input } from '@12-apps/ui/form/Input';\nimport { ToggleGroup } from '@12-apps/ui/form/ToggleGroup';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Heading } from '@12-apps/ui/typography/Heading';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewCoverage, EmailPreviewDetail, EmailPreviewIndex } from '../catalog';\n\nimport type { EmailPreviewScreenCopy } from './copy';\nimport { Failure, useLoadable } from './loadable';\nimport { MessageList, matchesFilter } from './message-list';\nimport { MessageView, type PreviewTab, type PreviewWidth } from './message-view';\nimport { fetchEmailPreview, fetchEmailPreviewIndex } from './transport';\n\n/**\n * The operator screen over the `./email/previews` catalogue.\n *\n * ## What a host supplies, and what it does not\n *\n * `apiBase` — where the routes are mounted — and `copy`, by name. Nothing else:\n * the owners, the languages, the messages and their subjects all arrive from\n * the surface, because they are facts about the host's own mail that no prop\n * could usefully restate.\n *\n * ## The selection lives in the URL, without a router\n *\n * `?id=` and `?locale=`, read and written through `history.replaceState`. A\n * link to one mail in one language is the actual workflow this screen serves —\n * \"look at what the reset mail says now\" — and local state would make every\n * such conversation a set of instructions instead of a link.\n *\n * Deliberately NOT a router integration: this package cannot know whether a\n * host runs react-router, TanStack Router or a framework's own, and a screen\n * that imported one would be unmountable in the other two. `replaceState` is\n * the one API all of them are built on, and `replace` rather than `push` so\n * browsing twenty mails is not twenty back-button steps.\n */\n\nexport interface EmailPreviewScreenConfig {\n /** Where the routes are mounted, e.g. `/api/platform/email-previews`. */\n readonly apiBase: string;\n /** The screen's words. REQUIRED — see `./copy`. */\n readonly copy: EmailPreviewScreenCopy;\n}\n\n/** Read one search param without assuming a router owns the URL. */\nfunction searchParam(name: string): string | null {\n if (typeof window === 'undefined') return null;\n return new URLSearchParams(window.location.search).get(name);\n}\n\n/**\n * Patch the query string in place, keeping everything else about the URL.\n *\n * The HASH is carried over deliberately, and it is not defensive coding: a host\n * that routes on `location.hash` — the consumer harness does, and so does any\n * SPA served from a static file — would otherwise be navigated off this screen\n * by its own locale switch, because rebuilding the URL from `pathname` alone\n * silently drops the fragment that says which page this is.\n *\n * `replaceState` rather than `pushState` for the reason the screen exists:\n * browsing twenty messages is one place to come back from, not twenty\n * back-button steps.\n */\nfunction patchSearch(patch: Record<string, string>): void {\n if (typeof window === 'undefined') return;\n const next = new URLSearchParams(window.location.search);\n for (const [key, value] of Object.entries(patch)) next.set(key, value);\n const { pathname, hash } = window.location;\n window.history.replaceState({}, '', `${pathname}?${next.toString()}${hash}`);\n}\n\n/** The surface's honest report about what it cannot show. */\nfunction CoverageNotice({\n coverage,\n copy,\n}: {\n coverage: EmailPreviewCoverage;\n copy: EmailPreviewScreenCopy;\n}): JSX.Element | null {\n if (coverage.missing.length === 0 && coverage.orphan.length === 0) return null;\n return (\n <Alert severity=\"warning\" data-testid=\"email-preview-coverage\">\n <Text as=\"p\" size=\"sm\" weight=\"medium\">\n {copy.coverageTitle}\n </Text>\n {coverage.missing.length > 0 ? (\n <Text as=\"p\" size=\"sm\">\n {copy.missingSamples(coverage.missing.join(', '))}\n </Text>\n ) : null}\n {coverage.orphan.length > 0 ? (\n <Text as=\"p\" size=\"sm\">\n {copy.orphanSamples(coverage.orphan.join(', '))}\n </Text>\n ) : null}\n </Alert>\n );\n}\n\n/** The right-hand pane: the selected message, or an invitation to pick one. */\nfunction PreviewPane({\n apiBase,\n id,\n locale,\n copy,\n tab,\n width,\n onTabChange,\n onWidthChange,\n}: {\n apiBase: string;\n id: string | null;\n locale: string;\n copy: EmailPreviewScreenCopy;\n tab: PreviewTab;\n width: PreviewWidth;\n onTabChange: (tab: PreviewTab) => void;\n onWidthChange: (width: PreviewWidth) => void;\n}): JSX.Element {\n const load = useCallback(\n () =>\n id === null\n ? Promise.resolve(null as EmailPreviewDetail | null)\n : fetchEmailPreview(apiBase, id, locale),\n [apiBase, id, locale],\n );\n const detail = useLoadable(load);\n\n if (id === null) {\n return (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-empty\">\n {copy.pickOne}\n </Text>\n );\n }\n if (detail.error !== null) {\n return <Failure message={detail.error} copy={copy} onRetry={detail.reload} />;\n }\n if (detail.data === null) {\n return (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-detail-loading\">\n {copy.loading}\n </Text>\n );\n }\n return (\n <MessageView\n detail={detail.data}\n copy={copy}\n tab={tab}\n width={width}\n onTabChange={onTabChange}\n onWidthChange={onWidthChange}\n />\n );\n}\n\n/** The left column: the filter, and the rows under their owners. */\nfunction CatalogueColumn({\n index,\n copy,\n selectedId,\n onSelect,\n}: {\n index: EmailPreviewIndex;\n copy: EmailPreviewScreenCopy;\n selectedId: string | null;\n onSelect: (id: string) => void;\n}): JSX.Element {\n const [filter, setFilter] = useState('');\n const visible = useMemo(\n () => index.items.filter((row) => matchesFilter(row, filter)),\n [index.items, filter],\n );\n return (\n /*\n The list owns its OWN scroll rather than growing the page.\n\n A host's console is typically a fixed-viewport shell whose centre column\n scrolls, and a twenty-mail catalogue then scrolls the preview frame off\n the screen — the two things this screen exists to show side by side\n cannot both be on it. `sticky` keeps the list put while the frame is\n read; the height is the viewport minus the chrome above it, so the column\n ends where the window does rather than at an arbitrary pixel count.\n */\n <Box\n sx={{\n width: 320,\n flexShrink: 0,\n display: 'flex',\n flexDirection: 'column',\n gap: 1.5,\n position: 'sticky',\n top: 0,\n maxHeight: 'calc(100dvh - 220px)',\n minHeight: 240,\n }}\n >\n <Input\n label={copy.searchLabel}\n placeholder={copy.searchPlaceholder}\n value={filter}\n data-testid=\"email-preview-filter\"\n onChange={(event) => setFilter(event.target.value)}\n fullWidth\n />\n {/* Only the ROWS scroll — the filter field stays reachable. */}\n <Box sx={{ overflowY: 'auto', flex: 1, pr: 0.5 }}>\n <MessageList rows={visible} selectedId={selectedId} copy={copy} onSelect={onSelect} />\n </Box>\n </Box>\n );\n}\n\n/** The catalogue and the preview, once the index has loaded. */\nfunction Browser({\n apiBase,\n index,\n copy,\n locale,\n selectedId,\n onPatch,\n}: {\n apiBase: string;\n index: EmailPreviewIndex;\n copy: EmailPreviewScreenCopy;\n locale: string;\n selectedId: string | null;\n onPatch: (patch: Record<string, string>) => void;\n}): JSX.Element {\n const [tab, setTab] = useState<PreviewTab>('html');\n const [width, setWidth] = useState<PreviewWidth>('desktop');\n\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n <CoverageNotice coverage={index.coverage} copy={copy} />\n <ToggleGroup\n dataTestId=\"email-preview-locale\"\n exclusive\n value={locale}\n size=\"sm\"\n options={index.locales.map((tag) => ({ value: tag, label: tag }))}\n onChange={(_event, value) => {\n if (value) onPatch({ locale: String(value) });\n }}\n />\n <Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', flexWrap: 'wrap' }}>\n <CatalogueColumn\n index={index}\n copy={copy}\n selectedId={selectedId}\n onSelect={(id) => onPatch({ id })}\n />\n <Box sx={{ flex: 1, minWidth: 360 }}>\n <PreviewPane\n apiBase={apiBase}\n id={selectedId}\n locale={locale}\n copy={copy}\n tab={tab}\n width={width}\n onTabChange={setTab}\n onWidthChange={setWidth}\n />\n </Box>\n </Box>\n </Box>\n );\n}\n\n/**\n * Build the screen. One call, one config object — the shape every factory in\n * this estate has.\n */\nexport function createEmailPreviewScreen(config: EmailPreviewScreenConfig): {\n page: () => JSX.Element;\n} {\n const { apiBase, copy } = config;\n\n function EmailPreviewsPage(): JSX.Element {\n // The VALUE is deliberately discarded: nothing reads the counter, and the\n // only thing it has to do is change, so React re-renders and the URL is\n // re-read below. It used to be a dependency of the catalogue fetch, which\n // is what made every row click refetch the list.\n const [, setUrlNonce] = useState(0);\n const locale = searchParam('locale') ?? '';\n const selectedId = searchParam('id');\n // The catalogue depends on the LANGUAGE and on nothing else. `urlNonce` is\n // deliberately absent: it counts every URL patch, selection included, and\n // including it here refetched the whole catalogue on each row click — for a\n // list whose contents cannot have changed. What the click actually needs is\n // a re-RENDER, so `selectedId` is re-read, and `setUrlNonce` already does\n // that on its own.\n const load = useCallback(\n () => fetchEmailPreviewIndex(apiBase, locale),\n [apiBase, locale],\n );\n const index = useLoadable(load, { keepPrevious: true });\n\n const patch = (next: Record<string, string>): void => {\n patchSearch(next);\n // `replaceState` does not notify React, so the screen re-reads the URL\n // through this counter rather than through a router's own subscription.\n setUrlNonce((n) => n + 1);\n };\n\n return (\n <Box data-testid=\"page-email-previews\" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n <Box>\n <Heading level=\"h2\">{copy.title}</Heading>\n <Text as=\"p\" size=\"sm\" color=\"secondary\">\n {copy.description}\n </Text>\n </Box>\n {index.error !== null ? (\n <Failure message={index.error} copy={copy} onRetry={index.reload} />\n ) : null}\n {index.error === null && index.data === null ? (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-index-loading\">\n {copy.loading}\n </Text>\n ) : null}\n {index.data !== null ? (\n <Browser\n apiBase={apiBase}\n index={index.data}\n copy={copy}\n locale={index.data.locale}\n selectedId={selectedId}\n onPatch={patch}\n />\n ) : null}\n </Box>\n );\n }\n\n return { page: EmailPreviewsPage };\n}\n","import type { JSX } from 'react';\nimport { useEffect, useState } from 'react';\n\nimport { Alert } from '@12-apps/ui/data-display/Alert';\nimport { Button } from '@12-apps/ui/form/Button';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewScreenCopy } from './copy';\n\n/**\n * The screen's async plumbing: one hook, and the state it cannot render itself.\n *\n * Its own module because it is the half with no opinion about e-mail. What is\n * left in `preview-screen.tsx` is composition — which columns, which panes,\n * what the URL says — and this is the machinery underneath all of it.\n *\n * `keepPrevious` is the load-bearing option and its docblock says why: a\n * consumer that renders children only while `data` is non-null gets those\n * children UNMOUNTED by a blanking reload, which silently discards whatever\n * state they held.\n */\n\n/** A load that can fail, in the two states a screen has to render. */\ninterface Loadable<T> {\n data: T | null;\n error: string | null;\n}\n\nexport function useLoadable<T>(\n load: () => Promise<T>,\n options: { keepPrevious?: boolean } = {},\n): Loadable<T> & { reload: () => void } {\n const [state, setState] = useState<Loadable<T>>({ data: null, error: null });\n const [nonce, setNonce] = useState(0);\n const { keepPrevious = false } = options;\n useEffect(() => {\n let live = true;\n // `keepPrevious` holds the last good answer on screen while the next one is\n // in flight, and it is not a nicety. The consumer of this hook renders its\n // children only while `data` is non-null, so blanking here UNMOUNTS them —\n // taking the filter text, the open tab and the chosen width with it. On a\n // fast connection the refetch lands before anyone notices; on a slow one\n // the operator watches what they just typed disappear.\n setState((previous) => (keepPrevious ? { ...previous, error: null } : { data: null, error: null }));\n load()\n .then((data) => live && setState({ data, error: null }))\n .catch(\n (error: unknown) =>\n live &&\n setState({ data: null, error: error instanceof Error ? error.message : String(error) }),\n );\n return () => {\n // A language switched twice in a second must not let the FIRST answer\n // land last — the screen would show a document the operator did not ask\n // for, with the toggle disagreeing.\n live = false;\n };\n }, [load, nonce]);\n return { ...state, reload: () => setNonce((n) => n + 1) };\n}\n\nexport function Failure({\n message,\n copy,\n onRetry,\n}: {\n message: string;\n copy: EmailPreviewScreenCopy;\n onRetry: () => void;\n}): JSX.Element {\n return (\n <Box data-testid=\"email-preview-error\" sx={{ display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'flex-start' }}>\n <Alert severity=\"error\">\n <Text as=\"p\" size=\"sm\">{copy.loadError}</Text>\n <Text as=\"p\" size=\"sm\">{message}</Text>\n </Alert>\n <Button size=\"sm\" variant=\"outline\" onClick={onRetry}>\n {copy.retry}\n </Button>\n </Box>\n );\n}\n","import type { JSX } from 'react';\n\nimport { Chip } from '@12-apps/ui/data-display/Chip';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { EmailPreviewRow } from '../catalog';\n\nimport type { EmailPreviewScreenCopy } from './copy';\n\n/**\n * The catalogue, grouped by the PACKAGE that owns each message.\n *\n * Grouping by owner rather than by family is the whole answer to \"which parts\n * of this system send mail\": the section headers ARE that list, derived from\n * the rows the surface sent rather than written down anywhere, so a package\n * that starts sending mail appears the day it does.\n *\n * The family stays visible as a chip on each row, because it is the other\n * question an operator asks — and one owner can span two families.\n */\n\ninterface OwnerGroup {\n owner: string;\n rows: EmailPreviewRow[];\n}\n\n/** Group in FIRST-SEEN order, so the list does not reshuffle between renders. */\nfunction groupByOwner(rows: readonly EmailPreviewRow[]): OwnerGroup[] {\n const groups = new Map<string, EmailPreviewRow[]>();\n for (const row of rows) {\n const bucket = groups.get(row.owner);\n if (bucket) bucket.push(row);\n else groups.set(row.owner, [row]);\n }\n return [...groups].map(([owner, ownerRows]) => ({ owner, rows: ownerRows }));\n}\n\n/** Does this row match what was typed? Subject, key, owner and family all count. */\nexport function matchesFilter(row: EmailPreviewRow, filter: string): boolean {\n const needle = filter.trim().toLowerCase();\n if (needle === '') return true;\n return [row.subject, row.key, row.owner, row.family].some((field) =>\n field.toLowerCase().includes(needle),\n );\n}\n\nfunction MessageRow({\n row,\n selected,\n onSelect,\n}: {\n row: EmailPreviewRow;\n selected: boolean;\n onSelect: (id: string) => void;\n}): JSX.Element {\n return (\n <Box\n component=\"button\"\n type=\"button\"\n data-testid={`email-preview-row-${row.id}`}\n aria-current={selected}\n onClick={() => onSelect(row.id)}\n sx={{\n appearance: 'none',\n textAlign: 'left',\n width: '100%',\n cursor: 'pointer',\n border: '1px solid',\n borderColor: selected ? 'primary.main' : 'divider',\n background: selected ? 'action.selected' : 'background.paper',\n borderRadius: 1.5,\n p: 1.25,\n display: 'flex',\n flexDirection: 'column',\n gap: 0.5,\n }}\n >\n <Text as=\"span\" size=\"sm\" weight=\"medium\">\n {row.subject}\n </Text>\n <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>\n <Chip label={row.family} size=\"sm\" variant=\"outlined\" color=\"neutral\" />\n <Text as=\"span\" size=\"xs\" color=\"secondary\">\n {row.key}\n </Text>\n </Box>\n </Box>\n );\n}\n\nexport function MessageList({\n rows,\n selectedId,\n copy,\n onSelect,\n}: {\n rows: readonly EmailPreviewRow[];\n selectedId: string | null;\n copy: EmailPreviewScreenCopy;\n onSelect: (id: string) => void;\n}): JSX.Element {\n if (rows.length === 0) {\n return (\n <Text as=\"p\" size=\"sm\" color=\"secondary\" data-testid=\"email-preview-no-matches\">\n {copy.noMatches}\n </Text>\n );\n }\n return (\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n {groupByOwner(rows).map((group) => (\n <Box\n key={group.owner}\n data-testid={`email-preview-owner-${group.owner}`}\n sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}\n >\n <Text as=\"p\" size=\"xs\" weight=\"medium\" color=\"secondary\">\n {group.owner}\n </Text>\n {group.rows.map((row) => (\n <MessageRow\n key={row.id}\n row={row}\n selected={row.id === selectedId}\n onSelect={onSelect}\n />\n ))}\n </Box>\n ))}\n </Box>\n );\n}\n","import type { EmailPreviewDetail, EmailPreviewIndex } from '../catalog';\n\n/**\n * How the screen reaches its own endpoints.\n *\n * Plain `fetch` rather than a data library: this package cannot know whether a\n * host runs react-query, SWR or nothing at all, and a screen that dragged one\n * in would put a second cache beside whichever the host already has. The two\n * calls here are a list and a document — neither needs invalidation, retries or\n * shared state, which is most of what a data library is for.\n *\n * Both unwrap the `{ data }` envelope the routes write, and both surface a\n * non-2xx as a thrown `Error` carrying whatever the surface said, so the screen\n * can show the operator the real refusal (an unknown locale, a 403 from the\n * host's own gate) rather than a generic failure.\n */\n\n/** The envelope every route in this package answers with. */\ninterface Envelope<T> {\n data?: T;\n error?: string;\n}\n\nasync function get<T>(url: string): Promise<T> {\n const response = await fetch(url, { headers: { Accept: 'application/json' } });\n let body: Envelope<T> = {};\n try {\n body = (await response.json()) as Envelope<T>;\n } catch {\n // A gate that refuses before the router runs may answer HTML, not JSON.\n // Falling through to the status line below is more useful than a parse\n // error naming a character offset.\n }\n if (!response.ok || body.data === undefined) {\n throw new Error(body.error ?? `The request failed (${response.status}).`);\n }\n return body.data;\n}\n\nconst withLocale = (base: string, locale: string): string =>\n `${base}?locale=${encodeURIComponent(locale)}`;\n\n/** The catalogue, with every subject rendered in `locale`. */\nexport function fetchEmailPreviewIndex(\n apiBase: string,\n locale: string,\n): Promise<EmailPreviewIndex> {\n return get<EmailPreviewIndex>(withLocale(apiBase, locale));\n}\n\n/** One rendered message. */\nexport function fetchEmailPreview(\n apiBase: string,\n id: string,\n locale: string,\n): Promise<EmailPreviewDetail> {\n return get<EmailPreviewDetail>(\n withLocale(`${apiBase}/${encodeURIComponent(id)}`, locale),\n );\n}\n"],"mappings":";;;;;AAEA,SAAS,mBAAmB;AAC5B,SAAS,WAAW;AACpB,SAAS,YAAY;AA6CjB,cAyEE,YAzEF;AAJJ,IAAM,eAAe;AAErB,SAAS,UAAU,EAAE,UAAU,OAAO,GAAsD;AAC1F,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAU;AAAA,MACV,eAAa;AAAA,MACb,IAAI;AAAA,QACF,GAAG;AAAA,QACH,GAAG;AAAA,QACH,cAAc;AAAA,QACd,QAAQ;AAAA,QACR,aAAa;AAAA,QACb,YAAY;AAAA,QACZ,UAAU;AAAA,QACV,YAAY;AAAA,QACZ,YAAY;AAAA,QACZ,WAAW;AAAA,QACX,WAAW;AAAA,QACX,UAAU;AAAA,MACZ;AAAA,MAEC;AAAA;AAAA,EACH;AAEJ;AAvBS;AAyBT,SAAS,UAAU;AAAA,EACjB;AAAA,EACA;AAAA,EACA;AACF,GAIgB;AACd,SACE;AAAA,IAAC;AAAA;AAAA,MACC,IAAI;AAAA,QACF,SAAS;AAAA,QACT,gBAAgB;AAAA,QAChB,cAAc;AAAA,QACd,QAAQ;AAAA,QACR,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MAEA;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,eAAY;AAAA,UACZ;AAAA,UACA,QAAQ,OAAO;AAAA,UACf,SAAQ;AAAA,UACR,IAAI;AAAA,YACF,QAAQ;AAAA,YACR,OAAO,UAAU,WAAW,eAAe;AAAA,YAC3C,QAAQ;AAAA,YACR,YAAY;AAAA,UACd;AAAA;AAAA,MACF;AAAA;AAAA,EACF;AAEJ;AAnCS;AA8CF,SAAS,YAAY,OAAsC;AAChE,QAAM,EAAE,QAAQ,MAAM,KAAK,OAAO,aAAa,cAAc,IAAI;AACjE,SACE,qBAAC,OAAI,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,IAAI,GAAG,eAAY,sBAC3E;AAAA,yBAAC,OACC;AAAA,0BAAC,QAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAC1B,eAAK,cACR;AAAA,MACA,oBAAC,QAAK,IAAG,KAAI,MAAK,MAAK,QAAO,UAAS,eAAY,yBAChD,iBAAO,SACV;AAAA,OACF;AAAA,IACA,qBAAC,OAAI,IAAI,EAAE,SAAS,QAAQ,UAAU,QAAQ,KAAK,GAAG,YAAY,SAAS,GACzE;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,YAAW;AAAA,UACX,WAAS;AAAA,UACT,OAAO;AAAA,UACP,MAAK;AAAA,UACL,SAAS;AAAA,YACP,EAAE,OAAO,QAAQ,OAAO,KAAK,QAAQ;AAAA,YACrC,EAAE,OAAO,QAAQ,OAAO,KAAK,QAAQ;AAAA,YACrC,EAAE,OAAO,UAAU,OAAO,KAAK,UAAU;AAAA,UAC3C;AAAA,UACA,UAAU,CAAC,QAAQ,UAAU;AAC3B,gBAAI,MAAO,aAAY,KAAmB;AAAA,UAC5C;AAAA;AAAA,MACF;AAAA,MACC,QAAQ,SACP;AAAA,QAAC;AAAA;AAAA,UACC,YAAW;AAAA,UACX,WAAS;AAAA,UACT,OAAO;AAAA,UACP,MAAK;AAAA,UACL,SAAS;AAAA,YACP,EAAE,OAAO,WAAW,OAAO,KAAK,aAAa;AAAA,YAC7C,EAAE,OAAO,UAAU,OAAO,KAAK,YAAY;AAAA,UAC7C;AAAA,UACA,UAAU,CAAC,QAAQ,UAAU;AAC3B,gBAAI,MAAO,eAAc,KAAqB;AAAA,UAChD;AAAA;AAAA,MACF,IACE;AAAA,OACN;AAAA,IACC,QAAQ,SAAS,oBAAC,aAAU,QAAgB,OAAc,OAAO,KAAK,YAAY,IAAK;AAAA,IACvF,QAAQ,SAAS,oBAAC,aAAU,QAAO,sBAAsB,iBAAO,MAAK,IAAe;AAAA,IACpF,QAAQ,WAAW,oBAAC,aAAU,QAAO,wBAAwB,iBAAO,MAAK,IAAe;AAAA,KAC3F;AAEJ;AAhDgB;;;ACtHhB,SAAS,aAAa,SAAS,YAAAA,iBAA0B;AAEzD,SAAS,SAAAC,cAAa;AACtB,SAAS,aAAa;AACtB,SAAS,eAAAC,oBAAmB;AAC5B,SAAS,OAAAC,YAAW;AACpB,SAAS,eAAe;AACxB,SAAS,QAAAC,aAAY;;;ACNrB,SAAS,WAAW,gBAAgB;AAEpC,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,OAAAC,YAAW;AACpB,SAAS,QAAAC,aAAY;AAmEf,SACE,OAAAC,MADF,QAAAC,aAAA;AA5CC,SAAS,YACd,MACA,UAAsC,CAAC,GACD;AACtC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAsB,EAAE,MAAM,MAAM,OAAO,KAAK,CAAC;AAC3E,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,CAAC;AACpC,QAAM,EAAE,eAAe,MAAM,IAAI;AACjC,YAAU,MAAM;AACd,QAAI,OAAO;AAOX,aAAS,CAAC,aAAc,eAAe,EAAE,GAAG,UAAU,OAAO,KAAK,IAAI,EAAE,MAAM,MAAM,OAAO,KAAK,CAAE;AAClG,SAAK,EACF,KAAK,CAAC,SAAS,QAAQ,SAAS,EAAE,MAAM,OAAO,KAAK,CAAC,CAAC,EACtD;AAAA,MACC,CAAC,UACC,QACA,SAAS,EAAE,MAAM,MAAM,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,CAAC;AAAA,IAC1F;AACF,WAAO,MAAM;AAIX,aAAO;AAAA,IACT;AAAA,EACF,GAAG,CAAC,MAAM,KAAK,CAAC;AAChB,SAAO,EAAE,GAAG,OAAO,QAAQ,6BAAM,SAAS,CAAC,MAAM,IAAI,CAAC,GAA3B,UAA6B;AAC1D;AA/BgB;AAiCT,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AACF,GAIgB;AACd,SACE,gBAAAA,MAACC,MAAA,EAAI,eAAY,uBAAsB,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,GAAG,YAAY,aAAa,GACtH;AAAA,oBAAAD,MAAC,SAAM,UAAS,SACd;AAAA,sBAAAD,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAM,eAAK,WAAU;AAAA,MACvC,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAM,mBAAQ;AAAA,OAClC;AAAA,IACA,gBAAAH,KAAC,UAAO,MAAK,MAAK,SAAQ,WAAU,SAAS,SAC1C,eAAK,OACR;AAAA,KACF;AAEJ;AApBgB;;;AC5DhB,SAAS,YAAY;AACrB,SAAS,OAAAI,YAAW;AACpB,SAAS,QAAAC,aAAY;AA0Ef,gBAAAC,MAGA,QAAAC,aAHA;AAlDN,SAAS,aAAa,MAAgD;AACpE,QAAM,SAAS,oBAAI,IAA+B;AAClD,aAAW,OAAO,MAAM;AACtB,UAAM,SAAS,OAAO,IAAI,IAAI,KAAK;AACnC,QAAI,OAAQ,QAAO,KAAK,GAAG;AAAA,QACtB,QAAO,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC;AAAA,EAClC;AACA,SAAO,CAAC,GAAG,MAAM,EAAE,IAAI,CAAC,CAAC,OAAO,SAAS,OAAO,EAAE,OAAO,MAAM,UAAU,EAAE;AAC7E;AARS;AAWF,SAAS,cAAc,KAAsB,QAAyB;AAC3E,QAAM,SAAS,OAAO,KAAK,EAAE,YAAY;AACzC,MAAI,WAAW,GAAI,QAAO;AAC1B,SAAO,CAAC,IAAI,SAAS,IAAI,KAAK,IAAI,OAAO,IAAI,MAAM,EAAE;AAAA,IAAK,CAAC,UACzD,MAAM,YAAY,EAAE,SAAS,MAAM;AAAA,EACrC;AACF;AANgB;AAQhB,SAAS,WAAW;AAAA,EAClB;AAAA,EACA;AAAA,EACA;AACF,GAIgB;AACd,SACE,gBAAAA;AAAA,IAACC;AAAA,IAAA;AAAA,MACC,WAAU;AAAA,MACV,MAAK;AAAA,MACL,eAAa,qBAAqB,IAAI,EAAE;AAAA,MACxC,gBAAc;AAAA,MACd,SAAS,MAAM,SAAS,IAAI,EAAE;AAAA,MAC9B,IAAI;AAAA,QACF,YAAY;AAAA,QACZ,WAAW;AAAA,QACX,OAAO;AAAA,QACP,QAAQ;AAAA,QACR,QAAQ;AAAA,QACR,aAAa,WAAW,iBAAiB;AAAA,QACzC,YAAY,WAAW,oBAAoB;AAAA,QAC3C,cAAc;AAAA,QACd,GAAG;AAAA,QACH,SAAS;AAAA,QACT,eAAe;AAAA,QACf,KAAK;AAAA,MACP;AAAA,MAEA;AAAA,wBAAAF,KAACG,OAAA,EAAK,IAAG,QAAO,MAAK,MAAK,QAAO,UAC9B,cAAI,SACP;AAAA,QACA,gBAAAF,MAACC,MAAA,EAAI,IAAI,EAAE,SAAS,QAAQ,YAAY,UAAU,KAAK,KAAK,GAC1D;AAAA,0BAAAF,KAAC,QAAK,OAAO,IAAI,QAAQ,MAAK,MAAK,SAAQ,YAAW,OAAM,WAAU;AAAA,UACtE,gBAAAA,KAACG,OAAA,EAAK,IAAG,QAAO,MAAK,MAAK,OAAM,aAC7B,cAAI,KACP;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;AA1CS;AA4CF,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAKgB;AACd,MAAI,KAAK,WAAW,GAAG;AACrB,WACE,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAAY,eAAY,4BAClD,eAAK,WACR;AAAA,EAEJ;AACA,SACE,gBAAAH,KAACE,MAAA,EAAI,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,EAAE,GACzD,uBAAa,IAAI,EAAE,IAAI,CAAC,UACvB,gBAAAD;AAAA,IAACC;AAAA,IAAA;AAAA,MAEC,eAAa,uBAAuB,MAAM,KAAK;AAAA,MAC/C,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,KAAK;AAAA,MAE1D;AAAA,wBAAAF,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,QAAO,UAAS,OAAM,aAC1C,gBAAM,OACT;AAAA,QACC,MAAM,KAAK,IAAI,CAAC,QACf,gBAAAH;AAAA,UAAC;AAAA;AAAA,YAEC;AAAA,YACA,UAAU,IAAI,OAAO;AAAA,YACrB;AAAA;AAAA,UAHK,IAAI;AAAA,QAIX,CACD;AAAA;AAAA;AAAA,IAdI,MAAM;AAAA,EAeb,CACD,GACH;AAEJ;AAzCgB;;;ACpEhB,eAAe,IAAO,KAAyB;AAC7C,QAAM,WAAW,MAAM,MAAM,KAAK,EAAE,SAAS,EAAE,QAAQ,mBAAmB,EAAE,CAAC;AAC7E,MAAI,OAAoB,CAAC;AACzB,MAAI;AACF,WAAQ,MAAM,SAAS,KAAK;AAAA,EAC9B,QAAQ;AAAA,EAIR;AACA,MAAI,CAAC,SAAS,MAAM,KAAK,SAAS,QAAW;AAC3C,UAAM,IAAI,MAAM,KAAK,SAAS,uBAAuB,SAAS,MAAM,IAAI;AAAA,EAC1E;AACA,SAAO,KAAK;AACd;AAde;AAgBf,IAAM,aAAa,wBAAC,MAAc,WAChC,GAAG,IAAI,WAAW,mBAAmB,MAAM,CAAC,IAD3B;AAIZ,SAAS,uBACd,SACA,QAC4B;AAC5B,SAAO,IAAuB,WAAW,SAAS,MAAM,CAAC;AAC3D;AALgB;AAQT,SAAS,kBACd,SACA,IACA,QAC6B;AAC7B,SAAO;AAAA,IACL,WAAW,GAAG,OAAO,IAAI,mBAAmB,EAAE,CAAC,IAAI,MAAM;AAAA,EAC3D;AACF;AARgB;;;AHkCZ,SACE,OAAAI,MADF,QAAAC,aAAA;AApCJ,SAAS,YAAY,MAA6B;AAChD,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,SAAO,IAAI,gBAAgB,OAAO,SAAS,MAAM,EAAE,IAAI,IAAI;AAC7D;AAHS;AAkBT,SAAS,YAAY,OAAqC;AACxD,MAAI,OAAO,WAAW,YAAa;AACnC,QAAM,OAAO,IAAI,gBAAgB,OAAO,SAAS,MAAM;AACvD,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,EAAG,MAAK,IAAI,KAAK,KAAK;AACrE,QAAM,EAAE,UAAU,KAAK,IAAI,OAAO;AAClC,SAAO,QAAQ,aAAa,CAAC,GAAG,IAAI,GAAG,QAAQ,IAAI,KAAK,SAAS,CAAC,GAAG,IAAI,EAAE;AAC7E;AANS;AAST,SAAS,eAAe;AAAA,EACtB;AAAA,EACA;AACF,GAGuB;AACrB,MAAI,SAAS,QAAQ,WAAW,KAAK,SAAS,OAAO,WAAW,EAAG,QAAO;AAC1E,SACE,gBAAAA,MAACC,QAAA,EAAM,UAAS,WAAU,eAAY,0BACpC;AAAA,oBAAAF,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,QAAO,UAC3B,eAAK,eACR;AAAA,IACC,SAAS,QAAQ,SAAS,IACzB,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MACf,eAAK,eAAe,SAAS,QAAQ,KAAK,IAAI,CAAC,GAClD,IACE;AAAA,IACH,SAAS,OAAO,SAAS,IACxB,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MACf,eAAK,cAAc,SAAS,OAAO,KAAK,IAAI,CAAC,GAChD,IACE;AAAA,KACN;AAEJ;AAzBS;AA4BT,SAAS,YAAY;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GASgB;AACd,QAAM,OAAO;AAAA,IACX,MACE,OAAO,OACH,QAAQ,QAAQ,IAAiC,IACjD,kBAAkB,SAAS,IAAI,MAAM;AAAA,IAC3C,CAAC,SAAS,IAAI,MAAM;AAAA,EACtB;AACA,QAAM,SAAS,YAAY,IAAI;AAE/B,MAAI,OAAO,MAAM;AACf,WACE,gBAAAH,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAAY,eAAY,uBAClD,eAAK,SACR;AAAA,EAEJ;AACA,MAAI,OAAO,UAAU,MAAM;AACzB,WAAO,gBAAAH,KAAC,WAAQ,SAAS,OAAO,OAAO,MAAY,SAAS,OAAO,QAAQ;AAAA,EAC7E;AACA,MAAI,OAAO,SAAS,MAAM;AACxB,WACE,gBAAAA,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAAY,eAAY,gCAClD,eAAK,SACR;AAAA,EAEJ;AACA,SACE,gBAAAH;AAAA,IAAC;AAAA;AAAA,MACC,QAAQ,OAAO;AAAA,MACf;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;AAvDS;AA0DT,SAAS,gBAAgB;AAAA,EACvB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAKgB;AACd,QAAM,CAAC,QAAQ,SAAS,IAAII,UAAS,EAAE;AACvC,QAAM,UAAU;AAAA,IACd,MAAM,MAAM,MAAM,OAAO,CAAC,QAAQ,cAAc,KAAK,MAAM,CAAC;AAAA,IAC5D,CAAC,MAAM,OAAO,MAAM;AAAA,EACtB;AACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWE,gBAAAH;AAAA,MAACI;AAAA,MAAA;AAAA,QACC,IAAI;AAAA,UACF,OAAO;AAAA,UACP,YAAY;AAAA,UACZ,SAAS;AAAA,UACT,eAAe;AAAA,UACf,KAAK;AAAA,UACL,UAAU;AAAA,UACV,KAAK;AAAA,UACL,WAAW;AAAA,UACX,WAAW;AAAA,QACb;AAAA,QAEA;AAAA,0BAAAL;AAAA,YAAC;AAAA;AAAA,cACC,OAAO,KAAK;AAAA,cACZ,aAAa,KAAK;AAAA,cAClB,OAAO;AAAA,cACP,eAAY;AAAA,cACZ,UAAU,CAAC,UAAU,UAAU,MAAM,OAAO,KAAK;AAAA,cACjD,WAAS;AAAA;AAAA,UACX;AAAA,UAEA,gBAAAA,KAACK,MAAA,EAAI,IAAI,EAAE,WAAW,QAAQ,MAAM,GAAG,IAAI,IAAI,GAC7C,0BAAAL,KAAC,eAAY,MAAM,SAAS,YAAwB,MAAY,UAAoB,GACtF;AAAA;AAAA;AAAA,IACF;AAAA;AAEJ;AAtDS;AAyDT,SAAS,QAAQ;AAAA,EACf;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAOgB;AACd,QAAM,CAAC,KAAK,MAAM,IAAII,UAAqB,MAAM;AACjD,QAAM,CAAC,OAAO,QAAQ,IAAIA,UAAuB,SAAS;AAE1D,SACE,gBAAAH,MAACI,MAAA,EAAI,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,EAAE,GAC1D;AAAA,oBAAAL,KAAC,kBAAe,UAAU,MAAM,UAAU,MAAY;AAAA,IACtD,gBAAAA;AAAA,MAACM;AAAA,MAAA;AAAA,QACC,YAAW;AAAA,QACX,WAAS;AAAA,QACT,OAAO;AAAA,QACP,MAAK;AAAA,QACL,SAAS,MAAM,QAAQ,IAAI,CAAC,SAAS,EAAE,OAAO,KAAK,OAAO,IAAI,EAAE;AAAA,QAChE,UAAU,CAAC,QAAQ,UAAU;AAC3B,cAAI,MAAO,SAAQ,EAAE,QAAQ,OAAO,KAAK,EAAE,CAAC;AAAA,QAC9C;AAAA;AAAA,IACF;AAAA,IACA,gBAAAL,MAACI,MAAA,EAAI,IAAI,EAAE,SAAS,QAAQ,KAAK,GAAG,YAAY,cAAc,UAAU,OAAO,GAC7E;AAAA,sBAAAL;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA;AAAA,UACA;AAAA,UACA,UAAU,CAAC,OAAO,QAAQ,EAAE,GAAG,CAAC;AAAA;AAAA,MAClC;AAAA,MACA,gBAAAA,KAACK,MAAA,EAAI,IAAI,EAAE,MAAM,GAAG,UAAU,IAAI,GAChC,0BAAAL;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,IAAI;AAAA,UACJ;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA,aAAa;AAAA,UACb,eAAe;AAAA;AAAA,MACjB,GACF;AAAA,OACF;AAAA,KACF;AAEJ;AArDS;AA2DF,SAAS,yBAAyB,QAEvC;AACA,QAAM,EAAE,SAAS,KAAK,IAAI;AAE1B,WAAS,oBAAiC;AAKxC,UAAM,CAAC,EAAE,WAAW,IAAII,UAAS,CAAC;AAClC,UAAM,SAAS,YAAY,QAAQ,KAAK;AACxC,UAAM,aAAa,YAAY,IAAI;AAOnC,UAAM,OAAO;AAAA,MACX,MAAM,uBAAuB,SAAS,MAAM;AAAA,MAC5C,CAAC,SAAS,MAAM;AAAA,IAClB;AACA,UAAM,QAAQ,YAAY,MAAM,EAAE,cAAc,KAAK,CAAC;AAEtD,UAAM,QAAQ,wBAAC,SAAuC;AACpD,kBAAY,IAAI;AAGhB,kBAAY,CAAC,MAAM,IAAI,CAAC;AAAA,IAC1B,GALc;AAOd,WACE,gBAAAH,MAACI,MAAA,EAAI,eAAY,uBAAsB,IAAI,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,EAAE,GAC5F;AAAA,sBAAAJ,MAACI,MAAA,EACC;AAAA,wBAAAL,KAAC,WAAQ,OAAM,MAAM,eAAK,OAAM;AAAA,QAChC,gBAAAA,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAC1B,eAAK,aACR;AAAA,SACF;AAAA,MACC,MAAM,UAAU,OACf,gBAAAH,KAAC,WAAQ,SAAS,MAAM,OAAO,MAAY,SAAS,MAAM,QAAQ,IAChE;AAAA,MACH,MAAM,UAAU,QAAQ,MAAM,SAAS,OACtC,gBAAAA,KAACG,OAAA,EAAK,IAAG,KAAI,MAAK,MAAK,OAAM,aAAY,eAAY,+BAClD,eAAK,SACR,IACE;AAAA,MACH,MAAM,SAAS,OACd,gBAAAH;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,OAAO,MAAM;AAAA,UACb;AAAA,UACA,QAAQ,MAAM,KAAK;AAAA,UACnB;AAAA,UACA,SAAS;AAAA;AAAA,MACX,IACE;AAAA,OACN;AAAA,EAEJ;AAvDS;AAyDT,SAAO,EAAE,MAAM,kBAAkB;AACnC;AA/DgB;","names":["useState","Alert","ToggleGroup","Box","Text","Box","Text","jsx","jsxs","Box","Text","Box","Text","jsx","jsxs","Box","Text","jsx","jsxs","Alert","Text","useState","Box","ToggleGroup"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/react/relative-time.ts","../src/react/live-section.tsx","../src/react/live-card.tsx"],"sourcesContent":["import type { NotificationMessages } from '../messages';\n\n/**\n * \"há 5 min\"-style relative timestamp, falling back to an absolute date for\n * anything older than a week. Every word comes from the messages table, so a\n * host in another locale changes the copy and the locale together.\n *\n * `now` is a parameter rather than a read, and the live section is why. A\n * relative phrase is only true for the instant it was computed, so something\n * has to CAUSE the render that recomputes it — and the live entries' own data\n * cannot: a host backed by react-query gets the previous object back whenever a\n * poll is deep-equal (`structuralSharing`, on by default), which within one\n * stage it always is. The section therefore ticks a clock and hands it down.\n * Defaulted, so every existing caller reads the wall clock exactly as before.\n */\nexport function relativeTime(\n iso: string,\n messages: NotificationMessages,\n now: number = Date.now(),\n): string {\n const elapsedMs = now - new Date(iso).getTime();\n const minutes = Math.round(elapsedMs / 60_000);\n if (minutes < 1) return messages.justNow;\n if (minutes < 60) return messages.minutesAgo(minutes);\n const hours = Math.round(minutes / 60);\n if (hours < 24) return messages.hoursAgo(hours);\n const days = Math.round(hours / 24);\n if (days < 7) return messages.daysAgo(days);\n return new Date(iso).toLocaleDateString(messages.dateLocale);\n}\n","/**\n * The pinned block at the top of the panel: everything that is happening NOW,\n * above everything that has already happened.\n *\n * ## Why it is here and not a second surface\n *\n * The notification centre is where a person goes to find out what they missed.\n * Splitting \"happening\" into its own bell would make them check two places to\n * answer one question, and the half they would stop checking is the one that\n * only has something in it occasionally — which is this one. Above the list,\n * inside the same drawer, it is on the path they already walk.\n *\n * ## What it deliberately does NOT do\n *\n * - It does not touch `unread`. A live entry is not news; counting it would put\n * a number on the bell that no amount of reading can clear.\n * - It renders nothing at all when there is nothing live — no heading, no empty\n * state, no reserved space. A panel with one permanent empty section in it is\n * a panel that has taught its reader to skip the top.\n * - It does not fetch. `useActivities` is the host's, and `active` tells it\n * whether anyone is looking.\n */\nimport { useEffect, useId, useState, type JSX, type ReactNode } from 'react';\n\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport type { LiveActivity } from '../live';\nimport type { NotificationMessages } from '../messages';\n\nimport { LiveActivityCard } from './live-card';\nimport type { LiveActivitiesConfig } from './live-config';\nimport type { LiveSeenStore } from './live-seen';\n\n/**\n * How often the section re-reads the clock.\n *\n * Every minute, because the timestamps under the cards are in minutes and a\n * tick that cannot change what is on screen is a wasted render — which is why\n * it is gated on there being something to tick as well as on the panel being\n * open. An open panel with nothing live schedules nothing at all; the earlier\n * gate was `active` alone, and it re-rendered a section that renders `null`\n * once a minute for as long as somebody left the inbox open.\n */\nconst TICK_MS = 60_000;\n\n/**\n * The current minute, re-read on a timer while there is something to tick.\n *\n * The caller passes `active && there are activities` — see {@link TICK_MS} for\n * why both halves are in it.\n */\nfunction useMinuteTick(active: boolean): number {\n const [now, setNow] = useState(() => Date.now());\n useEffect(() => {\n if (!active) return;\n // Re-read once on becoming active too: a panel reopened after ten minutes\n // would otherwise show the minute it was closed at until the first tick.\n setNow(Date.now());\n const timer = setInterval(() => setNow(Date.now()), TICK_MS);\n return () => clearInterval(timer);\n }, [active]);\n return now;\n}\n\nexport interface LiveSectionProps {\n config: LiveActivitiesConfig;\n messages: NotificationMessages;\n /** Whether the panel is open — passed straight through to the host's hook. */\n active: boolean;\n /**\n * Follow a card's link.\n *\n * Optional, and the panel omits it for a host with no router: a card that\n * cannot go anywhere renders as text rather than as a named control that\n * does nothing.\n */\n onOpen?: (activity: LiveActivity) => void;\n /**\n * The rest of the panel, given how many entries are live.\n *\n * A render prop rather than a sibling, because the count is knowable only\n * where the host's hook is CALLED, and it cannot be called anywhere else:\n * `live` is optional on the panel, so reading it there would mean calling a\n * hook conditionally — the failure React reports as a crash in some unrelated\n * component.\n *\n * The inbox needs the number for exactly one decision, and it is the decision\n * this section exists to inform: whether \"no notifications\" is true. A live\n * entry IS a notification, so a panel showing one under that sentence is\n * contradicting itself.\n */\n children?: (liveCount: number) => ReactNode;\n /**\n * Where \"the reader has seen these\" is recorded, for the bell to read.\n *\n * Written HERE because this is the component that puts them on screen, and\n * being on screen is what seen means. Optional so the section stays usable by\n * a host that mounts it outside the panel.\n */\n seen?: LiveSeenStore;\n}\n\n\n\nexport function LiveSection({\n config,\n messages,\n active,\n onOpen,\n children,\n seen,\n}: LiveSectionProps): JSX.Element {\n // Unconditional, because it is a hook. `active` is how it is told nobody is\n // looking — the same arrangement `useSignal` has one seam over.\n const activities = config.useActivities({ active });\n const now = useMinuteTick(active && activities.length > 0);\n // Per MOUNT, not per module: `LiveSection` is exported, and a host with a\n // desktop and a mobile panel would otherwise emit one id twice and have both\n // regions resolve their label to whichever came first.\n const headingId = useId();\n\n const liveCount = activities.length;\n\n // Only while somebody is looking. The panel keeps this mounted through the\n // closing transition, and marking there would swallow an update that arrived\n // in the frames after the reader turned away.\n useEffect(() => {\n if (active && liveCount > 0) seen?.mark(activities);\n }, [active, liveCount, activities, seen]);\n\n if (liveCount === 0) return <>{children?.(0)}</>;\n\n return (\n <>\n // A NAMED region. Without the label a screen-reader user meets a loose run\n // of controls ahead of the inbox with nothing saying what they are; the\n // panel's own title is the drawer's heading and cannot describe this block.\n <Box\n component=\"section\"\n aria-labelledby={headingId}\n data-testid=\"live-activities\"\n sx={{ pb: 1.5 }}\n >\n {/*\n A SPAN, not a heading. `aria-labelledby` names the region perfectly well\n from one, and an `<h2>` here would sit under the drawer's own `<h6>`\n title and ABOVE the inbox's `<h3>` empty state — an outline in which the\n inbox's states read as part of the live block, which is the opposite of\n what the two blocks are.\n */}\n <Text\n id={headingId}\n variant=\"caption\"\n size=\"xs\"\n color=\"secondary\"\n weight=\"semibold\"\n as=\"span\"\n >\n {config.messages.sectionTitle}\n </Text>\n <Box sx={{ pt: 0.75 }}>\n {activities.map((activity) => (\n <LiveActivityCard\n key={activity.id}\n activity={activity}\n messages={messages}\n live={config.messages}\n now={now}\n {...(onOpen ? { onOpen } : {})}\n {...(config.renderIcon ? { renderIcon: config.renderIcon } : {})}\n />\n ))}\n </Box>\n </Box>\n {children?.(liveCount)}\n </>\n );\n}\n","/**\n * ONE pinned live entry: a mark, what is happening, its lane, and when it last\n * moved.\n *\n * Visually a WASH rather than a fill — a tinted card with a brand-tinted border\n * — for the reason the inbox's unread row uses the same treatment: this sits at\n * the top of a list of other people's news, and a saturated block there\n * out-shouts everything it is supposed to be introducing.\n *\n * ## The card is a DIV, and the button is inside it\n *\n * The obvious shape — one `<button>` wrapping the whole card — is not\n * available, because `Stepper` draws every stop as a real `<button>`\n * (`@12-apps/ui`'s `StepButton` is `styled(Button)`), and `clickable={false}`\n * only sets `pointer-events: none`. A button inside a button is invalid HTML:\n * the parser auto-closes the outer one at the first nested one, so any host\n * that server-renders the panel open hydrates against a tree the browser\n * rewrote, and every adopter's dev console carries a React error besides.\n *\n * `aria-hidden` and `inert` on the lane fix the tab stops and the accessible\n * name — they do NOT fix the nesting, and an earlier draft of this file claimed\n * they did. So the tap target is the TEXT block, and the lane and the timestamp\n * are its siblings: valid markup, and a target that still covers everything a\n * reader would aim at.\n */\nimport { useId, type JSX, type ReactNode } from 'react';\n\nimport { Stepper } from '@12-apps/ui/data-display/Stepper';\nimport { Box } from '@12-apps/ui/mui/Box';\nimport { alpha, type Theme } from '@12-apps/ui/mui/styles';\nimport { Text } from '@12-apps/ui/typography/Text';\n\nimport { liveActivityLane, type LiveActivity } from '../live';\nimport type { NotificationMessages } from '../messages';\n\nimport type { LiveActivitiesConfig, LiveActivityMessages } from './live-config';\nimport { relativeTime } from './relative-time';\n\nconst cardSx = {\n // `relative`, so the button below can stretch a hit area over the whole card\n // — see `targetSx`.\n position: 'relative',\n border: '1px solid',\n borderColor: (t: Theme) => alpha(t.palette.primary.main, 0.35),\n bgcolor: (t: Theme) => alpha(t.palette.primary.main, 0.06),\n borderRadius: 1.5,\n p: 1.25,\n mb: 1,\n} as const;\n\n/** The text block: the mark, the heading and the sentence under it. */\nconst targetSx = {\n display: 'flex',\n alignItems: 'center',\n gap: 1,\n width: '100%',\n textAlign: 'left',\n font: 'inherit',\n color: 'inherit',\n border: 'none',\n background: 'none',\n p: 0,\n} as const;\n\n/**\n * The button, stretched over the WHOLE card.\n *\n * Taking the lane out of the link fixed the markup and left the card looking\n * like one target while only its top half was one — the lane is the most\n * visually distinctive part of it, and aiming at the obvious thing did nothing.\n *\n * A stretched pseudo-element is the remedy that keeps the structure: the\n * `<button>` stays a sibling of the lane in the tree, so nothing nests, and its\n * `::after` covers the card. All three declarations are load-bearing — a\n * pseudo-element with no `content` generates no box at all, and an absolutely\n * positioned box with auto offsets is 0×0.\n *\n * ## What actually lets a click on the LANE reach it\n *\n * Not paint order. `@12-apps/ui` gives each `StepItem` `position: relative`, and\n * this overlay is positioned too — so the two sit in the SAME painting layer\n * (positioned, `z-index: auto`), where tree order decides, and the lane comes\n * after the button. Every stop, and its label, therefore sits over this\n * overlay. `clickable={false}` does not save it either: the package puts\n * `pointer-events: none` on the step CIRCLE and not on the label beside it.\n *\n * It is `inert` on {@link ActivityLane} that does it: an inert subtree is\n * skipped by hit-testing, so a click on a stop falls through to the overlay\n * underneath. That makes the attribute load-bearing for the TARGET as well as\n * for the tab order it was added for — remove it and the lane silently swallows\n * clicks again, which is why the two are pinned by one test.\n */\nconst stretchedSx = {\n ...targetSx,\n cursor: 'pointer',\n '&::after': { content: '\"\"', position: 'absolute', inset: 0 },\n} as const;\n\n/**\n * Make a four-stop lane fit the panel.\n *\n * The drawer is 400px on a desktop and the full viewport on a phone, so the\n * narrow case is ~320px of card minus its padding. `Stepper` renders its labels\n * at `body2` for every size but `sm` and reserves 24px of connector plus 8px of\n * margin on each side, which is more row than four short words have — measured\n * on a 320px viewport, the last stop hung off the edge and the DRAWER scrolled\n * sideways.\n *\n * Three overrides, each buying back a specific number of pixels: 11px labels, a\n * step column allowed to shrink below the package's 44px floor (so the row's\n * min-content width is the longest WORD rather than the longest phrase), and\n * thinner connectors. `overflow: hidden` is the backstop and not the mechanism\n * — a locale with longer words than any of this anticipates clips its own card\n * instead of making the panel scroll.\n */\nconst laneSx = {\n pt: 1.25,\n px: 0.5,\n overflow: 'hidden',\n '& .MuiTypography-root': { fontSize: 11, lineHeight: 1.25 },\n '& [data-testid^=\"stepper-step-content-\"]': { minWidth: 0 },\n '& [data-testid^=\"stepper-connector-\"]': { minWidth: 6, mx: 0.75 },\n} as const;\n\n/**\n * The lane, or nothing.\n *\n * `aria-hidden` AND `inert`, and each earns its place twice over. The stops are\n * real buttons, so leaving four focusable, named controls per entry in front of\n * an inbox would cost a keyboard user the list they opened the panel for —\n * that is what the pair was added for. `inert` then turns out to be what makes\n * the card's own hit area work as well, because an inert subtree is skipped by\n * hit-testing: see {@link stretchedSx}.\n *\n * Nothing is lost by hiding it — the stop the subject is at is already the\n * card's heading, and the row of dots restates it visually.\n */\nfunction ActivityLane({ activity }: { activity: LiveActivity }): JSX.Element | null {\n const lane = liveActivityLane(activity);\n if (lane === null) return null;\n return (\n <Box sx={laneSx} aria-hidden inert>\n <Stepper\n steps={lane.steps.map((step) => ({ id: step.id, label: step.label }))}\n activeId={lane.activeStepId}\n completed={new Set(lane.completed)}\n orientation=\"horizontal\"\n size=\"xs\"\n clickable={false}\n data-testid={`live-activity-steps-${activity.id}`}\n />\n </Box>\n );\n}\n\n/** The card's props. Not part of the package's surface — see `./index`. */\ninterface LiveActivityCardProps {\n activity: LiveActivity;\n messages: NotificationMessages;\n live: LiveActivityMessages;\n renderIcon?: LiveActivitiesConfig['renderIcon'];\n /** The clock this render reads, so the \"last moved\" line can be ticked. */\n now: number;\n /**\n * Follow the card's link.\n *\n * Absent — as it is for a host with no router — renders the text as text. A\n * named, focusable control that does nothing is worse than no control.\n */\n onOpen?: (activity: LiveActivity) => void;\n}\n\n/**\n * The mark on the left, when the host draws one.\n *\n * PRESENTATIONAL ONLY. It is rendered inside the card's `<button>` and inside\n * an `aria-hidden` wrapper, so a host returning anything focusable — an\n * icon-button, a link — puts a button inside a button (invalid HTML, and the\n * defect this card was restructured to remove) and hides a focusable node from\n * the accessibility tree. An icon, an emoji, an `<svg>`: yes. A control: no.\n */\nfunction ActivityIcon({ icon }: { icon: ReactNode }): JSX.Element | null {\n if (icon === undefined || icon === null) return null;\n return (\n <Box aria-hidden sx={{ display: 'flex', flex: '0 0 auto', color: 'primary.main' }}>\n {icon}\n </Box>\n );\n}\n\n/** The mark, the heading and the line under it. */\nfunction ActivityTarget({\n activity,\n renderIcon,\n bodyId,\n}: Pick<LiveActivityCardProps, 'activity' | 'renderIcon'> & {\n /** Ties the sentence to the button, so a label does not swallow it. */\n bodyId: string;\n}): JSX.Element {\n return (\n <>\n <ActivityIcon icon={renderIcon?.(activity)} />\n {/* A COLUMN, not a bare block: `Text` sets no `display`, so two adjacent\n spans in an ordinary div run together on one line with not even a\n space between them — which is how the heading and the sentence under\n it ended up as one word in an earlier draft. `row.tsx` gets this right\n the same way. */}\n <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 0 }}>\n {/*\n The live region is THIS LINE and nothing else. The card also carries a\n relative timestamp that moves every minute for as long as the subject\n lasts, and announcing that is a polite interruption per minute for\n news the reader did not ask to be read. What is worth interrupting for\n is the subject MOVING, which is what the heading says.\n */}\n <Text\n variant=\"body\"\n size=\"sm\"\n weight=\"semibold\"\n as=\"span\"\n aria-live=\"polite\"\n data-testid={`live-activity-title-${activity.id}`}\n >\n {activity.title}\n </Text>\n {activity.body === null ? null : (\n <Text id={bodyId} variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"span\">\n {activity.body}\n </Text>\n )}\n </Box>\n </>\n );\n}\n\nexport function LiveActivityCard({\n activity,\n messages,\n live,\n renderIcon,\n now,\n onOpen,\n}: LiveActivityCardProps): JSX.Element {\n const followable = activity.link !== null && onOpen !== undefined;\n const bodyId = useId();\n const target = (\n <ActivityTarget\n activity={activity}\n bodyId={bodyId}\n {...(renderIcon ? { renderIcon } : {})}\n />\n );\n return (\n <Box data-testid={`live-activity-${activity.id}`} sx={cardSx}>\n {followable ? (\n <Box\n component=\"button\"\n type=\"button\"\n onClick={() => onOpen(activity)}\n // `aria-label` REPLACES the contents, so the sentence under the\n // heading — the detail that makes the heading actionable — would be\n // announced to nobody. `aria-describedby` puts it back.\n aria-label={live.openActivity(activity.title)}\n {...(activity.body === null ? {} : { 'aria-describedby': bodyId })}\n data-testid={`live-activity-open-${activity.id}`}\n sx={stretchedSx}\n >\n {target}\n </Box>\n ) : (\n <Box sx={targetSx}>{target}</Box>\n )}\n <ActivityLane activity={activity} />\n <Text variant=\"caption\" size=\"xs\" color=\"secondary\" as=\"span\" italic>\n {live.updated(relativeTime(activity.updatedAt, messages, now))}\n </Text>\n </Box>\n );\n}\n"],"mappings":"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","names":["useId","Box","Text","Fragment","jsx","jsxs","useId","Box","Text"]}
|
|
File without changes
|
|
File without changes
|