@12-apps/notifications 4.8.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 (114) hide show
  1. package/ADOPTING.md +173 -1
  2. package/README.md +138 -0
  3. package/dist/catalog-BOVZ2b-b.d.ts +132 -0
  4. package/dist/{chunk-M5XTROYM.js → chunk-5Y7QRORV.js} +8 -4
  5. package/dist/chunk-5Y7QRORV.js.map +1 -0
  6. package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
  7. package/dist/chunk-6W7INOYQ.js.map +1 -0
  8. package/dist/chunk-EKUSNUBT.js +222 -0
  9. package/dist/chunk-EKUSNUBT.js.map +1 -0
  10. package/dist/chunk-FTFLU6KH.js +114 -0
  11. package/dist/chunk-FTFLU6KH.js.map +1 -0
  12. package/dist/chunk-JCVRQ42B.js +226 -0
  13. package/dist/chunk-JCVRQ42B.js.map +1 -0
  14. package/dist/chunk-RTURLH5U.js +33 -0
  15. package/dist/chunk-RTURLH5U.js.map +1 -0
  16. package/dist/{chunk-GGAZOLNN.js → chunk-SWOWHIFE.js} +2 -2
  17. package/dist/{chunk-7234ROOP.js → chunk-WVRODNXQ.js} +28 -6
  18. package/dist/chunk-WVRODNXQ.js.map +1 -0
  19. package/dist/chunk-ZFIYBNZ7.js +502 -0
  20. package/dist/chunk-ZFIYBNZ7.js.map +1 -0
  21. package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
  22. package/dist/{create-api-notifications-WaexrZnn.d.ts → create-api-notifications-CcPYrM3p.d.ts} +35 -4
  23. package/dist/{create-web-notifications-DrgqtU4Q.d.ts → create-web-notifications-_NVYmlvy.d.ts} +89 -4
  24. package/dist/email/index.d.ts +26 -0
  25. package/dist/email/index.js +20 -0
  26. package/dist/email/index.js.map +1 -0
  27. package/dist/email/locales.d.ts +43 -0
  28. package/dist/email/locales.js +29 -0
  29. package/dist/email/locales.js.map +1 -0
  30. package/dist/email/previews/hono.d.ts +46 -0
  31. package/dist/email/previews/hono.js +33 -0
  32. package/dist/email/previews/hono.js.map +1 -0
  33. package/dist/email/previews/index.d.ts +67 -0
  34. package/dist/email/previews/index.js +12 -0
  35. package/dist/email/previews/index.js.map +1 -0
  36. package/dist/email/previews/react/index.d.ts +43 -0
  37. package/dist/email/previews/react/index.js +58 -0
  38. package/dist/email/previews/react/index.js.map +1 -0
  39. package/dist/{generators-CH5m8Ucj.d.ts → generators-qAD4fNPq.d.ts} +1 -1
  40. package/dist/hono/index.d.ts +6 -5
  41. package/dist/hono/index.js +5 -3
  42. package/dist/hono/index.js.map +1 -1
  43. package/dist/index.d.ts +4 -3
  44. package/dist/index.js +15 -5
  45. package/dist/{jobs-CcsSA0YR.d.ts → jobs--fex87-q.d.ts} +1 -1
  46. package/dist/live-DYxEFO49.d.ts +158 -0
  47. package/dist/manifest/index.d.ts +39 -1
  48. package/dist/manifest/index.js +9 -0
  49. package/dist/manifest/index.js.map +1 -1
  50. package/dist/manifest/server.d.ts +32 -7
  51. package/dist/manifest/server.js +16 -4
  52. package/dist/manifest/server.js.map +1 -1
  53. package/dist/manifest/web.d.ts +19 -4
  54. package/dist/manifest/web.js +9 -1
  55. package/dist/manifest/web.js.map +1 -1
  56. package/dist/{panel-UFXNO4AF.js → panel-T36JEMO3.js} +37 -13
  57. package/dist/panel-T36JEMO3.js.map +1 -0
  58. package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
  59. package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
  60. package/dist/react/index.d.ts +55 -6
  61. package/dist/react/index.js +16 -10
  62. package/dist/server/index.d.ts +8 -7
  63. package/dist/server/index.js +6 -4
  64. package/dist/template-Dlt5S54z.d.ts +196 -0
  65. package/dist/{types-CYJN1xCM.d.ts → types-BlqZkCWZ.d.ts} +11 -2
  66. package/dist/web-push/index.d.ts +2 -2
  67. package/dist/{web-push-iER5StBm.d.ts → web-push-Dnyaha2z.d.ts} +21 -1
  68. package/dist/{wire-CmRrB3_s.d.ts → wire-BG1kuoXX.d.ts} +1 -1
  69. package/package.json +22 -2
  70. package/src/email/chrome.en-US.ts +8 -0
  71. package/src/email/chrome.pt-BR.ts +21 -0
  72. package/src/email/index.ts +43 -0
  73. package/src/email/locales.ts +29 -0
  74. package/src/email/previews/catalog.ts +208 -0
  75. package/src/email/previews/hono.ts +69 -0
  76. package/src/email/previews/index.ts +26 -0
  77. package/src/email/previews/react/copy.en-US.ts +24 -0
  78. package/src/email/previews/react/copy.pt-BR.ts +24 -0
  79. package/src/email/previews/react/copy.ts +42 -0
  80. package/src/email/previews/react/index.tsx +14 -0
  81. package/src/email/previews/react/loadable.tsx +83 -0
  82. package/src/email/previews/react/message-list.tsx +133 -0
  83. package/src/email/previews/react/message-view.tsx +167 -0
  84. package/src/email/previews/react/preview-screen.tsx +342 -0
  85. package/src/email/previews/react/transport.ts +60 -0
  86. package/src/email/previews/routes.ts +111 -0
  87. package/src/email/template.ts +365 -0
  88. package/src/email/text.ts +59 -0
  89. package/src/email/theme.ts +77 -0
  90. package/src/index.ts +10 -0
  91. package/src/live.ts +179 -0
  92. package/src/manifest/index.ts +37 -0
  93. package/src/manifest/server.ts +23 -0
  94. package/src/manifest/web.ts +13 -0
  95. package/src/react/create-web-notifications.tsx +15 -1
  96. package/src/react/index.ts +29 -0
  97. package/src/react/live-card.tsx +279 -0
  98. package/src/react/live-config.ts +79 -0
  99. package/src/react/live-section.tsx +141 -0
  100. package/src/react/panel-lazy.tsx +3 -0
  101. package/src/react/panel.tsx +57 -10
  102. package/src/react/relative-time.ts +14 -2
  103. package/src/server/transports/email.ts +62 -0
  104. package/src/server/transports/web-push.ts +22 -0
  105. package/src/types.ts +10 -1
  106. package/dist/chunk-7234ROOP.js.map +0 -1
  107. package/dist/chunk-BGI4BQ66.js.map +0 -1
  108. package/dist/chunk-M5XTROYM.js.map +0 -1
  109. package/dist/chunk-O5BVUXPO.js +0 -22
  110. package/dist/chunk-O5BVUXPO.js.map +0 -1
  111. package/dist/panel-UFXNO4AF.js.map +0 -1
  112. /package/dist/{chunk-GGAZOLNN.js.map → chunk-SWOWHIFE.js.map} +0 -0
  113. /package/dist/{chunk-KU4FVOFA.js.map → chunk-ZIR3ILFH.js.map} +0 -0
  114. /package/dist/{preferences-screen-V3ZBWP7A.js.map → preferences-screen-SXUIGECY.js.map} +0 -0
package/src/live.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * LIVE ACTIVITIES — the notification centre's second kind of entry.
3
+ *
4
+ * An inbox notification is an EVENT: it happened, it is stamped, it is read or
5
+ * unread, and it is still there tomorrow saying the same thing. A live activity
6
+ * is ONGOING STATE: it is pinned above the list, it has no read/unread, nobody
7
+ * deletes it, it updates itself, and it is GONE the moment the thing it tracks
8
+ * finishes.
9
+ *
10
+ * The distinction is not cosmetic. A row that says "your order is on its way",
11
+ * read an hour later, is a claim about the past presented as news — and the
12
+ * more reliable the inbox is, the more of those a person accumulates. Somewhere
13
+ * in that list is the one question they actually have, which is *where is it
14
+ * now*. A live activity answers that question and then removes itself, which is
15
+ * the property no amount of better event copy can buy.
16
+ *
17
+ * ## This file is domain-free, and that is the whole design
18
+ *
19
+ * Nothing here knows what is being tracked. A host publishes activities through
20
+ * `LiveActivitiesConfig.useActivities` (see `./react/live-config`) and this
21
+ * package owns the contract, the placement, the rendering and the OS-level
22
+ * collapse. The package that raises an alert has never been this package's
23
+ * business — see the `generators` seam — and the thing an alert is ABOUT is not
24
+ * either.
25
+ *
26
+ * ## The lane
27
+ *
28
+ * `steps` + `activeStepId` describe a progress lane, because "how far along is
29
+ * this" is the shape almost every ongoing subject has. Both are optional in
30
+ * effect: an activity with no lane (`steps: []`, `activeStepId: null`) is a
31
+ * perfectly good live entry — a heading, a sentence and a timestamp that keeps
32
+ * moving.
33
+ */
34
+
35
+ /** One stop on a live activity's lane. */
36
+ export interface LiveActivityStep {
37
+ /** Stable across updates — the id is what `activeStepId` names. */
38
+ id: string;
39
+ /** What the reader sees under the dot. The HOST's word, in the reader's language. */
40
+ label: string;
41
+ }
42
+
43
+ /** One thing that is happening right now, as the notification centre shows it. */
44
+ export interface LiveActivity {
45
+ /**
46
+ * Stable for the whole life of the subject.
47
+ *
48
+ * The SAME id on every update, because it is the identity of the thing being
49
+ * tracked and not of the message: the panel keys on it so a stage change
50
+ * re-renders one card rather than swapping two, and it is what
51
+ * {@link livePushTag} collapses an OS notification onto.
52
+ *
53
+ * **Unique among the activities live at one moment**, for the same reason:
54
+ * it is the React key and the card's test id. Two activities sharing one
55
+ * gives a duplicate-key warning and a card that silently shows the wrong
56
+ * subject.
57
+ */
58
+ id: string;
59
+ /**
60
+ * Which host concern this belongs to, e.g. `order`, `delivery`, `import`.
61
+ *
62
+ * Free-form and never rendered — it exists so a host publishing from two
63
+ * sources can tell its own activities apart in a test or a log without
64
+ * parsing `id`.
65
+ */
66
+ kind: string;
67
+ /** The heading — what is happening. */
68
+ title: string;
69
+ /** The line under it: the detail that makes the heading actionable, or none. */
70
+ body: string | null;
71
+ /** Where tapping the card goes, as a same-origin path. `null` renders no link. */
72
+ link: string | null;
73
+ /** The lane, in the order it is walked. Empty when this subject has no lane. */
74
+ steps: readonly LiveActivityStep[];
75
+ /**
76
+ * The stop the subject is AT — one of `steps`, or `null` for a laneless entry.
77
+ *
78
+ * An id that names no step is neither, and it is handled rather than trusted:
79
+ * see {@link liveActivityLane}.
80
+ */
81
+ activeStepId: string | null;
82
+ /**
83
+ * ISO-8601 — when the subject last MOVED, not when it was last polled.
84
+ *
85
+ * A timestamp that advances on every read would render "just now" forever,
86
+ * which is the one thing a live entry must not say when nothing is happening.
87
+ */
88
+ updatedAt: string;
89
+ }
90
+
91
+ /** A live activity's lane, resolved for rendering. */
92
+ export interface LiveActivityLane {
93
+ steps: readonly LiveActivityStep[];
94
+ activeStepId: string;
95
+ /** Every stop BEFORE the active one — never the active one itself. */
96
+ completed: ReadonlySet<string>;
97
+ }
98
+
99
+ /**
100
+ * The lane to draw, or `null` when this activity has none to draw.
101
+ *
102
+ * Three cases collapse to `null`, and the third is the one worth writing down:
103
+ * no steps, no active step, and **an active step the lane does not contain**.
104
+ * That last one is not a theoretical defect — it is what a host produces the
105
+ * first time a subject reaches a stage the lane was filtered to exclude, and
106
+ * the symptom is a row of dots with NONE of them lit, which reads as a process
107
+ * that has stopped. A card with no lane still says what is happening and when
108
+ * it last moved; a dead lane says the tracking is broken.
109
+ *
110
+ * `completed` deliberately stops short of the active stop: a completed step
111
+ * draws a tick, and ticking the stop the subject is sitting in claims it has
112
+ * already left.
113
+ */
114
+ export function liveActivityLane(activity: LiveActivity): LiveActivityLane | null {
115
+ const activeStepId = activity.activeStepId;
116
+ if (activeStepId === null || activity.steps.length === 0) return null;
117
+ const reached = activity.steps.findIndex((step) => step.id === activeStepId);
118
+ if (reached < 0) return null;
119
+ return {
120
+ steps: activity.steps,
121
+ activeStepId,
122
+ completed: new Set(activity.steps.slice(0, reached).map((step) => step.id)),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The reserved `data` key that ties a NOTIFICATION to a live subject.
128
+ *
129
+ * A notification and a live activity are different objects with different
130
+ * lifetimes, and this is the only thing that joins them: a generator whose
131
+ * event is about something also tracked live puts the activity's id here, and
132
+ * every channel that can collapse gets to.
133
+ *
134
+ * data: { [LIVE_SUBJECT_KEY]: `order:${orderId}` }
135
+ *
136
+ * A plain string on purpose. An object here would grow a second wire contract
137
+ * inside a column this package stores verbatim.
138
+ */
139
+ export const LIVE_SUBJECT_KEY = 'liveSubject';
140
+
141
+ /**
142
+ * Namespaces the tray tag, so a live tag does not collide with the ones a host
143
+ * already uses.
144
+ *
145
+ * A convention rather than an enforcement — nothing stops a host emitting its
146
+ * own `live:`-prefixed tags — but it means the two id spaces have to be made to
147
+ * meet rather than meeting by accident.
148
+ */
149
+ export const LIVE_PUSH_TAG_PREFIX = 'live:';
150
+
151
+ /**
152
+ * The OS notification tag for a push about a live subject, or `null` for an
153
+ * ordinary event.
154
+ *
155
+ * ## What the tag buys, and why it is the PWA half of this feature
156
+ *
157
+ * A `tag` makes a new notification REPLACE the one already in the tray instead
158
+ * of stacking under it, and — unless the sender asks otherwise — replace it
159
+ * *silently*. So a subject that moves through four stages costs one tray entry
160
+ * and one buzz, and the entry that remains is the CURRENT one. Without it a
161
+ * phone accumulates one alert per stage, all of them still asserting a stage
162
+ * the subject has since left; the freshest is at the top and the reader has to
163
+ * work out that the three below it are history.
164
+ *
165
+ * That is as close as the web platform gets to an ongoing/live notification,
166
+ * and it is the half that reaches a person who does not have the app open —
167
+ * which is most of the time a live activity is live.
168
+ *
169
+ * The id is passed through unchanged: it is the host's, it is already unique
170
+ * per subject, and a hash would make the tray impossible to reason about from a
171
+ * log line.
172
+ */
173
+ export function livePushTag(
174
+ data: Readonly<Record<string, unknown>> | null | undefined,
175
+ ): string | null {
176
+ const subject = data?.[LIVE_SUBJECT_KEY];
177
+ if (typeof subject !== 'string' || subject === '') return null;
178
+ return `${LIVE_PUSH_TAG_PREFIX}${subject}`;
179
+ }
@@ -61,3 +61,40 @@ export const notificationsManifest = {
61
61
  server: ['http', 'jobs'],
62
62
  web: ['surface'],
63
63
  } as const satisfies PackageManifest;
64
+
65
+ /**
66
+ * The e-mail PREVIEW console — a second manifest, from the same package.
67
+ *
68
+ * ## Why a second manifest and not two more keys on the first
69
+ *
70
+ * `notificationsManifest` has already spent both slots this surface needs:
71
+ * `http` is the account inbox at `/api/account`, `surface` is the bell and the
72
+ * preference matrix. A capability is singular by the contract's shape, so a
73
+ * package with two genuinely different surfaces declares two manifests — which
74
+ * is exactly what `@12-apps/auth` does with `@12-apps/auth-platform`, and for
75
+ * the same reason it gives: those two switches turn a sign-in method off for
76
+ * EVERYBODY, so they do not belong behind the same gate as "reset my password".
77
+ *
78
+ * The split here is the same one. The inbox ships to every signed-in user; this
79
+ * console publishes the product's whole transactional-mail inventory and the
80
+ * exact wording and link shape of its verification and reset mails, which is
81
+ * the reference someone writing a convincing phishing mail would want. Two
82
+ * manifests keep that expressible: a host mounts the inbox and DECLINES the
83
+ * console, in writing, rather than silently getting both behind one gate.
84
+ *
85
+ * ## What it deliberately does not declare
86
+ *
87
+ * No `db` — a catalogue is DERIVED from what a host already sends, so an
88
+ * adopter mounts it without touching its schema. No `notifications`, because
89
+ * this surface authors no alert. And no `email`: that capability is a DELIVERY
90
+ * port, and this manifest renders and never sends. The delivery port is the
91
+ * OTHER half of this package, and it stays where it is.
92
+ */
93
+ export const notificationEmailPreviewsManifest = {
94
+ name: '@12-apps/notifications-email-previews',
95
+ contract: 1,
96
+ /** A refusal to render a preview files under its own namespace, not nowhere. */
97
+ observability: { namespace: 'email-previews' },
98
+ server: ['http'],
99
+ web: ['surface'],
100
+ } as const satisfies PackageManifest;
@@ -25,6 +25,8 @@
25
25
 
26
26
  import type { AnyServerManifest, WireRequest } from '@12-apps/wiring';
27
27
 
28
+ import { emailPreviewRoutes } from '../email/previews/routes';
29
+ import type { EmailPreviewsConfig } from '../email/previews/catalog';
28
30
  import {
29
31
  createApiNotifications,
30
32
  NOTIFICATIONS_JOBS,
@@ -84,3 +86,24 @@ export const notificationsServerManifest = {
84
86
  */
85
87
  jobs: NOTIFICATIONS_JOBS,
86
88
  } as const satisfies AnyServerManifest;
89
+
90
+ /**
91
+ * The preview console's server half — the two endpoints over the catalogue.
92
+ *
93
+ * A CONSTANT, not a factory. `http.create(config)` receives whatever the host
94
+ * bound at adoption, and "which messages exist" is precisely a host's binding:
95
+ * a package cannot know that a product sends a "your quota is exhausted"
96
+ * notice, let alone what data it renders from. Writing `EmailPreviewsConfig`
97
+ * as a factory argument instead would move that decision out of
98
+ * `bindings.http`, where `assemble()` can report on it, into a call the report
99
+ * never sees.
100
+ *
101
+ * The routes carry `kind: 'authenticated'` — see `../email/previews/routes`
102
+ * for why the descriptor states a posture it cannot itself enforce.
103
+ */
104
+ export const notificationEmailPreviewsServerManifest = {
105
+ name: '@12-apps/notifications-email-previews',
106
+ http: {
107
+ create: (config: EmailPreviewsConfig) => ({ routes: emailPreviewRoutes(config) }),
108
+ },
109
+ } as const satisfies AnyServerManifest;
@@ -38,9 +38,22 @@
38
38
 
39
39
  import type { AnyWebManifest } from '@12-apps/wiring';
40
40
 
41
+ import { createEmailPreviewScreen } from '../email/previews/react/preview-screen';
41
42
  import { createWebNotifications } from '../react/create-web-notifications';
42
43
 
43
44
  export const notificationsWebManifest = {
44
45
  name: '@12-apps/notifications',
45
46
  surface: { create: createWebNotifications },
46
47
  } as const satisfies AnyWebManifest;
48
+
49
+ /**
50
+ * The preview console's web half.
51
+ *
52
+ * One screen, named `page`, because a screen's NAME is what an area row
53
+ * resolves against — a surface that was itself the component is the shape that
54
+ * made an area row resolve to `undefined` in `@12-apps/auth` for a release.
55
+ */
56
+ export const notificationEmailPreviewsWebManifest = {
57
+ name: '@12-apps/notifications-email-previews',
58
+ surface: { create: createEmailPreviewScreen },
59
+ } as const satisfies AnyWebManifest;
@@ -10,6 +10,7 @@ import {
10
10
  type NotificationsSubscribe,
11
11
  } from './hooks';
12
12
  import { createInboxStore, type InboxStore } from './inbox-state';
13
+ import type { LiveActivitiesConfig } from './live-config';
13
14
  import { lazyNotificationsPanel } from './panel-lazy';
14
15
  import type { NotificationsPanelProps } from './panel';
15
16
  import { lazyPreferencesPage } from './page-lazy';
@@ -55,6 +56,15 @@ export interface NotificationsWebConfig {
55
56
  useSignal?: NotificationsSignalHook;
56
57
  /** The browser push enable step's host seams (SW path, platform hint). */
57
58
  webPush?: WebPushSetupConfig;
59
+ /**
60
+ * LIVE ACTIVITIES — the ongoing-state entries pinned above the inbox list.
61
+ *
62
+ * Opt-in, and absent means absent: a host that passes nothing gets the panel
63
+ * it had, with no section, no heading and no reserved space. See
64
+ * `./live-config` for the two things a host has to supply (where they come
65
+ * from, and what the section says) and `../live` for what one IS.
66
+ */
67
+ liveActivities?: LiveActivitiesConfig;
58
68
  }
59
69
 
60
70
  export interface WebNotifications {
@@ -110,7 +120,11 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
110
120
  const Bell: ComponentType<BellButtonProps> = (props) => (
111
121
  <BellButton {...props} store={store} messages={messages} {...subscribeOption} />
112
122
  );
113
- const Panel = lazyNotificationsPanel({ store, messages });
123
+ const Panel = lazyNotificationsPanel({
124
+ store,
125
+ messages,
126
+ ...(config.liveActivities ? { live: config.liveActivities } : {}),
127
+ });
114
128
 
115
129
  function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {
116
130
  return useUnreadCount(store, { ...options, ...subscribeOption });
@@ -44,6 +44,35 @@ export {
44
44
  type NotificationsSubscribe,
45
45
  } from './hooks';
46
46
 
47
+ // `LiveActivityCard` is deliberately NOT exported. It takes the clock as a
48
+ // prop, and the minute tick that produces one lives in `LiveSection` — so an
49
+ // external composer would either reimplement the tick or pass `Date.now()` once
50
+ // and get the frozen timestamp `relative-time.ts` was changed to prevent.
51
+ // `LiveSection` is the composable unit and carries its own clock.
52
+ export {
53
+ type LiveActivitiesConfig,
54
+ type LiveActivitiesHook,
55
+ type LiveActivityMessages,
56
+ } from './live-config';
57
+
58
+ /**
59
+ * The contract itself, re-exported from the entry a host WIRES through.
60
+ *
61
+ * It lives in the root, because both halves need it and neither owns it — but a
62
+ * host writing `useActivities` imports the seam from `./react` and then has to
63
+ * reach into a second entry for the one type that seam returns. Measured on the
64
+ * first adopter: a `LiveActivity` imported from here failed to compile with
65
+ * "declares 'LiveActivity' locally, but it is not exported", which is a
66
+ * confusing way to say "you wanted the other subpath".
67
+ */
68
+ export {
69
+ liveActivityLane,
70
+ type LiveActivity,
71
+ type LiveActivityLane,
72
+ type LiveActivityStep,
73
+ } from '../live';
74
+ export { LiveSection, type LiveSectionProps } from './live-section';
75
+
47
76
  export { relativeTime } from './relative-time';
48
77
 
49
78
  export {
@@ -0,0 +1,279 @@
1
+ /**
2
+ * ONE pinned live entry: a mark, what is happening, its lane, and when it last
3
+ * moved.
4
+ *
5
+ * Visually a WASH rather than a fill — a tinted card with a brand-tinted border
6
+ * — for the reason the inbox's unread row uses the same treatment: this sits at
7
+ * the top of a list of other people's news, and a saturated block there
8
+ * out-shouts everything it is supposed to be introducing.
9
+ *
10
+ * ## The card is a DIV, and the button is inside it
11
+ *
12
+ * The obvious shape — one `<button>` wrapping the whole card — is not
13
+ * available, because `Stepper` draws every stop as a real `<button>`
14
+ * (`@12-apps/ui`'s `StepButton` is `styled(Button)`), and `clickable={false}`
15
+ * only sets `pointer-events: none`. A button inside a button is invalid HTML:
16
+ * the parser auto-closes the outer one at the first nested one, so any host
17
+ * that server-renders the panel open hydrates against a tree the browser
18
+ * rewrote, and every adopter's dev console carries a React error besides.
19
+ *
20
+ * `aria-hidden` and `inert` on the lane fix the tab stops and the accessible
21
+ * name — they do NOT fix the nesting, and an earlier draft of this file claimed
22
+ * they did. So the tap target is the TEXT block, and the lane and the timestamp
23
+ * are its siblings: valid markup, and a target that still covers everything a
24
+ * reader would aim at.
25
+ */
26
+ import { useId, type JSX, type ReactNode } from 'react';
27
+
28
+ import { Stepper } from '@12-apps/ui/data-display/Stepper';
29
+ import { Box } from '@12-apps/ui/mui/Box';
30
+ import { alpha, type Theme } from '@12-apps/ui/mui/styles';
31
+ import { Text } from '@12-apps/ui/typography/Text';
32
+
33
+ import { liveActivityLane, type LiveActivity } from '../live';
34
+ import type { NotificationMessages } from '../messages';
35
+
36
+ import type { LiveActivitiesConfig, LiveActivityMessages } from './live-config';
37
+ import { relativeTime } from './relative-time';
38
+
39
+ const cardSx = {
40
+ // `relative`, so the button below can stretch a hit area over the whole card
41
+ // — see `targetSx`.
42
+ position: 'relative',
43
+ border: '1px solid',
44
+ borderColor: (t: Theme) => alpha(t.palette.primary.main, 0.35),
45
+ bgcolor: (t: Theme) => alpha(t.palette.primary.main, 0.06),
46
+ borderRadius: 1.5,
47
+ p: 1.25,
48
+ mb: 1,
49
+ } as const;
50
+
51
+ /** The text block: the mark, the heading and the sentence under it. */
52
+ const targetSx = {
53
+ display: 'flex',
54
+ alignItems: 'center',
55
+ gap: 1,
56
+ width: '100%',
57
+ textAlign: 'left',
58
+ font: 'inherit',
59
+ color: 'inherit',
60
+ border: 'none',
61
+ background: 'none',
62
+ p: 0,
63
+ } as const;
64
+
65
+ /**
66
+ * The button, stretched over the WHOLE card.
67
+ *
68
+ * Taking the lane out of the link fixed the markup and left the card looking
69
+ * like one target while only its top half was one — the lane is the most
70
+ * visually distinctive part of it, and aiming at the obvious thing did nothing.
71
+ *
72
+ * A stretched pseudo-element is the remedy that keeps the structure: the
73
+ * `<button>` stays a sibling of the lane in the tree, so nothing nests, and its
74
+ * `::after` covers the card. All three declarations are load-bearing — a
75
+ * pseudo-element with no `content` generates no box at all, and an absolutely
76
+ * positioned box with auto offsets is 0×0.
77
+ *
78
+ * ## What actually lets a click on the LANE reach it
79
+ *
80
+ * Not paint order. `@12-apps/ui` gives each `StepItem` `position: relative`, and
81
+ * this overlay is positioned too — so the two sit in the SAME painting layer
82
+ * (positioned, `z-index: auto`), where tree order decides, and the lane comes
83
+ * after the button. Every stop, and its label, therefore sits over this
84
+ * overlay. `clickable={false}` does not save it either: the package puts
85
+ * `pointer-events: none` on the step CIRCLE and not on the label beside it.
86
+ *
87
+ * It is `inert` on {@link ActivityLane} that does it: an inert subtree is
88
+ * skipped by hit-testing, so a click on a stop falls through to the overlay
89
+ * underneath. That makes the attribute load-bearing for the TARGET as well as
90
+ * for the tab order it was added for — remove it and the lane silently swallows
91
+ * clicks again, which is why the two are pinned by one test.
92
+ */
93
+ const stretchedSx = {
94
+ ...targetSx,
95
+ cursor: 'pointer',
96
+ '&::after': { content: '""', position: 'absolute', inset: 0 },
97
+ } as const;
98
+
99
+ /**
100
+ * Make a four-stop lane fit the panel.
101
+ *
102
+ * The drawer is 400px on a desktop and the full viewport on a phone, so the
103
+ * narrow case is ~320px of card minus its padding. `Stepper` renders its labels
104
+ * at `body2` for every size but `sm` and reserves 24px of connector plus 8px of
105
+ * margin on each side, which is more row than four short words have — measured
106
+ * on a 320px viewport, the last stop hung off the edge and the DRAWER scrolled
107
+ * sideways.
108
+ *
109
+ * Three overrides, each buying back a specific number of pixels: 11px labels, a
110
+ * step column allowed to shrink below the package's 44px floor (so the row's
111
+ * min-content width is the longest WORD rather than the longest phrase), and
112
+ * thinner connectors. `overflow: hidden` is the backstop and not the mechanism
113
+ * — a locale with longer words than any of this anticipates clips its own card
114
+ * instead of making the panel scroll.
115
+ */
116
+ const laneSx = {
117
+ pt: 1.25,
118
+ px: 0.5,
119
+ overflow: 'hidden',
120
+ '& .MuiTypography-root': { fontSize: 11, lineHeight: 1.25 },
121
+ '& [data-testid^="stepper-step-content-"]': { minWidth: 0 },
122
+ '& [data-testid^="stepper-connector-"]': { minWidth: 6, mx: 0.75 },
123
+ } as const;
124
+
125
+ /**
126
+ * The lane, or nothing.
127
+ *
128
+ * `aria-hidden` AND `inert`, and each earns its place twice over. The stops are
129
+ * real buttons, so leaving four focusable, named controls per entry in front of
130
+ * an inbox would cost a keyboard user the list they opened the panel for —
131
+ * that is what the pair was added for. `inert` then turns out to be what makes
132
+ * the card's own hit area work as well, because an inert subtree is skipped by
133
+ * hit-testing: see {@link stretchedSx}.
134
+ *
135
+ * Nothing is lost by hiding it — the stop the subject is at is already the
136
+ * card's heading, and the row of dots restates it visually.
137
+ */
138
+ function ActivityLane({ activity }: { activity: LiveActivity }): JSX.Element | null {
139
+ const lane = liveActivityLane(activity);
140
+ if (lane === null) return null;
141
+ return (
142
+ <Box sx={laneSx} aria-hidden inert>
143
+ <Stepper
144
+ steps={lane.steps.map((step) => ({ id: step.id, label: step.label }))}
145
+ activeId={lane.activeStepId}
146
+ completed={new Set(lane.completed)}
147
+ orientation="horizontal"
148
+ size="xs"
149
+ clickable={false}
150
+ data-testid={`live-activity-steps-${activity.id}`}
151
+ />
152
+ </Box>
153
+ );
154
+ }
155
+
156
+ /** The card's props. Not part of the package's surface — see `./index`. */
157
+ interface LiveActivityCardProps {
158
+ activity: LiveActivity;
159
+ messages: NotificationMessages;
160
+ live: LiveActivityMessages;
161
+ renderIcon?: LiveActivitiesConfig['renderIcon'];
162
+ /** The clock this render reads, so the "last moved" line can be ticked. */
163
+ now: number;
164
+ /**
165
+ * Follow the card's link.
166
+ *
167
+ * Absent — as it is for a host with no router — renders the text as text. A
168
+ * named, focusable control that does nothing is worse than no control.
169
+ */
170
+ onOpen?: (activity: LiveActivity) => void;
171
+ }
172
+
173
+ /**
174
+ * The mark on the left, when the host draws one.
175
+ *
176
+ * PRESENTATIONAL ONLY. It is rendered inside the card's `<button>` and inside
177
+ * an `aria-hidden` wrapper, so a host returning anything focusable — an
178
+ * icon-button, a link — puts a button inside a button (invalid HTML, and the
179
+ * defect this card was restructured to remove) and hides a focusable node from
180
+ * the accessibility tree. An icon, an emoji, an `<svg>`: yes. A control: no.
181
+ */
182
+ function ActivityIcon({ icon }: { icon: ReactNode }): JSX.Element | null {
183
+ if (icon === undefined || icon === null) return null;
184
+ return (
185
+ <Box aria-hidden sx={{ display: 'flex', flex: '0 0 auto', color: 'primary.main' }}>
186
+ {icon}
187
+ </Box>
188
+ );
189
+ }
190
+
191
+ /** The mark, the heading and the line under it. */
192
+ function ActivityTarget({
193
+ activity,
194
+ renderIcon,
195
+ bodyId,
196
+ }: Pick<LiveActivityCardProps, 'activity' | 'renderIcon'> & {
197
+ /** Ties the sentence to the button, so a label does not swallow it. */
198
+ bodyId: string;
199
+ }): JSX.Element {
200
+ return (
201
+ <>
202
+ <ActivityIcon icon={renderIcon?.(activity)} />
203
+ {/* A COLUMN, not a bare block: `Text` sets no `display`, so two adjacent
204
+ spans in an ordinary div run together on one line with not even a
205
+ space between them — which is how the heading and the sentence under
206
+ it ended up as one word in an earlier draft. `row.tsx` gets this right
207
+ the same way. */}
208
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 0 }}>
209
+ {/*
210
+ The live region is THIS LINE and nothing else. The card also carries a
211
+ relative timestamp that moves every minute for as long as the subject
212
+ lasts, and announcing that is a polite interruption per minute for
213
+ news the reader did not ask to be read. What is worth interrupting for
214
+ is the subject MOVING, which is what the heading says.
215
+ */}
216
+ <Text
217
+ variant="body"
218
+ size="sm"
219
+ weight="semibold"
220
+ as="span"
221
+ aria-live="polite"
222
+ data-testid={`live-activity-title-${activity.id}`}
223
+ >
224
+ {activity.title}
225
+ </Text>
226
+ {activity.body === null ? null : (
227
+ <Text id={bodyId} variant="caption" size="xs" color="secondary" as="span">
228
+ {activity.body}
229
+ </Text>
230
+ )}
231
+ </Box>
232
+ </>
233
+ );
234
+ }
235
+
236
+ export function LiveActivityCard({
237
+ activity,
238
+ messages,
239
+ live,
240
+ renderIcon,
241
+ now,
242
+ onOpen,
243
+ }: LiveActivityCardProps): JSX.Element {
244
+ const followable = activity.link !== null && onOpen !== undefined;
245
+ const bodyId = useId();
246
+ const target = (
247
+ <ActivityTarget
248
+ activity={activity}
249
+ bodyId={bodyId}
250
+ {...(renderIcon ? { renderIcon } : {})}
251
+ />
252
+ );
253
+ return (
254
+ <Box data-testid={`live-activity-${activity.id}`} sx={cardSx}>
255
+ {followable ? (
256
+ <Box
257
+ component="button"
258
+ type="button"
259
+ onClick={() => onOpen(activity)}
260
+ // `aria-label` REPLACES the contents, so the sentence under the
261
+ // heading — the detail that makes the heading actionable — would be
262
+ // announced to nobody. `aria-describedby` puts it back.
263
+ aria-label={live.openActivity(activity.title)}
264
+ {...(activity.body === null ? {} : { 'aria-describedby': bodyId })}
265
+ data-testid={`live-activity-open-${activity.id}`}
266
+ sx={stretchedSx}
267
+ >
268
+ {target}
269
+ </Box>
270
+ ) : (
271
+ <Box sx={targetSx}>{target}</Box>
272
+ )}
273
+ <ActivityLane activity={activity} />
274
+ <Text variant="caption" size="xs" color="secondary" as="span" italic>
275
+ {live.updated(relativeTime(activity.updatedAt, messages, now))}
276
+ </Text>
277
+ </Box>
278
+ );
279
+ }