@12-apps/notifications 4.10.0 → 4.10.1

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.
@@ -105,8 +105,77 @@ __name(httpNotificationsTransport, "httpNotificationsTransport");
105
105
  import { useState as useState2 } from "react";
106
106
 
107
107
  // src/react/bell-button.tsx
108
+ import { useSyncExternalStore } from "react";
108
109
  import { Badge } from "@12-apps/ui/data-display/Badge";
109
110
  import { Box } from "@12-apps/ui/mui/Box";
111
+
112
+ // src/react/live-seen.ts
113
+ var STORAGE_KEY = "12a.notifications.live-seen";
114
+ var EMPTY = {};
115
+ function instant(iso) {
116
+ if (iso === void 0) return null;
117
+ const ms = Date.parse(iso);
118
+ return Number.isNaN(ms) ? null : ms;
119
+ }
120
+ __name(instant, "instant");
121
+ function readStored() {
122
+ try {
123
+ const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
124
+ if (!raw) return EMPTY;
125
+ const parsed = JSON.parse(raw);
126
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return EMPTY;
127
+ const clean = {};
128
+ for (const [id, value] of Object.entries(parsed)) {
129
+ if (typeof value === "string") clean[id] = value;
130
+ }
131
+ return clean;
132
+ } catch {
133
+ return EMPTY;
134
+ }
135
+ }
136
+ __name(readStored, "readStored");
137
+ function writeStored(value) {
138
+ try {
139
+ globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(value));
140
+ } catch {
141
+ }
142
+ }
143
+ __name(writeStored, "writeStored");
144
+ function createLiveSeenStore() {
145
+ let current = readStored();
146
+ const listeners = /* @__PURE__ */ new Set();
147
+ return {
148
+ read: /* @__PURE__ */ __name(() => current, "read"),
149
+ mark: /* @__PURE__ */ __name((activities) => {
150
+ const next = {};
151
+ for (const activity of activities) next[activity.id] = activity.updatedAt;
152
+ const ids = Object.keys(next);
153
+ const same = ids.length === Object.keys(current).length && ids.every((id) => current[id] === next[id]);
154
+ if (same) return;
155
+ current = next;
156
+ writeStored(next);
157
+ for (const listener of listeners) listener();
158
+ }, "mark"),
159
+ subscribe: /* @__PURE__ */ __name((listener) => {
160
+ listeners.add(listener);
161
+ return () => {
162
+ listeners.delete(listener);
163
+ };
164
+ }, "subscribe")
165
+ };
166
+ }
167
+ __name(createLiveSeenStore, "createLiveSeenStore");
168
+ function hasUnseenActivity(activities, seen) {
169
+ return activities.some((activity) => {
170
+ const shown = instant(seen[activity.id]);
171
+ if (shown === null) return true;
172
+ const now = instant(activity.updatedAt);
173
+ return now === null || now > shown;
174
+ });
175
+ }
176
+ __name(hasUnseenActivity, "hasUnseenActivity");
177
+
178
+ // src/react/bell-button.tsx
110
179
  import { jsx } from "react/jsx-runtime";
111
180
  var triggerSx = {
112
181
  display: "inline-flex",
@@ -127,19 +196,12 @@ var triggerSx = {
127
196
  borderRadius: "50%"
128
197
  }
129
198
  };
130
- function BellButton({
199
+ function BellTrigger({
131
200
  onClick,
132
- enabled = true,
133
- store,
134
- messages,
135
- subscribe,
136
- useSignal
201
+ count,
202
+ hasNew,
203
+ messages
137
204
  }) {
138
- const count = useUnreadCount(store, {
139
- enabled,
140
- ...subscribe ? { subscribe } : {},
141
- ...useSignal ? { useSignal } : {}
142
- });
143
205
  return /* @__PURE__ */ jsx(
144
206
  Box,
145
207
  {
@@ -153,24 +215,70 @@ function BellButton({
153
215
  Badge,
154
216
  {
155
217
  content: count > 0 ? count : void 0,
156
- color: "primary",
218
+ color: hasNew ? "primary" : "neutral",
157
219
  variant: "count",
158
220
  max: 99,
159
221
  "data-testid": "notifications-badge",
222
+ "data-tone": hasNew ? "new" : "seen",
160
223
  children: /* @__PURE__ */ jsx(BellIcon, { size: 28 })
161
224
  }
162
225
  )
163
226
  }
164
227
  );
165
228
  }
229
+ __name(BellTrigger, "BellTrigger");
230
+ function BellButton({
231
+ onClick,
232
+ enabled = true,
233
+ store,
234
+ messages,
235
+ subscribe,
236
+ useSignal
237
+ }) {
238
+ const count = useUnreadCount(store, {
239
+ enabled,
240
+ ...subscribe ? { subscribe } : {},
241
+ ...useSignal ? { useSignal } : {}
242
+ });
243
+ return /* @__PURE__ */ jsx(BellTrigger, { onClick, count, hasNew: count > 0, messages });
244
+ }
166
245
  __name(BellButton, "BellButton");
246
+ function LiveBellButton({
247
+ onClick,
248
+ enabled = true,
249
+ store,
250
+ messages,
251
+ subscribe,
252
+ useSignal,
253
+ live,
254
+ seen
255
+ }) {
256
+ const unread = useUnreadCount(store, {
257
+ enabled,
258
+ ...subscribe ? { subscribe } : {},
259
+ ...useSignal ? { useSignal } : {}
260
+ });
261
+ const activities = live.useActivities({ active: enabled });
262
+ const seenIso = useSyncExternalStore(seen.subscribe, seen.read, seen.read);
263
+ const liveCount = enabled ? activities.length : 0;
264
+ return /* @__PURE__ */ jsx(
265
+ BellTrigger,
266
+ {
267
+ onClick,
268
+ count: unread + liveCount,
269
+ hasNew: unread > 0 || enabled && hasUnseenActivity(activities, seenIso),
270
+ messages
271
+ }
272
+ );
273
+ }
274
+ __name(LiveBellButton, "LiveBellButton");
167
275
 
168
276
  // src/react/panel-lazy.tsx
169
277
  import { Suspense, lazy, useEffect, useState } from "react";
170
278
  import { jsx as jsx2 } from "react/jsx-runtime";
171
279
  function lazyNotificationsPanel(parts) {
172
280
  const Bound = lazy(async () => {
173
- const { NotificationsPanel } = await import("./panel-T36JEMO3.js");
281
+ const { NotificationsPanel } = await import("./panel-OPB3DBLJ.js");
174
282
  return {
175
283
  default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx2(NotificationsPanel, { ...props, ...parts }), "default")
176
284
  };
@@ -217,11 +325,23 @@ function createWebNotifications(config) {
217
325
  ...subscribe ? { subscribe } : {},
218
326
  ...config.useSignal ? { useSignal: config.useSignal } : {}
219
327
  };
220
- const Bell = /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx4(BellButton, { ...props, store, messages, ...subscribeOption }), "Bell");
328
+ const liveSeen = createLiveSeenStore();
329
+ const live = config.liveActivities;
330
+ const Bell = live ? (props) => /* @__PURE__ */ jsx4(
331
+ LiveBellButton,
332
+ {
333
+ ...props,
334
+ store,
335
+ messages,
336
+ live,
337
+ seen: liveSeen,
338
+ ...subscribeOption
339
+ }
340
+ ) : (props) => /* @__PURE__ */ jsx4(BellButton, { ...props, store, messages, ...subscribeOption });
221
341
  const Panel = lazyNotificationsPanel({
222
342
  store,
223
343
  messages,
224
- ...config.liveActivities ? { live: config.liveActivities } : {}
344
+ ...live ? { live, liveSeen } : {}
225
345
  });
226
346
  function useBoundUnreadCount(options = {}) {
227
347
  return useUnreadCount(store, { ...options, ...subscribeOption });
@@ -264,4 +384,4 @@ export {
264
384
  httpNotificationsTransport,
265
385
  createWebNotifications
266
386
  };
267
- //# sourceMappingURL=chunk-5Y7QRORV.js.map
387
+ //# sourceMappingURL=chunk-I5QUMTCN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/api.ts","../src/react/transport.ts","../src/react/create-web-notifications.tsx","../src/react/bell-button.tsx","../src/react/live-seen.ts","../src/react/panel-lazy.tsx","../src/react/page-lazy.tsx"],"sourcesContent":["import type { ChannelRow } from '../preferences-core';\nimport type { NotificationChannel } from '../types';\nimport type { ListNotificationsResult } from '../wire';\n\nimport type { NotificationsResult, NotificationsTransport } from './transport';\n\n/**\n * The wire client, bound to one mount (12-15).\n *\n * Every path this package's screens can call, in one place — which is what\n * makes the api half's route table and the web half's URLs one contract instead\n * of two lists that drift.\n */\n\n/** `GET <mount>/notification-preferences` and the PUT's answer. */\nexport interface PreferencesPayload {\n preferences: Record<string, ChannelRow>;\n availability: Record<NotificationChannel, boolean>;\n /** The host's taxonomy, so the screen renders it without being told twice. */\n categories: string[];\n}\n\n/** `GET <mount>/push-subscriptions`. */\nexport interface PushRegistrationPayload {\n /** null = web push is not configured on this deployment. */\n vapidPublicKey: string | null;\n count: number;\n /**\n * Whether the endpoint asked about is still registered to the caller. Present\n * only when one was passed — see {@link NotificationsApiClient.getPushRegistration}.\n */\n registered?: boolean;\n}\n\nexport interface NotificationsApiClient {\n listNotifications(input: {\n cursor?: string | null;\n limit?: number;\n filter?: 'all' | 'unread';\n }): Promise<ListNotificationsResult>;\n unreadCount(): Promise<number>;\n markRead(ids: readonly string[]): Promise<NotificationsResult<{ updated: number }>>;\n markAllRead(): Promise<NotificationsResult<{ updated: number }>>;\n remove(ids: readonly string[]): Promise<NotificationsResult<{ deleted: number }>>;\n getPreferences(): Promise<PreferencesPayload>;\n savePreference(\n category: string,\n channel: NotificationChannel,\n enabled: boolean,\n ): Promise<NotificationsResult<PreferencesPayload>>;\n /**\n * The deployment's VAPID key and the caller's device count — and, when an\n * `endpoint` is passed, whether the SERVER still has that exact subscription\n * under the caller's id. The browser holding a subscription object is not\n * evidence of that: a re-own or a 404/410 prune drops the row and leaves the\n * browser's object in place.\n */\n getPushRegistration(input?: { endpoint?: string }): Promise<PushRegistrationPayload>;\n savePushSubscription(input: {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n }): Promise<NotificationsResult<{ count: number }>>;\n removePushSubscription(endpoint: string): Promise<NotificationsResult<{ count: number }>>;\n}\n\nexport function createNotificationsApiClient(\n apiBase: string,\n transport: NotificationsTransport,\n): NotificationsApiClient {\n const base = apiBase.replace(/\\/$/, '');\n const url = (path: string): string => `${base}${path}`;\n\n return {\n listNotifications({ cursor, limit, filter }) {\n const params = new URLSearchParams();\n if (limit !== undefined) params.set('limit', String(limit));\n if (cursor) params.set('cursor', cursor);\n if (filter) params.set('filter', filter);\n const query = params.toString();\n return transport.get<ListNotificationsResult>(\n url(`/notifications${query ? `?${query}` : ''}`),\n );\n },\n async unreadCount() {\n const { count } = await transport.get<{ count: number }>(\n url('/notifications/unread-count'),\n );\n return count;\n },\n markRead: (ids) =>\n transport.send(url('/notifications/mark-read'), 'POST', { ids: [...ids] }),\n markAllRead: () => transport.send(url('/notifications/mark-read'), 'POST', { all: true }),\n remove: (ids) => transport.send(url('/notifications/delete'), 'POST', { ids: [...ids] }),\n getPreferences: () => transport.get<PreferencesPayload>(url('/notification-preferences')),\n savePreference: (category, channel, enabled) =>\n transport.send(url('/notification-preferences'), 'PUT', {\n [category]: { [channel]: enabled },\n }),\n getPushRegistration: ({ endpoint } = {}) =>\n transport.get<PushRegistrationPayload>(\n url(\n endpoint\n ? `/push-subscriptions?endpoint=${encodeURIComponent(endpoint)}`\n : '/push-subscriptions',\n ),\n ),\n savePushSubscription: (input) => transport.send(url('/push-subscriptions'), 'POST', input),\n removePushSubscription: (endpoint) =>\n transport.send(url('/push-subscriptions'), 'DELETE', { endpoint }),\n };\n}\n","/**\n * How the notification screens reach their data (12-15) — the report-builder\n * transport doctrine: this is the ONLY way the surface performs I/O, so a\n * caller supplying one has substituted the entire backend without stubbing a\n * global. The default is same-origin `fetch` riding the browser's cookies.\n */\n\n/** A write outcome the screens branch on — never a thrown mutation. */\nexport type NotificationsResult<T> = { ok: true; data: T } | { ok: false; error: string };\n\n/** A failed read, carrying the status the screens branch on (401 = signed out). */\nexport class NotificationsHttpError extends Error {\n readonly status: number;\n constructor(status: number, message: string) {\n super(message);\n this.name = 'NotificationsHttpError';\n this.status = status;\n Object.setPrototypeOf(this, NotificationsHttpError.prototype);\n }\n}\n\nexport interface NotificationsTransport {\n /** A read. Returns the payload INSIDE the `{ data }` envelope. */\n get<T>(path: string): Promise<T>;\n /** A write. Returns a {@link NotificationsResult} rather than rejecting. */\n send<T>(path: string, method: string, body?: unknown): Promise<NotificationsResult<T>>;\n}\n\n/**\n * @param fallbackError What a failed write says when the server sent no\n * sentence of its own — REQUIRED, the host's words. `createWebNotifications`\n * already passes its (equally required) `messages.operationFailed`; only a\n * host constructing the transport directly writes it here. The old default\n * was one application's Portuguese, and the only string in this package the\n * required-messages port did not cover.\n */\nexport function httpNotificationsTransport(fallbackError: string): NotificationsTransport {\n return {\n async get<T>(path: string): Promise<T> {\n const response = await fetch(path, {\n credentials: 'same-origin',\n headers: { Accept: 'application/json' },\n });\n const payload = (await response.json().catch(() => null)) as\n | { data?: T; error?: string }\n | null;\n if (!response.ok) {\n throw new NotificationsHttpError(\n response.status,\n payload?.error ?? `HTTP ${response.status} for ${path}`,\n );\n }\n return (payload?.data ?? payload) as T;\n },\n\n async send<T>(path: string, method: string, body?: unknown): Promise<NotificationsResult<T>> {\n try {\n const response = await fetch(path, {\n method,\n credentials: 'same-origin',\n headers: {\n Accept: 'application/json',\n ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),\n },\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n if (response.status === 204) return { ok: true, data: undefined as T };\n const payload = (await response.json().catch(() => null)) as\n | { data?: T; error?: string }\n | null;\n if (!response.ok) return { ok: false, error: payload?.error ?? fallbackError };\n return { ok: true, data: (payload?.data ?? payload) as T };\n } catch {\n return { ok: false, error: fallbackError };\n }\n },\n };\n}\n","import { useState, type ComponentType, type JSX } from 'react';\n\nimport { messagesOf, type NotificationMessages } from '../messages';\n\nimport { createNotificationsApiClient, type NotificationsApiClient } from './api';\nimport { BellButton, LiveBellButton, type BellButtonProps } from './bell-button';\nimport {\n useUnreadCount,\n type NotificationsSignalHook,\n type NotificationsSubscribe,\n} from './hooks';\nimport { createInboxStore, type InboxStore } from './inbox-state';\nimport type { LiveActivitiesConfig } from './live-config';\nimport { createLiveSeenStore } from './live-seen';\nimport { lazyNotificationsPanel } from './panel-lazy';\nimport type { NotificationsPanelProps } from './panel';\nimport { lazyPreferencesPage } from './page-lazy';\nimport type { PreferencesScreenProps } from './preferences-screen';\nimport { httpNotificationsTransport, type NotificationsTransport } from './transport';\nimport type { WebPushSetupConfig } from './web-push-setup';\n\n/**\n * The one thing this package exposes to a FRONTEND host (12-15).\n *\n * Everything the notification centre IS — the bell with its live badge, the\n * slide-over inbox with its optimistic mark-read / delete / mark-all and its\n * cursor pager, the preferences matrix with its availability hints and the\n * per-browser push enable step, and every wire call between them — lives inside\n * this package. The host names where the API is mounted, and that is the whole\n * wiring.\n *\n * `page` is the standalone surface (the preferences screen), which is the one\n * thing a host routes to. The bell and the panel are a PAIR a host drops into\n * its own chrome, and they share one store, so a read in the panel moves the\n * badge in the same tick.\n */\n\nexport interface NotificationsWebConfig {\n /** The account mount the routes live under, e.g. `/api/account`. */\n apiBase: string;\n /** How the surface reaches its data. Default: same-origin fetch. */\n transport?: NotificationsTransport;\n /** User-facing copy overrides (pt-BR product copy by default). */\n messages: NotificationMessages;\n /**\n * How the surface learns an inbox changed without asking — the host's message\n * bus. Without it the badge keeps its 60 s poll, which is the standing\n * contract rather than a fallback: a dropped event must cost latency, never\n * correctness.\n */\n subscribe?: NotificationsSubscribe;\n /**\n * The same wiring as a HOOK, for a host whose realtime connection lives in\n * React context — see `NotificationsSignalHook`. `subscribe` is read at\n * factory time, which such a host cannot reach.\n */\n useSignal?: NotificationsSignalHook;\n /** The browser push enable step's host seams (SW path, platform hint). */\n webPush?: WebPushSetupConfig;\n /**\n * LIVE ACTIVITIES — the ongoing-state entries pinned above the inbox list.\n *\n * Opt-in, and absent means absent: a host that passes nothing gets the panel\n * it had, with no section, no heading and no reserved space. See\n * `./live-config` for the two things a host has to supply (where they come\n * from, and what the section says) and `../live` for what one IS.\n */\n liveActivities?: LiveActivitiesConfig;\n}\n\nexport interface WebNotifications {\n /**\n * The routed surface: the preferences screen.\n *\n * Loaded on demand — see `page-lazy.tsx`. A host that mounts only the bell and\n * the panel never downloads it, and a host that routes to it fetches it while\n * entering that route.\n */\n page: ComponentType<PreferencesScreenProps>;\n /** The bell, already bound to the shared store. */\n BellButton: ComponentType<BellButtonProps>;\n /**\n * The inbox slide-over, sharing that store.\n *\n * Loaded the first time it is opened — see `panel-lazy.tsx`. Until then a\n * host's chrome carries the bell and nothing else.\n */\n Panel: ComponentType<NotificationsPanelProps>;\n /**\n * Bell + panel as ONE element, for a host that just wants the feature in its\n * header and does not want to own the open/closed state.\n */\n BellWithPanel: ComponentType<{\n enabled?: boolean;\n onNavigate?: (link: string) => void;\n }>;\n /** The badge number, for a host with its own trigger chrome. */\n useUnreadCount: (options?: { enabled?: boolean }) => number;\n /** The shared client state, for host glue. */\n store: InboxStore;\n /** The bound wire client. */\n api: NotificationsApiClient;\n /** The copy in force, so a host's own chrome can reuse a sentence. */\n messages: NotificationMessages;\n}\n\nexport function createWebNotifications(config: NotificationsWebConfig): WebNotifications {\n const messages = messagesOf(config);\n const api = createNotificationsApiClient(\n config.apiBase,\n config.transport ?? httpNotificationsTransport(messages.operationFailed),\n );\n const store = createInboxStore(api);\n const webPush = config.webPush ?? {};\n const subscribe = config.subscribe;\n const subscribeOption = {\n ...(subscribe ? { subscribe } : {}),\n ...(config.useSignal ? { useSignal: config.useSignal } : {}),\n };\n\n // One store per factory, shared by the bell that READS it and the panel that\n // WRITES it — the same arrangement as the inbox store above, and for the same\n // reason: two independent copies would disagree about what the reader saw.\n const liveSeen = createLiveSeenStore();\n\n // Chosen ONCE, here, because `useActivities` is a hook and the choice must\n // not be made per render: a bell that read an optional config inside itself\n // would be calling a hook conditionally.\n const live = config.liveActivities;\n const Bell: ComponentType<BellButtonProps> = live\n ? (props) => (\n <LiveBellButton\n {...props}\n store={store}\n messages={messages}\n live={live}\n seen={liveSeen}\n {...subscribeOption}\n />\n )\n : (props) => (\n <BellButton {...props} store={store} messages={messages} {...subscribeOption} />\n );\n const Panel = lazyNotificationsPanel({\n store,\n messages,\n ...(live ? { live, liveSeen } : {}),\n });\n\n function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {\n return useUnreadCount(store, { ...options, ...subscribeOption });\n }\n\n function BellWithPanel({\n enabled = true,\n onNavigate,\n }: {\n enabled?: boolean;\n onNavigate?: (link: string) => void;\n }): JSX.Element {\n const [open, setOpen] = useState(false);\n return (\n <>\n <Bell enabled={enabled} onClick={() => setOpen(true)} />\n <Panel\n open={open}\n onClose={() => setOpen(false)}\n {...(onNavigate ? { onNavigate } : {})}\n />\n </>\n );\n }\n\n return {\n page: lazyPreferencesPage({ api, messages, webPush }),\n BellButton: Bell,\n Panel,\n BellWithPanel,\n useUnreadCount: useBoundUnreadCount,\n store,\n api,\n messages,\n };\n}\n","/**\n * Bare bell trigger with the live unread badge — for hosts that do not already\n * have a styled icon-button slot. A host with its own trigger chrome uses\n * `useUnreadCount` + `Panel` directly.\n */\nimport { useSyncExternalStore, type JSX } from 'react';\n\nimport { Badge } from '@12-apps/ui/data-display/Badge';\nimport { Box } from '@12-apps/ui/mui/Box';\n\nimport type { NotificationMessages } from '../messages';\n\nimport { BellIcon } from './bell-icon';\nimport { useUnreadCount, type NotificationsSignalHook, type NotificationsSubscribe } from './hooks';\nimport type { LiveActivitiesConfig } from './live-config';\nimport { hasUnseenActivity, type LiveSeenStore } from './live-seen';\nimport type { InboxStore } from './inbox-state';\n\nconst triggerSx = {\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n p: 0.5,\n border: 'none',\n background: 'none',\n cursor: 'pointer',\n color: 'text.primary',\n lineHeight: 0,\n '& *': { cursor: 'pointer' },\n '&:hover': { color: 'primary.main' },\n '&:focus-visible': {\n outline: '2px solid',\n outlineColor: 'primary.main',\n outlineOffset: '2px',\n borderRadius: '50%',\n },\n} as const;\n\nexport interface BellButtonProps {\n onClick: () => void;\n /** Signed-out hosts still mount the bell; `false` silences it. */\n enabled?: boolean;\n}\n\n/**\n * The trigger itself, given a count and whether any of it is NEW.\n *\n * Presentational, and shared by both bells below, so the two can never drift on\n * what the badge looks like — only on where the number comes from.\n *\n * ## The two tones\n *\n * `primary` says *something happened*; `neutral` says *something is present*. A\n * live activity is the reason that distinction has to exist: it stays on the\n * panel for as long as the thing is happening, so a bell that painted every\n * live entry as new would be permanently red for a pedido the reader already\n * looked at, and a bell that ignored them would say nothing at all while one\n * was running. Grey keeps the count honest without spending attention twice.\n */\nfunction BellTrigger({\n onClick,\n count,\n hasNew,\n messages,\n}: {\n onClick: () => void;\n count: number;\n hasNew: boolean;\n messages: NotificationMessages;\n}): JSX.Element {\n return (\n <Box\n component=\"button\"\n type=\"button\"\n onClick={onClick}\n // `openBellWithUnread` rather than a new message, and not for want of\n // precision: `NotificationMessages` is REQUIRED of every host, so adding\n // a field is a breaking change to a package several apps already mount.\n // The sentence a host wrote for \"you have N\" is the sentence this wants.\n aria-label={count > 0 ? messages.openBellWithUnread(count) : messages.openBell}\n data-testid=\"notifications-bell\"\n sx={triggerSx}\n >\n <Badge\n content={count > 0 ? count : undefined}\n color={hasNew ? 'primary' : 'neutral'}\n variant=\"count\"\n max={99}\n data-testid=\"notifications-badge\"\n // The tone is carried by a colour, and a colour is not something a\n // test can read — nor, on its own, a signal every reader can. This is\n // what the tests assert on.\n data-tone={hasNew ? 'new' : 'seen'}\n >\n <BellIcon size={28} />\n </Badge>\n </Box>\n );\n}\n\nexport function BellButton({\n onClick,\n enabled = true,\n store,\n messages,\n subscribe,\n useSignal,\n}: BellButtonProps & {\n store: InboxStore;\n messages: NotificationMessages;\n subscribe?: NotificationsSubscribe;\n useSignal?: NotificationsSignalHook;\n}): JSX.Element {\n const count = useUnreadCount(store, {\n enabled,\n ...(subscribe ? { subscribe } : {}),\n ...(useSignal ? { useSignal } : {}),\n });\n // No live config on this host: unread IS the whole count, and an unread row\n // is by definition something the reader has not seen.\n return <BellTrigger onClick={onClick} count={count} hasNew={count > 0} messages={messages} />;\n}\n\n/**\n * The bell for a host that configured live activities.\n *\n * A SECOND component rather than a flag on the one above, because the host's\n * `useActivities` is a hook: reading an optional config inside one component\n * would mean calling it conditionally, which React reports as a crash in some\n * unrelated component rather than here. The factory knows statically which host\n * it is building for and picks one.\n *\n * ## What it costs the host, stated plainly\n *\n * The bell is mounted for as long as the app is, so unlike the panel's copy of\n * this hook there is no \"nobody is looking\" state to stand down in — `active`\n * is simply `enabled`. A host that answers by polling therefore polls for every\n * signed-in reader whether or not they ever open the centre. That is the price\n * of a badge that knows about live activities at all, and the reason to answer\n * this hook from a pushed cache rather than from an interval.\n */\nexport function LiveBellButton({\n onClick,\n enabled = true,\n store,\n messages,\n subscribe,\n useSignal,\n live,\n seen,\n}: BellButtonProps & {\n store: InboxStore;\n messages: NotificationMessages;\n subscribe?: NotificationsSubscribe;\n useSignal?: NotificationsSignalHook;\n live: LiveActivitiesConfig;\n seen: LiveSeenStore;\n}): JSX.Element {\n const unread = useUnreadCount(store, {\n enabled,\n ...(subscribe ? { subscribe } : {}),\n ...(useSignal ? { useSignal } : {}),\n });\n const activities = live.useActivities({ active: enabled });\n const seenIso = useSyncExternalStore(seen.subscribe, seen.read, seen.read);\n const liveCount = enabled ? activities.length : 0;\n return (\n <BellTrigger\n onClick={onClick}\n // A live entry counts. It is a notification — it is the one the reader\n // most wants to know about — and the panel it opens lists it.\n count={unread + liveCount}\n hasNew={unread > 0 || (enabled && hasUnseenActivity(activities, seenIso))}\n messages={messages}\n />\n );\n}\n","/**\n * What the reader has already been shown, so the bell can say NEW rather than\n * merely PRESENT.\n *\n * A live activity is unlike an inbox row in the one way that matters here: it\n * stays on the panel for as long as the thing is happening, so its presence\n * cannot mean \"you have not seen this\". A pedido that has been `Preparo` for\n * ten minutes is still live and still worth counting, but nothing has happened\n * — and a badge that shouts for a subject the reader has already looked at is a\n * badge people stop reading.\n *\n * So presence and novelty are answered separately: the COUNT comes from how\n * many are live, and the TONE comes from this. The panel writes it — being on\n * screen is what seen means — and the bell reads it.\n *\n * ## Per subject, not one watermark\n *\n * A single \"newest instant already seen\" is smaller and was the first cut, and\n * it is wrong in a way that shows up in normal use: a pedido placed ten minutes\n * ago but only now reaching the client arrives with an `updatedAt` BEHIND the\n * watermark, and would be silently marked as already seen. The reader has never\n * laid eyes on it. Keyed by subject, an id that has not been recorded is new\n * whatever its clock says.\n *\n * Bounded by pruning rather than by expiry: every write keeps only the subjects\n * that are live at that moment, so the record can never outgrow the number of\n * things happening at once. A subject that finishes and later comes back is\n * news again, which is correct — it is a different occurrence.\n */\nimport type { LiveActivity } from '../live';\n\nconst STORAGE_KEY = '12a.notifications.live-seen';\n\n/** id -> the `updatedAt` that was on screen. */\ntype SeenMap = Readonly<Record<string, string>>;\n\nconst EMPTY: SeenMap = {};\n\n/** ms since epoch, or `null` for an absent or unparseable stamp. */\nfunction instant(iso: string | undefined): number | null {\n if (iso === undefined) return null;\n const ms = Date.parse(iso);\n return Number.isNaN(ms) ? null : ms;\n}\n\n/**\n * Read/write through `try`, every time.\n *\n * `localStorage` is not merely absent in SSR and in a worker — the ACCESSOR\n * itself throws in a browser set to block site data. A notification bell that\n * cannot render because storage is blocked is a worse failure than one that\n * forgets what was seen, and forgetting degrades in the safe direction: towards\n * saying something is happening.\n */\nfunction readStored(): SeenMap {\n try {\n const raw = globalThis.localStorage?.getItem(STORAGE_KEY);\n if (!raw) return EMPTY;\n const parsed: unknown = JSON.parse(raw);\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return EMPTY;\n // Anything can be in storage — another version of this package, or a person\n // with the devtools open. Keep only what has the shape this reads.\n const clean: Record<string, string> = {};\n for (const [id, value] of Object.entries(parsed)) {\n if (typeof value === 'string') clean[id] = value;\n }\n return clean;\n } catch {\n return EMPTY;\n }\n}\n\nfunction writeStored(value: SeenMap): void {\n try {\n globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(value));\n } catch {\n // Blocked or full. The badge stays new a while longer; nothing else breaks.\n }\n}\n\nexport interface LiveSeenStore {\n /** What has been shown, keyed by subject id. */\n read: () => SeenMap;\n /** Record that exactly these are on screen now, forgetting subjects that are not. */\n mark: (activities: readonly LiveActivity[]) => void;\n subscribe: (listener: () => void) => () => void;\n}\n\nexport function createLiveSeenStore(): LiveSeenStore {\n // Mirrored in memory as well as in storage: `useSyncExternalStore` compares\n // snapshots by IDENTITY and calls `read` on every render, so parsing storage\n // there would hand it a fresh object each time and re-render for ever.\n let current = readStored();\n const listeners = new Set<() => void>();\n\n return {\n read: () => current,\n mark: (activities) => {\n const next: Record<string, string> = {};\n for (const activity of activities) next[activity.id] = activity.updatedAt;\n // Identity is the snapshot, so an unchanged map must not become a new\n // object — see `read` above.\n const ids = Object.keys(next);\n const same =\n ids.length === Object.keys(current).length &&\n ids.every((id) => current[id] === next[id]);\n if (same) return;\n current = next;\n writeStored(next);\n for (const listener of listeners) listener();\n },\n subscribe: (listener) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n\n/**\n * Whether any of these has moved, or arrived, since the reader last looked.\n *\n * An id with nothing recorded is new — that is the case the per-subject record\n * exists for. An unparseable stamp is treated as new too: the alternative is\n * silently never alerting for a host whose clock format this does not read.\n */\nexport function hasUnseenActivity(\n activities: readonly LiveActivity[],\n seen: SeenMap,\n): boolean {\n return activities.some((activity) => {\n const shown = instant(seen[activity.id]);\n if (shown === null) return true;\n const now = instant(activity.updatedAt);\n return now === null || now > shown;\n });\n}\n","/**\n * The inbox slide-over, fetched the first time somebody opens it.\n *\n * The bell and the panel are a PAIR a host drops into its chrome, and that is\n * still true — but only the BELL is on screen when a page paints. The panel is\n * behind a tap, and a static import made every host pay for it up front: the\n * design-system `Drawer` and, through it, MUI's `SwipeableDrawer`, `Modal`,\n * `Slide` and the focus trap, plus the row, the empty state and the pager. On a\n * storefront that is a slide-over most visits never open, parsed before the\n * first screen can render.\n *\n * ## Why the gate is \"ever opened\" rather than `open`\n *\n * `lazy` fetches when a component first RENDERS, so a boundary that still\n * rendered the panel while closed would fetch immediately and buy nothing. This\n * renders `null` until the panel has been open once, which is what actually\n * defers the download to the tap.\n *\n * And once opened it STAYS mounted. Unmounting on close would throw away the\n * drawer's transition state, so the panel would vanish instead of sliding out,\n * and the entrance animation would re-run on every reopen — which someone\n * working through an inbox does repeatedly. The fetch happens once.\n *\n * The initial state reads `open` rather than starting at `false`, so a host that\n * mounts the panel already open renders it in the same commit instead of a frame\n * later.\n *\n * ## Why `null` for the fallback\n *\n * The only frame this can show anything is the one right after the tap, where a\n * spinner reads as a stall rather than as progress. The chunk is small and\n * same-origin.\n */\nimport { Suspense, lazy, useEffect, useState, type ComponentType, type JSX } from 'react';\n\nimport type { NotificationMessages } from '../messages';\n\nimport type { InboxStore } from './inbox-state';\nimport type { LiveActivitiesConfig } from './live-config';\nimport type { LiveSeenStore } from './live-seen';\nimport type { NotificationsPanelProps } from './panel';\n\n/** What the factory binds into the panel, and the host never passes. */\ninterface PanelParts {\n store: InboxStore;\n messages: NotificationMessages;\n /** Absent unless the host turned live activities on — see `./live-config`. */\n live?: LiveActivitiesConfig;\n /** Travels with `live`: where the panel records what the reader has seen. */\n liveSeen?: LiveSeenStore;\n}\n\nexport function lazyNotificationsPanel(\n parts: PanelParts,\n): ComponentType<NotificationsPanelProps> {\n const Bound = lazy(async () => {\n const { NotificationsPanel } = await import('./panel');\n return {\n default: (props: NotificationsPanelProps): JSX.Element => (\n <NotificationsPanel {...props} {...parts} />\n ),\n };\n });\n\n return function NotificationsPanelSlot(props: NotificationsPanelProps): JSX.Element | null {\n const [everOpened, setEverOpened] = useState(props.open);\n\n useEffect(() => {\n if (props.open) setEverOpened(true);\n }, [props.open]);\n\n if (!everOpened) return null;\n\n return (\n <Suspense fallback={null}>\n <Bound {...props} />\n </Suspense>\n );\n };\n}\n","/**\n * The routed preferences screen, fetched when a host actually routes to it.\n *\n * `createWebNotifications` returns two different KINDS of thing, and its own\n * docstring says so: `page` is \"the standalone surface … the one thing a host\n * routes to\", while the bell and the panel \"are a PAIR a host drops into its own\n * chrome\". Chrome is on screen from the first paint; a routed surface is not.\n *\n * A static import made that distinction invisible to a bundler. Every host that\n * put the bell in its header also shipped the preferences matrix — its channel\n * toggles, the per-browser push enable step, and the design-system `Switch`\n * behind them — in the same chunk as the header. A storefront paid for a\n * settings screen a shopper never opens, before its first screen could render;\n * a host that renders its OWN preferences page paid for this one twice.\n *\n * So `page` now loads on demand. Nothing else moves: the bell, the panel and\n * `BellWithPanel` stay exactly as eager as the chrome they belong to, because\n * that is what they are.\n *\n * NO PREFETCH, deliberately, and this is the opposite call from a surface a\n * host opens from chrome it already has. A routed surface is reached by\n * NAVIGATION, and every host here already code-splits its routes — so the\n * fetch happens while the route is being entered, which is the moment a\n * prefetch would have been trying to anticipate. Warming it at factory time\n * would put the screen back on the boot path of every app, which is the whole\n * cost this removes.\n */\nimport { Suspense, lazy, type ComponentType, type JSX } from 'react';\n\nimport type { NotificationMessages } from '../messages';\n\nimport type { NotificationsApiClient } from './api';\nimport type { PreferencesScreenProps } from './preferences-screen';\nimport type { WebPushSetupConfig } from './web-push-setup';\n\n/** What the factory binds into the screen, and the host never passes. */\ninterface PreferencesPageParts {\n api: NotificationsApiClient;\n messages: NotificationMessages;\n webPush: WebPushSetupConfig;\n}\n\n/**\n * The routed screen, bound and loaded on first render.\n *\n * `lazy` memoises its factory, so the binding below happens once however many\n * times a host mounts the page — the same guarantee the direct call gave.\n *\n * The fallback is `null` because a host routes to this: whatever it renders\n * around the route is already on screen, and a second spinner inside it would\n * be one more thing appearing and disappearing during a navigation the host is\n * already indicating.\n */\nexport function lazyPreferencesPage(\n parts: PreferencesPageParts,\n): ComponentType<PreferencesScreenProps> {\n const Bound = lazy(async () => {\n const { PreferencesScreen } = await import('./preferences-screen');\n return {\n default: (props: PreferencesScreenProps): JSX.Element => (\n <PreferencesScreen {...props} {...parts} />\n ),\n };\n });\n\n return function NotificationsPreferencesPage(props: PreferencesScreenProps): JSX.Element {\n return (\n <Suspense fallback={null}>\n <Bound {...props} />\n </Suspense>\n );\n };\n}\n"],"mappings":"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","names":["useState","jsx","Suspense","lazy","jsx","lazy","Suspense","jsx","useState"]}
@@ -153,7 +153,7 @@ function LiveActivityCard({
153
153
  __name(LiveActivityCard, "LiveActivityCard");
154
154
 
155
155
  // src/react/live-section.tsx
156
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
156
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
157
157
  var TICK_MS = 6e4;
158
158
  function useMinuteTick(active) {
159
159
  const [now, setNow] = useState(() => Date.now());
@@ -170,16 +170,20 @@ function LiveSection({
170
170
  config,
171
171
  messages,
172
172
  active,
173
- onOpen
173
+ onOpen,
174
+ children,
175
+ seen
174
176
  }) {
175
177
  const activities = config.useActivities({ active });
176
178
  const now = useMinuteTick(active && activities.length > 0);
177
179
  const headingId = useId2();
178
- if (activities.length === 0) return null;
179
- return (
180
- // A NAMED region. Without the label a screen-reader user meets a loose run
181
- // of controls ahead of the inbox with nothing saying what they are; the
182
- // panel's own title is the drawer's heading and cannot describe this block.
180
+ const liveCount = activities.length;
181
+ useEffect(() => {
182
+ if (active && liveCount > 0) seen?.mark(activities);
183
+ }, [active, liveCount, activities, seen]);
184
+ if (liveCount === 0) return /* @__PURE__ */ jsx2(Fragment2, { children: children?.(0) });
185
+ return /* @__PURE__ */ jsxs2(Fragment2, { children: [
186
+ "// A NAMED region. Without the label a screen-reader user meets a loose run // of controls ahead of the inbox with nothing saying what they are; the // panel's own title is the drawer's heading and cannot describe this block.",
183
187
  /* @__PURE__ */ jsxs2(
184
188
  Box2,
185
189
  {
@@ -214,8 +218,9 @@ function LiveSection({
214
218
  )) })
215
219
  ]
216
220
  }
217
- )
218
- );
221
+ ),
222
+ children?.(liveCount)
223
+ ] });
219
224
  }
220
225
  __name(LiveSection, "LiveSection");
221
226
 
@@ -223,4 +228,4 @@ export {
223
228
  relativeTime,
224
229
  LiveSection
225
230
  };
226
- //# sourceMappingURL=chunk-JCVRQ42B.js.map
231
+ //# sourceMappingURL=chunk-ZY32PC34.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 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"]}
@@ -1,5 +1,5 @@
1
1
  import { c as createEmailPreviewScreen } from '../preview-screen-DYJRAnAY.js';
2
- import { c as createWebNotifications } from '../create-web-notifications-_NVYmlvy.js';
2
+ import { c as createWebNotifications } from '../create-web-notifications-DV3Y8k7e.js';
3
3
  import 'react';
4
4
  import '../wire-BG1kuoXX.js';
5
5
  import '../types-BlqZkCWZ.js';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createWebNotifications
3
- } from "../chunk-5Y7QRORV.js";
3
+ } from "../chunk-I5QUMTCN.js";
4
4
  import "../chunk-BW723CX2.js";
5
5
  import {
6
6
  createEmailPreviewScreen
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  LiveSection,
3
3
  relativeTime
4
- } from "./chunk-JCVRQ42B.js";
4
+ } from "./chunk-ZY32PC34.js";
5
5
  import {
6
6
  BellIcon,
7
7
  useInboxList
@@ -107,14 +107,15 @@ function NotificationRow({
107
107
  __name(NotificationRow, "NotificationRow");
108
108
 
109
109
  // src/react/panel.tsx
110
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
110
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
111
111
  function PanelBody({
112
112
  state,
113
113
  messages,
114
114
  onRetry,
115
115
  onLoadMore,
116
116
  onOpen,
117
- onDelete
117
+ onDelete,
118
+ hasLive
118
119
  }) {
119
120
  if (state.status === "pending" || state.status === "idle") {
120
121
  return /* @__PURE__ */ jsx2(
@@ -140,7 +141,7 @@ function PanelBody({
140
141
  }
141
142
  );
142
143
  }
143
- if (state.items.length === 0) {
144
+ if (state.items.length === 0 && !hasLive) {
144
145
  return /* @__PURE__ */ jsx2(
145
146
  EmptyState,
146
147
  {
@@ -152,30 +153,37 @@ function PanelBody({
152
153
  }
153
154
  );
154
155
  }
155
- return /* @__PURE__ */ jsxs2(Box2, { children: [
156
- state.items.map((notification) => /* @__PURE__ */ jsx2(
157
- NotificationRow,
158
- {
159
- notification,
160
- messages,
161
- onOpen,
162
- onDelete
163
- },
164
- notification.id
165
- )),
166
- state.nextCursor ? /* @__PURE__ */ jsx2(Box2, { sx: { display: "flex", justifyContent: "center", py: 1.5 }, children: /* @__PURE__ */ jsx2(
167
- Button2,
168
- {
169
- variant: "outline",
170
- color: "neutral",
171
- size: "sm",
172
- disabled: state.loadingMore,
173
- onClick: onLoadMore,
174
- dataTestId: "notifications-load-more",
175
- children: state.loadingMore ? messages.loadingMore : messages.loadMore
176
- }
177
- ) }) : null
178
- ] });
156
+ return (
157
+ // A stable anchor for the inbox half, present whether or not it has rows.
158
+ // The panel's claim is that what is HAPPENING sits above what has already
159
+ // happened, and until this existed the only thing below the live section to
160
+ // point at was the empty state — which is exactly what stops rendering when
161
+ // something is live.
162
+ /* @__PURE__ */ jsxs2(Box2, { "data-testid": "notifications-inbox", children: [
163
+ state.items.map((notification) => /* @__PURE__ */ jsx2(
164
+ NotificationRow,
165
+ {
166
+ notification,
167
+ messages,
168
+ onOpen,
169
+ onDelete
170
+ },
171
+ notification.id
172
+ )),
173
+ state.nextCursor ? /* @__PURE__ */ jsx2(Box2, { sx: { display: "flex", justifyContent: "center", py: 1.5 }, children: /* @__PURE__ */ jsx2(
174
+ Button2,
175
+ {
176
+ variant: "outline",
177
+ color: "neutral",
178
+ size: "sm",
179
+ disabled: state.loadingMore,
180
+ onClick: onLoadMore,
181
+ dataTestId: "notifications-load-more",
182
+ children: state.loadingMore ? messages.loadingMore : messages.loadMore
183
+ }
184
+ ) }) : null
185
+ ] })
186
+ );
179
187
  }
180
188
  __name(PanelBody, "PanelBody");
181
189
  function usePanelOpeners(store, onClose, onNavigate) {
@@ -199,19 +207,64 @@ function usePanelOpeners(store, onClose, onNavigate) {
199
207
  };
200
208
  }
201
209
  __name(usePanelOpeners, "usePanelOpeners");
210
+ function PanelInbox({
211
+ state,
212
+ messages,
213
+ store,
214
+ onOpen,
215
+ hasLive
216
+ }) {
217
+ const hasUnread = state.items.some((item) => item.readAt === null);
218
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
219
+ hasUnread ? /* @__PURE__ */ jsx2(Box2, { sx: { display: "flex", justifyContent: "flex-end", pb: 1 }, children: /* @__PURE__ */ jsx2(
220
+ Button2,
221
+ {
222
+ variant: "ghost",
223
+ color: "primary",
224
+ size: "xs",
225
+ onClick: () => store.markAllRead(),
226
+ dataTestId: "notifications-mark-all-read",
227
+ children: messages.markAllRead
228
+ }
229
+ ) }) : null,
230
+ /* @__PURE__ */ jsx2(
231
+ PanelBody,
232
+ {
233
+ state,
234
+ messages,
235
+ onRetry: () => store.invalidate(),
236
+ onLoadMore: () => store.loadMore(),
237
+ onOpen,
238
+ onDelete: (id) => store.remove(id),
239
+ hasLive
240
+ }
241
+ )
242
+ ] });
243
+ }
244
+ __name(PanelInbox, "PanelInbox");
202
245
  function NotificationsPanel({
203
246
  open,
204
247
  onClose,
205
248
  onNavigate,
206
249
  store,
207
250
  messages,
208
- live
251
+ live,
252
+ liveSeen
209
253
  }) {
210
254
  const theme = useTheme();
211
255
  const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
212
256
  const state = useInboxList(store, open);
213
257
  const { openNotification, openLive } = usePanelOpeners(store, onClose, onNavigate);
214
- const hasUnread = state.items.some((item) => item.readAt === null);
258
+ const renderInbox = /* @__PURE__ */ __name((liveCount) => /* @__PURE__ */ jsx2(
259
+ PanelInbox,
260
+ {
261
+ state,
262
+ messages,
263
+ store,
264
+ onOpen: openNotification,
265
+ hasLive: liveCount > 0
266
+ }
267
+ ), "renderInbox");
215
268
  return /* @__PURE__ */ jsxs2(
216
269
  Drawer,
217
270
  {
@@ -223,39 +276,17 @@ function NotificationsPanel({
223
276
  dataTestId: "notifications-panel",
224
277
  children: [
225
278
  /* @__PURE__ */ jsx2(DrawerHeader, { onClose, children: messages.panelTitle }),
226
- /* @__PURE__ */ jsxs2(DrawerContent, { children: [
227
- live ? /* @__PURE__ */ jsx2(
228
- LiveSection,
229
- {
230
- config: live,
231
- messages,
232
- active: open,
233
- ...onNavigate ? { onOpen: openLive } : {}
234
- }
235
- ) : null,
236
- hasUnread ? /* @__PURE__ */ jsx2(Box2, { sx: { display: "flex", justifyContent: "flex-end", pb: 1 }, children: /* @__PURE__ */ jsx2(
237
- Button2,
238
- {
239
- variant: "ghost",
240
- color: "primary",
241
- size: "xs",
242
- onClick: () => store.markAllRead(),
243
- dataTestId: "notifications-mark-all-read",
244
- children: messages.markAllRead
245
- }
246
- ) }) : null,
247
- /* @__PURE__ */ jsx2(
248
- PanelBody,
249
- {
250
- state,
251
- messages,
252
- onRetry: () => store.invalidate(),
253
- onLoadMore: () => store.loadMore(),
254
- onOpen: openNotification,
255
- onDelete: (id) => store.remove(id)
256
- }
257
- )
258
- ] })
279
+ /* @__PURE__ */ jsx2(DrawerContent, { children: live ? /* @__PURE__ */ jsx2(
280
+ LiveSection,
281
+ {
282
+ config: live,
283
+ messages,
284
+ active: open,
285
+ ...onNavigate ? { onOpen: openLive } : {},
286
+ ...liveSeen ? { seen: liveSeen } : {},
287
+ children: renderInbox
288
+ }
289
+ ) : renderInbox(0) })
259
290
  ]
260
291
  }
261
292
  );
@@ -264,4 +295,4 @@ __name(NotificationsPanel, "NotificationsPanel");
264
295
  export {
265
296
  NotificationsPanel
266
297
  };
267
- //# sourceMappingURL=panel-T36JEMO3.js.map
298
+ //# sourceMappingURL=panel-OPB3DBLJ.js.map