@djangocfg/ui-core 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.
Files changed (34) hide show
  1. package/README.md +14 -0
  2. package/package.json +4 -4
  3. package/src/components/effects/GlowBackground.tsx +1 -1
  4. package/src/components/navigation/sidebar/sidebar.tsx +4 -4
  5. package/src/hooks/index.ts +1 -0
  6. package/src/hooks/tabs/activeTabStore.ts +167 -0
  7. package/src/hooks/tabs/index.ts +17 -0
  8. package/src/hooks/tabs/tabId.ts +44 -0
  9. package/src/hooks/tabs/useActiveTab.ts +64 -0
  10. package/src/styles/README.md +149 -123
  11. package/src/styles/base.css +3 -3
  12. package/src/styles/presets/presets.ts +0 -9
  13. package/src/styles/presets/themes/dense.ts +10 -10
  14. package/src/styles/presets/themes/django-cfg.ts +13 -13
  15. package/src/styles/presets/themes/high-contrast.ts +11 -11
  16. package/src/styles/presets/themes/index.ts +0 -18
  17. package/src/styles/presets/themes/ios.ts +64 -64
  18. package/src/styles/presets/themes/macos.ts +64 -64
  19. package/src/styles/presets/themes/soft.ts +25 -25
  20. package/src/styles/presets/themes/windows.ts +64 -64
  21. package/src/styles/presets/types.ts +26 -12
  22. package/src/styles/theme/dark.css +47 -53
  23. package/src/styles/theme/light.css +47 -53
  24. package/src/styles/theme/tokens.css +128 -113
  25. package/src/styles/utilities.css +44 -6
  26. package/src/styles/presets/themes/catppuccin.ts +0 -38
  27. package/src/styles/presets/themes/dracula.ts +0 -38
  28. package/src/styles/presets/themes/github.ts +0 -38
  29. package/src/styles/presets/themes/gruvbox.ts +0 -38
  30. package/src/styles/presets/themes/material.ts +0 -38
  31. package/src/styles/presets/themes/nord.ts +0 -38
  32. package/src/styles/presets/themes/one-dark.ts +0 -38
  33. package/src/styles/presets/themes/solarized.ts +0 -38
  34. package/src/styles/presets/themes/tokyo-night.ts +0 -38
package/README.md CHANGED
@@ -76,6 +76,7 @@ import { useNavigate, useLocation, useQueryParams, useRouter, useIsActive } from
76
76
  | **Theme** | `useThemeColor`, `useThemePalette` (palette-aware hex colors for Canvas/SVG) |
77
77
  | **Hotkey** | `useHotkey` (smart `inInput` + `preventDefault` policy), `useHotkeyChord` (sequences), `formatHotkey('mod+k') → ⌘K`, `useHotkeyHelp` (auto cheat-sheet) |
78
78
  | **Audio** | `createSoundBus`, `useNotificationSounds`, `useAudioPrefs`, `useSoundEffect` — Safari unlock, mute persist, per-event toggles + volume scale, native-host bridge |
79
+ | **Tabs** | `useActiveTab`, `useIsTabActive`, `useIsTabLeader` — cross-tab focus + leader election via BroadcastChannel |
79
80
  | **Feedback** | `useToast`, `toast` (Sonner) |
80
81
  | **Debug** | `useDebugTools` |
81
82
 
@@ -170,6 +171,19 @@ Lower level: `createSoundBus<E>({ sounds, getMuted, getVolume, isEnabled })` exp
170
171
 
171
172
  For an opinionated, fully-wired example with bundled audio assets see `useChatAudio` in [`@djangocfg/ui-tools`](../../ui-tools/src/tools/Chat/README.md#audio) — it inlines six notification mp3s into the lazy chat chunk as `data:`-URLs so consumers need no asset setup.
172
173
 
174
+ ## Cross-tab coordination
175
+
176
+ ```tsx
177
+ import { useActiveTab, useIsTabLeader } from '@djangocfg/ui-core/hooks';
178
+
179
+ const { isActive, isLeader, tabId } = useActiveTab();
180
+ // isActive — this tab has user focus (visibilitychange + focus/blur)
181
+ // isLeader — elected leader among open tabs (stable; oldest tab wins)
182
+ // tabId — stable per-tab id (sessionStorage, survives in-tab reload)
183
+ ```
184
+
185
+ Single shared coordinator over `BroadcastChannel` — leader election runs locally on every tab from the same peer-set view, no server. Use the leader flag to dedupe side-effects across tabs: only the leader mutates `document.title` / favicon, holds the websocket, plays a sound; followers stay silent but still read state. Zustand-backed, SSR-safe, single-tab fallback when `BroadcastChannel` is unavailable.
186
+
173
187
  ## Schema-driven configurators
174
188
 
175
189
  `@djangocfg/ui-core/lib` exports a portable JSON Schema 7 subset (`CustomJsonSchema7`, `CustomJsonUiSchema7`, `CustomJsonUiGroup`, `CustomJsonUiDisabledWhenRule`) for packages that ship configurator schemas without taking a runtime dependency on RJSF.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.394",
3
+ "version": "2.1.397",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -90,7 +90,7 @@
90
90
  "check": "tsc --noEmit"
91
91
  },
92
92
  "peerDependencies": {
93
- "@djangocfg/i18n": "^2.1.394",
93
+ "@djangocfg/i18n": "^2.1.397",
94
94
  "consola": "^3.4.2",
95
95
  "lucide-react": "^0.545.0",
96
96
  "moment": "^2.30.1",
@@ -160,8 +160,8 @@
160
160
  "vaul": "1.1.2"
161
161
  },
162
162
  "devDependencies": {
163
- "@djangocfg/i18n": "^2.1.394",
164
- "@djangocfg/typescript-config": "^2.1.394",
163
+ "@djangocfg/i18n": "^2.1.397",
164
+ "@djangocfg/typescript-config": "^2.1.397",
165
165
  "@types/node": "^24.7.2",
166
166
  "@types/react": "^19.1.0",
167
167
  "@types/react-dom": "^19.1.0",
@@ -40,7 +40,7 @@ export const GlowBackground = React.memo(({ className }: GlowBackgroundProps) =>
40
40
  width: 700,
41
41
  height: 700,
42
42
  borderRadius: '50%',
43
- background: 'radial-gradient(circle, hsl(var(--primary) / 0.45) 0%, transparent 70%)',
43
+ background: 'radial-gradient(circle, color-mix(in oklab, var(--primary) 45%, transparent) 0%, transparent 70%)',
44
44
  filter: 'blur(80px)',
45
45
  animation: 'blob 10s ease-in-out infinite',
46
46
  }} />
@@ -201,7 +201,7 @@ const Sidebar = React.forwardRef<
201
201
  )}
202
202
  style={
203
203
  {
204
- backgroundColor: "hsl(var(--sidebar-background))",
204
+ backgroundColor: "var(--sidebar-background)",
205
205
  } as React.CSSProperties
206
206
  }
207
207
  >
@@ -289,8 +289,8 @@ const Sidebar = React.forwardRef<
289
289
  aria-hidden
290
290
  className={cn(
291
291
  "pointer-events-none absolute inset-y-0 right-0 z-[1] w-px",
292
- "bg-[linear-gradient(180deg,hsl(var(--sidebar-border)_/_0.02)_0%,hsl(var(--sidebar-border)_/_0.22)_18%,hsl(var(--sidebar-border)_/_0.4)_50%,hsl(var(--sidebar-border)_/_0.2)_82%,hsl(var(--sidebar-border)_/_0.03)_100%)]",
293
- "dark:bg-[linear-gradient(180deg,hsl(var(--sidebar-border)_/_0.08)_0%,hsl(var(--sidebar-border)_/_0.34)_22%,hsl(var(--sidebar-border)_/_0.55)_50%,hsl(var(--sidebar-border)_/_0.28)_78%,hsl(var(--sidebar-border)_/_0.06)_100%)]"
292
+ "bg-[linear-gradient(180deg,color-mix(in_oklab,var(--sidebar-border)_2%,transparent)_0%,color-mix(in_oklab,var(--sidebar-border)_22%,transparent)_18%,color-mix(in_oklab,var(--sidebar-border)_40%,transparent)_50%,color-mix(in_oklab,var(--sidebar-border)_20%,transparent)_82%,color-mix(in_oklab,var(--sidebar-border)_3%,transparent)_100%)]",
293
+ "dark:bg-[linear-gradient(180deg,color-mix(in_oklab,var(--sidebar-border)_8%,transparent)_0%,color-mix(in_oklab,var(--sidebar-border)_34%,transparent)_22%,color-mix(in_oklab,var(--sidebar-border)_55%,transparent)_50%,color-mix(in_oklab,var(--sidebar-border)_28%,transparent)_78%,color-mix(in_oklab,var(--sidebar-border)_6%,transparent)_100%)]"
294
294
  )}
295
295
  />
296
296
  )}
@@ -610,7 +610,7 @@ const sidebarMenuButtonVariants = cva(
610
610
  variant: {
611
611
  default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
612
612
  outline:
613
- "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
613
+ "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
614
614
  },
615
615
  size: {
616
616
  default: "h-8 text-sm",
@@ -21,6 +21,7 @@ export * from './events';
21
21
  export * from './hotkey';
22
22
  export * from './audio';
23
23
  export * from './debug';
24
+ export * from './tabs';
24
25
 
25
26
  // ----------------------------------------------------------------------------
26
27
  // Router — framework-agnostic navigation primitives.
@@ -0,0 +1,167 @@
1
+ 'use client';
2
+
3
+ import { create } from 'zustand';
4
+
5
+ import { getTabId } from './tabId';
6
+
7
+ /**
8
+ * Cross-tab coordination store. Singleton, lazily initialised on the
9
+ * first hook subscription.
10
+ *
11
+ * Two orthogonal concepts:
12
+ * - `isActive` — this tab has user focus right now (visibilitychange
13
+ * + focus/blur).
14
+ * - `isLeader` — among open tabs of this app, this tab won the
15
+ * election. Leadership is stable; it only changes when the current
16
+ * leader closes/refreshes.
17
+ *
18
+ * Election protocol over a single BroadcastChannel:
19
+ * on mount:
20
+ * - announce HELLO with our tabId
21
+ * - listen for HELLO/CLAIM/BYE
22
+ * on HELLO from a peer:
23
+ * - if we think we're leader, reply CLAIM (so the newcomer learns
24
+ * the current leader without forcing a re-election)
25
+ * on CLAIM from a peer:
26
+ * - record their tabId in the peer set; the smallest tabId wins
27
+ * (tabIds are time-prefixed so smallest == oldest)
28
+ * on BYE (beforeunload):
29
+ * - remove peer; if it was the leader, re-elect locally
30
+ *
31
+ * Leader election runs locally on each tab from the same peer-set view,
32
+ * so all tabs converge on the same answer without a coordinator.
33
+ */
34
+
35
+ export type ActiveTabMessage =
36
+ | { type: 'hello'; tabId: string }
37
+ | { type: 'claim'; tabId: string }
38
+ | { type: 'bye'; tabId: string };
39
+
40
+ interface ActiveTabState {
41
+ /** Stable id for the current tab. */
42
+ tabId: string;
43
+ /** This tab has user focus. */
44
+ isActive: boolean;
45
+ /** This tab is the elected leader among open tabs. */
46
+ isLeader: boolean;
47
+ /** Currently elected leader id (may be this tab or another). */
48
+ leaderId: string;
49
+ /** All tabs we've seen, including ourselves. */
50
+ peers: string[];
51
+ }
52
+
53
+ const CHANNEL_NAME = 'djangocfg:active-tab';
54
+
55
+ const initialId = getTabId();
56
+
57
+ export const useActiveTabStore = create<ActiveTabState>(() => ({
58
+ tabId: initialId,
59
+ isActive: typeof document !== 'undefined' ? !document.hidden : true,
60
+ isLeader: true, // Optimistic — corrected by election on first peer HELLO.
61
+ leaderId: initialId,
62
+ peers: [initialId],
63
+ }));
64
+
65
+ let initialised = false;
66
+ let channel: BroadcastChannel | null = null;
67
+ let visibilityTeardown: (() => void) | null = null;
68
+
69
+ function elect(peers: string[]): string {
70
+ // Smallest tabId wins. tabIds are time-prefixed; smallest == oldest.
71
+ return peers.slice().sort()[0]!;
72
+ }
73
+
74
+ function applyPeers(nextPeers: string[]) {
75
+ const me = useActiveTabStore.getState().tabId;
76
+ const peers = Array.from(new Set([me, ...nextPeers])).sort();
77
+ const leaderId = elect(peers);
78
+ useActiveTabStore.setState({
79
+ peers,
80
+ leaderId,
81
+ isLeader: leaderId === me,
82
+ });
83
+ }
84
+
85
+ function post(msg: ActiveTabMessage) {
86
+ channel?.postMessage(msg);
87
+ }
88
+
89
+ function setupVisibility(): () => void {
90
+ if (typeof document === 'undefined') return () => {};
91
+
92
+ const sync = () => {
93
+ const hidden = document.hidden || (
94
+ typeof document.hasFocus === 'function' && !document.hasFocus()
95
+ );
96
+ useActiveTabStore.setState({ isActive: !hidden });
97
+ };
98
+
99
+ sync();
100
+ document.addEventListener('visibilitychange', sync);
101
+ window.addEventListener('focus', sync);
102
+ window.addEventListener('blur', sync);
103
+
104
+ return () => {
105
+ document.removeEventListener('visibilitychange', sync);
106
+ window.removeEventListener('focus', sync);
107
+ window.removeEventListener('blur', sync);
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Idempotent — first call wires up the channel and listeners, later
113
+ * calls are no-ops. Called automatically by `useActiveTab` on mount.
114
+ * Exposed for hosts that want to warm the coordinator before any hook
115
+ * subscribes (e.g. inside an app shell).
116
+ */
117
+ export function ensureActiveTabCoordinator(): void {
118
+ if (initialised) return;
119
+ initialised = true;
120
+
121
+ visibilityTeardown = setupVisibility();
122
+
123
+ if (typeof BroadcastChannel === 'undefined') {
124
+ // Single-tab fallback: we are trivially the leader.
125
+ return;
126
+ }
127
+
128
+ channel = new BroadcastChannel(CHANNEL_NAME);
129
+ const me = useActiveTabStore.getState().tabId;
130
+
131
+ channel.addEventListener('message', (e) => {
132
+ const msg = e.data as ActiveTabMessage | undefined;
133
+ if (!msg || typeof msg !== 'object') return;
134
+
135
+ const { peers: currentPeers } = useActiveTabStore.getState();
136
+
137
+ if (msg.type === 'hello') {
138
+ // Acknowledge the newcomer with a CLAIM so they learn about us.
139
+ applyPeers([...currentPeers, msg.tabId]);
140
+ post({ type: 'claim', tabId: me });
141
+ } else if (msg.type === 'claim') {
142
+ applyPeers([...currentPeers, msg.tabId]);
143
+ } else if (msg.type === 'bye') {
144
+ applyPeers(currentPeers.filter((id) => id !== msg.tabId));
145
+ }
146
+ });
147
+
148
+ // Announce ourselves.
149
+ post({ type: 'hello', tabId: me });
150
+
151
+ const onUnload = () => post({ type: 'bye', tabId: me });
152
+ window.addEventListener('beforeunload', onUnload);
153
+ window.addEventListener('pagehide', onUnload);
154
+ }
155
+
156
+ /**
157
+ * Tear down everything (channel, listeners). Tests only — production
158
+ * tabs live and die with the page.
159
+ */
160
+ export function disposeActiveTabCoordinator(): void {
161
+ if (!initialised) return;
162
+ initialised = false;
163
+ channel?.close();
164
+ channel = null;
165
+ visibilityTeardown?.();
166
+ visibilityTeardown = null;
167
+ }
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+
3
+ export {
4
+ useActiveTab,
5
+ useIsTabActive,
6
+ useIsTabLeader,
7
+ type UseActiveTabReturn,
8
+ } from './useActiveTab';
9
+
10
+ export {
11
+ useActiveTabStore,
12
+ ensureActiveTabCoordinator,
13
+ disposeActiveTabCoordinator,
14
+ type ActiveTabMessage,
15
+ } from './activeTabStore';
16
+
17
+ export { getTabId } from './tabId';
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Stable per-tab identifier. Persisted in sessionStorage so a single
3
+ * tab keeps its id across in-tab reloads (Cmd-R) but a new tab gets a
4
+ * fresh one. Falls back to an in-memory id when sessionStorage is
5
+ * unavailable (private mode, sandboxed iframes, SSR).
6
+ *
7
+ * The id includes `performance.timeOrigin` so leader election can
8
+ * prefer the longest-lived tab without an extra "born_at" field —
9
+ * lexicographic ordering of the id matches age ordering closely enough
10
+ * for our purposes (ties broken by random suffix).
11
+ */
12
+
13
+ const KEY = 'djangocfg:tab-id';
14
+
15
+ let cached: string | null = null;
16
+
17
+ function makeId(): string {
18
+ const origin = typeof performance !== 'undefined' ? performance.timeOrigin : Date.now();
19
+ const rand = Math.random().toString(36).slice(2, 10);
20
+ // Zero-padded so string compare matches numeric compare.
21
+ return `${Math.floor(origin).toString(10).padStart(16, '0')}-${rand}`;
22
+ }
23
+
24
+ export function getTabId(): string {
25
+ if (cached) return cached;
26
+ if (typeof sessionStorage === 'undefined') {
27
+ cached = makeId();
28
+ return cached;
29
+ }
30
+ try {
31
+ const existing = sessionStorage.getItem(KEY);
32
+ if (existing) {
33
+ cached = existing;
34
+ return existing;
35
+ }
36
+ const next = makeId();
37
+ sessionStorage.setItem(KEY, next);
38
+ cached = next;
39
+ return next;
40
+ } catch {
41
+ cached = makeId();
42
+ return cached;
43
+ }
44
+ }
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { useShallow } from 'zustand/react/shallow';
5
+
6
+ import {
7
+ ensureActiveTabCoordinator,
8
+ useActiveTabStore,
9
+ } from './activeTabStore';
10
+
11
+ export interface UseActiveTabReturn {
12
+ /** Stable id for this tab. Survives in-tab reload, dies with the tab. */
13
+ tabId: string;
14
+ /** True while the user has focus on this tab. */
15
+ isActive: boolean;
16
+ /** True when this tab is the elected leader across all open app tabs. */
17
+ isLeader: boolean;
18
+ /** Current leader id (may be this tab or another). */
19
+ leaderId: string;
20
+ }
21
+
22
+ /**
23
+ * Subscribe to cross-tab state: focus + leader election.
24
+ *
25
+ * Use cases:
26
+ * - Only the leader mutates `document.title` / favicon to avoid all
27
+ * open tabs blinking in unison.
28
+ * - Only the leader holds a websocket; followers consume snapshots
29
+ * via broadcast.
30
+ * - `isActive` for "the user is watching this exact tab" gating.
31
+ *
32
+ * The coordinator is shared across every consumer in the app — calling
33
+ * the hook in multiple components costs only a store subscription.
34
+ */
35
+ export function useActiveTab(): UseActiveTabReturn {
36
+ useEffect(() => {
37
+ ensureActiveTabCoordinator();
38
+ }, []);
39
+
40
+ return useActiveTabStore(
41
+ useShallow((s) => ({
42
+ tabId: s.tabId,
43
+ isActive: s.isActive,
44
+ isLeader: s.isLeader,
45
+ leaderId: s.leaderId,
46
+ })),
47
+ );
48
+ }
49
+
50
+ /** Subscribe to just the `isActive` flag — no leader-election reads. */
51
+ export function useIsTabActive(): boolean {
52
+ useEffect(() => {
53
+ ensureActiveTabCoordinator();
54
+ }, []);
55
+ return useActiveTabStore((s) => s.isActive);
56
+ }
57
+
58
+ /** Subscribe to just the `isLeader` flag. */
59
+ export function useIsTabLeader(): boolean {
60
+ useEffect(() => {
61
+ ensureActiveTabCoordinator();
62
+ }, []);
63
+ return useActiveTabStore((s) => s.isLeader);
64
+ }