@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,119 @@
1
+ import type { ChatMessage } from '../types';
2
+
3
+ export type TitleMode = 'rotate' | 'prefix';
4
+
5
+ export interface TitleRotatorOptions {
6
+ /** Snapshot taken on first `start()` if omitted. */
7
+ base?: string;
8
+ /**
9
+ * Build the alert string for the current unread state.
10
+ * Only used in `rotate` mode. Default: `"(N) Base"` — same shape as
11
+ * the prefix mode, so the two modes differ only in *whether* the
12
+ * title rotates, not in what it says.
13
+ */
14
+ template?: (count: number, latest?: ChatMessage | null, base?: string) => string;
15
+ /** Only used in `rotate` mode. Default 2000ms — gentle, not nervous. */
16
+ intervalMs?: number;
17
+ /**
18
+ * - `prefix` (default): render `"(N) Base"` once, no interval. The
19
+ * restrained Facebook-style cue: rely on the favicon dot for
20
+ * attention, keep the title readable.
21
+ * - `rotate`: swap between base and `template(...)` every
22
+ * `intervalMs`. Use when the host has no favicon (or the user
23
+ * pinned the tab and favicons are hidden).
24
+ */
25
+ mode?: TitleMode;
26
+ }
27
+
28
+ interface RotatorHandle {
29
+ start(count: number, latest?: ChatMessage | null): void;
30
+ update(count: number, latest?: ChatMessage | null): void;
31
+ stop(): void;
32
+ }
33
+
34
+ const DEFAULT_TEMPLATE = (count: number, _latest?: ChatMessage | null, base?: string) =>
35
+ base ? `(${count}) ${base}` : `(${count})`;
36
+
37
+ /**
38
+ * Mutates `document.title`. SSR-safe (returns no-op handle).
39
+ *
40
+ * Invariants:
41
+ * - `stop()` always restores the base title.
42
+ * - Multiple `start()` calls without an intervening `stop()` are
43
+ * equivalent to `update()`; we never stack timers.
44
+ * - In `rotate` mode the timer only ticks when the page is hidden
45
+ * (the caller is expected to gate this; see `useChatUnreadNotifier`).
46
+ */
47
+ export function createTitleRotator(opts: TitleRotatorOptions = {}): RotatorHandle {
48
+ if (typeof document === 'undefined') {
49
+ return { start() {}, update() {}, stop() {} };
50
+ }
51
+
52
+ const template = opts.template ?? DEFAULT_TEMPLATE;
53
+ const intervalMs = opts.intervalMs ?? 2000;
54
+ const mode: TitleMode = opts.mode ?? 'prefix';
55
+
56
+ let baseTitle: string | null = null;
57
+ let alertTitle = '';
58
+ let phase: 'base' | 'alert' = 'base';
59
+ let timer: ReturnType<typeof setInterval> | null = null;
60
+
61
+ const captureBaseOnce = () => {
62
+ if (baseTitle !== null) return;
63
+ baseTitle = opts.base ?? document.title;
64
+ };
65
+
66
+ const render = () => {
67
+ if (baseTitle === null) return;
68
+ document.title = phase === 'alert' ? alertTitle : baseTitle;
69
+ };
70
+
71
+ const tick = () => {
72
+ phase = phase === 'alert' ? 'base' : 'alert';
73
+ render();
74
+ };
75
+
76
+ const prefixTitle = (count: number) =>
77
+ baseTitle ? `(${count}) ${baseTitle}` : `(${count})`;
78
+
79
+ return {
80
+ start(count, latest) {
81
+ captureBaseOnce();
82
+ alertTitle = template(count, latest, baseTitle ?? undefined);
83
+
84
+ if (mode === 'prefix') {
85
+ document.title = prefixTitle(count);
86
+ return;
87
+ }
88
+
89
+ phase = 'alert';
90
+ render();
91
+ if (timer === null) {
92
+ timer = setInterval(tick, intervalMs);
93
+ }
94
+ },
95
+
96
+ update(count, latest) {
97
+ // Same path as start — base title is already captured, just refresh
98
+ // the alert string and re-render in case we're in alert phase.
99
+ captureBaseOnce();
100
+ alertTitle = template(count, latest, baseTitle ?? undefined);
101
+ if (mode === 'prefix') {
102
+ document.title = prefixTitle(count);
103
+ } else if (phase === 'alert') {
104
+ render();
105
+ }
106
+ },
107
+
108
+ stop() {
109
+ if (timer !== null) {
110
+ clearInterval(timer);
111
+ timer = null;
112
+ }
113
+ if (baseTitle !== null) {
114
+ document.title = baseTitle;
115
+ }
116
+ phase = 'base';
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,38 @@
1
+ import type { ChatMessage } from '../types';
2
+
3
+ /**
4
+ * Transport-agnostic surface for surfacing "you have new messages" to the
5
+ * user when they're not actively watching the chat.
6
+ *
7
+ * Two reference implementations ship with ui-tools:
8
+ * - `createBrowserNotifier` — Facebook-style document.title rotation +
9
+ * favicon badge, suitable for browser tabs.
10
+ * - `createNoopNotifier` — does nothing; default when no DOM is around
11
+ * and the safe pick inside Wails / Electron, where the host wires
12
+ * its own native dock-badge bridge instead.
13
+ *
14
+ * Hosts that want native badges (Wails dock, macOS notification dot)
15
+ * implement this interface themselves and pass the instance into
16
+ * `useChatUnreadNotifier({ notifier })`.
17
+ */
18
+ export interface ChatNotifier {
19
+ /**
20
+ * Called whenever the unread count is non-zero AND the page is not
21
+ * visible to the user. Implementations must be idempotent — they may
22
+ * be called repeatedly with the same count.
23
+ */
24
+ setUnread(count: number, latest?: ChatMessage | null): void;
25
+
26
+ /**
27
+ * Called when the user is back (page visible, or dock opened) OR when
28
+ * unread drops to zero. Implementations must fully restore any state
29
+ * they mutated (title, favicon).
30
+ */
31
+ clear(): void;
32
+
33
+ /**
34
+ * Optional: release any resources (DOM listeners, cached canvases).
35
+ * Called on hook unmount. Safe to omit.
36
+ */
37
+ dispose?(): void;
38
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Page-visibility helpers. SSR-safe — every entry point guards on
3
+ * `typeof document`.
4
+ *
5
+ * Why `hidden || !hasFocus` instead of just `hidden`:
6
+ * - `document.hidden` flips true only when the tab is fully
7
+ * backgrounded (other tab, minimised window).
8
+ * - A focused window in the foreground with the chat tab visible but
9
+ * the user typing into a different app still counts as "watching"
10
+ * for Chrome's purposes. We treat that as away too, so unread
11
+ * notifications kick in when you alt-tab to your editor.
12
+ *
13
+ * Inside Wails / Electron with a single window, `document.hidden` is
14
+ * always false and `document.hasFocus()` tracks the OS window — which
15
+ * is exactly what we want for a desktop dock badge if a host ever
16
+ * decides to reuse the browser notifier (most won't; they'll ship
17
+ * their own ChatNotifier).
18
+ */
19
+
20
+ export function isPageHidden(): boolean {
21
+ if (typeof document === 'undefined') return false;
22
+ if (document.hidden) return true;
23
+ if (typeof document.hasFocus === 'function' && !document.hasFocus()) return true;
24
+ return false;
25
+ }
26
+
27
+ /**
28
+ * Subscribe to visibility/focus changes. Returns an unsubscribe fn.
29
+ * No-op in SSR.
30
+ */
31
+ export function onVisibilityChange(cb: (hidden: boolean) => void): () => void {
32
+ if (typeof document === 'undefined') return () => {};
33
+
34
+ const fire = () => cb(isPageHidden());
35
+ document.addEventListener('visibilitychange', fire);
36
+ // `focus` / `blur` on window catch alt-tab-without-visibility-change
37
+ // (Chrome on macOS notably keeps `visibilityState === 'visible'` for
38
+ // background-but-on-screen tabs).
39
+ window.addEventListener('focus', fire);
40
+ window.addEventListener('blur', fire);
41
+
42
+ return () => {
43
+ document.removeEventListener('visibilitychange', fire);
44
+ window.removeEventListener('focus', fire);
45
+ window.removeEventListener('blur', fire);
46
+ };
47
+ }
@@ -156,8 +156,8 @@ export function ImageViewer({
156
156
  className={cn(
157
157
  'flex-1 h-full relative overflow-hidden outline-none',
158
158
  'bg-[length:16px_16px]',
159
- '[background-color:hsl(var(--muted)/0.2)]',
160
- '[background-image:linear-gradient(45deg,hsl(var(--muted)/0.4)_25%,transparent_25%),linear-gradient(-45deg,hsl(var(--muted)/0.4)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,hsl(var(--muted)/0.4)_75%),linear-gradient(-45deg,transparent_75%,hsl(var(--muted)/0.4)_75%)]',
159
+ '[background-color:color-mix(in_oklab,var(--muted)_20%,transparent)]',
160
+ '[background-image:linear-gradient(45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(-45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%),linear-gradient(-45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%)]',
161
161
  '[background-position:0_0,0_8px,8px_-8px,-8px_0px]'
162
162
  )}
163
163
  >
@@ -88,13 +88,13 @@
88
88
 
89
89
  /* Focus ring */
90
90
  .markdown-editor:focus-within {
91
- border-color: var(--color-ring, hsl(var(--ring, 0 0% 60%)));
92
- box-shadow: 0 0 0 2px var(--color-ring, hsl(var(--ring, 0 0% 60%) / 0.3));
91
+ border-color: var(--color-ring, var(--ring));
92
+ box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-ring, var(--ring)) 30%, transparent);
93
93
  }
94
94
 
95
95
  /* Mention inline chip */
96
96
  .markdown-mention {
97
- background: var(--color-primary, hsl(var(--primary)));
97
+ background: var(--color-primary, var(--primary));
98
98
  color: var(--color-primary-foreground, #fff);
99
99
  padding: 1px 6px;
100
100
  border-radius: 4px;
@@ -105,9 +105,9 @@
105
105
 
106
106
  /* Mention dropdown */
107
107
  .markdown-mention-list {
108
- background: var(--color-popover, hsl(var(--popover, 0 0% 8%)));
109
- color: var(--color-popover-foreground, hsl(var(--popover-foreground, 0 0% 98%)));
110
- border: 1px solid var(--color-border, hsl(var(--border, 0 0% 15%)));
108
+ background: var(--color-popover, var(--popover));
109
+ color: var(--color-popover-foreground, var(--popover-foreground));
110
+ border: 1px solid var(--color-border, var(--border));
111
111
  border-radius: 8px;
112
112
  padding: 4px;
113
113
  min-width: 220px;
@@ -133,7 +133,7 @@
133
133
 
134
134
  .markdown-mention-item:hover,
135
135
  .markdown-mention-item.selected {
136
- background: var(--color-muted, hsl(var(--muted, 0 0% 15%)));
136
+ background: var(--color-muted, var(--muted));
137
137
  }
138
138
 
139
139
  .markdown-mention-avatar {
@@ -15,7 +15,7 @@ interface UseMermaidRendererProps {
15
15
  }
16
16
 
17
17
  interface MermaidRenderResult {
18
- mermaidRef: React.RefObject<HTMLDivElement>;
18
+ mermaidRef: React.RefObject<HTMLDivElement | null>;
19
19
  svgContent: string;
20
20
  isVertical: boolean;
21
21
  isRendering: boolean;
@@ -223,7 +223,7 @@ export function countryFromTag(tag: string | null | undefined): string | null {
223
223
  const parts = tag.split('-');
224
224
  for (let i = parts.length - 1; i >= 0; i -= 1) {
225
225
  const p = parts[i];
226
- if (p.length === 2 && /^[A-Za-z]{2}$/.test(p)) return p.toUpperCase();
226
+ if (p && p.length === 2 && /^[A-Za-z]{2}$/.test(p)) return p.toUpperCase();
227
227
  }
228
228
  return null;
229
229
  }
@@ -31,7 +31,7 @@ export function useMicLevel(stream: MediaStream | null): number {
31
31
  const tick = (): void => {
32
32
  analyser.getFloatTimeDomainData(buf);
33
33
  let sum = 0;
34
- for (let i = 0; i < buf.length; i += 1) sum += buf[i] * buf[i];
34
+ for (let i = 0; i < buf.length; i += 1) sum += buf[i]! * buf[i]!;
35
35
  const rms = Math.sqrt(sum / buf.length);
36
36
  // soft compression so loud peaks don't dominate the meter
37
37
  setLevel(Math.min(1, rms * 2.5));
@@ -97,7 +97,7 @@ export function useSpeechLanguageInfo(): SpeechLanguageInfo {
97
97
  const found = findSpeechLanguage(tag);
98
98
  return {
99
99
  tag,
100
- iso: found?.language.iso ?? tag.split('-')[0].toLowerCase(),
100
+ iso: found?.language.iso ?? (tag.split('-')[0] ?? tag).toLowerCase(),
101
101
  country: countryFromTag(tag),
102
102
  name: found?.language.name ?? null,
103
103
  englishName: found?.language.englishName ?? null,