@12-apps/notifications 4.9.0 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/ADOPTING.md +60 -1
  2. package/README.md +73 -0
  3. package/dist/{chunk-M5XTROYM.js → chunk-5Y7QRORV.js} +8 -4
  4. package/dist/chunk-5Y7QRORV.js.map +1 -0
  5. package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
  6. package/dist/chunk-6W7INOYQ.js.map +1 -0
  7. package/dist/chunk-JCVRQ42B.js +226 -0
  8. package/dist/chunk-JCVRQ42B.js.map +1 -0
  9. package/dist/chunk-RTURLH5U.js +33 -0
  10. package/dist/chunk-RTURLH5U.js.map +1 -0
  11. package/dist/{chunk-GGAZOLNN.js → chunk-SWOWHIFE.js} +2 -2
  12. package/dist/{chunk-KZG7QGFA.js → chunk-WVRODNXQ.js} +8 -4
  13. package/dist/chunk-WVRODNXQ.js.map +1 -0
  14. package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
  15. package/dist/{create-api-notifications-B-VMezVB.d.ts → create-api-notifications-CcPYrM3p.d.ts} +4 -4
  16. package/dist/{create-web-notifications-B-xQ6R0B.d.ts → create-web-notifications-_NVYmlvy.d.ts} +89 -4
  17. package/dist/{generators-BsuXe8Mp.d.ts → generators-qAD4fNPq.d.ts} +1 -1
  18. package/dist/hono/index.d.ts +5 -5
  19. package/dist/hono/index.js +4 -3
  20. package/dist/hono/index.js.map +1 -1
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.js +12 -2
  23. package/dist/{jobs-D64B_bg7.d.ts → jobs--fex87-q.d.ts} +1 -1
  24. package/dist/live-DYxEFO49.d.ts +158 -0
  25. package/dist/manifest/server.d.ts +6 -6
  26. package/dist/manifest/server.js +5 -4
  27. package/dist/manifest/server.js.map +1 -1
  28. package/dist/manifest/web.d.ts +4 -3
  29. package/dist/manifest/web.js +1 -1
  30. package/dist/{panel-UFXNO4AF.js → panel-T36JEMO3.js} +37 -13
  31. package/dist/panel-T36JEMO3.js.map +1 -0
  32. package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
  33. package/dist/react/index.d.ts +55 -6
  34. package/dist/react/index.js +8 -2
  35. package/dist/server/index.d.ts +7 -7
  36. package/dist/server/index.js +5 -4
  37. package/dist/{types-BFmAY0P5.d.ts → types-BlqZkCWZ.d.ts} +10 -1
  38. package/dist/web-push/index.d.ts +2 -2
  39. package/dist/{web-push-D-ysQd5h.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
  40. package/dist/{wire-_coV3tJk.d.ts → wire-BG1kuoXX.d.ts} +1 -1
  41. package/package.json +2 -2
  42. package/src/index.ts +10 -0
  43. package/src/live.ts +179 -0
  44. package/src/react/create-web-notifications.tsx +15 -1
  45. package/src/react/index.ts +29 -0
  46. package/src/react/live-card.tsx +279 -0
  47. package/src/react/live-config.ts +79 -0
  48. package/src/react/live-section.tsx +141 -0
  49. package/src/react/panel-lazy.tsx +3 -0
  50. package/src/react/panel.tsx +57 -10
  51. package/src/react/relative-time.ts +14 -2
  52. package/src/server/transports/web-push.ts +22 -0
  53. package/src/types.ts +10 -1
  54. package/dist/chunk-BGI4BQ66.js.map +0 -1
  55. package/dist/chunk-KZG7QGFA.js.map +0 -1
  56. package/dist/chunk-M5XTROYM.js.map +0 -1
  57. package/dist/chunk-O5BVUXPO.js +0 -22
  58. package/dist/chunk-O5BVUXPO.js.map +0 -1
  59. package/dist/panel-UFXNO4AF.js.map +0 -1
  60. /package/dist/{chunk-GGAZOLNN.js.map → chunk-SWOWHIFE.js.map} +0 -0
  61. /package/dist/{chunk-KU4FVOFA.js.map → chunk-ZIR3ILFH.js.map} +0 -0
  62. /package/dist/{preferences-screen-V3ZBWP7A.js.map → preferences-screen-SXUIGECY.js.map} +0 -0
package/ADOPTING.md CHANGED
@@ -448,13 +448,72 @@ contract `EmailPreviewMessage` states. The one mistake a preview surface must be
448
448
  incapable of — putting a sample in somebody's inbox — is not reachable from that
449
449
  code at all, rather than merely not done.
450
450
 
451
+ ## Live activities (optional)
452
+
453
+ The panel's second kind of entry: ONGOING STATE, pinned above the inbox list.
454
+ Off unless you configure it, and off means *nothing rendered* — no section, no
455
+ heading, no reserved space. `README.md` carries the argument and the full shape;
456
+ this is the adoption contract.
457
+
458
+ **You supply two things and this package owns the rest.**
459
+
460
+ ```ts
461
+ createWebNotifications({
462
+ …,
463
+ liveActivities: {
464
+ useActivities: ({ active }) => useMyLiveThings({ enabled: active }),
465
+ messages: { sectionTitle, openActivity, updated },
466
+ },
467
+ });
468
+ ```
469
+
470
+ 1. **Where they come from.** A HOOK, not a fetcher and not a factory-time
471
+ subscribe — the answer almost always lives in React context (your tenant,
472
+ your session, your query client), which is the same reason
473
+ `NotificationsSignalHook` exists one seam over. `active` is `false` while the
474
+ panel is shut; pass it to your query's `enabled`. Ignoring it is correct and
475
+ merely costs money — and an unopened inbox is free either way, because the
476
+ panel is lazy and the drawer unmounts its content on close.
477
+ 2. **Three sentences**, in their own pack rather than on
478
+ `NotificationMessages` — live activities are opt-in, and requiring copy for a
479
+ section you never render is the tax that gets a required-config change
480
+ reverted instead of adopted.
481
+
482
+ **Two rules that are ours, not yours:**
483
+
484
+ - A live activity NEVER touches `unread`. It is not news, and a number the bell
485
+ cannot clear is worse than no number.
486
+ - It leaves when your hook stops returning it. There is no dismiss, no read and
487
+ no delete — the subject finishing is the only exit, which is what stops the
488
+ section becoming a second inbox.
489
+
490
+ **On a phone**, put the activity's id on the notifications you already send
491
+ about the same subject:
492
+
493
+ ```ts
494
+ import { LIVE_SUBJECT_KEY } from '@12-apps/notifications'; // the root entry
495
+
496
+ data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
497
+ ```
498
+
499
+ (`LiveActivity` itself comes from either entry — the root or `./react` — so the
500
+ hook and the type it returns are one import line.)
501
+
502
+ `formatWebPush` turns it into `tag` on the push payload; your service worker
503
+ passes `tag` to `showNotification` and the tray keeps ONE entry per subject
504
+ instead of one per stage. The worker is still yours (rule 12) — a worker that
505
+ ignores `tag` behaves exactly as it does today.
506
+
451
507
  ## What does NOT come with it
452
508
 
453
509
  - **The events.** Generators are host code (see rule 6).
454
510
  - **A queue.** `scheduleDispatch` is a seam, not an implementation.
455
511
  - **A plan model.** `channelPolicy` answers; it does not decide.
456
512
  - **An authorization engine.** `audience` answers; it does not decide.
457
- - **A service worker.** The file is the host's (rule 12).
513
+ - **A service worker.** The file is the host's (rule 12) — including the four
514
+ lines that read `tag` off a live push.
515
+ - **Anything to BE live about.** `useActivities` is a seam; this package has no
516
+ idea what is happening in your product, which is the point.
458
517
  - **The mail INVENTORY.** The preview console renders what you declare as
459
518
  sources; it cannot discover what your product sends.
460
519
  - **A brand, a palette or a sentence.** The layout requires the first, defaults
package/README.md CHANGED
@@ -186,6 +186,79 @@ holds no driver, no transport and no address.
186
186
 
187
187
  See **[ADOPTING.md](./ADOPTING.md)** for the mount, the gate and the sources.
188
188
 
189
+ ## Live activities — the centre's second kind of entry
190
+
191
+ An inbox notification is an EVENT: it happened, it is stamped, it is read or
192
+ unread, and tomorrow it still says the same thing. A **live activity** is
193
+ ONGOING STATE — pinned above the list, no read/unread, nothing to delete, it
194
+ updates itself, and it is gone the moment the thing it tracks finishes.
195
+
196
+ The distinction is not cosmetic. "Your order is on its way", read an hour later,
197
+ is a claim about the past presented as news; the more reliable the inbox is, the
198
+ more of those a person accumulates, and somewhere in the pile is the question
199
+ they actually have — *where is it now*.
200
+
201
+ Opt-in, and absent means absent: a host that passes nothing renders the panel it
202
+ had, with no section, no heading and no reserved space.
203
+
204
+ ```ts
205
+ createWebNotifications({
206
+ apiBase: '/api/account',
207
+ messages,
208
+ liveActivities: {
209
+ // A HOOK, so it may read context — the tenant, the session, a query client.
210
+ useActivities: ({ active }) => useMyLiveThings({ enabled: active }),
211
+ messages: { sectionTitle, openActivity, updated },
212
+ renderIcon: (activity) => ICONS[activity.kind],
213
+ },
214
+ });
215
+ ```
216
+
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, and
219
+ it is not what makes an unopened inbox free: the panel is fetched lazily and the
220
+ drawer unmounts its content on close, so a host that ignores `active` still
221
+ issues nothing until somebody opens the bell.
222
+
223
+ One activity is `{ id, kind, title, body, link, steps, activeStepId, updatedAt }`
224
+ — `LiveActivity`, importable from either entry (`@12-apps/notifications` or
225
+ `@12-apps/notifications/react`, so a host wiring the surface needs one import
226
+ line, not two). It is defined in `src/live.ts`. `steps` + `activeStepId` draw a lane, because "how far along is
227
+ this" is the shape almost every ongoing subject has; both are optional in effect
228
+ — an activity with no lane is a heading, a sentence and a timestamp that keeps
229
+ moving. An `activeStepId` naming no step draws NO lane rather than a lane with
230
+ nothing lit, which would read as a process that has stopped.
231
+
232
+ ### On a phone: one tray entry, one buzz
233
+
234
+ The other half is the OS notification, and it is one field. A generator whose
235
+ event is about something also tracked live puts the activity's id in `data`:
236
+
237
+ ```ts
238
+ import { LIVE_SUBJECT_KEY } from '@12-apps/notifications'; // the root entry
239
+
240
+ data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
241
+ ```
242
+
243
+ `formatWebPush` turns that into a `tag` on the push payload, and a tag makes the
244
+ next push about the same subject **replace** the one already in the tray —
245
+ silently — instead of stacking under it. Four stages then cost one entry and one
246
+ buzz, and the entry that remains is the current one. Without it a phone
247
+ accumulates one alert per stage, each still asserting a stage the subject has
248
+ since left.
249
+
250
+ `tag` is `null` for an ordinary event rather than absent, so a service worker
251
+ reads one payload shape. A worker that ignores it keeps today's behaviour
252
+ exactly — which is what makes the field safe to ship ahead of the workers:
253
+
254
+ ```js
255
+ self.registration.showNotification(payload.title, {
256
+ body: payload.body,
257
+ data: { link: payload.link },
258
+ ...(payload.tag ? { tag: payload.tag, renotify: false } : {}),
259
+ });
260
+ ```
261
+
189
262
  ## The models
190
263
 
191
264
  `prisma/notifications.prisma` — `Notification`, `NotificationDelivery`,
@@ -170,7 +170,7 @@ import { Suspense, lazy, useEffect, useState } from "react";
170
170
  import { jsx as jsx2 } from "react/jsx-runtime";
171
171
  function lazyNotificationsPanel(parts) {
172
172
  const Bound = lazy(async () => {
173
- const { NotificationsPanel } = await import("./panel-UFXNO4AF.js");
173
+ const { NotificationsPanel } = await import("./panel-T36JEMO3.js");
174
174
  return {
175
175
  default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx2(NotificationsPanel, { ...props, ...parts }), "default")
176
176
  };
@@ -191,7 +191,7 @@ import { Suspense as Suspense2, lazy as lazy2 } from "react";
191
191
  import { jsx as jsx3 } from "react/jsx-runtime";
192
192
  function lazyPreferencesPage(parts) {
193
193
  const Bound = lazy2(async () => {
194
- const { PreferencesScreen } = await import("./preferences-screen-V3ZBWP7A.js");
194
+ const { PreferencesScreen } = await import("./preferences-screen-SXUIGECY.js");
195
195
  return {
196
196
  default: /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx3(PreferencesScreen, { ...props, ...parts }), "default")
197
197
  };
@@ -218,7 +218,11 @@ function createWebNotifications(config) {
218
218
  ...config.useSignal ? { useSignal: config.useSignal } : {}
219
219
  };
220
220
  const Bell = /* @__PURE__ */ __name((props) => /* @__PURE__ */ jsx4(BellButton, { ...props, store, messages, ...subscribeOption }), "Bell");
221
- const Panel = lazyNotificationsPanel({ store, messages });
221
+ const Panel = lazyNotificationsPanel({
222
+ store,
223
+ messages,
224
+ ...config.liveActivities ? { live: config.liveActivities } : {}
225
+ });
222
226
  function useBoundUnreadCount(options = {}) {
223
227
  return useUnreadCount(store, { ...options, ...subscribeOption });
224
228
  }
@@ -260,4 +264,4 @@ export {
260
264
  httpNotificationsTransport,
261
265
  createWebNotifications
262
266
  };
263
- //# sourceMappingURL=chunk-M5XTROYM.js.map
267
+ //# sourceMappingURL=chunk-5Y7QRORV.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/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, 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 { 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 const Bell: ComponentType<BellButtonProps> = (props) => (\n <BellButton {...props} store={store} messages={messages} {...subscribeOption} />\n );\n const Panel = lazyNotificationsPanel({\n store,\n messages,\n ...(config.liveActivities ? { live: config.liveActivities } : {}),\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 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 { 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\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 return (\n <Box\n component=\"button\"\n type=\"button\"\n onClick={onClick}\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=\"primary\"\n variant=\"count\"\n max={99}\n data-testid=\"notifications-badge\"\n >\n <BellIcon size={28} />\n </Badge>\n </Box>\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 { 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}\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"]}
@@ -19,4 +19,4 @@ export {
19
19
  NOTIFICATION_CHANNELS,
20
20
  taxonomyOf
21
21
  };
22
- //# sourceMappingURL=chunk-BGI4BQ66.js.map
22
+ //# sourceMappingURL=chunk-6W7INOYQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["/**\n * Core types of the channel-agnostic notification system (12-15).\n *\n * Three decoupled layers, each open for extension without touching the others:\n * - GENERATORS map a typed domain event to agnostic content (title/body/…).\n * - The CHANNEL ROUTER always writes the notification-centre inbox record,\n * then fans out one delivery per enabled channel.\n * - TRANSPORTS format the agnostic content for one channel and send it.\n *\n * Nothing here knows about a concrete channel's wire format — that lives\n * entirely inside each transport adapter — and nothing here knows about a\n * concrete DOMAIN either: the event `type` set, the preference categories and\n * the channel list are all host config (see {@link NotificationTaxonomy}).\n */\n\n/** Transport channels a notification can fan out to (DB CHECK mirrors this). */\nexport const NOTIFICATION_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'] as const;\nexport type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];\n\n/**\n * The preference categories are the HOST's, and required.\n *\n * There used to be a `NOTIFICATION_CATEGORIES = ['orders','payments','stock',\n * 'system']` here — one product's set — and `taxonomyOf` fell back to it\n * whenever a host passed none. The docstring argued the case itself: \"it is\n * product vocabulary, not machinery\", and then shipped the vocabulary anyway as\n * the default, which is the only part a forgetful host would ever see.\n *\n * The consequence was quiet rather than loud: the settings screen renders four\n * rows a foreign host never chose, its own categories are absent, and every\n * preference a user sets is filed against a taxonomy nothing else in that\n * system uses. Nothing throws, because `category` is deliberately a free string\n * — the packaged migration puts **no CHECK** on it, precisely because a closed\n * set would be wrong for every host but the first. That freedom is what made\n * the default undetectable.\n *\n * `channel` and `status` are different and keep their CHECKs: those ARE this\n * library's own closed sets.\n */\nexport type NotificationCategory = string;\n\n/**\n * Per-channel delivery lifecycle (DB CHECK mirrors this).\n *\n * `SENDING` is the CLAIM: exactly one dispatcher moves a row out of `QUEUED`,\n * so two dispatchers can never both send the same delivery. A row left\n * `SENDING` is a dispatcher that died mid-send, and the sweep reclaims it once\n * it is older than the cutoff.\n *\n * `DEAD` is terminal: the attempt ceiling was reached (or the recipient no\n * longer exists), and no sweep will pick the row up again. Without it a\n * permanently invalid destination is a billed provider call on every sweep,\n * forever, and the sweep's working set only grows.\n */\nexport type DeliveryStatus = 'QUEUED' | 'SENDING' | 'SENT' | 'FAILED' | 'DEAD';\n\n/**\n * Channel-agnostic content a generator produces. This is what the inbox stores\n * verbatim and what every transport's formatter receives — no channel may leak\n * its wire format into it.\n */\nexport interface NotificationContent {\n title: string;\n body: string;\n /** In-app deep link (a same-origin path such as `/orders/123`). */\n link?: string;\n /**\n * Structured extras for consumers that want more than text.\n *\n * ONE KEY IS RESERVED: `liveSubject` (`LIVE_SUBJECT_KEY` in `./live`) ties\n * this notification to a live activity, and the WEB_PUSH transport turns it\n * into the tray `tag` that collapses every push about one subject onto a\n * single entry. A host already using that name for something else acquires\n * that behaviour without asking for it — so it is named here, on the field a\n * generator actually writes, and not only where the feature is documented.\n */\n data?: Record<string, unknown>;\n}\n\n/** Who receives a notification. `clientId` scopes it to a tenant when set. */\nexport interface NotificationRecipient {\n userId: string;\n clientId?: string;\n}\n\n/**\n * A typed domain event handed to `notify`. `type` selects the registered\n * generator; `payload` is that generator's typed input. Callers never touch\n * channels, formatting, or preferences.\n */\nexport interface NotificationEvent<TPayload = unknown> {\n type: string;\n recipient: NotificationRecipient;\n payload: TPayload;\n}\n\n/**\n * Maps one domain event type to agnostic content. Registered through the\n * server config (or `registerGenerator` for a late arrival); adding a\n * generator never touches existing generators, the router, or any transport\n * (open/closed).\n */\n/**\n * Who the content is being rendered FOR — the reader, at the moment the\n * generator is asked.\n *\n * A notification is stored as rendered TEXT: title and body are columns, so\n * the language is chosen once, when the row is written, and never again. That\n * makes this the only honest place to ask. A generator is registered at BOOT —\n * a host that resolved its words there would pin every future reader to\n * whichever language the process happened to start in, invisibly, because a\n * single-locale host cannot tell the difference.\n *\n * The tag is the RECIPIENT's, never the request's. The person who triggers a\n * notification is routinely not the person who reads it: an invite is sent\n * because an administrator acted and is read by the invitee. Reading\n * `Accept-Language` here would be a bug that only ever surfaces as somebody\n * being told things in a language they do not speak.\n *\n * Absent means \"nobody said\" — a host with one audience, or one that stores no\n * per-user language, populates nothing and every generator answers with its\n * own default exactly as it did before this existed.\n */\nexport interface NotificationGenerateContext {\n readonly locale?: string | null;\n}\n\nexport interface NotificationGenerator<TPayload = unknown> {\n /** The event key, dot-namespaced (\"order.paid\"). One generator per type. */\n type: string;\n /** The preference category the router gates this type's fan-out on. */\n category: NotificationCategory;\n /**\n * Render this event's content for ONE recipient.\n *\n * `context` is OPTIONAL, and that is what keeps every generator written\n * before it working: a one-parameter `generate` is assignable to this\n * signature unchanged. A host that passes nothing is stating a fact — it has\n * no language for this reader — rather than asserting a default.\n */\n generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;\n}\n\n/**\n * The recipient as a transport sees them: resolved destinations only. Built by\n * the router from the host's contact directory + the push subscriptions this\n * package owns; transports use it to answer\n * {@link NotificationTransport.supports}.\n */\nexport interface TransportRecipient {\n userId: string;\n email: string | null;\n /**\n * The recipient's own language, when the host's contact directory states\n * one. Absent means \"nobody said\" — see {@link NotificationGenerateContext}.\n */\n locale?: string | null;\n /** Phone as the host stores it (transports normalize per provider rules). */\n phone: string | null;\n /** How many active browser push subscriptions the user holds. */\n pushSubscriptionCount: number;\n}\n\n/**\n * One pluggable channel adapter: a FORMATTER (agnostic content → channel\n * message) plus a SENDER. Adding a channel = registering one of these; the\n * router dispatches through the registry and needs no change.\n *\n * `send` resolves on success and THROWS on failure — the router records the\n * error on the delivery row and isolates it from other channels. Sends must be\n * retry-safe: the router may re-dispatch a QUEUED/FAILED delivery.\n */\nexport interface NotificationTransport<TMessage = unknown> {\n channel: NotificationChannel;\n /**\n * Whether this recipient is addressable on this channel right now — the\n * destination exists (e-mail / phone / push subscription) AND the provider\n * is configured. `false` simply skips the channel (no delivery row).\n */\n supports(recipient: TransportRecipient): boolean;\n /** Transform the agnostic content into this channel's message shape. */\n format(content: NotificationContent): TMessage;\n /** Deliver the formatted message to the recipient. Throws on failure. */\n send(message: TMessage, recipient: TransportRecipient): Promise<void>;\n}\n\n/**\n * The host's product vocabulary. Everything below the surface (routing,\n * delivery rows, retries, the wire) is identical for every host; WHICH\n * categories exist and how they are labelled is not.\n */\nexport interface NotificationTaxonomy {\n /** The preference categories, in the order the settings screen lists them. */\n categories: readonly NotificationCategory[];\n}\n\n/**\n * The taxonomy in force. `categories` is REQUIRED — see above.\n *\n * The empty check was already here and stays: an empty list and a missing one\n * are the same mistake, and both now fail at assembly rather than rendering an\n * empty settings screen or somebody else's four rows.\n */\nexport function taxonomyOf(config: {\n categories: readonly NotificationCategory[];\n}): NotificationTaxonomy {\n const categories = config.categories;\n if (!categories || categories.length === 0) {\n throw new Error(\n '@12-apps/notifications: `categories` is required and must not be empty — ' +\n 'the preference categories are the host\\'s product vocabulary.',\n );\n }\n return { categories: [...categories] };\n}\n\n/** The host's logger. Defaults to the console (the @12-apps/jobs precedent). */\nexport interface NotificationLogger {\n info(message: string, ...meta: unknown[]): void;\n error(message: string, ...meta: unknown[]): void;\n}\n"],"mappings":";;;;;AAgBO,IAAM,wBAAwB,CAAC,SAAS,OAAO,YAAY,UAAU;AA2LrE,SAAS,WAAW,QAEF;AACvB,QAAM,aAAa,OAAO;AAC1B,MAAI,CAAC,cAAc,WAAW,WAAW,GAAG;AAC1C,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,EAAE,YAAY,CAAC,GAAG,UAAU,EAAE;AACvC;AAXgB;","names":[]}
@@ -0,0 +1,226 @@
1
+ import {
2
+ liveActivityLane
3
+ } from "./chunk-RTURLH5U.js";
4
+ import {
5
+ __name
6
+ } from "./chunk-7QVYU63E.js";
7
+
8
+ // src/react/relative-time.ts
9
+ function relativeTime(iso, messages, now = Date.now()) {
10
+ const elapsedMs = now - new Date(iso).getTime();
11
+ const minutes = Math.round(elapsedMs / 6e4);
12
+ if (minutes < 1) return messages.justNow;
13
+ if (minutes < 60) return messages.minutesAgo(minutes);
14
+ const hours = Math.round(minutes / 60);
15
+ if (hours < 24) return messages.hoursAgo(hours);
16
+ const days = Math.round(hours / 24);
17
+ if (days < 7) return messages.daysAgo(days);
18
+ return new Date(iso).toLocaleDateString(messages.dateLocale);
19
+ }
20
+ __name(relativeTime, "relativeTime");
21
+
22
+ // src/react/live-section.tsx
23
+ import { useEffect, useId as useId2, useState } from "react";
24
+ import { Box as Box2 } from "@12-apps/ui/mui/Box";
25
+ import { Text as Text2 } from "@12-apps/ui/typography/Text";
26
+
27
+ // src/react/live-card.tsx
28
+ import { useId } from "react";
29
+ import { Stepper } from "@12-apps/ui/data-display/Stepper";
30
+ import { Box } from "@12-apps/ui/mui/Box";
31
+ import { alpha } from "@12-apps/ui/mui/styles";
32
+ import { Text } from "@12-apps/ui/typography/Text";
33
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
34
+ var cardSx = {
35
+ // `relative`, so the button below can stretch a hit area over the whole card
36
+ // — see `targetSx`.
37
+ position: "relative",
38
+ border: "1px solid",
39
+ borderColor: /* @__PURE__ */ __name((t) => alpha(t.palette.primary.main, 0.35), "borderColor"),
40
+ bgcolor: /* @__PURE__ */ __name((t) => alpha(t.palette.primary.main, 0.06), "bgcolor"),
41
+ borderRadius: 1.5,
42
+ p: 1.25,
43
+ mb: 1
44
+ };
45
+ var targetSx = {
46
+ display: "flex",
47
+ alignItems: "center",
48
+ gap: 1,
49
+ width: "100%",
50
+ textAlign: "left",
51
+ font: "inherit",
52
+ color: "inherit",
53
+ border: "none",
54
+ background: "none",
55
+ p: 0
56
+ };
57
+ var stretchedSx = {
58
+ ...targetSx,
59
+ cursor: "pointer",
60
+ "&::after": { content: '""', position: "absolute", inset: 0 }
61
+ };
62
+ var laneSx = {
63
+ pt: 1.25,
64
+ px: 0.5,
65
+ overflow: "hidden",
66
+ "& .MuiTypography-root": { fontSize: 11, lineHeight: 1.25 },
67
+ '& [data-testid^="stepper-step-content-"]': { minWidth: 0 },
68
+ '& [data-testid^="stepper-connector-"]': { minWidth: 6, mx: 0.75 }
69
+ };
70
+ function ActivityLane({ activity }) {
71
+ const lane = liveActivityLane(activity);
72
+ if (lane === null) return null;
73
+ return /* @__PURE__ */ jsx(Box, { sx: laneSx, "aria-hidden": true, inert: true, children: /* @__PURE__ */ jsx(
74
+ Stepper,
75
+ {
76
+ steps: lane.steps.map((step) => ({ id: step.id, label: step.label })),
77
+ activeId: lane.activeStepId,
78
+ completed: new Set(lane.completed),
79
+ orientation: "horizontal",
80
+ size: "xs",
81
+ clickable: false,
82
+ "data-testid": `live-activity-steps-${activity.id}`
83
+ }
84
+ ) });
85
+ }
86
+ __name(ActivityLane, "ActivityLane");
87
+ function ActivityIcon({ icon }) {
88
+ if (icon === void 0 || icon === null) return null;
89
+ return /* @__PURE__ */ jsx(Box, { "aria-hidden": true, sx: { display: "flex", flex: "0 0 auto", color: "primary.main" }, children: icon });
90
+ }
91
+ __name(ActivityIcon, "ActivityIcon");
92
+ function ActivityTarget({
93
+ activity,
94
+ renderIcon,
95
+ bodyId
96
+ }) {
97
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
98
+ /* @__PURE__ */ jsx(ActivityIcon, { icon: renderIcon?.(activity) }),
99
+ /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", flexDirection: "column", gap: 0.25, minWidth: 0 }, children: [
100
+ /* @__PURE__ */ jsx(
101
+ Text,
102
+ {
103
+ variant: "body",
104
+ size: "sm",
105
+ weight: "semibold",
106
+ as: "span",
107
+ "aria-live": "polite",
108
+ "data-testid": `live-activity-title-${activity.id}`,
109
+ children: activity.title
110
+ }
111
+ ),
112
+ activity.body === null ? null : /* @__PURE__ */ jsx(Text, { id: bodyId, variant: "caption", size: "xs", color: "secondary", as: "span", children: activity.body })
113
+ ] })
114
+ ] });
115
+ }
116
+ __name(ActivityTarget, "ActivityTarget");
117
+ function LiveActivityCard({
118
+ activity,
119
+ messages,
120
+ live,
121
+ renderIcon,
122
+ now,
123
+ onOpen
124
+ }) {
125
+ const followable = activity.link !== null && onOpen !== void 0;
126
+ const bodyId = useId();
127
+ const target = /* @__PURE__ */ jsx(
128
+ ActivityTarget,
129
+ {
130
+ activity,
131
+ bodyId,
132
+ ...renderIcon ? { renderIcon } : {}
133
+ }
134
+ );
135
+ return /* @__PURE__ */ jsxs(Box, { "data-testid": `live-activity-${activity.id}`, sx: cardSx, children: [
136
+ followable ? /* @__PURE__ */ jsx(
137
+ Box,
138
+ {
139
+ component: "button",
140
+ type: "button",
141
+ onClick: () => onOpen(activity),
142
+ "aria-label": live.openActivity(activity.title),
143
+ ...activity.body === null ? {} : { "aria-describedby": bodyId },
144
+ "data-testid": `live-activity-open-${activity.id}`,
145
+ sx: stretchedSx,
146
+ children: target
147
+ }
148
+ ) : /* @__PURE__ */ jsx(Box, { sx: targetSx, children: target }),
149
+ /* @__PURE__ */ jsx(ActivityLane, { activity }),
150
+ /* @__PURE__ */ jsx(Text, { variant: "caption", size: "xs", color: "secondary", as: "span", italic: true, children: live.updated(relativeTime(activity.updatedAt, messages, now)) })
151
+ ] });
152
+ }
153
+ __name(LiveActivityCard, "LiveActivityCard");
154
+
155
+ // src/react/live-section.tsx
156
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
157
+ var TICK_MS = 6e4;
158
+ function useMinuteTick(active) {
159
+ const [now, setNow] = useState(() => Date.now());
160
+ useEffect(() => {
161
+ if (!active) return;
162
+ setNow(Date.now());
163
+ const timer = setInterval(() => setNow(Date.now()), TICK_MS);
164
+ return () => clearInterval(timer);
165
+ }, [active]);
166
+ return now;
167
+ }
168
+ __name(useMinuteTick, "useMinuteTick");
169
+ function LiveSection({
170
+ config,
171
+ messages,
172
+ active,
173
+ onOpen
174
+ }) {
175
+ const activities = config.useActivities({ active });
176
+ const now = useMinuteTick(active && activities.length > 0);
177
+ 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.
183
+ /* @__PURE__ */ jsxs2(
184
+ Box2,
185
+ {
186
+ component: "section",
187
+ "aria-labelledby": headingId,
188
+ "data-testid": "live-activities",
189
+ sx: { pb: 1.5 },
190
+ children: [
191
+ /* @__PURE__ */ jsx2(
192
+ Text2,
193
+ {
194
+ id: headingId,
195
+ variant: "caption",
196
+ size: "xs",
197
+ color: "secondary",
198
+ weight: "semibold",
199
+ as: "span",
200
+ children: config.messages.sectionTitle
201
+ }
202
+ ),
203
+ /* @__PURE__ */ jsx2(Box2, { sx: { pt: 0.75 }, children: activities.map((activity) => /* @__PURE__ */ jsx2(
204
+ LiveActivityCard,
205
+ {
206
+ activity,
207
+ messages,
208
+ live: config.messages,
209
+ now,
210
+ ...onOpen ? { onOpen } : {},
211
+ ...config.renderIcon ? { renderIcon: config.renderIcon } : {}
212
+ },
213
+ activity.id
214
+ )) })
215
+ ]
216
+ }
217
+ )
218
+ );
219
+ }
220
+ __name(LiveSection, "LiveSection");
221
+
222
+ export {
223
+ relativeTime,
224
+ LiveSection
225
+ };
226
+ //# sourceMappingURL=chunk-JCVRQ42B.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 } 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';\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\n\n\nexport function LiveSection({\n config,\n messages,\n active,\n onOpen,\n}: LiveSectionProps): JSX.Element | null {\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 if (activities.length === 0) return null;\n\n return (\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 );\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","jsx","jsxs","useId","Box","Text"]}
@@ -0,0 +1,33 @@
1
+ import {
2
+ __name
3
+ } from "./chunk-7QVYU63E.js";
4
+
5
+ // src/live.ts
6
+ function liveActivityLane(activity) {
7
+ const activeStepId = activity.activeStepId;
8
+ if (activeStepId === null || activity.steps.length === 0) return null;
9
+ const reached = activity.steps.findIndex((step) => step.id === activeStepId);
10
+ if (reached < 0) return null;
11
+ return {
12
+ steps: activity.steps,
13
+ activeStepId,
14
+ completed: new Set(activity.steps.slice(0, reached).map((step) => step.id))
15
+ };
16
+ }
17
+ __name(liveActivityLane, "liveActivityLane");
18
+ var LIVE_SUBJECT_KEY = "liveSubject";
19
+ var LIVE_PUSH_TAG_PREFIX = "live:";
20
+ function livePushTag(data) {
21
+ const subject = data?.[LIVE_SUBJECT_KEY];
22
+ if (typeof subject !== "string" || subject === "") return null;
23
+ return `${LIVE_PUSH_TAG_PREFIX}${subject}`;
24
+ }
25
+ __name(livePushTag, "livePushTag");
26
+
27
+ export {
28
+ liveActivityLane,
29
+ LIVE_SUBJECT_KEY,
30
+ LIVE_PUSH_TAG_PREFIX,
31
+ livePushTag
32
+ };
33
+ //# sourceMappingURL=chunk-RTURLH5U.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/live.ts"],"sourcesContent":["/**\n * LIVE ACTIVITIES — the notification centre's second kind of entry.\n *\n * An inbox notification is an EVENT: it happened, it is stamped, it is read or\n * unread, and it is still there tomorrow saying the same thing. A live activity\n * is ONGOING STATE: it is pinned above the list, it has no read/unread, nobody\n * deletes it, it updates itself, and it is GONE the moment the thing it tracks\n * finishes.\n *\n * The distinction is not cosmetic. A row that says \"your order is on its way\",\n * read an hour later, is a claim about the past presented as news — and the\n * more reliable the inbox is, the more of those a person accumulates. Somewhere\n * in that list is the one question they actually have, which is *where is it\n * now*. A live activity answers that question and then removes itself, which is\n * the property no amount of better event copy can buy.\n *\n * ## This file is domain-free, and that is the whole design\n *\n * Nothing here knows what is being tracked. A host publishes activities through\n * `LiveActivitiesConfig.useActivities` (see `./react/live-config`) and this\n * package owns the contract, the placement, the rendering and the OS-level\n * collapse. The package that raises an alert has never been this package's\n * business — see the `generators` seam — and the thing an alert is ABOUT is not\n * either.\n *\n * ## The lane\n *\n * `steps` + `activeStepId` describe a progress lane, because \"how far along is\n * this\" is the shape almost every ongoing subject has. Both are optional in\n * effect: an activity with no lane (`steps: []`, `activeStepId: null`) is a\n * perfectly good live entry — a heading, a sentence and a timestamp that keeps\n * moving.\n */\n\n/** One stop on a live activity's lane. */\nexport interface LiveActivityStep {\n /** Stable across updates — the id is what `activeStepId` names. */\n id: string;\n /** What the reader sees under the dot. The HOST's word, in the reader's language. */\n label: string;\n}\n\n/** One thing that is happening right now, as the notification centre shows it. */\nexport interface LiveActivity {\n /**\n * Stable for the whole life of the subject.\n *\n * The SAME id on every update, because it is the identity of the thing being\n * tracked and not of the message: the panel keys on it so a stage change\n * re-renders one card rather than swapping two, and it is what\n * {@link livePushTag} collapses an OS notification onto.\n *\n * **Unique among the activities live at one moment**, for the same reason:\n * it is the React key and the card's test id. Two activities sharing one\n * gives a duplicate-key warning and a card that silently shows the wrong\n * subject.\n */\n id: string;\n /**\n * Which host concern this belongs to, e.g. `order`, `delivery`, `import`.\n *\n * Free-form and never rendered — it exists so a host publishing from two\n * sources can tell its own activities apart in a test or a log without\n * parsing `id`.\n */\n kind: string;\n /** The heading — what is happening. */\n title: string;\n /** The line under it: the detail that makes the heading actionable, or none. */\n body: string | null;\n /** Where tapping the card goes, as a same-origin path. `null` renders no link. */\n link: string | null;\n /** The lane, in the order it is walked. Empty when this subject has no lane. */\n steps: readonly LiveActivityStep[];\n /**\n * The stop the subject is AT — one of `steps`, or `null` for a laneless entry.\n *\n * An id that names no step is neither, and it is handled rather than trusted:\n * see {@link liveActivityLane}.\n */\n activeStepId: string | null;\n /**\n * ISO-8601 — when the subject last MOVED, not when it was last polled.\n *\n * A timestamp that advances on every read would render \"just now\" forever,\n * which is the one thing a live entry must not say when nothing is happening.\n */\n updatedAt: string;\n}\n\n/** A live activity's lane, resolved for rendering. */\nexport interface LiveActivityLane {\n steps: readonly LiveActivityStep[];\n activeStepId: string;\n /** Every stop BEFORE the active one — never the active one itself. */\n completed: ReadonlySet<string>;\n}\n\n/**\n * The lane to draw, or `null` when this activity has none to draw.\n *\n * Three cases collapse to `null`, and the third is the one worth writing down:\n * no steps, no active step, and **an active step the lane does not contain**.\n * That last one is not a theoretical defect — it is what a host produces the\n * first time a subject reaches a stage the lane was filtered to exclude, and\n * the symptom is a row of dots with NONE of them lit, which reads as a process\n * that has stopped. A card with no lane still says what is happening and when\n * it last moved; a dead lane says the tracking is broken.\n *\n * `completed` deliberately stops short of the active stop: a completed step\n * draws a tick, and ticking the stop the subject is sitting in claims it has\n * already left.\n */\nexport function liveActivityLane(activity: LiveActivity): LiveActivityLane | null {\n const activeStepId = activity.activeStepId;\n if (activeStepId === null || activity.steps.length === 0) return null;\n const reached = activity.steps.findIndex((step) => step.id === activeStepId);\n if (reached < 0) return null;\n return {\n steps: activity.steps,\n activeStepId,\n completed: new Set(activity.steps.slice(0, reached).map((step) => step.id)),\n };\n}\n\n/**\n * The reserved `data` key that ties a NOTIFICATION to a live subject.\n *\n * A notification and a live activity are different objects with different\n * lifetimes, and this is the only thing that joins them: a generator whose\n * event is about something also tracked live puts the activity's id here, and\n * every channel that can collapse gets to.\n *\n * data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }\n *\n * A plain string on purpose. An object here would grow a second wire contract\n * inside a column this package stores verbatim.\n */\nexport const LIVE_SUBJECT_KEY = 'liveSubject';\n\n/**\n * Namespaces the tray tag, so a live tag does not collide with the ones a host\n * already uses.\n *\n * A convention rather than an enforcement — nothing stops a host emitting its\n * own `live:`-prefixed tags — but it means the two id spaces have to be made to\n * meet rather than meeting by accident.\n */\nexport const LIVE_PUSH_TAG_PREFIX = 'live:';\n\n/**\n * The OS notification tag for a push about a live subject, or `null` for an\n * ordinary event.\n *\n * ## What the tag buys, and why it is the PWA half of this feature\n *\n * A `tag` makes a new notification REPLACE the one already in the tray instead\n * of stacking under it, and — unless the sender asks otherwise — replace it\n * *silently*. So a subject that moves through four stages costs one tray entry\n * and one buzz, and the entry that remains is the CURRENT one. Without it a\n * phone accumulates one alert per stage, all of them still asserting a stage\n * the subject has since left; the freshest is at the top and the reader has to\n * work out that the three below it are history.\n *\n * That is as close as the web platform gets to an ongoing/live notification,\n * and it is the half that reaches a person who does not have the app open —\n * which is most of the time a live activity is live.\n *\n * The id is passed through unchanged: it is the host's, it is already unique\n * per subject, and a hash would make the tray impossible to reason about from a\n * log line.\n */\nexport function livePushTag(\n data: Readonly<Record<string, unknown>> | null | undefined,\n): string | null {\n const subject = data?.[LIVE_SUBJECT_KEY];\n if (typeof subject !== 'string' || subject === '') return null;\n return `${LIVE_PUSH_TAG_PREFIX}${subject}`;\n}\n"],"mappings":";;;;;AAiHO,SAAS,iBAAiB,UAAiD;AAChF,QAAM,eAAe,SAAS;AAC9B,MAAI,iBAAiB,QAAQ,SAAS,MAAM,WAAW,EAAG,QAAO;AACjE,QAAM,UAAU,SAAS,MAAM,UAAU,CAAC,SAAS,KAAK,OAAO,YAAY;AAC3E,MAAI,UAAU,EAAG,QAAO;AACxB,SAAO;AAAA,IACL,OAAO,SAAS;AAAA,IAChB;AAAA,IACA,WAAW,IAAI,IAAI,SAAS,MAAM,MAAM,GAAG,OAAO,EAAE,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC;AAAA,EAC5E;AACF;AAVgB;AAyBT,IAAM,mBAAmB;AAUzB,IAAM,uBAAuB;AAwB7B,SAAS,YACd,MACe;AACf,QAAM,UAAU,OAAO,gBAAgB;AACvC,MAAI,OAAO,YAAY,YAAY,YAAY,GAAI,QAAO;AAC1D,SAAO,GAAG,oBAAoB,GAAG,OAAO;AAC1C;AANgB;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  NOTIFICATION_CHANNELS
3
- } from "./chunk-BGI4BQ66.js";
3
+ } from "./chunk-6W7INOYQ.js";
4
4
  import {
5
5
  __name
6
6
  } from "./chunk-7QVYU63E.js";
@@ -131,4 +131,4 @@ export {
131
131
  normalizePhoneE164,
132
132
  inboxWire
133
133
  };
134
- //# sourceMappingURL=chunk-GGAZOLNN.js.map
134
+ //# sourceMappingURL=chunk-SWOWHIFE.js.map