@djangocfg/layouts 2.1.284 → 2.1.286

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.284",
3
+ "version": "2.1.286",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -74,14 +74,14 @@
74
74
  "check": "tsc --noEmit"
75
75
  },
76
76
  "peerDependencies": {
77
- "@djangocfg/api": "^2.1.284",
78
- "@djangocfg/centrifugo": "^2.1.284",
79
- "@djangocfg/debuger": "^2.1.284",
80
- "@djangocfg/i18n": "^2.1.284",
81
- "@djangocfg/monitor": "^2.1.284",
82
- "@djangocfg/ui-core": "^2.1.284",
83
- "@djangocfg/ui-nextjs": "^2.1.284",
84
- "@djangocfg/ui-tools": "^2.1.284",
77
+ "@djangocfg/api": "^2.1.286",
78
+ "@djangocfg/centrifugo": "^2.1.286",
79
+ "@djangocfg/debuger": "^2.1.286",
80
+ "@djangocfg/i18n": "^2.1.286",
81
+ "@djangocfg/monitor": "^2.1.286",
82
+ "@djangocfg/ui-core": "^2.1.286",
83
+ "@djangocfg/ui-nextjs": "^2.1.286",
84
+ "@djangocfg/ui-tools": "^2.1.286",
85
85
  "@hookform/resolvers": "^5.2.2",
86
86
  "consola": "^3.4.2",
87
87
  "lucide-react": "^0.545.0",
@@ -110,15 +110,15 @@
110
110
  "uuid": "^11.1.0"
111
111
  },
112
112
  "devDependencies": {
113
- "@djangocfg/api": "^2.1.284",
114
- "@djangocfg/centrifugo": "^2.1.284",
115
- "@djangocfg/debuger": "^2.1.284",
116
- "@djangocfg/i18n": "^2.1.284",
117
- "@djangocfg/monitor": "^2.1.284",
118
- "@djangocfg/typescript-config": "^2.1.284",
119
- "@djangocfg/ui-core": "^2.1.284",
120
- "@djangocfg/ui-nextjs": "^2.1.284",
121
- "@djangocfg/ui-tools": "^2.1.284",
113
+ "@djangocfg/api": "^2.1.286",
114
+ "@djangocfg/centrifugo": "^2.1.286",
115
+ "@djangocfg/debuger": "^2.1.286",
116
+ "@djangocfg/i18n": "^2.1.286",
117
+ "@djangocfg/monitor": "^2.1.286",
118
+ "@djangocfg/typescript-config": "^2.1.286",
119
+ "@djangocfg/ui-core": "^2.1.286",
120
+ "@djangocfg/ui-nextjs": "^2.1.286",
121
+ "@djangocfg/ui-tools": "^2.1.286",
122
122
  "@types/node": "^24.7.2",
123
123
  "@types/react": "^19.1.0",
124
124
  "@types/react-dom": "^19.1.0",
@@ -41,64 +41,182 @@ export interface ErrorLayoutProps {
41
41
  supportEmail?: string;
42
42
  }
43
43
 
44
- // Local function to select the icon based on the code.
45
- // This is safe as it's defined and used inside a Client Component.
44
+ /**
45
+ * Error glyphs inline SVG, stroke-based.
46
+ *
47
+ * Inline on purpose: `ErrorLayout` renders inside Next.js error boundaries
48
+ * (`error.tsx`, `not-found.tsx`, `global-error.tsx`). When the root layout
49
+ * or a provider throws, the React tree is partially blown out — external
50
+ * icon libs (`lucide-react` et al.) may fail to resolve because their
51
+ * provider-dependent runtime is gone. Inline SVG has zero dependencies
52
+ * and ships in the same bundle as `ErrorLayout` itself, so it always renders.
53
+ *
54
+ * Design: 1.25px stroke, `currentColor`, no heavy filled circles. The visual
55
+ * weight comes from a soft radial glow on the wrapper, not the icon itself.
56
+ */
57
+
58
+ interface GlyphSpec {
59
+ /** Accent tint used for the glow and the gradient stop. */
60
+ accent: string;
61
+ /** viewBox for the inner SVG. Default 24 24. */
62
+ viewBox?: string;
63
+ /** Inner SVG children. Must use `currentColor` for stroke. */
64
+ paths: React.ReactNode;
65
+ }
66
+
67
+ const GLYPH_SPECS: Record<string, GlyphSpec> = {
68
+ // 404 — magnifier over a page corner (not-found)
69
+ '404': {
70
+ accent: 'rgb(139 92 246)', // violet-500
71
+ paths: (
72
+ <>
73
+ <path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7.5" />
74
+ <path d="M13.5 3 19 8.5" />
75
+ <path d="M13 3v4a2 2 0 0 0 2 2h4" />
76
+ <circle cx="11" cy="14.5" r="2.5" />
77
+ <path d="m13 16.5 2 2" />
78
+ </>
79
+ ),
80
+ },
81
+ // 500 — server stack with a warning bolt (server-side failure)
82
+ '500': {
83
+ accent: 'rgb(239 68 68)', // red-500
84
+ paths: (
85
+ <>
86
+ <rect x="3" y="4" width="18" height="6" rx="1.5" />
87
+ <rect x="3" y="14" width="18" height="6" rx="1.5" />
88
+ <path d="M7 7h.01" />
89
+ <path d="M7 17h.01" />
90
+ <path d="m15 6-2 4h3l-2 4" strokeLinejoin="round" />
91
+ </>
92
+ ),
93
+ },
94
+ // 502 — two servers with a broken link between
95
+ '502': {
96
+ accent: 'rgb(249 115 22)', // orange-500
97
+ paths: (
98
+ <>
99
+ <rect x="3" y="4" width="8" height="16" rx="1.5" />
100
+ <rect x="13" y="4" width="8" height="16" rx="1.5" />
101
+ <path d="M6 8h.01" />
102
+ <path d="M16 8h.01" />
103
+ <path d="M11 11.5h-1" />
104
+ <path d="M14 12.5h-1" />
105
+ <path d="m11 10 2 4" strokeLinejoin="round" />
106
+ </>
107
+ ),
108
+ },
109
+ // 503 — power off / plug pulled
110
+ '503': {
111
+ accent: 'rgb(249 115 22)', // orange-500
112
+ paths: (
113
+ <>
114
+ <path d="M12 2v10" />
115
+ <path d="M18.4 6.6a9 9 0 1 1-12.77 0" />
116
+ </>
117
+ ),
118
+ },
119
+ // 504 — clock with slash (gateway timeout)
120
+ '504': {
121
+ accent: 'rgb(234 179 8)', // yellow-500
122
+ paths: (
123
+ <>
124
+ <circle cx="12" cy="12" r="9" />
125
+ <path d="M12 7v5l3 2" />
126
+ </>
127
+ ),
128
+ },
129
+ // 408 — clock (request timeout)
130
+ '408': {
131
+ accent: 'rgb(234 179 8)', // yellow-500
132
+ paths: (
133
+ <>
134
+ <circle cx="12" cy="12" r="9" />
135
+ <path d="M12 7v5l3 2" />
136
+ </>
137
+ ),
138
+ },
139
+ // 403 — shield with a prohibition line (forbidden)
140
+ '403': {
141
+ accent: 'rgb(239 68 68)', // red-500
142
+ paths: (
143
+ <>
144
+ <path d="M12 2 4 5v6c0 5 3.5 9 8 11 4.5-2 8-6 8-11V5l-8-3Z" />
145
+ <path d="m9 9 6 6" strokeLinejoin="round" />
146
+ </>
147
+ ),
148
+ },
149
+ // 401 — key (auth required)
150
+ '401': {
151
+ accent: 'rgb(59 130 246)', // blue-500
152
+ paths: (
153
+ <>
154
+ <circle cx="8" cy="15" r="4" />
155
+ <path d="m10.8 12.2 8.2-8.2" />
156
+ <path d="m17 5 3 3" />
157
+ <path d="m15 7 2 2" />
158
+ </>
159
+ ),
160
+ },
161
+ // 400 — general alert triangle (bad request)
162
+ '400': {
163
+ accent: 'rgb(234 179 8)', // yellow-500
164
+ paths: (
165
+ <>
166
+ <path d="m12 3 10 18H2L12 3Z" strokeLinejoin="round" />
167
+ <path d="M12 9v5" />
168
+ <path d="M12 17.5h.01" />
169
+ </>
170
+ ),
171
+ },
172
+ };
173
+
174
+ const DEFAULT_GLYPH: GlyphSpec = {
175
+ accent: 'rgb(113 113 122)', // zinc-500
176
+ paths: (
177
+ <>
178
+ <circle cx="12" cy="12" r="9" />
179
+ <path d="M12 8v4" />
180
+ <path d="M12 16h.01" />
181
+ </>
182
+ ),
183
+ };
184
+
185
+ /**
186
+ * Render the glyph for a given code, wrapped in a container with a soft radial
187
+ * glow behind the icon. Returns `null` if nothing should be shown.
188
+ */
46
189
  function getErrorIcon(code?: string | number): React.ReactNode {
47
- const c = code ? String(code) : '';
48
-
49
- // NOTE: You can replace these SVG paths with imported Lucid Icons
50
- // (e.g., <AlertTriangle />) if you prefer.
51
- switch (c) {
52
- case '404':
53
- return (
54
- <svg
55
- className="w-32 h-32 mx-auto text-foreground/80"
56
- viewBox="0 0 32 32"
57
- fill="currentColor"
58
- aria-hidden="true"
59
- >
60
- {/* Cloud with 404 Icon */}
61
- <g>
62
- <path fillRule="evenodd" d="M19.889 21.734v-.947l-.631.947z" />
63
- <path d="M15.484 19.636h1.032a.017.017 0 0 1 .017.017v3.1a.016.016 0 0 1-.016.016h-1.034a.016.016 0 0 1-.016-.016v-3.1a.017.017 0 0 1 .017-.017z" />
64
- <g fillRule="evenodd">
65
- <path d="M12.402 21.734v-.947l-.631.947z" />
66
- <path d="M16 1.5A14.5 14.5 0 1 0 30.5 16 14.507 14.507 0 0 0 16 1.5zm-2.324 21.234H13.4v.532a.5.5 0 0 1-1 0v-.532h-1.563a.5.5 0 0 1-.416-.778l2.067-3.1a.5.5 0 0 1 .914.28v2.6h.274a.5.5 0 0 1 0 1zm-2.137-9.9A4.14 4.14 0 0 1 15.545 9.8a.5.5 0 0 1 0 1 3.138 3.138 0 0 0-3.04 2.3.5.5 0 0 1-.966-.259zm5.994 9.911a1.017 1.017 0 0 1-1.017 1.016h-1.032a1.017 1.017 0 0 1-1.017-1.016v-3.1a1.017 1.017 0 0 1 1.017-1.016h1.032a1.017 1.017 0 0 1 1.017 1.016zm3.63-.016h-.274v.532a.5.5 0 0 1-1 0v-.532h-1.565a.5.5 0 0 1-.416-.778l2.067-3.1a.5.5 0 0 1 .914.28v2.6h.274a.5.5 0 0 1 0 1zm1.036-1.52a.5.5 0 1 1-.4-.917 3.263 3.263 0 0 0-1.337-6.266.5.5 0 0 1-.5-.5 4.6 4.6 0 0 0-9.2 0 4.45 4.45 0 0 0 .153 1.161.5.5 0 0 1-.411.625 2.633 2.633 0 0 0-.364 5.148.5.5 0 0 1-.278.961 3.63 3.63 0 0 1-.024-6.984 5.467 5.467 0 0 1-.076-.911 5.6 5.6 0 0 1 11.182-.474 4.258 4.258 0 0 1 1.256 8.162z" />
67
- </g>
68
- </g>
69
- </svg>
70
- );
71
- case '500':
72
- return (
73
- <svg
74
- className="w-32 h-32 mx-auto text-foreground/80"
75
- viewBox="0 0 512 512"
76
- fill="currentColor"
77
- aria-hidden="true"
78
- >
79
- {/* Server Error Icon - Circle with Exclamation */}
80
- <g>
81
- <path d="M256 118c-76.1 0-138 61.88-138 138s61.9 138.05 138 138.05 138-61.93 138-138S332.1 118 256 118zm0 237.93a30.12 30.12 0 1 1 30.11-30.12A30.15 30.15 0 0 1 256 355.88zm30.11-80.31a8.48 8.48 0 0 1-8.47 8.48h-43.28a8.48 8.48 0 0 1-8.47-8.48v-111a8.47 8.47 0 0 1 8.47-8.47h43.28a8.47 8.47 0 0 1 8.47 8.47z" />
82
- <path d="M256 312.6a13.17 13.17 0 1 0 13.16 13.16A13.17 13.17 0 0 0 256 312.6zM242.84 173.07h26.32v94.02h-26.32z" />
83
- <path d="M256 0C114.62 0 0 114.62 0 256s114.62 256 256 256 256-114.62 256-256S397.38 0 256 0zm109.57 365.6A155 155 0 1 1 411 256a153.91 153.91 0 0 1-45.43 109.6z" />
84
- </g>
85
- </svg>
86
- );
87
- case '403':
88
- return (
89
- <svg
90
- className="w-32 h-32 mx-auto text-foreground/80"
91
- viewBox="0 0 24 24"
92
- fill="currentColor"
93
- aria-hidden="true"
94
- >
95
- {/* Forbidden Icon - Circle with X */}
96
- <path d="M12 1a11 11 0 1 0 11 11A11.013 11.013 0 0 0 12 1zm4.242 13.829a1 1 0 1 1-1.414 1.414L12 13.414l-2.828 2.829a1 1 0 0 1-1.414-1.414L10.586 12 7.758 9.171a1 1 0 1 1 1.414-1.414L12 10.586l2.828-2.829a1 1 0 1 1 1.414 1.414L13.414 12z" />
97
- </svg>
98
- );
99
- default:
100
- return null;
101
- }
190
+ if (code === undefined || code === null || code === '') return null;
191
+ const c = String(code);
192
+ const spec = GLYPH_SPECS[c] ?? DEFAULT_GLYPH;
193
+
194
+ return (
195
+ <div
196
+ className="relative mx-auto flex size-28 items-center justify-center"
197
+ aria-hidden="true"
198
+ >
199
+ {/* soft radial glow */}
200
+ <div
201
+ className="pointer-events-none absolute inset-0 rounded-full blur-2xl opacity-60"
202
+ style={{
203
+ background: `radial-gradient(circle at 50% 50%, ${spec.accent} 0%, transparent 65%)`,
204
+ }}
205
+ />
206
+ {/* icon */}
207
+ <svg
208
+ className="relative size-20"
209
+ viewBox={spec.viewBox ?? '0 0 24 24'}
210
+ fill="none"
211
+ stroke="currentColor"
212
+ strokeWidth={1.25}
213
+ strokeLinecap="round"
214
+ style={{ color: spec.accent }}
215
+ >
216
+ {spec.paths}
217
+ </svg>
218
+ </div>
219
+ );
102
220
  }
103
221
 
104
222
  /**
@@ -38,6 +38,7 @@ import { ClientOnly, Suspense } from '../../components/core';
38
38
  import { usePathnameWithoutLocale } from '../../hooks';
39
39
  import { matchesPath } from '../../utils/pathMatcher';
40
40
  import { BaseApp } from './BaseApp';
41
+ import { ForcedThemeProvider, ThemeOverride, resolveForcedTheme } from '@djangocfg/ui-nextjs/theme';
41
42
 
42
43
  import type {
43
44
  ThemeConfig,
@@ -53,6 +54,7 @@ import type {
53
54
  export type { I18nLayoutConfig } from '../types';
54
55
  import type { AuthConfig } from '@djangocfg/api/auth';
55
56
  import type { MonitorConfig } from '@djangocfg/monitor';
57
+ import type { ThemeOverrideRule } from '@djangocfg/ui-nextjs/theme';
56
58
 
57
59
  import type { FloatingNavbarConfig } from '../PublicLayout/navbars/FloatingNavbar';
58
60
  import type { DefaultFooterConfig } from '../PublicLayout/footers/DefaultFooter/types';
@@ -178,6 +180,23 @@ export interface AppLayoutLayoutsConfig {
178
180
  authPath?: string;
179
181
  /** Merged over root `publicChrome` on `AppLayout` (see `mergeAppLayoutPublicChrome`). */
180
182
  publicChrome?: AppLayoutPublicChrome;
183
+ /**
184
+ * Per-route theme overrides. Each rule uses the same matcher as `enabledPath`
185
+ * (string, string[], or glob with `*` / `**`). When the pathname matches a
186
+ * rule, the forced theme is applied via `next-themes`; when navigation leaves
187
+ * the matched path, the user's previous theme choice is restored.
188
+ *
189
+ * Rules are evaluated top-to-bottom — first match wins.
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * themeOverrides: [
194
+ * { path: '/', theme: 'dark' }, // marketing landing always dark
195
+ * { path: ['/legal', '/legal/**'], theme: 'light' }, // legal always light
196
+ * ]
197
+ * ```
198
+ */
199
+ themeOverrides?: ThemeOverrideRule[];
181
200
  }
182
201
 
183
202
  export interface AppLayoutBaseAppConfig {
@@ -260,6 +279,12 @@ export interface AppLayoutProps {
260
279
  /** Base layer for `publicChrome`; `layouts.publicChrome` overlays this. */
261
280
  publicChrome?: AppLayoutPublicChrome;
262
281
 
282
+ /**
283
+ * Per-route theme overrides. Shortcut for `layouts.themeOverrides`.
284
+ * See `AppLayoutLayoutsConfig.themeOverrides` for the full docs.
285
+ */
286
+ themeOverrides?: ThemeOverrideRule[];
287
+
263
288
  /** Monitor configuration — initialises window.monitor + auto-captures JS errors & console */
264
289
  monitor?: MonitorConfig;
265
290
 
@@ -275,6 +300,7 @@ interface AppLayoutContentProps {
275
300
  authPath?: string;
276
301
  i18n?: I18nLayoutConfig;
277
302
  publicChrome?: AppLayoutPublicChrome;
303
+ themeOverrides?: ThemeOverrideRule[];
278
304
  }
279
305
 
280
306
  /**
@@ -292,6 +318,7 @@ function AppLayoutContent({
292
318
  authPath = '/auth',
293
319
  i18n,
294
320
  publicChrome,
321
+ themeOverrides,
295
322
  }: AppLayoutContentProps) {
296
323
  // Use pathname without locale prefix for route matching.
297
324
  // Pass locale from i18n so strip is exact — avoids regex misfiring on /ui, /ko, etc.
@@ -389,8 +416,23 @@ function AppLayoutContent({
389
416
  }
390
417
  };
391
418
 
419
+ // Prepare everything above the JSX — no inline conditionals in return().
420
+ const hasThemeOverrides = Boolean(themeOverrides && themeOverrides.length > 0);
421
+ const forcedTheme = hasThemeOverrides
422
+ ? resolveForcedTheme(pathname, themeOverrides)
423
+ : null;
424
+ const themeOverrideElement = hasThemeOverrides
425
+ ? <ThemeOverride pathname={pathname} rules={themeOverrides!} />
426
+ : null;
427
+ const layoutElement = renderLayout();
428
+
392
429
  // No providers here - all providers now in BaseApp
393
- return renderLayout();
430
+ return (
431
+ <ForcedThemeProvider value={forcedTheme}>
432
+ {themeOverrideElement}
433
+ {layoutElement}
434
+ </ForcedThemeProvider>
435
+ );
394
436
  }
395
437
 
396
438
  /**
@@ -406,6 +448,7 @@ export function AppLayout(props: AppLayoutProps) {
406
448
  const noLayoutPaths = layoutsConfig?.noLayoutPaths ?? props.noLayoutPaths;
407
449
  const authPath = layoutsConfig?.authPath ?? props.authPath;
408
450
  const publicChrome = mergeAppLayoutPublicChrome(props.publicChrome, layoutsConfig?.publicChrome);
451
+ const themeOverrides = layoutsConfig?.themeOverrides ?? props.themeOverrides;
409
452
 
410
453
  const {
411
454
  i18n,
@@ -447,6 +490,7 @@ export function AppLayout(props: AppLayoutProps) {
447
490
  authPath={authPath}
448
491
  i18n={i18n}
449
492
  publicChrome={publicChrome}
493
+ themeOverrides={themeOverrides}
450
494
  />
451
495
  </BaseApp>
452
496
  );
@@ -0,0 +1,88 @@
1
+ # AppLayout
2
+
3
+ Smart layout router + all-in-one providers wrapper for Next.js apps.
4
+
5
+ Picks `public` / `private` / `admin` layout based on the current pathname, mounts `BaseApp` (theme, auth, analytics, centrifugo, SWR, error boundary, monitor, PWA), and optionally forces a theme on specific routes.
6
+
7
+ ```tsx
8
+ import { AppLayout } from '@djangocfg/layouts';
9
+
10
+ <AppLayout
11
+ layouts={{
12
+ public: { component: PublicLayout, enabledPath: ['/', '/about'] },
13
+ private: { component: DashboardLayout, enabledPath: '/dashboard' },
14
+ admin: { component: AdminLayout, enabledPath: '/admin' },
15
+ noLayoutPaths: ['/embed', '/print'],
16
+ themeOverrides: [
17
+ { path: '/', theme: 'dark' }, // landing always dark
18
+ { path: ['/legal', '/legal/**'], theme: 'light' }, // legal always light
19
+ ],
20
+ }}
21
+ baseApp={{
22
+ theme: { defaultTheme: 'system', storageKey: 'myapp-theme' },
23
+ auth: { apiUrl: settings.api.baseUrl },
24
+ analytics: { googleTrackingId: 'G-...' },
25
+ }}
26
+ >
27
+ {children}
28
+ </AppLayout>
29
+ ```
30
+
31
+ ## Path matcher
32
+
33
+ All `enabledPath` / `themeOverrides[].path` fields use the same matcher:
34
+
35
+ | Pattern | Matches |
36
+ |---|---|
37
+ | `/dashboard` | `/dashboard`, `/dashboard/anything` (prefix) |
38
+ | `/projects/*/edit` | `/projects/123/edit` (single segment) |
39
+ | `/admin/**` | `/admin`, `/admin/a/b/c` (any depth) |
40
+ | `['/a', '/b']` | any of the listed paths |
41
+
42
+ Pathname is stripped of the locale prefix before matching (`/ru/dashboard` → `/dashboard`).
43
+
44
+ ## themeOverrides
45
+
46
+ Per-route theme override — evaluated top-to-bottom, first match wins. While the pathname matches, the forced theme is applied via `next-themes`. When you navigate away, the user's previous choice is restored from `localStorage`.
47
+
48
+ ```ts
49
+ themeOverrides: [
50
+ { path: '/', theme: 'dark' },
51
+ { path: '/legal/**', theme: 'light' },
52
+ ]
53
+ ```
54
+
55
+ Use this for marketing landings that must always be dark, legal/compliance pages that must always be readable, or print views that must always be light — without locking the whole app.
56
+
57
+ For a local, scoped override (a single section that needs different CSS vars without touching the global theme), use `<ForceTheme>` from `@djangocfg/ui-nextjs/theme` instead.
58
+
59
+ ## Full config surface
60
+
61
+ ```ts
62
+ interface AppLayoutProps {
63
+ layouts?: {
64
+ public?: { component; enabledPath? };
65
+ private?: { component; enabledPath? };
66
+ admin?: { component; enabledPath? };
67
+ noLayoutPaths?: string | string[];
68
+ authPath?: string; // default '/auth' — always fullscreen
69
+ publicChrome?: AppLayoutPublicChrome; // navbar/footer defaults
70
+ themeOverrides?: ThemeOverrideRule[];
71
+ };
72
+ baseApp?: {
73
+ project?; theme?; auth?; analytics?; centrifugo?;
74
+ errorTracking?; errorBoundary?; swr?; pwaInstall?;
75
+ monitor?; debug?;
76
+ };
77
+ i18n?: I18nLayoutConfig;
78
+ children: ReactNode;
79
+ }
80
+ ```
81
+
82
+ Top-level shortcuts (`theme`, `auth`, `analytics`, `publicLayout`, …) mirror the nested fields and are merged — nested wins.
83
+
84
+ ## Related
85
+
86
+ - `BaseApp` — the provider stack. Exported separately if you need it without the layout router.
87
+ - `ForceTheme` / `ThemeOverride` (`@djangocfg/ui-nextjs/theme`) — theme primitives used by `themeOverrides`.
88
+ - `ErrorLayout` (`@djangocfg/layouts/components`) — universal error page for `error.tsx` / `not-found.tsx`.
@@ -20,3 +20,17 @@ export { mergeAppLayoutPublicChrome } from './AppLayout';
20
20
  export { BaseApp } from './BaseApp';
21
21
  export type { BaseAppProps } from './BaseApp';
22
22
 
23
+ // Re-export theme-override primitives so consumers can import both the
24
+ // AppLayout config surface and the rule/theme types from one place.
25
+ export {
26
+ ThemeOverride,
27
+ resolveForcedTheme,
28
+ ForcedThemeProvider,
29
+ useForcedTheme,
30
+ } from '@djangocfg/ui-nextjs/theme';
31
+ export type {
32
+ ThemeOverrideRule,
33
+ ThemeOverrideProps,
34
+ ForcedTheme,
35
+ } from '@djangocfg/ui-nextjs/theme';
36
+
@@ -10,7 +10,7 @@ import React, { useEffect, useState } from 'react';
10
10
  import { Laptop, Moon, Sun } from 'lucide-react';
11
11
 
12
12
  import { Button } from '@djangocfg/ui-core/components';
13
- import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
13
+ import { useForcedTheme, useThemeContext } from '@djangocfg/ui-nextjs/theme';
14
14
 
15
15
  import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
16
16
  import { useLinkComponent } from '../../primitives/LinkComponentContext';
@@ -19,51 +19,85 @@ import { FooterProjectInfo } from './FooterProjectInfo';
19
19
 
20
20
  import type { DefaultFooterProps } from './types';
21
21
 
22
- function ThemeModeControl() {
22
+ interface ThemeModeControlProps {
23
+ /** What to do when the route forces a theme via `ThemeOverride`. */
24
+ lockedBehavior?: 'disable' | 'hide';
25
+ /** Tooltip shown when locked. */
26
+ lockedTitle?: string;
27
+ }
28
+
29
+ function ThemeModeControl({
30
+ lockedBehavior = 'disable',
31
+ lockedTitle = 'Theme is set for this page',
32
+ }: ThemeModeControlProps) {
23
33
  const { theme, setTheme } = useThemeContext();
34
+ const forcedTheme = useForcedTheme();
24
35
  const [mounted, setMounted] = useState(false);
25
36
 
26
37
  useEffect(() => {
27
38
  setMounted(true);
28
39
  }, []);
29
40
 
30
- const currentTheme = mounted ? (theme || 'system') : 'system';
31
- const isActive = (value: 'system' | 'light' | 'dark') => currentTheme === value;
41
+ const isLocked = Boolean(forcedTheme);
42
+ // When locked, the active item reflects the forced theme not the user's pick.
43
+ const currentTheme: 'system' | 'light' | 'dark' = isLocked
44
+ ? forcedTheme!
45
+ : mounted
46
+ ? ((theme as 'system' | 'light' | 'dark' | undefined) || 'system')
47
+ : 'system';
48
+
49
+ // Prepare item descriptors above JSX — no inline conditionals in the tree.
50
+ const items: Array<{
51
+ key: 'system' | 'light' | 'dark';
52
+ icon: typeof Laptop;
53
+ label: string;
54
+ }> = [
55
+ { key: 'system', icon: Laptop, label: 'Use system theme' },
56
+ { key: 'light', icon: Sun, label: 'Use light theme' },
57
+ { key: 'dark', icon: Moon, label: 'Use dark theme' },
58
+ ];
59
+
32
60
  const baseItemClass = 'h-8 w-8 rounded-full p-0 text-muted-foreground hover:text-foreground';
33
61
  const activeItemClass = 'bg-background/80 text-foreground shadow-sm';
62
+ const lockedItemClass = 'cursor-not-allowed opacity-60 hover:text-muted-foreground';
63
+
64
+ if (isLocked && lockedBehavior === 'hide') {
65
+ return null;
66
+ }
67
+
68
+ const containerClass = [
69
+ 'inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/30 p-1',
70
+ isLocked ? 'cursor-not-allowed' : '',
71
+ ].filter(Boolean).join(' ');
72
+ const containerTitle = isLocked ? lockedTitle : undefined;
34
73
 
35
74
  return (
36
- <div className="inline-flex items-center gap-1 rounded-full border border-border/60 bg-muted/30 p-1">
37
- <Button
38
- type="button"
39
- variant="ghost"
40
- size="icon"
41
- className={`${baseItemClass} ${isActive('system') ? activeItemClass : ''}`}
42
- onClick={() => setTheme('system')}
43
- aria-label="Use system theme"
44
- >
45
- <Laptop className="h-4 w-4" />
46
- </Button>
47
- <Button
48
- type="button"
49
- variant="ghost"
50
- size="icon"
51
- className={`${baseItemClass} ${isActive('light') ? activeItemClass : ''}`}
52
- onClick={() => setTheme('light')}
53
- aria-label="Use light theme"
54
- >
55
- <Sun className="h-4 w-4" />
56
- </Button>
57
- <Button
58
- type="button"
59
- variant="ghost"
60
- size="icon"
61
- className={`${baseItemClass} ${isActive('dark') ? activeItemClass : ''}`}
62
- onClick={() => setTheme('dark')}
63
- aria-label="Use dark theme"
64
- >
65
- <Moon className="h-4 w-4" />
66
- </Button>
75
+ <div className={containerClass} title={containerTitle} aria-disabled={isLocked}>
76
+ {items.map((item) => {
77
+ const Icon = item.icon;
78
+ const isActive = currentTheme === item.key;
79
+ const className = [
80
+ baseItemClass,
81
+ isActive ? activeItemClass : '',
82
+ isLocked ? lockedItemClass : '',
83
+ ].filter(Boolean).join(' ');
84
+ const handleClick = isLocked ? undefined : () => setTheme(item.key);
85
+ return (
86
+ <Button
87
+ key={item.key}
88
+ type="button"
89
+ variant="ghost"
90
+ size="icon"
91
+ className={className}
92
+ onClick={handleClick}
93
+ disabled={isLocked}
94
+ aria-label={item.label}
95
+ title={isLocked ? lockedTitle : item.label}
96
+ >
97
+ <Icon className="h-4 w-4" />
98
+ </Button>
99
+ );
100
+ })}
67
101
  </div>
68
102
  );
69
103
  }