@12-apps/notifications 4.9.0 → 4.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/ADOPTING.md +60 -1
  2. package/README.md +73 -0
  3. package/dist/{chunk-BGI4BQ66.js → chunk-6W7INOYQ.js} +1 -1
  4. package/dist/chunk-6W7INOYQ.js.map +1 -0
  5. package/dist/{chunk-M5XTROYM.js → chunk-I5QUMTCN.js} +141 -17
  6. package/dist/chunk-I5QUMTCN.js.map +1 -0
  7. package/dist/chunk-RTURLH5U.js +33 -0
  8. package/dist/chunk-RTURLH5U.js.map +1 -0
  9. package/dist/{chunk-GGAZOLNN.js → chunk-SWOWHIFE.js} +2 -2
  10. package/dist/{chunk-KZG7QGFA.js → chunk-WVRODNXQ.js} +8 -4
  11. package/dist/chunk-WVRODNXQ.js.map +1 -0
  12. package/dist/{chunk-KU4FVOFA.js → chunk-ZIR3ILFH.js} +2 -2
  13. package/dist/chunk-ZY32PC34.js +231 -0
  14. package/dist/chunk-ZY32PC34.js.map +1 -0
  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-DV3Y8k7e.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-OPB3DBLJ.js} +116 -61
  31. package/dist/panel-OPB3DBLJ.js.map +1 -0
  32. package/dist/{preferences-screen-V3ZBWP7A.js → preferences-screen-SXUIGECY.js} +2 -2
  33. package/dist/react/index.d.ts +119 -7
  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/bell-button.tsx +112 -16
  45. package/src/react/create-web-notifications.tsx +40 -5
  46. package/src/react/index.ts +29 -0
  47. package/src/react/live-card.tsx +279 -0
  48. package/src/react/live-config.ts +79 -0
  49. package/src/react/live-section.tsx +179 -0
  50. package/src/react/live-seen.ts +138 -0
  51. package/src/react/panel-lazy.tsx +6 -0
  52. package/src/react/panel.tsx +144 -34
  53. package/src/react/relative-time.ts +14 -2
  54. package/src/server/transports/web-push.ts +22 -0
  55. package/src/types.ts +10 -1
  56. package/dist/chunk-BGI4BQ66.js.map +0 -1
  57. package/dist/chunk-KZG7QGFA.js.map +0 -1
  58. package/dist/chunk-M5XTROYM.js.map +0 -1
  59. package/dist/chunk-O5BVUXPO.js +0 -22
  60. package/dist/chunk-O5BVUXPO.js.map +0 -1
  61. package/dist/panel-UFXNO4AF.js.map +0 -1
  62. /package/dist/{chunk-GGAZOLNN.js.map → chunk-SWOWHIFE.js.map} +0 -0
  63. /package/dist/{chunk-KU4FVOFA.js.map → chunk-ZIR3ILFH.js.map} +0 -0
  64. /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
+ }
@@ -3,7 +3,7 @@
3
3
  * have a styled icon-button slot. A host with its own trigger chrome uses
4
4
  * `useUnreadCount` + `Panel` directly.
5
5
  */
6
- import type { JSX } from 'react';
6
+ import { useSyncExternalStore, type JSX } from 'react';
7
7
 
8
8
  import { Badge } from '@12-apps/ui/data-display/Badge';
9
9
  import { Box } from '@12-apps/ui/mui/Box';
@@ -12,6 +12,8 @@ import type { NotificationMessages } from '../messages';
12
12
 
13
13
  import { BellIcon } from './bell-icon';
14
14
  import { useUnreadCount, type NotificationsSignalHook, type NotificationsSubscribe } from './hooks';
15
+ import type { LiveActivitiesConfig } from './live-config';
16
+ import { hasUnseenActivity, type LiveSeenStore } from './live-seen';
15
17
  import type { InboxStore } from './inbox-state';
16
18
 
17
19
  const triggerSx = {
@@ -40,42 +42,136 @@ export interface BellButtonProps {
40
42
  enabled?: boolean;
41
43
  }
42
44
 
43
- export function BellButton({
45
+ /**
46
+ * The trigger itself, given a count and whether any of it is NEW.
47
+ *
48
+ * Presentational, and shared by both bells below, so the two can never drift on
49
+ * what the badge looks like — only on where the number comes from.
50
+ *
51
+ * ## The two tones
52
+ *
53
+ * `primary` says *something happened*; `neutral` says *something is present*. A
54
+ * live activity is the reason that distinction has to exist: it stays on the
55
+ * panel for as long as the thing is happening, so a bell that painted every
56
+ * live entry as new would be permanently red for a pedido the reader already
57
+ * looked at, and a bell that ignored them would say nothing at all while one
58
+ * was running. Grey keeps the count honest without spending attention twice.
59
+ */
60
+ function BellTrigger({
44
61
  onClick,
45
- enabled = true,
46
- store,
62
+ count,
63
+ hasNew,
47
64
  messages,
48
- subscribe,
49
- useSignal,
50
- }: BellButtonProps & {
51
- store: InboxStore;
65
+ }: {
66
+ onClick: () => void;
67
+ count: number;
68
+ hasNew: boolean;
52
69
  messages: NotificationMessages;
53
- subscribe?: NotificationsSubscribe;
54
- useSignal?: NotificationsSignalHook;
55
70
  }): JSX.Element {
56
- const count = useUnreadCount(store, {
57
- enabled,
58
- ...(subscribe ? { subscribe } : {}),
59
- ...(useSignal ? { useSignal } : {}),
60
- });
61
71
  return (
62
72
  <Box
63
73
  component="button"
64
74
  type="button"
65
75
  onClick={onClick}
76
+ // `openBellWithUnread` rather than a new message, and not for want of
77
+ // precision: `NotificationMessages` is REQUIRED of every host, so adding
78
+ // a field is a breaking change to a package several apps already mount.
79
+ // The sentence a host wrote for "you have N" is the sentence this wants.
66
80
  aria-label={count > 0 ? messages.openBellWithUnread(count) : messages.openBell}
67
81
  data-testid="notifications-bell"
68
82
  sx={triggerSx}
69
83
  >
70
84
  <Badge
71
85
  content={count > 0 ? count : undefined}
72
- color="primary"
86
+ color={hasNew ? 'primary' : 'neutral'}
73
87
  variant="count"
74
88
  max={99}
75
89
  data-testid="notifications-badge"
90
+ // The tone is carried by a colour, and a colour is not something a
91
+ // test can read — nor, on its own, a signal every reader can. This is
92
+ // what the tests assert on.
93
+ data-tone={hasNew ? 'new' : 'seen'}
76
94
  >
77
95
  <BellIcon size={28} />
78
96
  </Badge>
79
97
  </Box>
80
98
  );
81
99
  }
100
+
101
+ export function BellButton({
102
+ onClick,
103
+ enabled = true,
104
+ store,
105
+ messages,
106
+ subscribe,
107
+ useSignal,
108
+ }: BellButtonProps & {
109
+ store: InboxStore;
110
+ messages: NotificationMessages;
111
+ subscribe?: NotificationsSubscribe;
112
+ useSignal?: NotificationsSignalHook;
113
+ }): JSX.Element {
114
+ const count = useUnreadCount(store, {
115
+ enabled,
116
+ ...(subscribe ? { subscribe } : {}),
117
+ ...(useSignal ? { useSignal } : {}),
118
+ });
119
+ // No live config on this host: unread IS the whole count, and an unread row
120
+ // is by definition something the reader has not seen.
121
+ return <BellTrigger onClick={onClick} count={count} hasNew={count > 0} messages={messages} />;
122
+ }
123
+
124
+ /**
125
+ * The bell for a host that configured live activities.
126
+ *
127
+ * A SECOND component rather than a flag on the one above, because the host's
128
+ * `useActivities` is a hook: reading an optional config inside one component
129
+ * would mean calling it conditionally, which React reports as a crash in some
130
+ * unrelated component rather than here. The factory knows statically which host
131
+ * it is building for and picks one.
132
+ *
133
+ * ## What it costs the host, stated plainly
134
+ *
135
+ * The bell is mounted for as long as the app is, so unlike the panel's copy of
136
+ * this hook there is no "nobody is looking" state to stand down in — `active`
137
+ * is simply `enabled`. A host that answers by polling therefore polls for every
138
+ * signed-in reader whether or not they ever open the centre. That is the price
139
+ * of a badge that knows about live activities at all, and the reason to answer
140
+ * this hook from a pushed cache rather than from an interval.
141
+ */
142
+ export function LiveBellButton({
143
+ onClick,
144
+ enabled = true,
145
+ store,
146
+ messages,
147
+ subscribe,
148
+ useSignal,
149
+ live,
150
+ seen,
151
+ }: BellButtonProps & {
152
+ store: InboxStore;
153
+ messages: NotificationMessages;
154
+ subscribe?: NotificationsSubscribe;
155
+ useSignal?: NotificationsSignalHook;
156
+ live: LiveActivitiesConfig;
157
+ seen: LiveSeenStore;
158
+ }): JSX.Element {
159
+ const unread = useUnreadCount(store, {
160
+ enabled,
161
+ ...(subscribe ? { subscribe } : {}),
162
+ ...(useSignal ? { useSignal } : {}),
163
+ });
164
+ const activities = live.useActivities({ active: enabled });
165
+ const seenIso = useSyncExternalStore(seen.subscribe, seen.read, seen.read);
166
+ const liveCount = enabled ? activities.length : 0;
167
+ return (
168
+ <BellTrigger
169
+ onClick={onClick}
170
+ // A live entry counts. It is a notification — it is the one the reader
171
+ // most wants to know about — and the panel it opens lists it.
172
+ count={unread + liveCount}
173
+ hasNew={unread > 0 || (enabled && hasUnseenActivity(activities, seenIso))}
174
+ messages={messages}
175
+ />
176
+ );
177
+ }
@@ -3,13 +3,15 @@ import { useState, type ComponentType, type JSX } from 'react';
3
3
  import { messagesOf, type NotificationMessages } from '../messages';
4
4
 
5
5
  import { createNotificationsApiClient, type NotificationsApiClient } from './api';
6
- import { BellButton, type BellButtonProps } from './bell-button';
6
+ import { BellButton, LiveBellButton, type BellButtonProps } from './bell-button';
7
7
  import {
8
8
  useUnreadCount,
9
9
  type NotificationsSignalHook,
10
10
  type NotificationsSubscribe,
11
11
  } from './hooks';
12
12
  import { createInboxStore, type InboxStore } from './inbox-state';
13
+ import type { LiveActivitiesConfig } from './live-config';
14
+ import { createLiveSeenStore } from './live-seen';
13
15
  import { lazyNotificationsPanel } from './panel-lazy';
14
16
  import type { NotificationsPanelProps } from './panel';
15
17
  import { lazyPreferencesPage } from './page-lazy';
@@ -55,6 +57,15 @@ export interface NotificationsWebConfig {
55
57
  useSignal?: NotificationsSignalHook;
56
58
  /** The browser push enable step's host seams (SW path, platform hint). */
57
59
  webPush?: WebPushSetupConfig;
60
+ /**
61
+ * LIVE ACTIVITIES — the ongoing-state entries pinned above the inbox list.
62
+ *
63
+ * Opt-in, and absent means absent: a host that passes nothing gets the panel
64
+ * it had, with no section, no heading and no reserved space. See
65
+ * `./live-config` for the two things a host has to supply (where they come
66
+ * from, and what the section says) and `../live` for what one IS.
67
+ */
68
+ liveActivities?: LiveActivitiesConfig;
58
69
  }
59
70
 
60
71
  export interface WebNotifications {
@@ -107,10 +118,34 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
107
118
  ...(config.useSignal ? { useSignal: config.useSignal } : {}),
108
119
  };
109
120
 
110
- const Bell: ComponentType<BellButtonProps> = (props) => (
111
- <BellButton {...props} store={store} messages={messages} {...subscribeOption} />
112
- );
113
- const Panel = lazyNotificationsPanel({ store, messages });
121
+ // One store per factory, shared by the bell that READS it and the panel that
122
+ // WRITES it — the same arrangement as the inbox store above, and for the same
123
+ // reason: two independent copies would disagree about what the reader saw.
124
+ const liveSeen = createLiveSeenStore();
125
+
126
+ // Chosen ONCE, here, because `useActivities` is a hook and the choice must
127
+ // not be made per render: a bell that read an optional config inside itself
128
+ // would be calling a hook conditionally.
129
+ const live = config.liveActivities;
130
+ const Bell: ComponentType<BellButtonProps> = live
131
+ ? (props) => (
132
+ <LiveBellButton
133
+ {...props}
134
+ store={store}
135
+ messages={messages}
136
+ live={live}
137
+ seen={liveSeen}
138
+ {...subscribeOption}
139
+ />
140
+ )
141
+ : (props) => (
142
+ <BellButton {...props} store={store} messages={messages} {...subscribeOption} />
143
+ );
144
+ const Panel = lazyNotificationsPanel({
145
+ store,
146
+ messages,
147
+ ...(live ? { live, liveSeen } : {}),
148
+ });
114
149
 
115
150
  function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {
116
151
  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 {