@djangocfg/layouts 2.1.257 → 2.1.259

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 (29) hide show
  1. package/README.md +101 -203
  2. package/package.json +18 -18
  3. package/src/index.ts +4 -1
  4. package/src/layouts/AppLayout/AppLayout.tsx +97 -8
  5. package/src/layouts/AppLayout/BaseApp.tsx +2 -0
  6. package/src/layouts/AppLayout/index.ts +6 -0
  7. package/src/layouts/PrivateLayout/PrivateLayout.tsx +3 -1
  8. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +4 -1
  9. package/src/layouts/PublicLayout/PublicLayout.tsx +31 -8
  10. package/src/layouts/PublicLayout/components/PublicFooter/FooterProjectInfo.tsx +17 -24
  11. package/src/layouts/PublicLayout/components/PublicFooter/PublicFooter.tsx +79 -95
  12. package/src/layouts/PublicLayout/components/PublicFooter/index.ts +2 -0
  13. package/src/layouts/PublicLayout/components/PublicFooter/types.ts +41 -31
  14. package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +69 -30
  15. package/src/layouts/PublicLayout/components/PublicNavbar.tsx +24 -34
  16. package/src/layouts/PublicLayout/components/PublicNavigation.tsx +162 -94
  17. package/src/layouts/PublicLayout/components/ThemeBrandMark.tsx +83 -0
  18. package/src/layouts/PublicLayout/components/index.ts +2 -0
  19. package/src/layouts/PublicLayout/index.ts +5 -0
  20. package/src/layouts/PublicLayout/navbarTypes.ts +8 -0
  21. package/src/layouts/PublicLayout/publicShellShadow.ts +12 -0
  22. package/src/layouts/_components/UserMenu.tsx +2 -2
  23. package/src/layouts/types/index.ts +9 -1
  24. package/src/layouts/types/providers.types.ts +10 -0
  25. package/src/theme/ThemeStyleBridge.tsx +41 -0
  26. package/src/theme/buildThemeStyleSheet.ts +71 -0
  27. package/src/theme/index.ts +16 -0
  28. package/src/theme/themeStyle.types.ts +89 -0
  29. package/src/theme/themeStylePresets.ts +202 -0
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Public Layout Navigation
3
3
  *
4
- * Navigation component for PublicLayout with mobile drawer support
4
+ * Navigation component for PublicLayout with mobile drawer support.
5
+ *
6
+ * Colors: use Tailwind semantic utilities backed by `@djangocfg/ui-core` theme
7
+ * (`packages/ui-core/src/styles/theme/light.css`, `dark.css`, `tokens.css`).
5
8
  */
6
9
 
7
10
  'use client';
@@ -23,12 +26,12 @@ import {
23
26
  Button,
24
27
  } from '@djangocfg/ui-core/components';
25
28
  import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
26
- // cn is reserved for future conditional styling
27
- import { cn as _cn } from '@djangocfg/ui-core/lib';
29
+ import { cn } from '@djangocfg/ui-core/lib';
28
30
  import { usePathnameWithoutLocale } from '../../../hooks';
29
31
 
30
32
  import { UserMenu } from '../../_components/UserMenu';
31
33
  import { usePublicLayoutOptional } from '../context';
34
+ import { publicFloatingChromeClassName } from '../publicShellShadow';
32
35
  import type { PublicNavbarPosition, PublicNavbarVariant } from '../navbarTypes';
33
36
 
34
37
  import type { NavigationItem, UserMenuConfig } from '../../types';
@@ -47,12 +50,12 @@ export interface PublicDesktopDropdownRenderProps {
47
50
  export type PublicDesktopDropdownRenderer = (props: PublicDesktopDropdownRenderProps) => React.ReactNode;
48
51
 
49
52
  interface PublicNavigationProps {
50
- /** Custom brand node (full control over logo/text/link) */
53
+ /**
54
+ * Brand area: any React node, or a plain string (wrapped in `<Link href={brandHref}>` as the title).
55
+ */
51
56
  brand?: ReactNode;
52
- /** Brand link for default brand renderer */
57
+ /** Used when `brand` is a string; also for deduping a lone nav link that matches this href (desktop). @default '/' */
53
58
  brandHref?: string;
54
- logo?: string;
55
- siteName?: string;
56
59
  navigation?: NavigationItem[];
57
60
  userMenu?: UserMenuConfig;
58
61
  containerClassName?: string;
@@ -61,6 +64,11 @@ interface PublicNavigationProps {
61
64
  renderDesktopDropdown?: PublicDesktopDropdownRenderer;
62
65
  /** Max visible top-level desktop items before collapsing into "More" */
63
66
  desktopMaxPrimaryItems?: number;
67
+ /**
68
+ * Tailwind rounding for the floating navbar shell (e.g. `rounded-3xl`). Defaults to `rounded-2xl`.
69
+ * Prefer `PublicNavbar` `config.shell.rounding` (or `AppLayout` `publicChrome.navbar.shell.rounding`).
70
+ */
71
+ rounding?: string;
64
72
  mobileMenuOpen?: boolean;
65
73
  onMobileMenuToggle?: () => void;
66
74
  }
@@ -69,11 +77,10 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
69
77
  const context = usePublicLayoutOptional();
70
78
  const brand = props.brand;
71
79
  const brandHref = props.brandHref ?? '/';
72
- const logo = props.logo;
73
- const siteName = props.siteName ?? 'App';
74
80
  const navigation = props.navigation ?? [];
75
81
  const userMenu = props.userMenu;
76
82
  const containerClassName = props.containerClassName;
83
+ const rounding = props.rounding;
77
84
  const navbarVariant = props.navbarVariant ?? 'floating';
78
85
  const navbarPosition = props.navbarPosition ?? 'sticky';
79
86
  const renderDesktopDropdown = props.renderDesktopDropdown;
@@ -90,8 +97,31 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
90
97
  const navOuterRef = useRef<HTMLDivElement | null>(null);
91
98
 
92
99
  const toggleMobileLabel = useMemo(() => t('layouts.navigation.toggleMobile'), [t]);
93
- const desktopNavItemClass =
94
- 'inline-flex h-8 items-center rounded-md px-2 text-sm font-medium text-foreground/90 transition-colors hover:text-foreground hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50';
100
+
101
+ /** Long i18n labels: ellipsis instead of stretching the bar (desktop top-level + “More”). */
102
+ const desktopNavLabelClass = 'min-w-0 max-w-[11rem] truncate sm:max-w-[13rem]';
103
+ /** Dropdown / overflow menu rows — a bit wider than top-level pills. */
104
+ /** Top-level desktop nav control — larger hit target, works in light/dark. */
105
+ const desktopNavItemClass = cn(
106
+ // No `border-transparent` (1px “ghost” stroke still paints); use `border-0` and only add stroke in dark when needed.
107
+ 'inline-flex min-h-9 items-center justify-center gap-1 rounded-full border-0 px-4 py-1.5 text-sm font-medium',
108
+ 'ring-0 focus-visible:ring-0',
109
+ 'text-foreground/90 transition-colors',
110
+ 'hover:bg-accent/55 hover:text-foreground',
111
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
112
+ );
113
+ /** Light: fill only. Dark: optional hairline + muted fill. */
114
+ const desktopNavItemActiveClass =
115
+ 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted dark:shadow-none';
116
+
117
+ const subMenuLinkClass = (active: boolean) =>
118
+ cn(
119
+ 'flex min-h-9 min-w-0 max-w-[min(17rem,calc(100vw-5rem))] items-center rounded-full border-0 px-4 py-2 text-sm font-medium transition-colors',
120
+ 'hover:bg-accent/55',
121
+ active
122
+ ? 'border-0 bg-accent font-semibold text-foreground shadow-sm dark:border dark:border-border dark:bg-muted/90 dark:shadow-none'
123
+ : 'border-0 text-foreground/90',
124
+ );
95
125
 
96
126
  const clearOpenTimer = () => {
97
127
  if (openTimerRef.current) {
@@ -192,17 +222,15 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
192
222
  return `${positionClass} ${topClass} inset-x-0 z-50 ${insetPaddingClass}`.trim().replace(/\s+/g, ' ');
193
223
  })();
194
224
 
195
- const navInnerClassName = (() => {
196
- const base =
197
- 'mx-auto w-full border border-border/40 dark:border-border/70';
198
-
199
- const visual =
200
- navbarVariant === 'floating'
201
- ? 'rounded-2xl shadow-[0_10px_28px_rgba(0,0,0,0.14)] dark:shadow-[0_10px_28px_rgba(0,0,0,0.22)]'
202
- : 'rounded-none border-x-0 border-t-0 shadow-none';
203
-
204
- return `${base} ${visual} ${containerClassName || ''}`.trim().replace(/\s+/g, ' ');
205
- })();
225
+ const navInnerClassName = cn(
226
+ 'mx-auto w-full',
227
+ navbarVariant === 'floating'
228
+ ? cn(rounding ?? 'rounded-2xl', publicFloatingChromeClassName)
229
+ : 'rounded-none border-x-0 border-t-0 border-b border-border/40 dark:border-border/70 shadow-none',
230
+ containerClassName,
231
+ // Last: guarantee shell has no stroke on light; `containerClassName` cannot reintroduce a border.
232
+ navbarVariant === 'floating' && '!border-0 dark:!border dark:!border-border/75',
233
+ );
206
234
 
207
235
  const isActivePath = (href: string) => {
208
236
  if (href === '/') return pathname === '/';
@@ -216,41 +244,50 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
216
244
  };
217
245
 
218
246
  const closeDropdown = () => setOpenDropdownKey(null);
219
- const desktopPrimaryNavigation = navigation.slice(0, desktopMaxPrimaryItems);
220
- const desktopOverflowNavigation = navigation.slice(desktopMaxPrimaryItems);
247
+
248
+ /** Desktop: omit a lone top-level link that duplicates the brand link — saves space; drawer keeps full list. */
249
+ const desktopNavItems = useMemo(() => {
250
+ return navigation.filter((item) => {
251
+ if (item.items && item.items.length > 0) return true;
252
+ return item.href !== brandHref;
253
+ });
254
+ }, [navigation, brandHref]);
255
+
256
+ const desktopPrimaryNavigation = desktopNavItems.slice(0, desktopMaxPrimaryItems);
257
+ const desktopOverflowNavigation = desktopNavItems.slice(desktopMaxPrimaryItems);
221
258
 
222
259
  const renderDesktopNavItem = (item: NavigationItem) => {
223
260
  if (item.items && item.items.length > 0) {
224
261
  const dropdownKey = `${item.label}-${item.href}`;
225
262
  const defaultItems = (
226
263
  <>
227
- {item.items.map((subItem) => (
228
- <div key={`${item.label}-${subItem.href}`} className="rounded-md">
229
- {subItem.external ? (
230
- <a
231
- href={subItem.href}
232
- target="_blank"
233
- rel="noopener noreferrer"
234
- className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
235
- >
236
- {subItem.label}
237
- </a>
238
- ) : (
239
- <Link
240
- href={subItem.href}
241
- className="block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40"
242
- >
243
- {subItem.label}
244
- </Link>
245
- )}
246
- </div>
247
- ))}
264
+ {item.items.map((subItem) => {
265
+ const subActive = isActivePath(subItem.href);
266
+ return (
267
+ <div key={`${item.label}-${subItem.href}`} className="rounded-full">
268
+ {subItem.external ? (
269
+ <a
270
+ href={subItem.href}
271
+ target="_blank"
272
+ rel="noopener noreferrer"
273
+ className={subMenuLinkClass(subActive)}
274
+ >
275
+ <span className="min-w-0 truncate" title={subItem.label}>{subItem.label}</span>
276
+ </a>
277
+ ) : (
278
+ <Link href={subItem.href} className={subMenuLinkClass(subActive)}>
279
+ <span className="min-w-0 truncate" title={subItem.label}>{subItem.label}</span>
280
+ </Link>
281
+ )}
282
+ </div>
283
+ );
284
+ })}
248
285
  </>
249
286
  );
250
287
 
251
288
  const defaultPopover = (
252
289
  <div
253
- className="absolute left-0 top-full mt-1 z-[1200] min-w-56 rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1 shadow-[0_10px_28px_rgba(0,0,0,0.22)]"
290
+ className="absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]"
254
291
  onMouseEnter={() => {
255
292
  clearOpenTimer();
256
293
  clearCloseTimer();
@@ -273,12 +310,23 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
273
310
  <Button
274
311
  variant="ghost"
275
312
  size="sm"
276
- className={`group ${desktopNavItemClass} ${isOpen || isActive ? 'bg-accent/50 text-foreground' : ''}`}
313
+ className={cn(
314
+ // Override Button base [&_svg]:size-16px so hover/layout doesn’t fight icon size; fixed chevron box avoids neighbor jitter when rotating.
315
+ 'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
316
+ desktopNavItemClass,
317
+ (isOpen || isActive) && desktopNavItemActiveClass,
318
+ isOpen && 'border-0 dark:border dark:border-border',
319
+ )}
277
320
  >
278
- <span>{item.label}</span>
279
- <ChevronDown
280
- className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
281
- />
321
+ <span className={desktopNavLabelClass} title={item.label}>{item.label}</span>
322
+ <span
323
+ className="inline-flex size-3.5 shrink-0 items-center justify-center"
324
+ aria-hidden
325
+ >
326
+ <ChevronDown
327
+ className={`size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform ${isOpen ? 'rotate-180' : ''}`}
328
+ />
329
+ </span>
282
330
  </Button>
283
331
 
284
332
  {isOpen && (
@@ -297,41 +345,54 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
297
345
  );
298
346
  }
299
347
 
348
+ const linkActive = isActivePath(item.href);
300
349
  return (
301
350
  <Link
302
351
  key={item.href}
303
352
  href={item.href}
304
- className={`${desktopNavItemClass} ${isActivePath(item.href) ? 'bg-accent/50 text-foreground' : ''}`}
353
+ className={cn(desktopNavItemClass, linkActive && desktopNavItemActiveClass)}
305
354
  >
306
- {item.label}
355
+ <span className={desktopNavLabelClass} title={item.label}>{item.label}</span>
307
356
  </Link>
308
357
  );
309
358
  };
310
359
 
360
+ const hasDesktopOverflowNav = desktopOverflowNavigation.length > 0;
361
+ const isMoreMenuOpen = openDropdownKey === '__overflow-more';
362
+ const moreMenuButtonActive =
363
+ isMoreMenuOpen || desktopOverflowNavigation.some((navItem) => isGroupActive(navItem));
364
+
311
365
  return (
312
366
  <div ref={navOuterRef} className={navOuterClassName}>
313
367
  <nav
314
- className={navInnerClassName}
315
- style={{ backgroundColor: 'hsl(var(--background) / 0.72)', backdropFilter: 'blur(10px)' }}
368
+ className={cn(
369
+ navInnerClassName,
370
+ // Light: glass over page bg. Dark: slightly lifted vs --background (see ui-core theme/dark.css --card).
371
+ 'bg-background/72 backdrop-blur-[10px] dark:bg-card/80',
372
+ )}
316
373
  >
317
374
  <div className="w-full pl-6 pr-3 sm:px-4 lg:px-6">
318
375
  <div className="flex items-center justify-between py-3.5">
319
- {/* Logo */}
320
- {brand ? (
321
- brand
322
- ) : (
323
- <Link href={brandHref} className="flex items-center gap-1.5">
324
- {logo && (
325
- <img src={logo} alt={siteName} className="h-5.5 w-auto object-contain" />
326
- )}
327
- <span className="font-bold text-[15px]">{siteName}</span>
328
- </Link>
329
- )}
376
+ {/* Brand */}
377
+ <div className="min-w-0 shrink-0 flex items-center">
378
+ {brand != null && brand !== '' && brand !== false
379
+ ? typeof brand === 'string'
380
+ ? (
381
+ <Link
382
+ href={brandHref}
383
+ className="font-bold text-[15px] text-foreground hover:opacity-90 transition-opacity"
384
+ >
385
+ {brand}
386
+ </Link>
387
+ )
388
+ : brand
389
+ : null}
390
+ </div>
330
391
 
331
392
  {/* Desktop Navigation */}
332
- <div className="hidden lg:flex items-center gap-3">
393
+ <div className="hidden isolate lg:flex items-center gap-1">
333
394
  {desktopPrimaryNavigation.map(renderDesktopNavItem)}
334
- {desktopOverflowNavigation.length > 0 && (
395
+ {hasDesktopOverflowNav && (
335
396
  <div
336
397
  className="relative"
337
398
  onMouseEnter={() => scheduleOpen('__overflow-more')}
@@ -340,41 +401,48 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
340
401
  <Button
341
402
  variant="ghost"
342
403
  size="sm"
343
- className={`group ${desktopNavItemClass} ${
344
- openDropdownKey === '__overflow-more' || desktopOverflowNavigation.some((item) => isGroupActive(item))
345
- ? 'bg-accent/50 text-foreground'
346
- : ''
347
- }`}
404
+ className={cn(
405
+ 'group h-auto min-h-9 max-w-[15rem] gap-1 shadow-none [&_svg]:size-3.5 [&_svg]:shrink-0',
406
+ desktopNavItemClass,
407
+ moreMenuButtonActive && desktopNavItemActiveClass,
408
+ isMoreMenuOpen && 'border-0 dark:border dark:border-border',
409
+ )}
348
410
  >
349
- <span>More</span>
350
- <ChevronDown
351
- className={`ml-1 h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
352
- openDropdownKey === '__overflow-more' ? 'rotate-180' : ''
353
- }`}
354
- />
411
+ <span className={desktopNavLabelClass}>More</span>
412
+ <span
413
+ className="inline-flex size-3.5 shrink-0 items-center justify-center"
414
+ aria-hidden
415
+ >
416
+ <ChevronDown
417
+ className={`size-3.5 origin-center text-muted-foreground transition-transform duration-200 ease-out will-change-transform ${
418
+ isMoreMenuOpen ? 'rotate-180' : ''
419
+ }`}
420
+ />
421
+ </span>
355
422
  </Button>
356
423
 
357
- {openDropdownKey === '__overflow-more' && (
424
+ {isMoreMenuOpen && (
358
425
  <div
359
- className="absolute right-0 top-full mt-1 z-[1200] min-w-56 rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1 shadow-[0_10px_28px_rgba(0,0,0,0.22)]"
426
+ className="absolute right-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]"
360
427
  onMouseEnter={() => {
361
428
  clearOpenTimer();
362
429
  clearCloseTimer();
363
430
  }}
364
431
  onMouseLeave={() => scheduleClose('__overflow-more')}
365
432
  >
366
- {desktopOverflowNavigation.map((item) => (
367
- <div key={`overflow-${item.href}`} className="rounded-md">
368
- <Link
369
- href={item.href}
370
- className={`block rounded-md px-2.5 py-2 text-sm font-medium hover:bg-accent/40 ${
371
- isGroupActive(item) ? 'bg-accent/45 text-foreground' : ''
372
- }`}
373
- >
374
- {item.label}
375
- </Link>
376
- </div>
377
- ))}
433
+ {desktopOverflowNavigation.map((navItem) => {
434
+ const overflowActive = isGroupActive(navItem);
435
+ return (
436
+ <div key={`overflow-${navItem.href}`} className="rounded-full">
437
+ <Link
438
+ href={navItem.href}
439
+ className={subMenuLinkClass(overflowActive)}
440
+ >
441
+ <span className="min-w-0 truncate" title={navItem.label}>{navItem.label}</span>
442
+ </Link>
443
+ </div>
444
+ );
445
+ })}
378
446
  </div>
379
447
  )}
380
448
  </div>
@@ -401,7 +469,7 @@ export function PublicNavigation(props: PublicNavigationProps = {}) {
401
469
  size="icon"
402
470
  aria-label={toggleMobileLabel}
403
471
  data-mobile-menu-trigger="true"
404
- className="lg:hidden"
472
+ className="lg:hidden rounded-full"
405
473
  onClick={toggleMobileMenu}
406
474
  >
407
475
  {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Theme-aware brand mark for public chrome (navbar, footer).
3
+ *
4
+ * Uses two layers and Tailwind `dark:` so the correct asset is chosen with **no JS theme hook**
5
+ * and **no hydration mismatch** — the same thing `next-themes` does for `html.dark` / class strategy.
6
+ *
7
+ * Prefer this over `useTheme().resolvedTheme` + one `<img>` unless you have a strong reason
8
+ * (single-DOM node only).
9
+ */
10
+
11
+ 'use client';
12
+
13
+ import React, { type ReactNode } from 'react';
14
+
15
+ import { cn } from '@djangocfg/ui-core/lib';
16
+
17
+ export interface ThemeBrandMarkProps {
18
+ /**
19
+ * Mark when the app is in **light** appearance (`html` without `.dark`).
20
+ * Usually a dark-colored logo on a light bar.
21
+ */
22
+ light: ReactNode;
23
+ /**
24
+ * Mark when the app is in **dark** appearance (`html.dark`).
25
+ * Usually a light-colored logo on a dark bar.
26
+ */
27
+ dark: ReactNode;
28
+ /** Outer wrapper — keep sizing here (e.g. `h-5.5 w-auto`). */
29
+ className?: string;
30
+ /** `role="img"` label when children are decorative. */
31
+ 'aria-label'?: string;
32
+ }
33
+
34
+ /**
35
+ * Renders two branches; only one is visible. Same idea as pairing
36
+ * `className="dark:hidden"` / `className="hidden dark:block"` on `<img>`, but works with
37
+ * any node (SVG component, picture, etc.).
38
+ */
39
+ export function ThemeBrandMark({ light, dark, className, 'aria-label': ariaLabel }: ThemeBrandMarkProps) {
40
+ return (
41
+ <span
42
+ className={cn('inline-flex shrink-0 items-center justify-center', className)}
43
+ role={ariaLabel ? 'img' : undefined}
44
+ aria-label={ariaLabel}
45
+ >
46
+ <span className="flex items-center justify-center dark:hidden">{light}</span>
47
+ <span className="hidden items-center justify-center dark:flex">{dark}</span>
48
+ </span>
49
+ );
50
+ }
51
+
52
+ export interface ThemeBrandMarkImgProps {
53
+ /** Logo URL for light UI (no `html.dark`). */
54
+ srcLight: string;
55
+ /** Logo URL for dark UI (`html.dark`). */
56
+ srcDark: string;
57
+ alt?: string;
58
+ /** Applied to both images (e.g. `h-5.5 w-auto object-contain`). */
59
+ className?: string;
60
+ wrapperClassName?: string;
61
+ 'aria-label'?: string;
62
+ }
63
+
64
+ /**
65
+ * Convenience wrapper around {@link ThemeBrandMark} for the common “two raster/SVG URLs” case.
66
+ */
67
+ export function ThemeBrandMarkImg({
68
+ srcLight,
69
+ srcDark,
70
+ alt = '',
71
+ className,
72
+ wrapperClassName,
73
+ 'aria-label': ariaLabel,
74
+ }: ThemeBrandMarkImgProps) {
75
+ return (
76
+ <ThemeBrandMark
77
+ className={wrapperClassName}
78
+ aria-label={ariaLabel}
79
+ light={<img src={srcLight} alt={alt} className={className} />}
80
+ dark={<img src={srcDark} alt={alt} className={className} />}
81
+ />
82
+ );
83
+ }
@@ -6,4 +6,6 @@ export { PublicNavigation } from './PublicNavigation';
6
6
  export { PublicMobileDrawer } from './PublicMobileDrawer';
7
7
  export { PublicNavbar } from './PublicNavbar';
8
8
  export { PublicFooter } from './PublicFooter';
9
+ export { ThemeBrandMark, ThemeBrandMarkImg } from './ThemeBrandMark';
10
+ export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './ThemeBrandMark';
9
11
 
@@ -9,6 +9,7 @@ export type {
9
9
  PublicNavbarSurface,
10
10
  PublicNavbarVariant,
11
11
  PublicNavbarPosition,
12
+ PublicNavbarShellConfig,
12
13
  } from './navbarTypes';
13
14
  export type {
14
15
  PublicDesktopDropdownRenderer,
@@ -23,8 +24,12 @@ export {
23
24
  FooterSocialLinksComponent,
24
25
  DjangoCFGLogo,
25
26
  } from './components/PublicFooter';
27
+ export { ThemeBrandMark, ThemeBrandMarkImg } from './components/ThemeBrandMark';
28
+ export type { ThemeBrandMarkProps, ThemeBrandMarkImgProps } from './components/ThemeBrandMark';
26
29
  export type {
27
30
  PublicFooterProps,
31
+ PublicFooterConfig,
32
+ FooterProjectInfoProps,
28
33
  } from './components/PublicFooter';
29
34
  export { PublicLayoutProvider, usePublicLayout } from './context';
30
35
 
@@ -10,3 +10,11 @@ export interface PublicNavbarSurface {
10
10
  variant: PublicNavbarVariant;
11
11
  position: PublicNavbarPosition;
12
12
  }
13
+
14
+ /** Floating bar + mobile drawer shell (rounding, width / centering). */
15
+ export interface PublicNavbarShellConfig {
16
+ /** Tailwind rounding class (e.g. `rounded-3xl`). */
17
+ rounding?: string;
18
+ /** Strip + drawer wrapper (e.g. `mx-auto max-w-7xl`). */
19
+ className?: string;
20
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared “floating glass” chrome for navbar + mobile drawer.
3
+ *
4
+ * Light: barely-there lift. Dark: one soft outer shadow (border already defines the edge).
5
+ */
6
+ export const publicFloatingChromeClassName =
7
+ [
8
+ '!border-0 ring-0 outline-none',
9
+ 'shadow-[0_1px_6px_rgba(0,0,0,0.028)]',
10
+ 'dark:!border dark:!border-border/75',
11
+ 'dark:shadow-[0_3px_14px_rgba(0,0,0,0.07)]',
12
+ ].join(' ');
@@ -138,7 +138,7 @@ export function UserMenu({
138
138
  <div className="pt-4 border-t border-border/50">
139
139
  <Link
140
140
  href={authPath}
141
- className="group flex items-center justify-between rounded-lg px-2 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
141
+ className="group flex items-center justify-between rounded-full px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
142
142
  >
143
143
  <span>{labels.signIn}</span>
144
144
  <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
@@ -148,7 +148,7 @@ export function UserMenu({
148
148
  }
149
149
  return (
150
150
  <Link href={authPath}>
151
- <Button variant="default" size="sm">
151
+ <Button variant="default" size="sm" className="rounded-full px-5">
152
152
  {labels.signIn}
153
153
  </Button>
154
154
  </Link>
@@ -10,7 +10,15 @@
10
10
  // ============================================================================
11
11
 
12
12
  // Local provider configs
13
- export type { ThemeConfig, SWRConfigOptions, CentrifugoConfig } from './providers.types';
13
+ export type {
14
+ ThemeConfig,
15
+ ThemeStyleConfig,
16
+ ThemeCssVarKey,
17
+ ThemeCssVarMap,
18
+ ThemeStylePresetId,
19
+ SWRConfigOptions,
20
+ CentrifugoConfig,
21
+ } from './providers.types';
14
22
 
15
23
  // External provider configs (re-export for convenience)
16
24
  export type { AnalyticsConfig } from '../../snippets/Analytics/types';
@@ -5,6 +5,11 @@
5
5
  * Note: Analytics, PWA, Push, and Error types are defined in their respective modules
6
6
  */
7
7
 
8
+ import type { ThemeStyleConfig } from '../../theme/themeStyle.types';
9
+
10
+ // Re-export for consumers that only import from `layouts/types`
11
+ export type { ThemeStyleConfig, ThemeCssVarKey, ThemeCssVarMap, ThemeStylePresetId } from '../../theme/themeStyle.types';
12
+
8
13
  // ============================================================================
9
14
  // Theme Configuration
10
15
  // ============================================================================
@@ -12,6 +17,11 @@
12
17
  export interface ThemeConfig {
13
18
  defaultTheme?: 'light' | 'dark' | 'system';
14
19
  storageKey?: string;
20
+ /**
21
+ * Typed CSS variable presets and per-mode overrides (see `ThemeStyleBridge` in BaseApp).
22
+ * For heavy visual editing, use the playground Theme Configurator and export CSS instead.
23
+ */
24
+ style?: ThemeStyleConfig;
15
25
  }
16
26
 
17
27
  // ============================================================================
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo } from 'react';
4
+
5
+ import { buildThemeStyleSheet } from './buildThemeStyleSheet';
6
+
7
+ import type { ThemeStyleConfig } from './themeStyle.types';
8
+
9
+ const STYLE_ELEMENT_ID = 'djangocfg-baseapp-theme-style';
10
+
11
+ export interface ThemeStyleBridgeProps {
12
+ style?: ThemeStyleConfig;
13
+ }
14
+
15
+ /**
16
+ * Injects merged theme CSS variables after globals (preset + typed overrides).
17
+ * Renders nothing; safe to mount once under ThemeProvider.
18
+ */
19
+ export function ThemeStyleBridge({ style }: ThemeStyleBridgeProps) {
20
+ const css = useMemo(() => buildThemeStyleSheet(style), [style]);
21
+
22
+ useEffect(() => {
23
+ if (typeof document === 'undefined') return;
24
+
25
+ let el = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;
26
+ if (!css) {
27
+ el?.remove();
28
+ return;
29
+ }
30
+
31
+ if (!el) {
32
+ el = document.createElement('style');
33
+ el.id = STYLE_ELEMENT_ID;
34
+ document.head.appendChild(el);
35
+ }
36
+
37
+ el.textContent = css;
38
+ }, [css]);
39
+
40
+ return null;
41
+ }