@djangocfg/layouts 2.1.591 → 2.1.592

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 (71) hide show
  1. package/package.json +12 -12
  2. package/src/layouts/PrivateLayout/PrivateLayout.tsx +37 -26
  3. package/src/layouts/PrivateLayout/README.md +20 -12
  4. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +29 -42
  5. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +23 -92
  6. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +32 -62
  7. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +45 -0
  8. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +64 -152
  9. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -30
  10. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +55 -115
  11. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +37 -100
  12. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +34 -0
  13. package/src/layouts/PrivateLayout/components/SidebarResizeHandle.tsx +76 -0
  14. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +14 -53
  15. package/src/layouts/PrivateLayout/components/index.ts +0 -4
  16. package/src/layouts/PrivateLayout/context.tsx +10 -115
  17. package/src/layouts/PrivateLayout/geometry.ts +17 -0
  18. package/src/layouts/PrivateLayout/hooks/index.ts +2 -1
  19. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  20. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +33 -93
  21. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +151 -0
  22. package/src/layouts/PrivateLayout/hooks/useSidebarWidth.ts +83 -0
  23. package/src/layouts/PrivateLayout/types.ts +13 -20
  24. package/src/layouts/SettingsLayout/README.md +23 -10
  25. package/src/layouts/SettingsLayout/SettingsDialog.tsx +14 -8
  26. package/src/layouts/SettingsLayout/SettingsForm.tsx +13 -9
  27. package/src/layouts/SettingsLayout/components/ApiKeySection/ApiKeySection.tsx +9 -7
  28. package/src/layouts/SettingsLayout/components/ApiKeySection/context.tsx +6 -41
  29. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +11 -3
  30. package/src/layouts/SettingsLayout/components/SettingsNav.tsx +5 -9
  31. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +5 -7
  32. package/src/layouts/SettingsLayout/components/SettingsPanel.tsx +5 -10
  33. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +5 -3
  34. package/src/layouts/SettingsLayout/components/SettingsShell.tsx +14 -21
  35. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +52 -105
  36. package/src/layouts/SettingsLayout/components/index.ts +0 -1
  37. package/src/layouts/SettingsLayout/context/SettingsContext.tsx +5 -2
  38. package/src/layouts/SettingsLayout/i18n/index.ts +39 -0
  39. package/src/layouts/SettingsLayout/i18n/locales/ar.ts +66 -0
  40. package/src/layouts/SettingsLayout/i18n/locales/da.ts +66 -0
  41. package/src/layouts/SettingsLayout/i18n/locales/de.ts +66 -0
  42. package/src/layouts/SettingsLayout/i18n/locales/en.ts +66 -0
  43. package/src/layouts/SettingsLayout/i18n/locales/es.ts +66 -0
  44. package/src/layouts/SettingsLayout/i18n/locales/fr.ts +66 -0
  45. package/src/layouts/SettingsLayout/i18n/locales/it.ts +66 -0
  46. package/src/layouts/SettingsLayout/i18n/locales/ja.ts +66 -0
  47. package/src/layouts/SettingsLayout/i18n/locales/ko.ts +66 -0
  48. package/src/layouts/SettingsLayout/i18n/locales/nl.ts +66 -0
  49. package/src/layouts/SettingsLayout/i18n/locales/no.ts +66 -0
  50. package/src/layouts/SettingsLayout/i18n/locales/pl.ts +66 -0
  51. package/src/layouts/SettingsLayout/i18n/locales/ptBR.ts +66 -0
  52. package/src/layouts/SettingsLayout/i18n/locales/ru.ts +66 -0
  53. package/src/layouts/SettingsLayout/i18n/locales/sv.ts +66 -0
  54. package/src/layouts/SettingsLayout/i18n/locales/tr.ts +66 -0
  55. package/src/layouts/SettingsLayout/i18n/locales/zh.ts +66 -0
  56. package/src/layouts/SettingsLayout/i18n/types.ts +65 -0
  57. package/src/layouts/SettingsLayout/index.ts +0 -1
  58. package/src/layouts/SettingsLayout/sections/AccountSection.tsx +9 -4
  59. package/src/layouts/SettingsLayout/sections/DeleteAccountRow.tsx +4 -1
  60. package/src/layouts/SettingsLayout/sections/GeneralSection.tsx +25 -0
  61. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +12 -4
  62. package/src/layouts/SettingsLayout/sections/builtins.tsx +34 -29
  63. package/src/layouts/SettingsLayout/sections/index.ts +2 -2
  64. package/src/layouts/SettingsLayout/types.ts +12 -4
  65. package/src/layouts/types/layout.types.ts +5 -6
  66. package/src/styles/dashboard.css +10 -0
  67. package/src/layouts/PrivateLayout/density.ts +0 -48
  68. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +0 -110
  69. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +0 -130
  70. package/src/layouts/SettingsLayout/components/SettingsRailFooter.tsx +0 -74
  71. package/src/layouts/SettingsLayout/sections/SecuritySection.tsx +0 -15
@@ -1,27 +1,11 @@
1
1
  /**
2
- * Sidebar Keyboard Navigation Hook
3
- *
4
- * Global shortcuts:
5
- * - Ctrl/Cmd + B → toggle sidebar expanded/collapsed (desktop)
6
- * - Escape → close mobile drawer / hover overlay
7
- *
8
- * Focus-trap navigation inside the sidebar:
9
- * - ArrowDown / ArrowUp → move focus between focusable items
10
- * - Home → first focusable item
11
- * - End → last focusable item
12
- * - Enter / Space → activate (native behaviour, no override)
2
+ * Sidebar focus navigation (ArrowUp/Down, Home, End between focusable rows).
3
+ * Cmd/Ctrl+B belongs to `SidebarProvider`; the mobile drawer closes itself on Escape.
13
4
  */
14
5
 
15
6
  'use client';
16
7
 
17
- import { useCallback, useEffect, useRef } from 'react';
18
-
19
- import { useSidebar } from '@djangocfg/ui-core/components';
20
-
21
- interface UseSidebarKeyboardOptions {
22
- /** Whether keyboard shortcuts are active */
23
- enabled?: boolean;
24
- }
8
+ import { useCallback, useRef } from 'react';
25
9
 
26
10
  const FOCUSABLE_SELECTOR = [
27
11
  'a[href]',
@@ -29,83 +13,39 @@ const FOCUSABLE_SELECTOR = [
29
13
  '[tabindex]:not([tabindex="-1"])',
30
14
  ].join(', ');
31
15
 
32
- export function useSidebarKeyboard({ enabled = true }: UseSidebarKeyboardOptions = {}) {
33
- const { toggleSidebar, setOpenMobile, isMobile, openMobile } = useSidebar();
16
+ export function useSidebarKeyboard() {
34
17
  const sidebarRef = useRef<HTMLElement | null>(null);
35
18
 
36
- // ---------------------------------------------------------------------------
37
- // Global shortcuts (Ctrl/Cmd+B, Escape)
38
- // ---------------------------------------------------------------------------
39
-
40
- useEffect(() => {
41
- if (!enabled) return;
42
-
43
- const onKeyDown = (e: KeyboardEvent) => {
44
- // Ctrl/Cmd + B → toggle sidebar
45
- if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
46
- e.preventDefault();
47
- if (isMobile) {
48
- setOpenMobile(!openMobile);
49
- } else {
50
- toggleSidebar();
51
- }
19
+ const handleSidebarKeyDown = useCallback((e: React.KeyboardEvent<HTMLElement>) => {
20
+ const root = sidebarRef.current;
21
+ if (!root) return;
22
+
23
+ const focusables = Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
24
+ (el) => el.offsetParent !== null,
25
+ );
26
+ const current = focusables.indexOf(document.activeElement as HTMLElement);
27
+ if (current === -1) return;
28
+
29
+ let next = current;
30
+ switch (e.key) {
31
+ case 'ArrowDown':
32
+ next = (current + 1) % focusables.length;
33
+ break;
34
+ case 'ArrowUp':
35
+ next = (current - 1 + focusables.length) % focusables.length;
36
+ break;
37
+ case 'Home':
38
+ next = 0;
39
+ break;
40
+ case 'End':
41
+ next = focusables.length - 1;
42
+ break;
43
+ default:
52
44
  return;
53
- }
54
-
55
- // Escape → close mobile drawer or hover overlay
56
- if (e.key === 'Escape') {
57
- if (isMobile && openMobile) {
58
- setOpenMobile(false);
59
- }
60
- }
61
- };
62
-
63
- window.addEventListener('keydown', onKeyDown);
64
- return () => window.removeEventListener('keydown', onKeyDown);
65
- }, [enabled, isMobile, openMobile, setOpenMobile, toggleSidebar]);
66
-
67
- // ---------------------------------------------------------------------------
68
- // Focus navigation inside sidebar (arrow keys, Home, End)
69
- // ---------------------------------------------------------------------------
70
-
71
- const handleSidebarKeyDown = useCallback(
72
- (e: React.KeyboardEvent<HTMLElement>) => {
73
- if (!sidebarRef.current) return;
74
-
75
- const focusables = Array.from(
76
- sidebarRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
77
- ).filter((el) => !el.hasAttribute('disabled') && el.offsetParent !== null);
78
-
79
- const currentIndex = focusables.indexOf(document.activeElement as HTMLElement);
80
- if (currentIndex === -1) return;
81
-
82
- let nextIndex = currentIndex;
83
-
84
- switch (e.key) {
85
- case 'ArrowDown':
86
- e.preventDefault();
87
- nextIndex = (currentIndex + 1) % focusables.length;
88
- break;
89
- case 'ArrowUp':
90
- e.preventDefault();
91
- nextIndex = (currentIndex - 1 + focusables.length) % focusables.length;
92
- break;
93
- case 'Home':
94
- e.preventDefault();
95
- nextIndex = 0;
96
- break;
97
- case 'End':
98
- e.preventDefault();
99
- nextIndex = focusables.length - 1;
100
- break;
101
- default:
102
- return;
103
- }
104
-
105
- focusables[nextIndex]?.focus();
106
- },
107
- [],
108
- );
45
+ }
46
+ e.preventDefault();
47
+ focusables[next]?.focus();
48
+ }, []);
109
49
 
110
50
  const setSidebarRef = useCallback((node: HTMLElement | null) => {
111
51
  sidebarRef.current = node;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Peek: while the sidebar is collapsed, hovering its trigger zone floats the
3
+ * full sidebar over the page. The panel keeps itself open while the pointer is
4
+ * on it, or while a menu opened from it (account, workspace switcher) is open.
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React, {
10
+ createContext,
11
+ useCallback,
12
+ useContext,
13
+ useEffect,
14
+ useMemo,
15
+ useRef,
16
+ useState,
17
+ } from 'react';
18
+
19
+ import { useSidebar } from '@djangocfg/ui-core/components';
20
+
21
+ // Opens on entry with no delay, then stays at least MIN_OPEN_MS even if the
22
+ // pointer only grazed the zone, so a user who didn't notice it still sees it.
23
+ const MIN_OPEN_MS = 900;
24
+ const CLOSE_DELAY_MS = 300;
25
+
26
+ interface PointerHandlers {
27
+ onPointerEnter: (e: React.PointerEvent) => void;
28
+ onPointerLeave: (e: React.PointerEvent) => void;
29
+ }
30
+
31
+ export interface SidebarPeek {
32
+ /** True while the sidebar is collapsed on desktop: the trigger zone shows. */
33
+ enabled: boolean;
34
+ open: boolean;
35
+ zoneProps: PointerHandlers;
36
+ panelProps: PointerHandlers;
37
+ close: () => void;
38
+ /** Keep the panel open while something launched from it is open. */
39
+ pin: (pinned: boolean) => void;
40
+ }
41
+
42
+ const PeekContext = createContext<SidebarPeek | null>(null);
43
+
44
+ export function useSidebarPeek(): SidebarPeek | null {
45
+ return useContext(PeekContext);
46
+ }
47
+
48
+ /** Pins the peek panel for as long as `active` is true. */
49
+ export function usePeekPin(active: boolean): void {
50
+ const peek = useContext(PeekContext);
51
+ const pin = peek?.pin;
52
+ useEffect(() => {
53
+ if (!pin) return;
54
+ pin(active);
55
+ return () => pin(false);
56
+ }, [pin, active]);
57
+ }
58
+
59
+ interface SidebarPeekProviderProps {
60
+ pathname: string;
61
+ children: React.ReactNode;
62
+ }
63
+
64
+ export function SidebarPeekProvider({ pathname, children }: SidebarPeekProviderProps) {
65
+ const { state, isMobile } = useSidebar();
66
+ const enabled = !isMobile && state === 'collapsed';
67
+
68
+ const [open, setOpen] = useState(false);
69
+ const pinned = useRef(false);
70
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
71
+ const openedAt = useRef(0);
72
+
73
+ const cancel = useCallback(() => {
74
+ if (timer.current) clearTimeout(timer.current);
75
+ timer.current = null;
76
+ }, []);
77
+
78
+ const scheduleClose = useCallback(() => {
79
+ cancel();
80
+ if (pinned.current) return;
81
+ const grace = openedAt.current + MIN_OPEN_MS - Date.now();
82
+ timer.current = setTimeout(() => setOpen(false), Math.max(CLOSE_DELAY_MS, grace));
83
+ }, [cancel]);
84
+
85
+ const show = useCallback(() => {
86
+ cancel();
87
+ setOpen((was) => {
88
+ if (!was) openedAt.current = Date.now();
89
+ return true;
90
+ });
91
+ }, [cancel]);
92
+
93
+ const close = useCallback(() => {
94
+ cancel();
95
+ setOpen(false);
96
+ }, [cancel]);
97
+
98
+ const pin = useCallback(
99
+ (value: boolean) => {
100
+ pinned.current = value;
101
+ if (!value) scheduleClose();
102
+ },
103
+ [scheduleClose],
104
+ );
105
+
106
+ // Hover is a mouse affordance; touch taps the trigger to dock instead.
107
+ const zoneProps = useMemo<PointerHandlers>(
108
+ () => ({
109
+ onPointerEnter: (e) => {
110
+ if (e.pointerType !== 'mouse') return;
111
+ show();
112
+ },
113
+ onPointerLeave: scheduleClose,
114
+ }),
115
+ [show, scheduleClose],
116
+ );
117
+
118
+ const panelProps = useMemo<PointerHandlers>(
119
+ () => ({
120
+ onPointerEnter: show,
121
+ onPointerLeave: scheduleClose,
122
+ }),
123
+ [show, scheduleClose],
124
+ );
125
+
126
+ useEffect(() => {
127
+ if (!enabled) close();
128
+ }, [enabled, close]);
129
+
130
+ useEffect(() => {
131
+ close();
132
+ }, [pathname, close]);
133
+
134
+ useEffect(() => {
135
+ if (!open) return;
136
+ const onKey = (e: KeyboardEvent) => {
137
+ if (e.key === 'Escape') close();
138
+ };
139
+ window.addEventListener('keydown', onKey);
140
+ return () => window.removeEventListener('keydown', onKey);
141
+ }, [open, close]);
142
+
143
+ useEffect(() => cancel, [cancel]);
144
+
145
+ const value = useMemo<SidebarPeek>(
146
+ () => ({ enabled, open: enabled && open, zoneProps, panelProps, close, pin }),
147
+ [enabled, open, zoneProps, panelProps, close, pin],
148
+ );
149
+
150
+ return <PeekContext.Provider value={value}>{children}</PeekContext.Provider>;
151
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Sidebar width: the user drags the edge, the choice is remembered.
3
+ *
4
+ * The width is one CSS variable (`--sidebar-width`) on the provider, so the
5
+ * docked sidebar, its spacer and the peek panel all follow it with no prop
6
+ * plumbing.
7
+ */
8
+
9
+ 'use client';
10
+
11
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
12
+ import type { CSSProperties } from 'react';
13
+
14
+ const STORAGE_KEY = 'cfg.layouts.sidebar-width';
15
+ const DEFAULT_WIDTH = 256; // 16rem
16
+ const MIN_WIDTH = 224; // 14rem
17
+ const MAX_WIDTH = 384; // 24rem
18
+
19
+ const clamp = (px: number) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(px)));
20
+
21
+ function readStored(): number | null {
22
+ try {
23
+ const raw = window.localStorage.getItem(STORAGE_KEY);
24
+ const px = raw ? Number(raw) : NaN;
25
+ return Number.isFinite(px) ? clamp(px) : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export interface SidebarWidth {
32
+ enabled: boolean;
33
+ width: number;
34
+ min: number;
35
+ max: number;
36
+ dragging: boolean;
37
+ setWidth: (px: number) => void;
38
+ setDragging: (value: boolean) => void;
39
+ reset: () => void;
40
+ /** CSS variables for the provider element. */
41
+ style: CSSProperties | undefined;
42
+ }
43
+
44
+ export function useSidebarWidth(enabled: boolean): SidebarWidth {
45
+ const [width, setWidthState] = useState(DEFAULT_WIDTH);
46
+ const [dragging, setDragging] = useState(false);
47
+
48
+ useEffect(() => {
49
+ if (!enabled) return;
50
+ const stored = readStored();
51
+ if (stored) setWidthState(stored);
52
+ }, [enabled]);
53
+
54
+ // Persist once the drag ends, not on every pointer move.
55
+ useEffect(() => {
56
+ if (!enabled || dragging) return;
57
+ try {
58
+ if (width === DEFAULT_WIDTH) window.localStorage.removeItem(STORAGE_KEY);
59
+ else window.localStorage.setItem(STORAGE_KEY, String(width));
60
+ } catch {
61
+ // Storage unavailable: the width simply lasts for the session.
62
+ }
63
+ }, [enabled, dragging, width]);
64
+
65
+ const setWidth = useCallback((px: number) => setWidthState(clamp(px)), []);
66
+ const reset = useCallback(() => setWidthState(DEFAULT_WIDTH), []);
67
+
68
+ const style = useMemo<CSSProperties | undefined>(
69
+ () => (enabled ? ({ '--sidebar-width': `${width}px` } as CSSProperties) : undefined),
70
+ [enabled, width],
71
+ );
72
+
73
+ return useMemo(
74
+ () => ({ enabled, width, min: MIN_WIDTH, max: MAX_WIDTH, dragging, setWidth, setDragging, reset, style }),
75
+ [enabled, width, dragging, setWidth, reset, style],
76
+ );
77
+ }
78
+
79
+ export const SidebarWidthContext = createContext<SidebarWidth | null>(null);
80
+
81
+ export function useSidebarWidthContext(): SidebarWidth | null {
82
+ return useContext(SidebarWidthContext);
83
+ }
@@ -88,26 +88,24 @@ export interface SidebarConfig {
88
88
  * (above `footer` + account block).
89
89
  */
90
90
  menuEnd?: ReactNode;
91
- /**
92
- * Keep `menuStart` visible when the desktop sidebar is collapsed to the
93
- * icon rail. Default `false` — most slot content is full-width and looks
94
- * broken at ~56px. Set `true` only when the slot renders well in compact mode.
95
- */
91
+ /** @deprecated No effect: a collapsed sidebar is hidden, not an icon rail. */
96
92
  menuStartShowOnCollapsed?: boolean;
97
- /** Same as `menuStartShowOnCollapsed`, but for `menuEnd`. Default `false`. */
93
+ /** @deprecated No effect: a collapsed sidebar is hidden, not an icon rail. */
98
94
  menuEndShowOnCollapsed?: boolean;
95
+ /**
96
+ * Let the user drag the sidebar's right edge (14–24rem, remembered; double-click
97
+ * resets). Default `true`.
98
+ */
99
+ resizable?: boolean;
99
100
  /** Custom footer component rendered at the bottom of the sidebar */
100
101
  footer?: ReactNode;
101
- /** Active-state visual on nav items. Default `'background'` (legacy). */
102
+ /** @deprecated No effect: the active item is always a surface. */
102
103
  activeIndicator?: SidebarActiveIndicator;
103
- /** Style of group labels. Default `'uppercase'` (legacy). Collapsible groups always use `plain`. */
104
+ /** @deprecated No effect: group labels are always sentence case. */
104
105
  groupLabelStyle?: SidebarGroupLabelStyle;
105
106
  /** Featured CTA tile rendered below all groups, above `menuEnd`. */
106
107
  featured?: SidebarFeaturedConfig;
107
- /**
108
- * Override the auto-calculated nav density. When omitted density is derived
109
- * from total item count (comfortable ≤5, default ≤9, compact >9).
110
- */
108
+ /** @deprecated No effect: rows are one desktop size and one 44px touch size. */
111
109
  density?: 'comfortable' | 'default' | 'compact';
112
110
  }
113
111
 
@@ -167,11 +165,7 @@ export interface HeaderConfig {
167
165
  brandLetter?: string;
168
166
  /** User menu groups (account panel in the sidebar footer) */
169
167
  groups?: UserMenuConfig['groups'];
170
- /**
171
- * Behaviour of the footer account button.
172
- * - `'menu'` (default) — opens a DropdownMenu with account links, locale/theme controls, and sign-out.
173
- * - `'dialog'` — opens the global ProfileDialog (managed via Zustand store).
174
- */
168
+ /** @deprecated No effect: the footer account button always opens the account menu. */
175
169
  accountAction?: 'menu' | 'dialog';
176
170
  /** Auth page path (for sign in button) */
177
171
  authPath?: string;
@@ -213,9 +207,8 @@ export interface PrivateLayoutProps {
213
207
  */
214
208
  contentScroll?: 'auto' | 'hidden';
215
209
  /**
216
- * Visual style of the shell. Defaults to `'boxed'` (inset rounded card on a
217
- * sidebar-coloured canvas). Pass `{ variant: 'full-bleed' }` for the legacy
218
- * edge-to-edge layout.
210
+ * Visual style of the shell. Defaults to `'full-bleed'` (flat, Claude-style:
211
+ * sidebar + border + page). Pass `{ variant: 'boxed' }` for the inset rounded card.
219
212
  */
220
213
  visual?: LayoutVisualConfig;
221
214
  /**
@@ -1,6 +1,6 @@
1
1
  # SettingsLayout
2
2
 
3
- A Claude.ai-style **settings surface**: a searchable, grouped section rail on the
3
+ A Claude.ai-style **settings surface**: a grouped section rail (search appears from 10 sections, `searchable`) on the
4
4
  left and a scrollable detail panel on the right. Available as a **global dialog**
5
5
  (URL-hash driven, zustand-backed, openable from anywhere) or an **inline form**.
6
6
 
@@ -127,7 +127,7 @@ ordering/labelling, or just name an ad-hoc `group` on a section:
127
127
 
128
128
  ```ts
129
129
  const groups = [
130
- { id: 'general', label: 'General', order: 0 },
130
+ { id: 'general', order: 0 },
131
131
  { id: 'workspace', label: 'Workspace', order: 1 },
132
132
  ];
133
133
  ```
@@ -139,16 +139,19 @@ toggled via `builtins`:
139
139
 
140
140
  | id | flag | default | content |
141
141
  | ----------- | -------------------- | ------- | ------------------------------------ |
142
- | `account` | `builtins.account` | `true` | avatar + inline-editable profile/work fields, Preferences (Language dropdown + `ThemeSegmented`), and a **collapsible Danger zone** (delete account) |
143
- | `security` | `builtins.security` | `false` | two-factor auth (row-style) |
142
+ | `account` | `builtins.account` | `true` | avatar, inline-editable profile/work fields, **Security** block (two-factor auth; hide with `builtins.security: false`) and a **collapsible Danger zone** (delete account) |
143
+ | `general` | `builtins.general` | `true` | **General** block: **Theme** (`ThemeSegmented`), **Time zone** (`TimezoneSelect` → `useTimezone`, sent as `X-Timezone`) and **Language** (shown when a layout i18n context exists); host rows from `generalRows` follow in a second block ("Other") |
144
144
  | `api-keys` | `builtins.apiKeys` | `true` | API key display / regenerate / test (row-style) |
145
145
 
146
- Built-ins are merged **ahead of** app sections. They reuse the localized labels
146
+ `generalRows` (on `SettingsDialog` / `SettingsForm` / `PrivateLayout settings`) takes
147
+ your own `SettingRow`s for preferences only the host knows (e.g. a product-specific toggle); the layout adds none itself.
148
+
149
+ Rail order: **Account** (opens first), **Preferences** (id `general`), then Platform → API keys. Built-ins are merged **ahead of** app sections. They reuse the localized labels
147
150
  and save/validation logic from `ProfileProvider`, so behaviour stays in one place.
148
151
  The built-in bodies are built from the ui-core `SettingRow`/`SettingsBlock`
149
152
  primitives (see below) — flat rows with hairline dividers, no card frames.
150
153
 
151
- > The `security` / `api-keys` bodies hit the real backend (2FA status, API key
154
+ > The Account security block and `api-keys` body hit the real backend (2FA status, API key
152
155
  > retrieval). With no backend (e.g. Storybook) they show an error/empty state —
153
156
  > that's expected; gate them with `builtins` where there's no API.
154
157
 
@@ -215,17 +218,18 @@ SettingsLayout/
215
218
  │ └── useSettingsSections.ts pure: merge/order/group/filter sections
216
219
  ├── components/
217
220
  │ ├── SettingsShell.tsx desktop rail+panel vs mobile tabs+panel (useIsMobile)
218
- │ ├── SettingsNav.tsx desktop rail: title + search + grouped items
221
+ │ ├── SettingsShell.tsx header bar + rail + panel (desktop), tabs + panel (mobile)
222
+ │ ├── SettingsNav.tsx desktop rail: search + grouped items
219
223
  │ ├── SettingsNavItem.tsx one rail row
220
224
  │ ├── SettingsTabs.tsx mobile horizontal tab strip (text chips, auto-scroll active)
221
225
  │ ├── SettingsSearch.tsx filter box
222
226
  │ └── SettingsPanel.tsx header + independently-scrolling body
223
227
  └── sections/
224
- ├── builtins.tsx account/security/api-keys factory
225
- ├── AccountSection.tsx avatar + fields + preferences + danger zone (row-style)
228
+ ├── builtins.tsx general/account/api-keys factory
229
+ ├── GeneralSection.tsx Appearance + Language + host `generalRows`
230
+ ├── AccountSection.tsx avatar + fields + 2FA + danger zone (row-style)
226
231
  ├── PreferencesRows.tsx Language dropdown + ThemeSegmented as SettingRows
227
232
  ├── DeleteAccountRow.tsx compact destructive row (inside collapsible Danger zone)
228
- ├── SecuritySection.tsx 2FA wrapper
229
233
  └── ApiKeysSection.tsx API-keys wrapper
230
234
  ```
231
235
 
@@ -256,3 +260,12 @@ exported too; the row primitives come from `@djangocfg/ui-core/components`.
256
260
  2. `useProfileDialogStore.open({ initialTab })` → `useSettingsDialog().open(id)`.
257
261
  3. Mount `<SettingsDialog/>` instead of `<ProfileDialog/>` (or pass `settings` to
258
262
  PrivateLayout). The account button can be repointed to `useSettingsDialog()`.
263
+
264
+ ## Localization
265
+
266
+ Built-in text (rail labels, Preferences, Security, API key) comes from the
267
+ self-contained dictionaries in `i18n/` (17 locales, `useSettingsCopy`), following the
268
+ `@djangocfg/i18n` provider the host mounts, with English as the fallback. Widgets a host adds as sections (for example `mcpSettingsSection()` from `@djangocfg/widget-mcp`)
269
+ follow the same provider. Text the host passes in (`sections`, `groups`, `generalRows`, titles) is the
270
+ host's to translate; profile field labels still come from the central `layouts.profilePage.*`
271
+ catalogue. A new built-in string goes into every `i18n/locales/*.ts` (`tsc` names a missing key).
@@ -13,6 +13,7 @@ import { cn } from '@djangocfg/ui-core/lib';
13
13
 
14
14
  import { SettingsForm } from './SettingsForm';
15
15
  import { useSettingsUrl } from './hooks/useSettingsUrl';
16
+ import { useSettingsCopy } from './i18n';
16
17
  import { buildBuiltinSections } from './sections';
17
18
  import { useSettingsDialogStore } from './store';
18
19
  import type { SettingsDialogProps } from './types';
@@ -34,8 +35,9 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
34
35
  sections: appSections = [],
35
36
  groups,
36
37
  builtins = {},
38
+ generalRows,
37
39
  initialSection,
38
- searchable = true,
40
+ searchable = 'auto',
39
41
  enableDeleteAccount = true,
40
42
  }) => {
41
43
  const isOpen = useSettingsDialogStore((s) => s.isOpen);
@@ -43,15 +45,16 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
43
45
  const close = useSettingsDialogStore((s) => s.close);
44
46
  const setSection = useSettingsDialogStore((s) => s.setSection);
45
47
  const isMobile = useIsMobile();
48
+ const copy = useSettingsCopy();
46
49
 
47
50
  // Set of valid ids, so a bad URL hash falls back gracefully instead of
48
51
  // selecting a non-existent section. Cheap to recompute; built-ins are pure.
49
52
  const knownIds = useMemo(() => {
50
- const builtin = buildBuiltinSections({ builtins, enableDeleteAccount });
53
+ const builtin = buildBuiltinSections({ builtins, enableDeleteAccount, copy });
51
54
  const ids = new Set<string>();
52
55
  [...builtin, ...appSections].forEach((s) => ids.add(s.id));
53
56
  return ids;
54
- }, [builtins, enableDeleteAccount, appSections]);
57
+ }, [builtins, enableDeleteAccount, appSections, copy]);
55
58
 
56
59
  const resolveSection = useCallback(
57
60
  (id: string | null) => (id && knownIds.has(id) ? id : null),
@@ -60,22 +63,24 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
60
63
 
61
64
  useSettingsUrl({ urlKey, enabled: syncUrl, resolveSection });
62
65
 
63
- const resolvedTitle = title ?? 'Settings';
66
+ const resolvedTitle = title ?? copy.nav.title;
64
67
 
65
68
  return (
66
69
  <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
67
70
  <DialogContent
68
- // Desktop: centered card with breathing room (capped, gap to edges).
71
+ // Desktop: large centred card, proportional to the viewport.
69
72
  // Mobile: near-fullscreen sheet (small inset) so the phone settings get
70
73
  // the whole screen, like Claude. Elevated `bg-card` lifts it off the page.
71
74
  className={cn(
72
- 'flex max-w-none flex-col gap-0 overflow-hidden border border-border bg-card p-0',
75
+ 'flex max-w-none flex-col gap-0 overflow-hidden border border-foreground/5 bg-card p-0',
73
76
  isMobile
74
77
  ? 'h-[calc(100dvh-2rem)] w-[calc(100vw-1.5rem)]'
75
- : 'h-[min(620px,calc(100vh-6rem))] w-[min(900px,calc(100vw-4rem))]',
78
+ // Claude proportions: 1024×800 at most, 1.28:1 at every size (ui-core
79
+ // DialogContent derives the width from the viewport height).
80
+ : '[--aspect-dialog:1.28] [--width-dialog:1024px]',
76
81
  )}
77
82
  >
78
- {/* Accessible label — the visible heading lives in the nav rail. */}
83
+ {/* Accessible label — the visible heading is the shell header bar. */}
79
84
  <DialogTitle className="sr-only">
80
85
  {typeof resolvedTitle === 'string' ? resolvedTitle : 'Settings'}
81
86
  </DialogTitle>
@@ -89,6 +94,7 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
89
94
  sections={appSections}
90
95
  groups={groups}
91
96
  builtins={builtins}
97
+ generalRows={generalRows}
92
98
  initialSection={initialSection}
93
99
  searchable={searchable}
94
100
  enableDeleteAccount={enableDeleteAccount}
@@ -8,7 +8,8 @@ import { Preloader } from '@djangocfg/ui-core/components';
8
8
 
9
9
  import { SettingsShell } from './components';
10
10
  import { SettingsProvider } from './context';
11
- import { buildBuiltinSections, BUILTIN_GROUP } from './sections';
11
+ import { useSettingsCopy } from './i18n';
12
+ import { buildBuiltinSections, BUILTIN_GROUP, PLATFORM_GROUP } from './sections';
12
13
  import type { SettingsFormProps, SettingsSection } from './types';
13
14
 
14
15
  /**
@@ -30,8 +31,9 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
30
31
  sections: appSections = [],
31
32
  groups,
32
33
  builtins = {},
34
+ generalRows,
33
35
  initialSection,
34
- searchable = true,
36
+ searchable = 'auto',
35
37
  enableDeleteAccount = true,
36
38
  activeSection,
37
39
  onSectionChange,
@@ -40,21 +42,23 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
40
42
  bare = false,
41
43
  }: SettingsFormProps) => {
42
44
  const t = useAppT();
45
+ const copy = useSettingsCopy();
43
46
  const { user, isLoading } = useAuth();
44
47
 
45
48
  // Built-ins first, then app sections. Memoized so JSX identity is stable.
46
49
  const allSections = useMemo<SettingsSection[]>(() => {
47
- const builtin = buildBuiltinSections({ builtins, enableDeleteAccount });
50
+ const builtin = buildBuiltinSections({ builtins, enableDeleteAccount, generalRows, copy });
48
51
  return [...builtin, ...appSections];
49
- }, [builtins, enableDeleteAccount, appSections]);
52
+ }, [builtins, enableDeleteAccount, generalRows, appSections, copy]);
50
53
 
51
54
  // Ensure the built-in group exists/ordered even if the app passed its own groups.
52
55
  const allGroups = useMemo(() => {
53
56
  const provided = groups ?? [];
54
- return provided.some((g) => g.id === BUILTIN_GROUP.id)
55
- ? provided
56
- : [BUILTIN_GROUP, ...provided];
57
- }, [groups]);
57
+ const missing = [BUILTIN_GROUP, { ...PLATFORM_GROUP, label: copy.nav.platform }].filter(
58
+ (b) => !provided.some((g) => g.id === b.id),
59
+ );
60
+ return [...missing, ...provided];
61
+ }, [groups, copy]);
58
62
 
59
63
  // Uncontrolled selection fallback.
60
64
  const [localActive, setLocalActive] = useState<string | null>(initialSection ?? null);
@@ -82,7 +86,7 @@ export const SettingsForm: React.FC<SettingsFormProps> = ({
82
86
  }
83
87
 
84
88
  // Default to "Settings" — this surface is broader than the old Profile page.
85
- const resolvedTitle = title ?? 'Settings';
89
+ const resolvedTitle = title ?? copy.nav.title;
86
90
 
87
91
  return (
88
92
  <SettingsProvider