@djangocfg/ui-tools 2.1.394 → 2.1.397

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.
@@ -0,0 +1,132 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type { ChatContextValue } from '../context';
4
+ import type { ChatDockPrefs } from '../hooks/useChatDockPrefs';
5
+
6
+ /**
7
+ * Declarative reset slot. The launcher renders the standard
8
+ * `<ChatHeaderResetButton>` wired to `onReset`, defaulting `onSuccess`
9
+ * to `ctx.clearMessages()`. The button hides itself until a `sessionId`
10
+ * exists on the chat context.
11
+ */
12
+ export interface ChatHeaderResetSlot {
13
+ /** Backend reset call. Resolves to `true` on success. */
14
+ onReset: () => Promise<boolean>;
15
+ /** Show a confirm dialog before calling `onReset`. @default true */
16
+ confirm?: boolean;
17
+ /** Confirm dialog title. */
18
+ confirmTitle?: string;
19
+ /** Confirm dialog message. */
20
+ confirmMessage?: string;
21
+ /** Override tooltip / aria label. */
22
+ ariaLabel?: string;
23
+ /**
24
+ * Called after a successful reset. Defaults to `ctx.clearMessages()`.
25
+ * Override to also re-fetch history, navigate, fire analytics, etc.
26
+ */
27
+ onSuccess?: (ctx: ChatContextValue) => void;
28
+ /** Called on failure (returned `false` or threw). */
29
+ onError?: (err?: unknown) => void;
30
+ }
31
+
32
+ /** Declarative language-picker slot. */
33
+ export interface ChatHeaderLanguageSlot {
34
+ /** Subset of BCP-47 tags to offer. */
35
+ allowedTags?: string[];
36
+ /** Override aria-label. */
37
+ ariaLabel?: string;
38
+ /** Hide the globe fallback icon when no flag resolves. */
39
+ hideFallbackIcon?: boolean;
40
+ }
41
+
42
+ /**
43
+ * Declarative mode-toggle slot. The launcher owns `useChatDockPrefs`
44
+ * internally — set `persistAs` to a localStorage key for persistence
45
+ * (omit for ephemeral / session-only mode toggling).
46
+ */
47
+ export interface ChatHeaderModeToggleSlot {
48
+ /**
49
+ * localStorage key for `useChatDockPrefs`. When provided the toggle
50
+ * persists across reloads; omit to use an ephemeral in-memory toggle.
51
+ */
52
+ persistAs?: string;
53
+ /** Override the default `{ mode: 'popover', side: 'right', sideWidth: 420 }`. */
54
+ defaults?: Partial<ChatDockPrefs>;
55
+ /** Show the toggle even on viewports below `lg` (1024px). @default true */
56
+ forceVisible?: boolean;
57
+ /** Tooltip / aria for popover → side. */
58
+ expandLabel?: string;
59
+ /** Tooltip / aria for side → popover. */
60
+ collapseLabel?: string;
61
+ }
62
+
63
+ /**
64
+ * Header buttons rendered INSIDE the `<ChatProvider>` mounted by
65
+ * `<ChatLauncher>`. Each entry is rendered in the fixed order:
66
+ *
67
+ * custom · languagePicker · modeToggle · audio · reset
68
+ *
69
+ * (close icon is always last, owned by `<ChatHeader>`).
70
+ *
71
+ * Slots that accept `boolean | object`:
72
+ * - `true` → render with default config
73
+ * - `false` → hide explicitly
74
+ * - object → render with the given options
75
+ *
76
+ * Defaults:
77
+ * - `audio`: rendered automatically when `audio` prop is passed to
78
+ * `<ChatLauncher>` AND the resolved instance is not silent.
79
+ * - `modeToggle`, `languagePicker`: off unless opted in.
80
+ * - `reset`: off unless `onReset` is provided.
81
+ */
82
+ export interface ChatHeaderSlots {
83
+ /** Auto-mute toggle. Defaults to true when launcher `audio` is configured. */
84
+ audio?: boolean;
85
+ /** Popover ↔ side mode toggle. */
86
+ modeToggle?: boolean | ChatHeaderModeToggleSlot;
87
+ /** Speech-recognition language picker. */
88
+ languagePicker?: boolean | ChatHeaderLanguageSlot;
89
+ /** Reset-conversation button. */
90
+ reset?: ChatHeaderResetSlot;
91
+ /** Arbitrary extra buttons rendered first (left-most). */
92
+ custom?: (ctx: ChatContextValue) => ReactNode;
93
+ }
94
+
95
+ /**
96
+ * Resolved mode-toggle config used by the launcher to pick between
97
+ * the dock's mode/side props and the prefs slot.
98
+ */
99
+ export interface ResolvedChatHeaderSlots {
100
+ audio: boolean;
101
+ modeToggle: ChatHeaderModeToggleSlot | null;
102
+ languagePicker: ChatHeaderLanguageSlot | null;
103
+ reset: ChatHeaderResetSlot | null;
104
+ custom: ((ctx: ChatContextValue) => ReactNode) | null;
105
+ }
106
+
107
+ export function resolveHeaderSlots(
108
+ slots: ChatHeaderSlots | undefined,
109
+ audioConfigured: boolean,
110
+ ): ResolvedChatHeaderSlots {
111
+ const s = slots ?? {};
112
+ const audio = s.audio ?? audioConfigured;
113
+ const modeToggle: ChatHeaderModeToggleSlot | null =
114
+ s.modeToggle === true
115
+ ? {}
116
+ : s.modeToggle && typeof s.modeToggle === 'object'
117
+ ? s.modeToggle
118
+ : null;
119
+ const languagePicker: ChatHeaderLanguageSlot | null =
120
+ s.languagePicker === true
121
+ ? {}
122
+ : s.languagePicker && typeof s.languagePicker === 'object'
123
+ ? s.languagePicker
124
+ : null;
125
+ return {
126
+ audio,
127
+ modeToggle,
128
+ languagePicker,
129
+ reset: s.reset ?? null,
130
+ custom: s.custom ?? null,
131
+ };
132
+ }
@@ -136,9 +136,11 @@ export {
136
136
  DEFAULT_DOCK_PREFS,
137
137
  useFocusOnEmptyClick,
138
138
  useChatUnread,
139
+ useChatUnreadNotifier,
139
140
  useChatLightbox,
140
141
  type UseChatUnreadOptions,
141
142
  type UseChatUnreadReturn,
143
+ type UseChatUnreadNotifierOptions,
142
144
  type UseChatConfig,
143
145
  type UseChatReturn,
144
146
  type UseChatComposerOptions,
@@ -171,6 +173,24 @@ export {
171
173
  type UseChatAudioReturn,
172
174
  } from './core/audio';
173
175
 
176
+ // Notifier — title rotation + favicon badge + cross-tab decorator. Pure
177
+ // browser-API code, no React, no UI components.
178
+ export {
179
+ createBrowserNotifier,
180
+ createNoopNotifier,
181
+ createTitleRotator,
182
+ createFaviconBadge,
183
+ createCrossTabNotifier,
184
+ isPageHidden,
185
+ onVisibilityChange,
186
+ type ChatNotifier,
187
+ type BrowserNotifierOptions,
188
+ type TitleRotatorOptions,
189
+ type TitleMode,
190
+ type FaviconBadgeOptions,
191
+ type CrossTabNotifierOptions,
192
+ } from './notifier';
193
+
174
194
  // Tool-call payload dispatcher — pure
175
195
  export {
176
196
  dispatchToolPayload,
@@ -269,6 +289,10 @@ export type {
269
289
  ChatLauncherProps,
270
290
  ChatLauncherHotkey,
271
291
  ChatLauncherGreeting,
292
+ ChatHeaderSlots,
293
+ ChatHeaderResetSlot,
294
+ ChatHeaderLanguageSlot,
295
+ ChatHeaderModeToggleSlot,
272
296
  ChatFABProps,
273
297
  ChatFABPosition,
274
298
  ChatFABVariant,
@@ -0,0 +1,64 @@
1
+ import type { ChatMessage } from '../types';
2
+
3
+ import { createFaviconBadge, type FaviconBadgeOptions } from './faviconBadge';
4
+ import { createTitleRotator, type TitleRotatorOptions } from './titleRotator';
5
+ import type { ChatNotifier } from './types';
6
+
7
+ export interface BrowserNotifierOptions {
8
+ /** Title rotation config. Pass `false` to disable title mutation. */
9
+ title?: TitleRotatorOptions | false;
10
+ /** Favicon badge config. Pass `false` to disable favicon mutation. */
11
+ favicon?: FaviconBadgeOptions | false;
12
+ }
13
+
14
+ const NOOP: ChatNotifier = { setUnread() {}, clear() {} };
15
+
16
+ /**
17
+ * Facebook-style unread notifier: alternates `document.title` between
18
+ * the base title and an alert, plus paints a small badge over the
19
+ * favicon. Both surfaces are optional and individually toggleable.
20
+ *
21
+ * Returns a no-op in SSR or non-DOM environments — safe to construct
22
+ * unconditionally.
23
+ *
24
+ * The notifier itself is **stateless w.r.t. visibility** by design;
25
+ * the hook layer (`useChatUnreadNotifier`) decides when to call
26
+ * `setUnread` vs `clear` based on page focus. Keeping the policy in
27
+ * the hook lets hosts swap in their own notifier (Wails dock badge,
28
+ * cross-tab Zustand broadcaster) without duplicating the gating logic.
29
+ */
30
+ export function createBrowserNotifier(opts: BrowserNotifierOptions = {}): ChatNotifier {
31
+ if (typeof document === 'undefined') return NOOP;
32
+
33
+ const title = opts.title === false ? null : createTitleRotator(opts.title);
34
+ const favicon = opts.favicon === false ? null : createFaviconBadge(opts.favicon);
35
+
36
+ let active = false;
37
+
38
+ return {
39
+ setUnread(count: number, latest?: ChatMessage | null) {
40
+ if (count <= 0) {
41
+ this.clear();
42
+ return;
43
+ }
44
+ if (active) {
45
+ title?.update(count, latest);
46
+ favicon?.set(count);
47
+ } else {
48
+ active = true;
49
+ title?.start(count, latest);
50
+ favicon?.set(count);
51
+ }
52
+ },
53
+ clear() {
54
+ if (!active) return;
55
+ active = false;
56
+ title?.stop();
57
+ favicon?.clear();
58
+ },
59
+ };
60
+ }
61
+
62
+ export function createNoopNotifier(): ChatNotifier {
63
+ return NOOP;
64
+ }
@@ -0,0 +1,99 @@
1
+ import type { ChatMessage } from '../types';
2
+
3
+ import type { ChatNotifier } from './types';
4
+
5
+ export interface CrossTabNotifierOptions {
6
+ /**
7
+ * The underlying notifier that performs the actual title/favicon
8
+ * mutation (usually `createBrowserNotifier()`). The decorator only
9
+ * forwards calls when this tab is the elected leader; followers stay
10
+ * silent so the title doesn't flicker in stereo across 4 open tabs.
11
+ */
12
+ inner: ChatNotifier;
13
+ /**
14
+ * Live "this tab is the leader" flag. Read from `useActiveTab` in
15
+ * the hook layer and passed in via the live-getter form so the
16
+ * notifier sees the latest value without React re-rendering it.
17
+ */
18
+ isLeader: () => boolean;
19
+ /**
20
+ * Optional broadcast channel name. Followers subscribe to count
21
+ * updates here so the in-tab badge UI (FAB counter) stays in sync
22
+ * even when they're not the one mutating the title.
23
+ *
24
+ * Default: `djangocfg-chat:unread`.
25
+ */
26
+ channel?: string;
27
+ /**
28
+ * Callback for follower tabs: fired when a peer (leader) reports an
29
+ * unread count update. Use it to drive a Zustand store that the FAB
30
+ * badge subscribes to.
31
+ */
32
+ onPeerUpdate?: (count: number) => void;
33
+ }
34
+
35
+ interface BroadcastPayload {
36
+ count: number;
37
+ }
38
+
39
+ const DEFAULT_CHANNEL = 'djangocfg-chat:unread';
40
+
41
+ /**
42
+ * Decorator over an inner notifier that adds cross-tab coordination.
43
+ *
44
+ * Behaviour:
45
+ * - Leader tab → forwards `setUnread/clear` to the inner notifier
46
+ * AND broadcasts the count so follower tabs can update their FAB
47
+ * badge UI.
48
+ * - Follower tab → does NOT call inner (silent on title/favicon),
49
+ * but still broadcasts via `onPeerUpdate` so the host store
50
+ * reflects the truth.
51
+ *
52
+ * When leadership flips at runtime (e.g. the previous leader closed
53
+ * its tab), the next `setUnread` call from the new leader will start
54
+ * mutating the title — there's no special handoff needed because the
55
+ * inner notifier is idempotent.
56
+ */
57
+ export function createCrossTabNotifier(opts: CrossTabNotifierOptions): ChatNotifier {
58
+ const { inner, isLeader, onPeerUpdate } = opts;
59
+ const channelName = opts.channel ?? DEFAULT_CHANNEL;
60
+
61
+ let channel: BroadcastChannel | null = null;
62
+ if (typeof BroadcastChannel !== 'undefined') {
63
+ channel = new BroadcastChannel(channelName);
64
+ if (onPeerUpdate) {
65
+ channel.addEventListener('message', (e) => {
66
+ const data = e.data as BroadcastPayload | undefined;
67
+ if (!data || typeof data.count !== 'number') return;
68
+ onPeerUpdate(data.count);
69
+ });
70
+ }
71
+ }
72
+
73
+ return {
74
+ setUnread(count: number, latest?: ChatMessage | null) {
75
+ // Broadcast first so followers learn the count even if our local
76
+ // inner notifier no-ops in this environment.
77
+ channel?.postMessage({ count } satisfies BroadcastPayload);
78
+ if (isLeader()) {
79
+ inner.setUnread(count, latest);
80
+ }
81
+ },
82
+ clear() {
83
+ channel?.postMessage({ count: 0 } satisfies BroadcastPayload);
84
+ if (isLeader()) {
85
+ inner.clear();
86
+ } else {
87
+ // Followers never armed the inner notifier, but call clear()
88
+ // anyway in case leadership flipped between an old setUnread
89
+ // and this clear — inner is idempotent.
90
+ inner.clear();
91
+ }
92
+ },
93
+ dispose() {
94
+ channel?.close();
95
+ channel = null;
96
+ inner.dispose?.();
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,280 @@
1
+ export interface FaviconBadgeOptions {
2
+ /**
3
+ * Badge fill color. Default Facebook-ish red.
4
+ */
5
+ badgeColor?: string;
6
+ /**
7
+ * Text/number color. Default white.
8
+ */
9
+ textColor?: string;
10
+ /**
11
+ * `true` → paint the count inside the badge (caps at 99+).
12
+ * `false` → flat dot. Default `false` — Facebook-style restraint.
13
+ */
14
+ showCount?: boolean;
15
+ /**
16
+ * Optional explicit base favicon URL. If omitted, we read the current
17
+ * `<link rel="icon">` href; if there is none, we paint onto a blank
18
+ * canvas so something useful still appears on the tab.
19
+ */
20
+ baseUrl?: string;
21
+ /**
22
+ * Canvas size in CSS px. 32 is the standard favicon hi-DPI size.
23
+ */
24
+ size?: number;
25
+ /**
26
+ * Pulse the badge by alternating between the badged frame and the
27
+ * bare base favicon. Default `true` — periphery-vision cue that
28
+ * makes the dot findable in a tab bar of dozens of icons. Set
29
+ * `false` for a static dot (quieter; matches enterprise tastes).
30
+ */
31
+ pulse?: boolean;
32
+ /**
33
+ * Milliseconds the badge stays visible per cycle. Default 600.
34
+ */
35
+ pulseOnMs?: number;
36
+ /**
37
+ * Milliseconds the badge is hidden per cycle. Default 400.
38
+ * Asymmetric on/off (default 600/400) gives a Slack/FB-style
39
+ * heartbeat — the badge is the dominant state, the gap is brief.
40
+ */
41
+ pulseOffMs?: number;
42
+ }
43
+
44
+ interface BadgeHandle {
45
+ set(count: number): void;
46
+ clear(): void;
47
+ }
48
+
49
+ const LINK_REL_VARIANTS = ['icon', 'shortcut icon'] as const;
50
+ const MANAGED_ATTR = 'data-chat-favicon-badge';
51
+
52
+ /**
53
+ * Paints a small badge over the page's favicon using a hidden canvas
54
+ * and swaps the resulting data URL into `<link rel="icon">`. SSR-safe.
55
+ *
56
+ * Notes / gotchas (encoded as invariants):
57
+ * - SVG favicons can't be rastered via `<img>` reliably across
58
+ * browsers without inlining. We attempt anyway; on failure we paint
59
+ * a blank background + the badge so the tab still signals unread.
60
+ * - We never mutate the host's original `<link>`. We add a managed
61
+ * `<link rel="icon" data-chat-favicon-badge>` ahead of it; on clear
62
+ * we remove that managed node. This is the cleanest way to handle
63
+ * hosts that swap their own favicons (e.g. Next.js dynamic icons).
64
+ * - Cross-origin favicons need CORS to draw; if drawing throws we
65
+ * fall back to a CORS-less canvas (badge only, no base image).
66
+ */
67
+ export function createFaviconBadge(opts: FaviconBadgeOptions = {}): BadgeHandle {
68
+ if (typeof document === 'undefined') {
69
+ return { set() {}, clear() {} };
70
+ }
71
+
72
+ const badgeColor = opts.badgeColor ?? '#ef4444';
73
+ const textColor = opts.textColor ?? '#ffffff';
74
+ const showCount = opts.showCount ?? false;
75
+ const size = opts.size ?? 32;
76
+ const pulse = opts.pulse ?? true;
77
+ const pulseOnMs = opts.pulseOnMs ?? 600;
78
+ const pulseOffMs = opts.pulseOffMs ?? 400;
79
+
80
+ let baseImg: HTMLImageElement | null = null;
81
+ let baseLoaded = false;
82
+ let lastCount = -1;
83
+
84
+ // Pulse animation state.
85
+ let frameOn: string | null = null; // badged frame data URL
86
+ let frameOff: string | null = null; // bare base frame data URL (or empty)
87
+ let pulseTimer: ReturnType<typeof setTimeout> | null = null;
88
+ let pulsePhase: 'on' | 'off' = 'on';
89
+
90
+ const detectBaseUrl = (): string | null => {
91
+ if (opts.baseUrl) return opts.baseUrl;
92
+ for (const rel of LINK_REL_VARIANTS) {
93
+ const link = document.querySelector<HTMLLinkElement>(
94
+ `link[rel="${rel}"]:not([${MANAGED_ATTR}])`,
95
+ );
96
+ if (link?.href) return link.href;
97
+ }
98
+ return null;
99
+ };
100
+
101
+ const loadBase = () => {
102
+ if (baseImg) return;
103
+ const url = detectBaseUrl();
104
+ if (!url) return;
105
+ const img = new Image();
106
+ img.crossOrigin = 'anonymous';
107
+ img.onload = () => {
108
+ baseLoaded = true;
109
+ // If a `set()` came in before load completed, re-render now.
110
+ if (lastCount >= 0) renderFrames(lastCount);
111
+ };
112
+ img.onerror = () => {
113
+ // Network/CORS failure: leave baseLoaded=false; we'll paint badge
114
+ // on a blank canvas.
115
+ baseLoaded = false;
116
+ };
117
+ img.src = url;
118
+ baseImg = img;
119
+ };
120
+
121
+ const removeManagedLinks = () => {
122
+ document
123
+ .querySelectorAll(`link[${MANAGED_ATTR}]`)
124
+ .forEach((node) => node.remove());
125
+ };
126
+
127
+ const applyDataUrl = (dataUrl: string) => {
128
+ removeManagedLinks();
129
+ const link = document.createElement('link');
130
+ link.rel = 'icon';
131
+ link.type = 'image/png';
132
+ link.href = dataUrl;
133
+ link.setAttribute(MANAGED_ATTR, 'true');
134
+ document.head.appendChild(link);
135
+ };
136
+
137
+ // Paint the off-phase frame. If we have a usable base favicon, this
138
+ // is the bare base; otherwise a transparent canvas. Returning a
139
+ // transparent frame (rather than `null` + removing the link) is
140
+ // load-bearing: Chrome only re-rasters the tab favicon when the
141
+ // managed `<link>`'s href *changes*, so off-phase MUST swap to a
142
+ // different data URL — not vanish — for the pulse to be visible.
143
+ const renderOffFrame = (): string | null => {
144
+ const canvas = document.createElement('canvas');
145
+ canvas.width = size;
146
+ canvas.height = size;
147
+ const ctx = canvas.getContext('2d');
148
+ if (!ctx) return null;
149
+
150
+ if (baseImg && baseLoaded) {
151
+ try {
152
+ ctx.drawImage(baseImg, 0, 0, size, size);
153
+ } catch {
154
+ // Tainted canvas → fall through to the empty frame.
155
+ ctx.clearRect(0, 0, size, size);
156
+ }
157
+ }
158
+ // If no base, the canvas stays fully transparent — Chrome falls
159
+ // back to its default tab glyph, which still gives a visible
160
+ // on/off contrast against the red badge.
161
+ try {
162
+ return canvas.toDataURL('image/png');
163
+ } catch {
164
+ return null;
165
+ }
166
+ };
167
+
168
+ // Paint the badged frame. Reused for both the static (no-pulse) case
169
+ // and the `on` phase of the pulse loop.
170
+ const renderBadged = (count: number): string | null => {
171
+ const canvas = document.createElement('canvas');
172
+ canvas.width = size;
173
+ canvas.height = size;
174
+ const ctx = canvas.getContext('2d');
175
+ if (!ctx) return null;
176
+
177
+ if (baseImg && baseLoaded) {
178
+ try {
179
+ ctx.drawImage(baseImg, 0, 0, size, size);
180
+ } catch {
181
+ ctx.clearRect(0, 0, size, size);
182
+ }
183
+ }
184
+
185
+ const r = size * 0.28;
186
+ const cx = size - r - size * 0.04;
187
+ const cy = size - r - size * 0.04;
188
+
189
+ ctx.fillStyle = badgeColor;
190
+ ctx.beginPath();
191
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
192
+ ctx.fill();
193
+
194
+ if (showCount) {
195
+ const label = count > 99 ? '99+' : String(count);
196
+ ctx.fillStyle = textColor;
197
+ const fontSize = label.length >= 3 ? r * 0.85 : r * 1.25;
198
+ ctx.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif`;
199
+ ctx.textAlign = 'center';
200
+ ctx.textBaseline = 'middle';
201
+ ctx.fillText(label, cx, cy + fontSize * 0.06);
202
+ }
203
+
204
+ try {
205
+ return canvas.toDataURL('image/png');
206
+ } catch {
207
+ // Tainted-canvas fallback: badge only, no base.
208
+ const fb = document.createElement('canvas');
209
+ fb.width = size;
210
+ fb.height = size;
211
+ const fctx = fb.getContext('2d');
212
+ if (!fctx) return null;
213
+ fctx.fillStyle = badgeColor;
214
+ fctx.beginPath();
215
+ fctx.arc(cx, cy, r, 0, Math.PI * 2);
216
+ fctx.fill();
217
+ return fb.toDataURL('image/png');
218
+ }
219
+ };
220
+
221
+ const stopPulse = () => {
222
+ if (pulseTimer !== null) {
223
+ clearTimeout(pulseTimer);
224
+ pulseTimer = null;
225
+ }
226
+ };
227
+
228
+ const tickPulse = () => {
229
+ if (frameOn === null) return;
230
+ // Always swap the managed <link>'s href between two distinct data
231
+ // URLs — Chrome / Safari skip the redraw if the href is identical
232
+ // to what was just set, so removing-and-readding the node isn't
233
+ // enough on its own (`renderOffFrame` guarantees a non-null off
234
+ // frame whenever frameOn exists).
235
+ if (pulsePhase === 'on') {
236
+ applyDataUrl(frameOn);
237
+ pulsePhase = 'off';
238
+ pulseTimer = setTimeout(tickPulse, pulseOnMs);
239
+ } else {
240
+ applyDataUrl(frameOff ?? frameOn);
241
+ pulsePhase = 'on';
242
+ pulseTimer = setTimeout(tickPulse, pulseOffMs);
243
+ }
244
+ };
245
+
246
+ const renderFrames = (count: number) => {
247
+ frameOn = renderBadged(count);
248
+ frameOff = renderOffFrame();
249
+
250
+ if (frameOn === null) return;
251
+
252
+ if (!pulse) {
253
+ applyDataUrl(frameOn);
254
+ return;
255
+ }
256
+
257
+ // (Re-)start the pulse loop. If a previous loop was running we
258
+ // tear it down first so the cadence doesn't double up.
259
+ stopPulse();
260
+ pulsePhase = 'on';
261
+ tickPulse();
262
+ };
263
+
264
+ return {
265
+ set(count) {
266
+ lastCount = count;
267
+ loadBase();
268
+ // If base not loaded yet, the onload handler will call
269
+ // renderFrames(lastCount) once it resolves.
270
+ renderFrames(count);
271
+ },
272
+ clear() {
273
+ lastCount = -1;
274
+ stopPulse();
275
+ frameOn = null;
276
+ frameOff = null;
277
+ removeManagedLinks();
278
+ },
279
+ };
280
+ }
@@ -0,0 +1,20 @@
1
+ export type { ChatNotifier } from './types';
2
+ export {
3
+ createBrowserNotifier,
4
+ createNoopNotifier,
5
+ type BrowserNotifierOptions,
6
+ } from './createBrowserNotifier';
7
+ export {
8
+ createTitleRotator,
9
+ type TitleRotatorOptions,
10
+ type TitleMode,
11
+ } from './titleRotator';
12
+ export {
13
+ createFaviconBadge,
14
+ type FaviconBadgeOptions,
15
+ } from './faviconBadge';
16
+ export { isPageHidden, onVisibilityChange } from './visibility';
17
+ export {
18
+ createCrossTabNotifier,
19
+ type CrossTabNotifierOptions,
20
+ } from './createCrossTabNotifier';