@djangocfg/ui-tools 2.1.393 → 2.1.395

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,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';
@@ -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 {
@@ -67,3 +67,9 @@ export const LazyPrettyCode = createLazyComponent<PrettyCodeProps>(
67
67
  fallback: <CodeLoadingFallback />,
68
68
  }
69
69
  );
70
+
71
+ // `PrettyCode` is the historical named export — same component as
72
+ // `LazyPrettyCode`, kept for backwards compatibility with callers that
73
+ // imported it from the old root barrel.
74
+ export { LazyPrettyCode as PrettyCode };
75
+ export { default } from './index';