@astryxdesign/cli 0.4.7 → 0.5.0-canary.009bcb3

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 (105) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +53 -51
  3. package/api/docs/docs.doc.mjs +2 -2
  4. package/api/index.d.mts +1 -1
  5. package/api/index.mjs +1 -1
  6. package/api/search/search.mjs +50 -4
  7. package/api/search/search.test.mjs +71 -0
  8. package/api/template/data-token-fallbacks.test.mjs +85 -0
  9. package/api/template/template.doc.mjs +2 -2
  10. package/api/theme/build/build.mjs +43 -44
  11. package/api/theme/targets/targets.d.mts +18 -0
  12. package/api/theme/targets/targets.mjs +87 -0
  13. package/api/theme/targets/targets.test.mjs +66 -0
  14. package/api/theme/theme.d.mts +1 -0
  15. package/api/theme/theme.mjs +3 -1
  16. package/api/theme/theme.type.d.mts +23 -0
  17. package/api/theme/theme.type.mjs +21 -1
  18. package/api/theme/themeTargets.doc.d.mts +11 -0
  19. package/api/theme/themeTargets.doc.mjs +58 -0
  20. package/api/theme/themeTemplate.doc.mjs +3 -3
  21. package/assets/codemods/__tests__/registry.test.mjs +1 -0
  22. package/assets/codemods/registry.mjs +1 -0
  23. package/assets/codemods/transforms/v0.5.0/__tests__/next-codemods.test.mjs +127 -0
  24. package/assets/codemods/transforms/v0.5.0/banner-collapsible-content.mjs +171 -0
  25. package/assets/codemods/transforms/v0.5.0/index.mjs +20 -0
  26. package/assets/docs/README.md +50 -0
  27. package/assets/docs/cli-integrations.doc.mjs +4 -4
  28. package/assets/docs/theme.doc.dense.mjs +1 -1
  29. package/assets/docs/theme.doc.mjs +1 -1
  30. package/assets/docs/typography.doc.mjs +2 -2
  31. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.doc.mjs +1 -1
  32. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.tsx +1 -1
  33. package/assets/templates/blocks/components/DateInput/DateInputClearable.tsx +5 -1
  34. package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +6 -1
  35. package/assets/templates/blocks/components/DateInput/DateInputFormats.tsx +5 -1
  36. package/assets/templates/blocks/components/DateInput/DateInputShowcase.tsx +4 -1
  37. package/assets/templates/blocks/components/DateInput/DateInputWithDescription.tsx +5 -1
  38. package/assets/templates/blocks/components/DateInput/DateInputWithValidation.tsx +5 -1
  39. package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.doc.mjs +25 -0
  40. package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.tsx +167 -0
  41. package/assets/templates/blocks/components/Dialog/DialogScrollingContent.tsx +1 -1
  42. package/assets/templates/blocks/components/HoverCard/HoverCardHookUsage.tsx +4 -1
  43. package/assets/templates/blocks/components/Step/StepContent.doc.mjs +14 -0
  44. package/assets/templates/blocks/components/Step/StepContent.tsx +32 -0
  45. package/assets/templates/blocks/components/Step/StepIndicator.doc.mjs +14 -0
  46. package/assets/templates/blocks/components/Step/StepIndicator.tsx +60 -0
  47. package/assets/templates/blocks/components/Step/StepShowcase.doc.mjs +15 -0
  48. package/assets/templates/blocks/components/Step/StepShowcase.tsx +26 -0
  49. package/assets/templates/blocks/components/Step/StepStates.doc.mjs +14 -0
  50. package/assets/templates/blocks/components/Step/StepStates.tsx +46 -0
  51. package/assets/templates/blocks/components/Stepper/StepperCustomContent.doc.mjs +22 -0
  52. package/assets/templates/blocks/components/Stepper/StepperCustomContent.tsx +126 -0
  53. package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.doc.mjs +1 -1
  54. package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.tsx +17 -5
  55. package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.doc.mjs +14 -0
  56. package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.tsx +25 -0
  57. package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.doc.mjs +2 -2
  58. package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.tsx +1 -1
  59. package/assets/templates/blocks/components/Stepper/StepperShowcase.doc.mjs +1 -1
  60. package/assets/templates/blocks/components/Stepper/StepperShowcase.tsx +6 -7
  61. package/assets/templates/blocks/components/Stepper/StepperStatus.tsx +1 -1
  62. package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +1 -1
  63. package/assets/templates/pages/dashboard-data/page.tsx +1 -1
  64. package/assets/templates/pages/dashboard-portfolio/page.tsx +5 -5
  65. package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1 -1
  66. package/assets/templates/pages/mixed-gallery/page.tsx +12 -3
  67. package/assets/templates/pages/settings-dialog/page.tsx +2543 -757
  68. package/assets/templates/pages/settings-dialog/template.doc.mjs +1 -2
  69. package/assets/templates/pages/table-filter/page.tsx +4093 -0
  70. package/assets/templates/pages/table-filter/template.doc.mjs +12 -0
  71. package/assets/templates/pages/table-grouped/page.tsx +151 -144
  72. package/assets/templates/pages/theme-showcase/page.tsx +36 -17
  73. package/assets/templates/themes/chocolate/chocolateTheme.ts +3 -1
  74. package/assets/templates/themes/matcha/matchaTheme.ts +3 -1
  75. package/assets/templates/themes/neutral/neutralTheme.ts +16 -9
  76. package/assets/templates/themes/stone/stoneTheme.ts +3 -1
  77. package/clients/cli/commands/build-theme.color-scheme.test.mjs +7 -6
  78. package/clients/cli/commands/build-theme.data-tokens.test.mjs +144 -0
  79. package/clients/cli/commands/build-theme.mjs +85 -0
  80. package/clients/cli/commands/build-theme.variants.test.mjs +3 -0
  81. package/clients/cli/commands/dialog-adaptive-template.test.mjs +24 -0
  82. package/clients/cli/commands/theme-targets.behavior.test.mjs +64 -0
  83. package/clients/cli/commands/theme-targets.doc.mjs +38 -0
  84. package/clients/cli/commands/theme-template.doc.mjs +2 -2
  85. package/clients/cli/commands/theme.doc.mjs +4 -2
  86. package/clients/cli/index.mjs +1 -0
  87. package/clients/cli/lib/component-format.mjs +2 -2
  88. package/clients/cli/lib/manifest.mjs +2 -0
  89. package/foundation/discovery/component-discovery.mjs +10 -9
  90. package/foundation/discovery/hook-discovery.mjs +2 -1
  91. package/foundation/discovery/hook-discovery.test.mjs +156 -37
  92. package/foundation/discovery/theming-targets.d.mts +86 -0
  93. package/foundation/discovery/theming-targets.mjs +202 -0
  94. package/foundation/discovery/theming-targets.test.mjs +245 -0
  95. package/foundation/fs/paths.d.mts +16 -0
  96. package/foundation/fs/paths.mjs +36 -0
  97. package/foundation/fs/paths.test.mjs +29 -1
  98. package/foundation/response/response-types.doc.mjs +7 -2
  99. package/package.json +9 -9
  100. package/assets/templates/blocks/components/Stepper/StepperHorizontal.doc.mjs +0 -14
  101. package/assets/templates/blocks/components/Stepper/StepperHorizontal.tsx +0 -24
  102. package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.doc.mjs +0 -14
  103. package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.tsx +0 -92
  104. package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.doc.mjs +0 -14
  105. package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.tsx +0 -40
@@ -2,816 +2,2602 @@
2
2
 
3
3
  'use client';
4
4
 
5
- import React, {useState, type CSSProperties} from 'react';
6
- import {
7
- VStack,
8
- HStack,
9
- StackItem,
10
- Layout,
11
- LayoutPanel,
12
- LayoutContent,
13
- } from '@astryxdesign/core/Layout';
14
- import {Text, Heading} from '@astryxdesign/core/Text';
5
+ /**
6
+ * A settings dialog for a product with enough preferences to need navigation,
7
+ * search, grouping and responsive behavior—not just a short form.
8
+ *
9
+ * The side navigation and panel content read from one registry, so adding or
10
+ * renaming a destination updates both desktop and narrow layouts. Settings are
11
+ * grouped into muted cards of aligned rows; visual choices show live previews;
12
+ * every row switches to the same stacked layout when the panel runs out of
13
+ * width.
14
+ *
15
+ * The dialog is named by the visible Settings heading via `aria-labelledby`,
16
+ * the close control stays reachable while the panel scrolls, and the side rail
17
+ * becomes a horizontal tab strip below 640px rather than being squeezed into
18
+ * an unusable narrow column.
19
+ *
20
+ * ## Extending this template
21
+ *
22
+ * **Add a row before you add a panel.** A setting is one row in an existing
23
+ * subject. A panel is a navigation destination, so adding one is a hierarchy
24
+ * decision rather than a convenient place to put one control. The grouped
25
+ * registry below is the one list both shells read; add a panel there only when
26
+ * its settings cannot be named honestly by an existing destination.
27
+ *
28
+ * **Customise from the system outward.** Stop at the first level that works:
29
+ * (1) an Astryx component prop, (2) a theme token or component override when
30
+ * the value belongs across the surface, (3) local `xstyle` for layout the
31
+ * component cannot express. Anything after that needs a comment recording the
32
+ * missing system capability; otherwise one local fix becomes the next
33
+ * contributor's copy-paste pattern.
34
+ *
35
+ * **Choose a control from the value's shape, not taste:** on/off → `Switch`;
36
+ * 2–3 named choices → `SegmentedControl`; 4+ named choices → `Selector`;
37
+ * number → `NumberInput`; free text → `TextInput`; action rather than value →
38
+ * `Button`. A visual choice is the exception: use `SelectableCard` only when
39
+ * comparing the previews IS how someone decides. The row owns the visible
40
+ * label, so every control inside one keeps `isLabelHidden`.
41
+ *
42
+ * **Cards group one subject.** A heading that needs “and” names two cards.
43
+ * Cards carry no description: if a group needs a second sentence to explain
44
+ * why its rows belong together, the rows are not similar enough. Put the
45
+ * explanation on the setting it qualifies instead.
46
+ *
47
+ * **Separate settings by who they affect.** Most panels have one scope and need
48
+ * no extra heading. If a panel mixes settings that change something for a team
49
+ * with settings that change only the current person's experience, group the
50
+ * two scopes explicitly and state who is affected. Never interleave them for
51
+ * topical convenience: mistaking “only me” for “everyone” is a correctness and
52
+ * privacy bug, not a grouping preference.
53
+ *
54
+ * **Preview the result, not the control.** A few visual choices can show one
55
+ * preview per `SelectableCard`; many choices use one `Selector` and one live
56
+ * preview below it. Prefer the real component being configured so the sample
57
+ * cannot drift. For detail most readers do not need, expand a `Collapsible`
58
+ * inside the row. Never open a dialog from this dialog: nested focus traps and
59
+ * two overlapping dismiss paths are not progressive disclosure. If the detail
60
+ * is too large to expand in place, it belongs on a page.
61
+ */
62
+
15
63
  import {Button} from '@astryxdesign/core/Button';
16
- import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
17
- import {List, ListItem} from '@astryxdesign/core/List';
64
+ import {AspectRatio} from '@astryxdesign/core/AspectRatio';
65
+ import {Card} from '@astryxdesign/core/Card';
66
+ import {Center} from '@astryxdesign/core/Center';
67
+ import {Dialog} from '@astryxdesign/core/Dialog';
18
68
  import {Divider} from '@astryxdesign/core/Divider';
69
+ import {Grid} from '@astryxdesign/core/Grid';
70
+ import {HStack} from '@astryxdesign/core/HStack';
71
+ import {useMediaQuery} from '@astryxdesign/core/hooks';
72
+ import {Icon} from '@astryxdesign/core/Icon';
73
+ import {IconButton} from '@astryxdesign/core/IconButton';
74
+ import {Kbd} from '@astryxdesign/core/Kbd';
75
+ import {Layout, LayoutContent} from '@astryxdesign/core/Layout';
76
+ import {ListItem} from '@astryxdesign/core/List';
77
+ import {
78
+ SegmentedControl,
79
+ SegmentedControlItem,
80
+ } from '@astryxdesign/core/SegmentedControl';
81
+ import {SelectableCard} from '@astryxdesign/core/SelectableCard';
19
82
  import {Selector} from '@astryxdesign/core/Selector';
20
- import {TextInput} from '@astryxdesign/core/TextInput';
21
- import {Card} from '@astryxdesign/core/Card';
83
+ import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
84
+ import {Stack, StackItem} from '@astryxdesign/core/Stack';
22
85
  import {Switch} from '@astryxdesign/core/Switch';
23
- import {Link} from '@astryxdesign/core/Link';
24
- import {TabList, Tab} from '@astryxdesign/core/TabList';
25
- import {Badge} from '@astryxdesign/core/Badge';
26
- import {Icon} from '@astryxdesign/core/Icon';
27
- import {Center} from '@astryxdesign/core/Center';
86
+ import {Heading, Text} from '@astryxdesign/core/Text';
87
+ import {TextInput} from '@astryxdesign/core/TextInput';
88
+ import {Theme} from '@astryxdesign/core/theme';
89
+ import {neutralTheme} from '@astryxdesign/theme-neutral/built';
90
+ import {Token} from '@astryxdesign/core/Token';
91
+ import {Tooltip} from '@astryxdesign/core/Tooltip';
92
+ import {VStack} from '@astryxdesign/core/VStack';
28
93
  import {
29
- UserIcon,
30
- LockClosedIcon,
31
- GlobeAltIcon,
32
- ShieldCheckIcon,
33
- ComputerDesktopIcon,
34
- WrenchScrewdriverIcon,
94
+ ArrowDownOnSquareIcon,
95
+ ArrowDownTrayIcon,
96
+ ArrowUturnLeftIcon,
97
+ AtSymbolIcon,
98
+ Bars3BottomLeftIcon,
99
+ Bars3Icon,
100
+ BeakerIcon,
35
101
  BellIcon,
36
- DocumentTextIcon,
37
- CreditCardIcon,
38
102
  BriefcaseIcon,
103
+ CalendarDaysIcon,
104
+ ChatBubbleLeftRightIcon,
105
+ ChevronRightIcon,
106
+ ClockIcon,
107
+ CodeBracketIcon,
108
+ Cog6ToothIcon,
109
+ CommandLineIcon,
110
+ ComputerDesktopIcon,
111
+ CreditCardIcon,
112
+ CubeIcon,
113
+ DevicePhoneMobileIcon,
114
+ DocumentMagnifyingGlassIcon,
115
+ DocumentTextIcon,
116
+ EnvelopeIcon,
117
+ EyeIcon,
118
+ FingerPrintIcon,
119
+ FolderIcon,
120
+ GlobeAltIcon,
121
+ HandRaisedIcon,
122
+ KeyIcon,
123
+ LanguageIcon,
124
+ LinkIcon,
125
+ LockClosedIcon,
126
+ MagnifyingGlassIcon,
127
+ MapPinIcon,
128
+ MoonIcon,
129
+ PaintBrushIcon,
39
130
  PencilSquareIcon,
40
- ShareIcon,
131
+ PuzzlePieceIcon,
132
+ QuestionMarkCircleIcon,
133
+ ReceiptPercentIcon,
134
+ ShieldCheckIcon,
135
+ SignalIcon,
136
+ SparklesIcon,
137
+ SpeakerWaveIcon,
138
+ SunIcon,
139
+ SwatchIcon,
140
+ TagIcon,
141
+ TrashIcon,
142
+ UserCircleIcon,
143
+ UserIcon,
144
+ UsersIcon,
41
145
  } from '@heroicons/react/24/outline';
146
+ import * as stylex from '@stylexjs/stylex';
147
+ import {
148
+ Children,
149
+ type ComponentType,
150
+ type CSSProperties,
151
+ useCallback,
152
+ useEffect,
153
+ useId,
154
+ useMemo,
155
+ useState,
156
+ type ComponentProps,
157
+ type ReactNode,
158
+ type SVGProps,
159
+ } from 'react';
42
160
 
43
- const iconBox: CSSProperties = {
44
- borderRadius: 'var(--radius-container)',
45
- backgroundColor: 'var(--color-background-surface)',
46
- flexShrink: 0,
47
- };
48
- // Sticky dialog header bar no Astryx prop for sticky/background/z-index.
49
- // Inline + block padding comes from the parent LayoutContent `padding`.
50
- const headerSticky: CSSProperties = {
51
- position: 'sticky',
52
- top: 0,
53
- backgroundColor: 'var(--color-background-surface)',
54
- zIndex: 1,
55
- };
56
- // No `maxWidth` prop on VStack — width only. Inline padding comes from
57
- // the parent LayoutContent `padding`.
58
- const contentMaxWidth: CSSProperties = {
59
- maxWidth: 680,
60
- };
61
- // Aligns the sidebar heading with list-item label text. No heading margin prop.
62
- const sideNavHeading: CSSProperties = {
63
- marginInline: 'var(--spacing-4)',
64
- };
65
- const dialogHeight: CSSProperties = {
66
- height: '85vh',
67
- };
161
+ /**
162
+ * Where the hub swaps its two-column shell for the one-column phone layout.
163
+ *
164
+ * The SHELL's breakpoint only. Rows do not read it — they stack on the panel's
165
+ * own width instead (`ROW_STACK`), because the two questions have different
166
+ * answers: the pane is the dialog's width less a 260px rail, and the rail goes
167
+ * away below this line, so the panel is ~380px at a 700px viewport and ~390px
168
+ * at 420px. Keyed to the viewport, rows would stack hardest exactly where they
169
+ * had just been handed the most room.
170
+ */
171
+ const NARROW_VIEWPORT = '(max-width: 640px)';
68
172
 
69
- const NAV_ITEMS = [
70
- {label: 'Personal information', icon: UserIcon},
71
- {label: 'Login & security', icon: LockClosedIcon},
72
- {label: 'Privacy', icon: ShieldCheckIcon},
73
- {label: 'Notifications', icon: BellIcon},
74
- {label: 'Taxes', icon: DocumentTextIcon},
75
- {label: 'Payments', icon: CreditCardIcon},
76
- {label: 'Languages & currency', icon: GlobeAltIcon},
77
- {label: 'Travel for work', icon: BriefcaseIcon},
78
- ];
173
+ /** The panel column rows measure themselves against. */
174
+ const PANEL_CONTAINER = 'settings-panel';
79
175
 
80
- const LOGIN_ROWS = [
81
- {label: 'Password', value: 'Not created', action: 'Create'},
82
- ];
176
+ /**
177
+ * The ONE width at which every row on a panel goes from two columns to one.
178
+ *
179
+ * Deliberately one number for the whole panel rather than per-row wrapping.
180
+ * Wrapping each row on its own contents is more precise and reads as chaos:
181
+ * every row has a different control, so they cross over at half a dozen
182
+ * different widths and the panel appears to come apart a line at a time as it
183
+ * narrows. Rows that break together read as one layout changing; rows that
184
+ * break separately read as a bug.
185
+ *
186
+ * 480px is where the tightest row stops fitting — a ~260px name column, a 12px
187
+ * gutter, and the widest control on the surface. Rows with smaller controls
188
+ * have room to spare at that width and stack anyway, which is the point.
189
+ */
190
+ const ROW_STACK = `@container ${PANEL_CONTAINER} (max-width: 480px)`;
83
191
 
84
- const SOCIAL_ROWS = [
85
- {label: 'Google', value: 'Connected', action: 'Disconnect'},
86
- ];
192
+ /** The one inset for this surface: spacing step 4 === 16px. */
193
+ const ROW_PADDING = 4;
194
+
195
+ /**
196
+ * The close control is real and keyboard-reachable; it has nothing to close
197
+ * only because this page renders its dialog inline (see the default export).
198
+ * Point it at the state that owns `isOpen` when you make this modal.
199
+ */
200
+ const noop = () => {};
201
+
202
+ /**
203
+ * The one width every selector and text input in a row takes, so a single
204
+ * control column runs down the panel instead of each control sizing to its own
205
+ * longest label — which is what makes the column scannable rather than ragged.
206
+ */
207
+ const CONTROL_WIDTH = 192;
208
+
209
+ const styles = stylex.create({
210
+ // `SideNav`'s root sets a starting width but no `flex-shrink`, so as a flex
211
+ // child it gives that width back to whatever the content pane demands — the
212
+ // rail visibly narrows as you click through panels until labels ellipsize.
213
+ sideNav: {
214
+ flexShrink: 0,
215
+ },
216
+ // Neither `SideNav` nor `SideNavSection` sets a gap, so the only separation
217
+ // between two groups is each section's own block padding. The default reads
218
+ // as one long list rather than five groups.
219
+ sideNavSection: {
220
+ paddingBlock: 'var(--spacing-2)',
221
+ },
222
+ // The pinned close control. It sits first in the scrolling column and takes
223
+ // no height, so it overlays the heading's line instead of claiming a row —
224
+ // and, being a child of the whole column rather than of the heading, it
225
+ // stays pinned for the entire scroll (a sticky box is clamped by its
226
+ // containing block).
227
+ closeAnchor: {
228
+ position: 'sticky',
229
+ // 0 pins the control against the pane's CONTENT box — which is where it
230
+ // rests unscrolled, so it holds that corner exactly rather than jumping
231
+ // into the gutter the moment the body moves. Verify this after any change
232
+ // to the pane's padding: the pin line follows the content box, not the
233
+ // padding box, so the two only agree when this is 0.
234
+ insetBlockStart: 0,
235
+ zIndex: 1,
236
+ height: 0,
237
+ },
238
+ // Opaque, or the body scrolls visibly through the button. Only the control's
239
+ // own box is covered — a full-width band would wipe a strip of the body for
240
+ // no one's benefit.
241
+ closeBacking: {
242
+ display: 'flex',
243
+ // The dialog's own canvas, so the button covers the body scrolling under it.
244
+ backgroundColor: 'var(--color-background-surface)',
245
+ borderRadius: 'var(--radius-element)',
246
+ },
247
+ // ONE height for every panel. A dialog sizes to its content, so without this
248
+ // the frame grows and shrinks as you click through the navigation — the rail
249
+ // and the close button move under the cursor between one panel and the next.
250
+ // `Dialog maxHeight` cannot do it: a maximum only stops a tall panel, it does
251
+ // not hold a short one open. So the shell states the height and the panels
252
+ // scroll inside it.
253
+ shell: {
254
+ height: 'min(800px, calc(100dvh - 2rem))',
255
+ },
256
+ });
257
+
258
+ /** The panel column, and the settings row that measures itself against it. */
259
+ const row = stylex.create({
260
+ // The column every row asks its width question of. It is this element and
261
+ // not the scroll pane, because a row is as wide as the column, and the
262
+ // column is what a future `contentWidth` cap would narrow.
263
+ panel: {
264
+ containerType: 'inline-size',
265
+ containerName: PANEL_CONTAINER,
266
+ },
267
+ line: {
268
+ display: 'flex',
269
+ flexDirection: {default: 'row', [ROW_STACK]: 'column'},
270
+ // The one alignment that moves: centred against the control beside it,
271
+ // stretched to the full row above it — which is what puts a stacked control
272
+ // on the row's own inset, the edge the icon starts at.
273
+ alignItems: {default: 'center', [ROW_STACK]: 'stretch'},
274
+ // Stacked, the control drops into the same column as `detail`, which the
275
+ // row already spaces at 8px — so 8px here too, and a stacked row reads as
276
+ // one rhythm rather than a 12px step to the control and an 8px step to its
277
+ // detail.
278
+ gap: {
279
+ default: 'var(--spacing-3)',
280
+ [ROW_STACK]: 'var(--spacing-2)',
281
+ },
282
+ },
283
+ textColumn: {
284
+ // Lifted once stacked: holding 420px inside a ~400px column would just be a
285
+ // narrower column for no reason.
286
+ maxWidth: {default: 420, [ROW_STACK]: 'none'},
287
+ },
288
+ control: {
289
+ flexShrink: 0,
290
+ },
291
+ });
292
+
293
+ /** The miniature drawn inside each theme card. */
294
+ const preview = stylex.create({
295
+ // One bar of "text". Astryx has no primitive for a content placeholder at
296
+ // this scale — `Skeleton` is the near miss, and it animates.
297
+ bar: {
298
+ height: 4,
299
+ width: '50%',
300
+ borderRadius: 999,
301
+ backgroundColor: 'var(--color-border-emphasized)',
302
+ },
303
+ barWide: {
304
+ width: '66%',
305
+ },
306
+ clip: {
307
+ overflow: 'hidden',
308
+ borderRadius: 'var(--radius-element)',
309
+ },
310
+ // The two borderless regions of the miniature. `Theme` re-declares these
311
+ // tokens for the mode it is previewing, so Light stays light in a dark app.
312
+ mutedSurface: {
313
+ backgroundColor: 'var(--color-background-muted)',
314
+ },
315
+ contentSurface: {
316
+ backgroundColor: 'var(--color-background-surface)',
317
+ },
318
+ inert: {
319
+ pointerEvents: 'none',
320
+ },
321
+ });
322
+
323
+ /**
324
+ * The chosen face, as a TOKEN override rather than a `font-family` on the
325
+ * words. Astryx's type rules resolve `var(--font-family-body)`, so
326
+ * re-declaring the token re-points every `Text` inside the preview whatever
327
+ * specificity those rules carry; setting `font-family` reaches only what
328
+ * inherits it, and a preview built that way silently keeps showing the system
329
+ * font. It rides on `style` because StyleX's `xstyle` types accept only known
330
+ * CSS properties, and a custom property is not one.
331
+ */
332
+ function faceStyle(stack: string): CSSProperties {
333
+ const style: Record<string, string> = {'--font-family-body': stack};
334
+ return style;
335
+ }
336
+
337
+ type PanelId =
338
+ | 'profile'
339
+ | 'security'
340
+ | 'billing'
341
+ | 'appearance'
342
+ | 'notifications'
343
+ | 'shortcuts'
344
+ | 'language'
345
+ | 'privacy'
346
+ | 'integrations'
347
+ | 'developer';
87
348
 
88
- const DEVICE_ROWS: {
349
+ /**
350
+ * Icons are heroicons outline, and only heroicons outline.
351
+ *
352
+ * Not a style preference: Astryx's own semantic icons — what
353
+ * `<Icon icon="close" />` renders — are drawn at strokeWidth 1.5 to match
354
+ * heroicons, and this dialog uses one for its close control. lucide draws at
355
+ * 2, so a lucide row icon puts two stroke weights in one surface. One set
356
+ * means one weight, one optical size and one visual language down the column.
357
+ */
358
+ type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
359
+
360
+ interface PanelConfig {
361
+ id: PanelId;
89
362
  label: string;
90
- badge?: string;
91
- location: string;
92
- action?: string;
93
- }[] = [
363
+ description: string;
364
+ icon: IconComponent;
365
+ }
366
+
367
+ /**
368
+ * The one list both shells read. Groups are the navigation's sections and the
369
+ * tab strip's order, so a panel cannot drift out of one by being added to the
370
+ * other.
371
+ */
372
+ const PANEL_GROUPS: {label: string; panels: PanelConfig[]}[] = [
373
+ {
374
+ label: 'Account',
375
+ panels: [
376
+ {
377
+ id: 'profile',
378
+ label: 'Profile',
379
+ description: 'How you appear to everyone else in the workspace.',
380
+ icon: UserCircleIcon,
381
+ },
382
+ {
383
+ id: 'security',
384
+ label: 'Login & security',
385
+ description: 'Sign-in methods and the devices signed in right now.',
386
+ icon: LockClosedIcon,
387
+ },
388
+ {
389
+ id: 'billing',
390
+ label: 'Billing',
391
+ description: 'Your plan, seats, and where invoices are sent.',
392
+ icon: CreditCardIcon,
393
+ },
394
+ ],
395
+ },
94
396
  {
95
- label: 'OS X 10.15.7 · Chrome',
96
- badge: 'CURRENT SESSION',
97
- location: 'McKinney, Texas · March 30, 2026 at 19:31',
397
+ label: 'Workspace',
398
+ panels: [
399
+ {
400
+ id: 'appearance',
401
+ label: 'Appearance',
402
+ description: 'Theme and density for this browser.',
403
+ icon: PaintBrushIcon,
404
+ },
405
+ {
406
+ id: 'notifications',
407
+ label: 'Notifications',
408
+ description: 'What reaches you, and where it arrives.',
409
+ icon: BellIcon,
410
+ },
411
+ {
412
+ id: 'shortcuts',
413
+ label: 'Keyboard shortcuts',
414
+ description: 'Rebind or switch off any shortcut on this surface.',
415
+ icon: CommandLineIcon,
416
+ },
417
+ {
418
+ id: 'language',
419
+ label: 'Language & region',
420
+ description: 'Language, formats, and time zone.',
421
+ icon: GlobeAltIcon,
422
+ },
423
+ ],
98
424
  },
99
- {label: 'Session', location: 'August 9, 2023 at 04:19', action: 'Log out'},
100
425
  {
101
- label: 'OS X 10.15.7 · unknown',
102
- location: 'Sunnyvale, California · April 14, 2023 at 17:47',
103
- action: 'Log out',
426
+ label: 'Advanced',
427
+ panels: [
428
+ {
429
+ id: 'privacy',
430
+ label: 'Privacy',
431
+ description: 'What you share, and what we keep.',
432
+ icon: ShieldCheckIcon,
433
+ },
434
+ {
435
+ id: 'integrations',
436
+ label: 'Connected apps',
437
+ description: 'Services with access to this workspace.',
438
+ icon: PuzzlePieceIcon,
439
+ },
440
+ {
441
+ id: 'developer',
442
+ label: 'Developer',
443
+ description: 'API access and pre-release features.',
444
+ icon: CodeBracketIcon,
445
+ },
446
+ ],
104
447
  },
105
448
  ];
106
449
 
107
- const LANGUAGES = [
108
- {label: 'English (Canada)', value: 'en-CA'},
109
- {label: 'English (US)', value: 'en-US'},
110
- {label: 'French', value: 'fr'},
111
- {label: 'Spanish', value: 'es'},
112
- {label: 'German', value: 'de'},
113
- {label: 'Japanese', value: 'ja'},
450
+ const PANELS: PanelConfig[] = PANEL_GROUPS.flatMap(group => group.panels);
451
+ const DEFAULT_PANEL: PanelId = 'profile';
452
+
453
+ function panelConfig(id: PanelId): PanelConfig {
454
+ return PANELS.find(panel => panel.id === id) ?? PANELS[0];
455
+ }
456
+
457
+ interface SearchableSetting {
458
+ title: string;
459
+ description: string;
460
+ panel: PanelId;
461
+ icon: IconComponent;
462
+ }
463
+
464
+ /**
465
+ * The search catalog. It is a list rather than something derived from the
466
+ * rendered rows on purpose: a setting stays findable by the words people
467
+ * actually reach for, which are rarely the words on its label.
468
+ */
469
+ const SEARCHABLE_SETTINGS: SearchableSetting[] = [
470
+ {
471
+ title: 'Display name',
472
+ description: 'The name shown on your posts and comments',
473
+ panel: 'profile',
474
+ icon: UserCircleIcon,
475
+ },
476
+ {
477
+ title: 'Two-factor authentication',
478
+ description: 'Require a second step when signing in',
479
+ panel: 'security',
480
+ icon: KeyIcon,
481
+ },
482
+ {
483
+ title: 'Active sessions',
484
+ description: 'Devices currently signed in to this account',
485
+ panel: 'security',
486
+ icon: ComputerDesktopIcon,
487
+ },
488
+ {
489
+ title: 'Plan',
490
+ description: 'Your current subscription and seat count',
491
+ panel: 'billing',
492
+ icon: CreditCardIcon,
493
+ },
494
+ {
495
+ title: 'Color theme',
496
+ description: 'Light, dark, or follow the system',
497
+ panel: 'appearance',
498
+ icon: PaintBrushIcon,
499
+ },
500
+ {
501
+ title: 'Interface density',
502
+ description: 'How much breathing room rows and lists get',
503
+ panel: 'appearance',
504
+ icon: PaintBrushIcon,
505
+ },
506
+ {
507
+ title: 'Email notifications',
508
+ description: 'Mentions, comments, and the weekly digest',
509
+ panel: 'notifications',
510
+ icon: EnvelopeIcon,
511
+ },
512
+ {
513
+ title: 'Quiet hours',
514
+ description: 'Hold desktop notifications until the morning',
515
+ panel: 'notifications',
516
+ icon: ClockIcon,
517
+ },
518
+ {
519
+ title: 'Keyboard shortcuts',
520
+ description: 'Rebind a shortcut, or switch one off',
521
+ panel: 'shortcuts',
522
+ icon: CommandLineIcon,
523
+ },
524
+ {
525
+ title: 'Time zone',
526
+ description: 'Used for scheduling and timestamps',
527
+ panel: 'language',
528
+ icon: GlobeAltIcon,
529
+ },
530
+ {
531
+ title: 'Read receipts',
532
+ description: 'Let people see when you have read a message',
533
+ panel: 'privacy',
534
+ icon: ShieldCheckIcon,
535
+ },
536
+ {
537
+ title: 'Export my data',
538
+ description: 'Download everything this account holds',
539
+ panel: 'privacy',
540
+ icon: ArrowDownTrayIcon,
541
+ },
542
+ {
543
+ title: 'Connected apps',
544
+ description: 'Calendar, storage, and chat integrations',
545
+ panel: 'integrations',
546
+ icon: PuzzlePieceIcon,
547
+ },
548
+ {
549
+ title: 'API access',
550
+ description: 'Personal access tokens and webhooks',
551
+ panel: 'developer',
552
+ icon: CodeBracketIcon,
553
+ },
114
554
  ];
115
555
 
116
- const CURRENCIES = [
117
- {label: 'Canadian dollar (CAD)', value: 'CAD'},
118
- {label: 'US dollar (USD)', value: 'USD'},
119
- {label: 'Euro (EUR)', value: 'EUR'},
120
- {label: 'British pound (GBP)', value: 'GBP'},
121
- {label: 'Japanese yen (JPY)', value: 'JPY'},
556
+ function settingMatchesSearch(
557
+ setting: SearchableSetting,
558
+ normalizedQuery: string,
559
+ ): boolean {
560
+ return (
561
+ setting.title.toLowerCase().includes(normalizedQuery) ||
562
+ setting.description.toLowerCase().includes(normalizedQuery) ||
563
+ panelConfig(setting.panel).label.toLowerCase().includes(normalizedQuery)
564
+ );
565
+ }
566
+
567
+ /* ------------------------------------------------------------------ *
568
+ * The settings row idiom
569
+ * ------------------------------------------------------------------ */
570
+
571
+ /**
572
+ * A filled, divided group of settings rows.
573
+ *
574
+ * `variant="muted"` does two jobs: it is the well the group reads as against
575
+ * the dialog canvas, and — because Astryx draws a border on the `default`
576
+ * variant only — it is also what removes the hairline, so the group is
577
+ * separated once rather than twice.
578
+ *
579
+ * `padding={0}` because the rows carry the inset themselves. A padded Card
580
+ * around padded rows double-pads every edge, and the rows' own padding is what
581
+ * keeps the dividers full-bleed.
582
+ *
583
+ * A card has a subject heading and no description. If the group needs a second
584
+ * sentence, put it on the row it qualifies or split the card — a reader should
585
+ * know why these settings belong together from the heading and row names.
586
+ */
587
+ function SettingsCard({
588
+ title,
589
+ children,
590
+ }: {
591
+ title?: string;
592
+ children: ReactNode;
593
+ }) {
594
+ const rows = Children.toArray(children);
595
+
596
+ return (
597
+ <VStack gap={1.5}>
598
+ {title != null && (
599
+ // Flush with the card's edge, NOT inset to meet the row titles inside
600
+ // it: the heading labels the card as an object, and lining it up with
601
+ // the rows would read as the first row of content instead.
602
+ //
603
+ // Typed to match `SideNavSection`'s own title — the navigation and
604
+ // these cards are the same idea at two scales, so a reader meets one
605
+ // grouping label, not two.
606
+ <Text type="supporting" weight="semibold" color="secondary">
607
+ {title}
608
+ </Text>
609
+ )}
610
+ <Card padding={0} width="100%" variant="muted">
611
+ <VStack as="ul" role="list" gap={0}>
612
+ {rows.map((row, index) => (
613
+ <VStack key={index} as="li" gap={0}>
614
+ {index > 0 && <Divider variant="subtle" />}
615
+ {row}
616
+ </VStack>
617
+ ))}
618
+ </VStack>
619
+ </Card>
620
+ </VStack>
621
+ );
622
+ }
623
+
624
+ /**
625
+ * One setting: name and explanation on the left, the control on the right —
626
+ * or, below the panel's stacking width, the control underneath and full width.
627
+ *
628
+ * Every row on a panel makes that switch at the same moment, on the panel's
629
+ * own width rather than the viewport's — see `ROW_STACK`. There is deliberately
630
+ * no per-row opt-out: a row that keeps two columns while its neighbours have
631
+ * given up reads as a bug rather than as a decision.
632
+ *
633
+ * Pass the control `isLabelHidden`. This row is its visible label, and a
634
+ * control that keeps its own prints every row's name twice.
635
+ */
636
+ function SettingsRow({
637
+ title,
638
+ titleAccessory,
639
+ description,
640
+ icon,
641
+ control,
642
+ detail,
643
+ }: {
644
+ title: string;
645
+ /**
646
+ * A chip that is part of the setting's NAME rather than a fact about it — a
647
+ * plan tier, a "beta" tag. It sits on the title's own line: the supporting
648
+ * line below is prose the eye reads second, so a chip parked there stops
649
+ * reading as part of the name.
650
+ */
651
+ titleAccessory?: ReactNode;
652
+ description?: ReactNode;
653
+ /**
654
+ * REQUIRED, and required on purpose. An icon shifts its row's title 24px
655
+ * right, so a card where only some rows carry one has its titles on two
656
+ * different vertical lines — the one-content-line rule broken inside a
657
+ * single group, and the most visible way a settings surface stops looking
658
+ * designed. Optional, it drifts: a row added later quietly leaves its icon
659
+ * out and the column goes ragged with no error. The type is the enforcement.
660
+ */
661
+ icon: IconComponent;
662
+ control?: ReactNode;
663
+ /**
664
+ * Content belonging to this setting that cannot fit beside it — a live
665
+ * preview, a picker of tiles. It rides INSIDE the row's own box rather than
666
+ * as a following sibling, because Astryx stacks take only two padding axes:
667
+ * a sibling would pay a second full inset and land twice as far from the
668
+ * control it belongs to.
669
+ *
670
+ * The row is the one inset owner, so pass the block bare and its own edge
671
+ * lands on the panel's content line — the line the row titles sit on. A
672
+ * block that pays a second inset of its own puts its contents half a step
673
+ * past every title above it, which is the double-padding the layout docs
674
+ * name as an anti-pattern.
675
+ */
676
+ detail?: ReactNode;
677
+ }) {
678
+ return (
679
+ <VStack padding={ROW_PADDING} gap={2}>
680
+ {/* `Stack` with the direction in `xstyle` rather than in `direction`:
681
+ the switch happens inside a container query, which a prop cannot
682
+ express. There is no cascade fight to lose here — `Stack` spreads
683
+ `xstyle` LAST into its own `stylex.props`, so StyleX resolves the two
684
+ by property rather than by stylesheet order.
685
+
686
+ The `data-` marks are for tests: "every row on the panel stacks at
687
+ the same width" is the invariant this layout is FOR, and it is only
688
+ checkable by reading the computed direction off every line at once. */}
689
+ <Stack xstyle={row.line} data-settings-line>
690
+ {/* `fill` means "take the width the control leaves", and carries the
691
+ flex `min-width: 0` reset that lets the text wrap instead of
692
+ widening the row. */}
693
+ <StackItem size="fill">
694
+ <HStack gap={2} align="start">
695
+ {/* 2px down, which is spacing step 0.5. The stack aligns the
696
+ icon's BOX to the title's box, but a glyph is optically centred
697
+ in its box while a cap-height sits low in its line box, so box
698
+ alignment leaves the icon reading high. */}
699
+ <VStack paddingBlockStart={0.5}>
700
+ <Icon icon={icon} size="sm" color="secondary" />
701
+ </VStack>
702
+ {/* Capped so a description wraps on its own terms instead of
703
+ against the control. `fill` hands the text every pixel the
704
+ control does not take, which on a switch row puts the last word
705
+ a hair from the thumb; the cap turns that surplus into the
706
+ gutter the layout reads by. Lifted once stacked — there is no
707
+ control beside the text, so no gutter to protect. */}
708
+ <VStack gap={0.5} xstyle={row.textColumn}>
709
+ {titleAccessory == null ? (
710
+ <Text type="label">{title}</Text>
711
+ ) : (
712
+ <HStack gap={1.5} align="center">
713
+ <Text type="label">{title}</Text>
714
+ {titleAccessory}
715
+ </HStack>
716
+ )}
717
+ {description != null && (
718
+ <Text type="supporting" color="secondary">
719
+ {description}
720
+ </Text>
721
+ )}
722
+ </VStack>
723
+ </HStack>
724
+ </StackItem>
725
+ {control != null && (
726
+ // The control does not shrink: a flex item shrinks by default, and a
727
+ // shrinking `SegmentedControl` puts its longest label on two lines
728
+ // rather than admitting it has run out of room. Stacked, the wrapper
729
+ // stretches to the row and the control keeps the `CONTROL_WIDTH` it
730
+ // has above the breakpoint — so the control column reads the same
731
+ // either way, and a control with no width of its own (a switch, a
732
+ // button) stays hugged to its own size.
733
+ <VStack data-settings-control xstyle={row.control}>
734
+ {control}
735
+ </VStack>
736
+ )}
737
+ </Stack>
738
+ {detail}
739
+ </VStack>
740
+ );
741
+ }
742
+
743
+ /* ------------------------------------------------------------------ *
744
+ * Panel bodies
745
+ * ------------------------------------------------------------------ */
746
+
747
+ interface SettingsState {
748
+ displayName: string;
749
+ jobTitle: string;
750
+ pronouns: string;
751
+ showLocalTime: boolean;
752
+ presence: string;
753
+ twoFactor: boolean;
754
+ billingEmail: string;
755
+ emailInvoices: boolean;
756
+ theme: string;
757
+ accent: string;
758
+ font: string;
759
+ density: string;
760
+ reduceMotion: boolean;
761
+ emailMentions: boolean;
762
+ emailComments: boolean;
763
+ weeklyDigest: boolean;
764
+ desktopAlerts: boolean;
765
+ notificationSounds: boolean;
766
+ quietHours: string;
767
+ language: string;
768
+ region: string;
769
+ timezone: string;
770
+ weekStart: string;
771
+ discoverable: boolean;
772
+ readReceipts: boolean;
773
+ activityStatus: boolean;
774
+ personalization: boolean;
775
+ apiAccess: boolean;
776
+ webhookUrl: string;
777
+ earlyAccess: boolean;
778
+ verboseLogging: boolean;
779
+ }
780
+
781
+ const INITIAL_SETTINGS: SettingsState = {
782
+ displayName: 'Alex Johnson',
783
+ jobTitle: 'Product designer',
784
+ pronouns: 'they',
785
+ showLocalTime: true,
786
+ presence: 'available',
787
+ twoFactor: true,
788
+ billingEmail: 'billing@example.com',
789
+ emailInvoices: true,
790
+ theme: 'system',
791
+ accent: 'blue',
792
+ font: 'system',
793
+ density: 'comfortable',
794
+ reduceMotion: false,
795
+ emailMentions: true,
796
+ emailComments: false,
797
+ weeklyDigest: true,
798
+ desktopAlerts: true,
799
+ notificationSounds: false,
800
+ quietHours: 'none',
801
+ language: 'en-US',
802
+ region: 'US',
803
+ timezone: 'PT',
804
+ weekStart: 'sun',
805
+ discoverable: true,
806
+ readReceipts: true,
807
+ activityStatus: false,
808
+ personalization: true,
809
+ apiAccess: false,
810
+ webhookUrl: '',
811
+ earlyAccess: false,
812
+ verboseLogging: false,
813
+ };
814
+
815
+ type UpdateSetting = <K extends keyof SettingsState>(
816
+ key: K,
817
+ value: SettingsState[K],
818
+ ) => void;
819
+
820
+ interface PanelProps {
821
+ settings: SettingsState;
822
+ update: UpdateSetting;
823
+ }
824
+
825
+ function ProfilePanel({settings, update}: PanelProps) {
826
+ return (
827
+ <VStack gap={5}>
828
+ <SettingsCard title="Identity">
829
+ <SettingsRow
830
+ title="Display name"
831
+ icon={UserIcon}
832
+ description="Shown on your posts, comments, and mentions."
833
+ control={
834
+ <TextInput
835
+ label="Display name"
836
+ isLabelHidden
837
+ size="sm"
838
+ width={CONTROL_WIDTH}
839
+ value={settings.displayName}
840
+ onChange={value => update('displayName', value)}
841
+ />
842
+ }
843
+ />
844
+ <SettingsRow
845
+ title="Job title"
846
+ icon={BriefcaseIcon}
847
+ description="Appears under your name on your profile card."
848
+ control={
849
+ <TextInput
850
+ label="Job title"
851
+ isLabelHidden
852
+ size="sm"
853
+ width={CONTROL_WIDTH}
854
+ value={settings.jobTitle}
855
+ onChange={value => update('jobTitle', value)}
856
+ />
857
+ }
858
+ />
859
+ <SettingsRow
860
+ title="Pronouns"
861
+ icon={TagIcon}
862
+ control={
863
+ <Selector
864
+ label="Pronouns"
865
+ isLabelHidden
866
+ size="sm"
867
+ width={CONTROL_WIDTH}
868
+ value={settings.pronouns}
869
+ onChange={value => update('pronouns', value)}
870
+ options={[
871
+ {value: 'none', label: 'Not specified'},
872
+ {value: 'they', label: 'they/them'},
873
+ {value: 'she', label: 'she/her'},
874
+ {value: 'he', label: 'he/him'},
875
+ ]}
876
+ />
877
+ }
878
+ />
879
+ </SettingsCard>
880
+
881
+ <SettingsCard title="Availability">
882
+ <SettingsRow
883
+ title="Show my local time"
884
+ icon={ClockIcon}
885
+ description="Helps people across time zones pick a sane hour."
886
+ control={
887
+ <Switch
888
+ label="Show my local time"
889
+ isLabelHidden
890
+ value={settings.showLocalTime}
891
+ onChange={value => update('showLocalTime', value)}
892
+ />
893
+ }
894
+ />
895
+ <SettingsRow
896
+ title="Presence"
897
+ icon={SignalIcon}
898
+ control={
899
+ <Selector
900
+ label="Presence"
901
+ isLabelHidden
902
+ size="sm"
903
+ width={CONTROL_WIDTH}
904
+ value={settings.presence}
905
+ onChange={value => update('presence', value)}
906
+ options={[
907
+ {value: 'available', label: 'Available'},
908
+ {value: 'busy', label: 'Busy'},
909
+ {value: 'away', label: 'Away'},
910
+ ]}
911
+ />
912
+ }
913
+ />
914
+ </SettingsCard>
915
+ </VStack>
916
+ );
917
+ }
918
+
919
+ function SecurityPanel({settings, update}: PanelProps) {
920
+ return (
921
+ <VStack gap={5}>
922
+ <SettingsCard title="Sign-in">
923
+ <SettingsRow
924
+ title="Password"
925
+ description="Last changed 4 months ago."
926
+ icon={KeyIcon}
927
+ control={<Button label="Change" variant="secondary" size="sm" />}
928
+ />
929
+ <SettingsRow
930
+ title="Two-factor authentication"
931
+ titleAccessory={<Token label="Recommended" size="sm" color="green" />}
932
+ description="Ask for a code from your authenticator app at sign-in."
933
+ icon={ShieldCheckIcon}
934
+ control={
935
+ <Switch
936
+ label="Two-factor authentication"
937
+ isLabelHidden
938
+ value={settings.twoFactor}
939
+ onChange={value => update('twoFactor', value)}
940
+ />
941
+ }
942
+ />
943
+ <SettingsRow
944
+ title="Passkeys"
945
+ icon={FingerPrintIcon}
946
+ description="No passkeys yet."
947
+ control={<Button label="Add passkey" variant="secondary" size="sm" />}
948
+ />
949
+ </SettingsCard>
950
+
951
+ <SettingsCard title="Active sessions">
952
+ <SettingsRow
953
+ title="MacBook Pro · Chrome"
954
+ titleAccessory={<Token label="This device" size="sm" />}
955
+ description="San Francisco, California · Active now"
956
+ icon={ComputerDesktopIcon}
957
+ />
958
+ <SettingsRow
959
+ title="iPhone 15 · Safari"
960
+ description="San Francisco, California · 2 hours ago"
961
+ icon={DevicePhoneMobileIcon}
962
+ control={<Button label="Sign out" variant="ghost" size="sm" />}
963
+ />
964
+ <SettingsRow
965
+ title="Windows · Edge"
966
+ description="Austin, Texas · March 30 at 19:31"
967
+ icon={ComputerDesktopIcon}
968
+ control={<Button label="Sign out" variant="ghost" size="sm" />}
969
+ />
970
+ </SettingsCard>
971
+ </VStack>
972
+ );
973
+ }
974
+
975
+ function BillingPanel({settings, update}: PanelProps) {
976
+ return (
977
+ <VStack gap={5}>
978
+ <SettingsCard title="Plan">
979
+ <SettingsRow
980
+ title="Current plan"
981
+ icon={CubeIcon}
982
+ titleAccessory={<Token label="Team" size="sm" color="blue" />}
983
+ description="$12 per seat, per month. Renews on April 1."
984
+ control={<Button label="Upgrade" variant="primary" size="sm" />}
985
+ />
986
+ <SettingsRow
987
+ title="Seats"
988
+ icon={UsersIcon}
989
+ description="18 of 25 seats in use."
990
+ control={
991
+ <Button label="Manage seats" variant="secondary" size="sm" />
992
+ }
993
+ />
994
+ </SettingsCard>
995
+
996
+ <SettingsCard title="Invoices">
997
+ <SettingsRow
998
+ title="Billing email"
999
+ icon={EnvelopeIcon}
1000
+ description="Where receipts and payment failures are sent."
1001
+ control={
1002
+ <TextInput
1003
+ label="Billing email"
1004
+ isLabelHidden
1005
+ type="email"
1006
+ size="sm"
1007
+ width={CONTROL_WIDTH}
1008
+ value={settings.billingEmail}
1009
+ onChange={value => update('billingEmail', value)}
1010
+ />
1011
+ }
1012
+ />
1013
+ <SettingsRow
1014
+ title="Email invoices"
1015
+ icon={ReceiptPercentIcon}
1016
+ description="Send a PDF copy every time we charge the card."
1017
+ control={
1018
+ <Switch
1019
+ label="Email invoices"
1020
+ isLabelHidden
1021
+ value={settings.emailInvoices}
1022
+ onChange={value => update('emailInvoices', value)}
1023
+ />
1024
+ }
1025
+ />
1026
+ <SettingsRow
1027
+ title="Payment method"
1028
+ description="Visa ending 4242 · Expires 09/28"
1029
+ icon={CreditCardIcon}
1030
+ control={<Button label="Update" variant="secondary" size="sm" />}
1031
+ />
1032
+ </SettingsCard>
1033
+ </VStack>
1034
+ );
1035
+ }
1036
+
1037
+ /* ------------------------------------------------------------------ *
1038
+ * The theme picker: a radio group of pictures
1039
+ * ------------------------------------------------------------------ */
1040
+
1041
+ /**
1042
+ * A miniature of the app used as a theme swatch — a title bar and a card of
1043
+ * rows, i.e. this dialog, drawn small.
1044
+ *
1045
+ * ## How a Light swatch survives inside a dark app
1046
+ *
1047
+ * It cannot be drawn with `--color-background-card` and friends. Every Astryx
1048
+ * colour ships as a `light-dark()` pair, and `light-dark()` resolves where the
1049
+ * custom property is DECLARED, not where it is used: by the time a token
1050
+ * inherits down from the theme root it is already the flat dark value, and no
1051
+ * local `color-scheme` reopens it.
1052
+ *
1053
+ * The supported answer is a nested `Theme`: it re-declares semantic tokens for
1054
+ * the mode it is drawing. A Light swatch therefore stays light in a dark app
1055
+ * with no copied colours. This template uses the bundled neutral theme—the
1056
+ * same stable composition used by the docsite and generated applications.
1057
+ */
1058
+ function ThemePreviewCanvas({mode}: {mode: 'light' | 'dark'}) {
1059
+ const canvas = (
1060
+ // No Cards here. A Card's default variant owns a border, and nesting one
1061
+ // inside a selected SelectableCard produced three competing outlines: the
1062
+ // selection ring, a frame around the preview, and another around its body.
1063
+ // These are decorative regions, not content containers, so flat token
1064
+ // surfaces are the weaker and correct grouping primitive.
1065
+ <VStack padding={2} gap={1.5} height="100%" xstyle={preview.mutedSurface}>
1066
+ <VStack gap={1} align="center">
1067
+ <VStack xstyle={[preview.bar, preview.barWide]} />
1068
+ <VStack xstyle={preview.bar} />
1069
+ </VStack>
1070
+ <StackItem size="fill">
1071
+ <VStack
1072
+ padding={2}
1073
+ gap={1}
1074
+ justify="center"
1075
+ height="100%"
1076
+ xstyle={preview.contentSurface}>
1077
+ {[0, 1, 2].map(row => (
1078
+ <VStack key={row} xstyle={preview.bar} />
1079
+ ))}
1080
+ </VStack>
1081
+ </StackItem>
1082
+ </VStack>
1083
+ );
1084
+
1085
+ return (
1086
+ // Use the bundled theme directly rather than discovering the ambient theme
1087
+ // through registry hooks. Template previews run against the installed
1088
+ // package's runtime surface, which can lag this repository's source types;
1089
+ // `Theme` + `neutralTheme` is the stable public composition the docsite and
1090
+ // generated applications already use.
1091
+ <Theme theme={neutralTheme} mode={mode}>
1092
+ {canvas}
1093
+ </Theme>
1094
+ );
1095
+ }
1096
+
1097
+ function ThemePreviewMock({scheme}: {scheme: 'light' | 'dark' | 'system'}) {
1098
+ return (
1099
+ // One AspectRatio path for every option. System splits that same 3:2 frame
1100
+ // into two equal regions rather than drawing two full windows on top of one
1101
+ // another, so there are no absolute layers and no seams with stray borders.
1102
+ <AspectRatio ratio={3 / 2} xstyle={preview.inert}>
1103
+ <VStack aria-hidden="true" height="100%" xstyle={preview.clip}>
1104
+ {scheme === 'system' ? (
1105
+ <HStack gap={0} height="100%">
1106
+ <StackItem size="fill">
1107
+ <ThemePreviewCanvas mode="light" />
1108
+ </StackItem>
1109
+ <StackItem size="fill">
1110
+ <ThemePreviewCanvas mode="dark" />
1111
+ </StackItem>
1112
+ </HStack>
1113
+ ) : (
1114
+ <ThemePreviewCanvas mode={scheme} />
1115
+ )}
1116
+ </VStack>
1117
+ </AspectRatio>
1118
+ );
1119
+ }
1120
+
1121
+ const THEME_CHOICES: {
1122
+ value: string;
1123
+ label: string;
1124
+ scheme: 'light' | 'dark' | 'system';
1125
+ icon: IconComponent;
1126
+ }[] = [
1127
+ {value: 'light', label: 'Light', scheme: 'light', icon: SunIcon},
1128
+ {value: 'dark', label: 'Dark', scheme: 'dark', icon: MoonIcon},
1129
+ {
1130
+ value: 'system',
1131
+ label: 'System',
1132
+ scheme: 'system',
1133
+ icon: ComputerDesktopIcon,
1134
+ },
122
1135
  ];
123
1136
 
124
- const TIMEZONES = [
125
- {label: '(GMT-05:00) Eastern Time (US & Canada)', value: 'ET'},
126
- {label: '(GMT-06:00) Central Time (US & Canada)', value: 'CT'},
127
- {label: '(GMT-07:00) Mountain Time (US & Canada)', value: 'MT'},
128
- {label: '(GMT-08:00) Pacific Time (US & Canada)', value: 'PT'},
129
- {label: '(GMT+00:00) UTC', value: 'UTC'},
130
- {label: '(GMT+01:00) London', value: 'GMT+1'},
1137
+ /**
1138
+ * The choices as pictures of what they do, the way macOS and VS Code put
1139
+ * appearance settings a segmented control names the options, a card shows
1140
+ * them.
1141
+ *
1142
+ * `SelectableCard` is the Astryx card that carries selection: it owns the
1143
+ * accent border, the pressed semantics, the hover and the focus ring, so a
1144
+ * tile here is a picture and a name and nothing else. Keep the preview inset,
1145
+ * as this example does with `padding={2}`: the component draws its selection
1146
+ * ring as an inset shadow, underneath its children, so a full-bleed child with
1147
+ * an opaque background paints over the selected state.
1148
+ *
1149
+ * Note what it is NOT: its control is a checkbox, so three mutually exclusive
1150
+ * choices are three independent tab stops rather than one arrow-key group.
1151
+ * Single-select is enforced here instead — `onChange` only ever selects, so
1152
+ * re-picking the current theme is the no-op it should be — and the group is
1153
+ * named for a screen reader by the `radiogroup` around it. If you need the
1154
+ * roving tab stop, that is a native `<input type="radio">` group, not this.
1155
+ *
1156
+ * `RadioList` is out for a different reason: it lays a radio, a label and a
1157
+ * description on one line, and each choice here is a picture with a name under
1158
+ * it.
1159
+ *
1160
+ * Driving this from a test: click the CARD, not the checkbox. `SelectableCard`
1161
+ * puts the click handler on the card surface and keeps a visually-hidden
1162
+ * `<input type="checkbox">` for the role, name and checked state — so
1163
+ * `getByRole('checkbox').click()` aims at an element the card's own content
1164
+ * covers, and times out on a control a user can click perfectly well.
1165
+ */
1166
+ function ThemeChoiceCards({
1167
+ value,
1168
+ onChange,
1169
+ }: {
1170
+ value: string;
1171
+ onChange: (value: string) => void;
1172
+ }) {
1173
+ return (
1174
+ <Grid
1175
+ role="radiogroup"
1176
+ aria-label="Color theme"
1177
+ // Three across wherever three fit; below that the icon and the name stop
1178
+ // fitting the card, so the grid folds rather than drawing slivers.
1179
+ columns={{minWidth: 96, max: 3}}
1180
+ gap={2}
1181
+ // Capped, or the tiles grow with the dialog: three stretched tracks in a
1182
+ // 1120px shell make a ~250px picture of a window per option, which is a
1183
+ // hero image rather than a control. The cap holds them at a size the row
1184
+ // can carry, and the grid still folds to two-then-one below it.
1185
+ maxWidth={480}>
1186
+ {THEME_CHOICES.map(choice => {
1187
+ const isSelected = choice.value === value;
1188
+ return (
1189
+ <SelectableCard
1190
+ key={choice.value}
1191
+ // Pinned here rather than taken from the card's contents, so the
1192
+ // accessible name cannot drift with the picture.
1193
+ label={choice.label}
1194
+ isSelected={isSelected}
1195
+ padding={2}
1196
+ onChange={() => {
1197
+ // Select-only: a card cannot deselect itself into "no theme".
1198
+ if (!isSelected) {
1199
+ onChange(choice.value);
1200
+ }
1201
+ }}>
1202
+ <VStack gap={1.5}>
1203
+ <ThemePreviewMock scheme={choice.scheme} />
1204
+ <HStack gap={1} align="center" justify="center">
1205
+ <Icon icon={choice.icon} size="sm" color="secondary" />
1206
+ <Text
1207
+ type="supporting"
1208
+ weight={isSelected ? 'semibold' : undefined}>
1209
+ {choice.label}
1210
+ </Text>
1211
+ </HStack>
1212
+ </VStack>
1213
+ </SelectableCard>
1214
+ );
1215
+ })}
1216
+ </Grid>
1217
+ );
1218
+ }
1219
+
1220
+ const INTERFACE_FONTS: {value: string; label: string; stack: string}[] = [
1221
+ {value: 'system', label: 'System', stack: 'var(--font-family-body)'},
1222
+ {value: 'serif', label: 'Serif', stack: 'Georgia, "Times New Roman", serif'},
1223
+ {
1224
+ value: 'mono',
1225
+ label: 'Monospace',
1226
+ stack: 'var(--font-family-code)',
1227
+ },
131
1228
  ];
132
1229
 
133
- interface ExpandableRowProps {
1230
+ function interfaceFont(value: string) {
1231
+ return (
1232
+ INTERFACE_FONTS.find(font => font.value === value) ?? INTERFACE_FONTS[0]
1233
+ );
1234
+ }
1235
+
1236
+ /**
1237
+ * The selector-plus-preview pattern: a control that changes how something
1238
+ * READS gets a sample of the thing itself, not a description of it.
1239
+ *
1240
+ * The face is applied by re-declaring `--font-family-body` on the surface, not
1241
+ * by setting `font-family` on the words. Astryx's own type rules resolve that
1242
+ * token, so overriding it re-points every `Text` inside; setting `font-family`
1243
+ * instead reaches only what inherits it, and a preview built that way silently
1244
+ * keeps showing the system font whatever the selector says.
1245
+ *
1246
+ * It sits on the dialog's canvas inside the card's muted fill, so it reads as a
1247
+ * window onto another surface rather than as more of the settings form.
1248
+ */
1249
+ function FontPreview({value}: {value: string}) {
1250
+ const font = interfaceFont(value);
1251
+ return (
1252
+ // A `default` Card — the white one — inside the group's muted fill. The
1253
+ // sample is a window onto another surface, and the two variants are what
1254
+ // says so: muted inside muted has no contrast and reads as more of the
1255
+ // settings form. The card's own EDGE sits on the panel's content line, the
1256
+ // same line the theme tiles' edges sit on, so the previews in this panel
1257
+ // line up with each other and with the row titles above them.
1258
+ <Card padding={3} width="100%">
1259
+ <VStack gap={1} style={faceStyle(font.stack)}>
1260
+ <Text type="label">Weekly digest</Text>
1261
+ <Text type="supporting" color="secondary">
1262
+ Eight threads moved while you were away, and three are waiting on you.
1263
+ </Text>
1264
+ </VStack>
1265
+ </Card>
1266
+ );
1267
+ }
1268
+
1269
+ function AppearancePanel({settings, update}: PanelProps) {
1270
+ return (
1271
+ <VStack gap={5}>
1272
+ {/* Unlabelled on purpose: a "Theme" heading under an "Appearance" title
1273
+ says the same word twice. */}
1274
+ <SettingsCard>
1275
+ <SettingsRow
1276
+ title="Color theme"
1277
+ icon={PaintBrushIcon}
1278
+ description="Applies to this browser only."
1279
+ detail={
1280
+ <ThemeChoiceCards
1281
+ value={settings.theme}
1282
+ onChange={value => update('theme', value)}
1283
+ />
1284
+ }
1285
+ />
1286
+ <SettingsRow
1287
+ title="Accent color"
1288
+ icon={SwatchIcon}
1289
+ description="Used for links, selection, and primary buttons."
1290
+ control={
1291
+ <Selector
1292
+ label="Accent color"
1293
+ isLabelHidden
1294
+ size="sm"
1295
+ width={CONTROL_WIDTH}
1296
+ value={settings.accent}
1297
+ onChange={value => update('accent', value)}
1298
+ options={[
1299
+ {value: 'blue', label: 'Blue'},
1300
+ {value: 'teal', label: 'Teal'},
1301
+ {value: 'purple', label: 'Purple'},
1302
+ {value: 'orange', label: 'Orange'},
1303
+ ]}
1304
+ />
1305
+ }
1306
+ />
1307
+ </SettingsCard>
1308
+
1309
+ <SettingsCard title="Typography">
1310
+ <SettingsRow
1311
+ title="Interface font"
1312
+ icon={DocumentTextIcon}
1313
+ description="Menus, labels, and message text."
1314
+ control={
1315
+ <Selector
1316
+ label="Interface font"
1317
+ isLabelHidden
1318
+ size="sm"
1319
+ width={CONTROL_WIDTH}
1320
+ value={settings.font}
1321
+ onChange={value => update('font', value)}
1322
+ options={INTERFACE_FONTS.map(font => ({
1323
+ value: font.value,
1324
+ label: font.label,
1325
+ }))}
1326
+ />
1327
+ }
1328
+ detail={<FontPreview value={settings.font} />}
1329
+ />
1330
+ </SettingsCard>
1331
+
1332
+ <SettingsCard title="Layout">
1333
+ <SettingsRow
1334
+ title="Interface density"
1335
+ icon={Bars3Icon}
1336
+ description="How much breathing room rows and lists get."
1337
+ control={
1338
+ <SegmentedControl
1339
+ label="Interface density"
1340
+ size="sm"
1341
+ value={settings.density}
1342
+ onChange={value => update('density', value)}>
1343
+ <SegmentedControlItem value="comfortable" label="Comfortable" />
1344
+ <SegmentedControlItem value="compact" label="Compact" />
1345
+ </SegmentedControl>
1346
+ }
1347
+ />
1348
+ <SettingsRow
1349
+ title="Reduce motion"
1350
+ icon={HandRaisedIcon}
1351
+ description="Skip transitions and animated transforms."
1352
+ control={
1353
+ <Switch
1354
+ label="Reduce motion"
1355
+ isLabelHidden
1356
+ value={settings.reduceMotion}
1357
+ onChange={value => update('reduceMotion', value)}
1358
+ />
1359
+ }
1360
+ />
1361
+ </SettingsCard>
1362
+ </VStack>
1363
+ );
1364
+ }
1365
+
1366
+ function NotificationsPanel({settings, update}: PanelProps) {
1367
+ return (
1368
+ <VStack gap={5}>
1369
+ <SettingsCard title="Email">
1370
+ <SettingsRow
1371
+ title="Mentions"
1372
+ description="Someone types your name."
1373
+ icon={AtSymbolIcon}
1374
+ control={
1375
+ <Switch
1376
+ label="Email me about mentions"
1377
+ isLabelHidden
1378
+ value={settings.emailMentions}
1379
+ onChange={value => update('emailMentions', value)}
1380
+ />
1381
+ }
1382
+ />
1383
+ <SettingsRow
1384
+ title="Comments"
1385
+ description="Replies on threads you are part of."
1386
+ icon={ChatBubbleLeftRightIcon}
1387
+ control={
1388
+ <Switch
1389
+ label="Email me about comments"
1390
+ isLabelHidden
1391
+ value={settings.emailComments}
1392
+ onChange={value => update('emailComments', value)}
1393
+ />
1394
+ }
1395
+ />
1396
+ <SettingsRow
1397
+ title="Weekly digest"
1398
+ description="One summary every Monday morning."
1399
+ icon={EnvelopeIcon}
1400
+ control={
1401
+ <Switch
1402
+ label="Send the weekly digest"
1403
+ isLabelHidden
1404
+ value={settings.weeklyDigest}
1405
+ onChange={value => update('weeklyDigest', value)}
1406
+ />
1407
+ }
1408
+ />
1409
+ </SettingsCard>
1410
+
1411
+ <SettingsCard title="Desktop">
1412
+ <SettingsRow
1413
+ title="Desktop notifications"
1414
+ icon={ComputerDesktopIcon}
1415
+ description="Requires permission from your browser."
1416
+ control={
1417
+ <Switch
1418
+ label="Desktop notifications"
1419
+ isLabelHidden
1420
+ value={settings.desktopAlerts}
1421
+ onChange={value => update('desktopAlerts', value)}
1422
+ />
1423
+ }
1424
+ />
1425
+ <SettingsRow
1426
+ title="Play a sound"
1427
+ icon={SpeakerWaveIcon}
1428
+ control={
1429
+ <Switch
1430
+ label="Play a sound"
1431
+ isLabelHidden
1432
+ value={settings.notificationSounds}
1433
+ onChange={value => update('notificationSounds', value)}
1434
+ />
1435
+ }
1436
+ />
1437
+ <SettingsRow
1438
+ title="Quiet hours"
1439
+ description="Hold notifications and deliver them in the morning."
1440
+ icon={ClockIcon}
1441
+ control={
1442
+ <Selector
1443
+ label="Quiet hours"
1444
+ isLabelHidden
1445
+ size="sm"
1446
+ width={CONTROL_WIDTH}
1447
+ value={settings.quietHours}
1448
+ onChange={value => update('quietHours', value)}
1449
+ options={[
1450
+ {value: 'none', label: 'Off'},
1451
+ {value: 'evening', label: '18:00 – 09:00'},
1452
+ {value: 'night', label: '22:00 – 07:00'},
1453
+ ]}
1454
+ />
1455
+ }
1456
+ />
1457
+ </SettingsCard>
1458
+ </VStack>
1459
+ );
1460
+ }
1461
+
1462
+ /* ------------------------------------------------------------------ *
1463
+ * Keyboard shortcuts
1464
+ * ------------------------------------------------------------------ */
1465
+
1466
+ interface Chord {
1467
+ /** The platform's command key — ⌘ on a Mac, Ctrl elsewhere. `Kbd` decides. */
1468
+ mod?: boolean;
1469
+ shift?: boolean;
1470
+ alt?: boolean;
1471
+ key: string;
1472
+ }
1473
+
1474
+ interface ShortcutDef {
1475
+ id: string;
134
1476
  label: string;
135
- value: string;
136
- children: React.ReactNode;
137
- isExpanded: boolean;
138
- onEdit: () => void;
139
- onCancel: () => void;
140
- onSave: () => void;
1477
+ icon: IconComponent;
1478
+ binding: Chord;
141
1479
  }
142
1480
 
143
- function ExpandableRow({
144
- label,
145
- value,
146
- children,
147
- isExpanded,
148
- onEdit,
1481
+ const SHORTCUTS: ShortcutDef[] = [
1482
+ {
1483
+ id: 'search',
1484
+ label: 'Search everything',
1485
+ icon: MagnifyingGlassIcon,
1486
+ binding: {mod: true, key: 'k'},
1487
+ },
1488
+ {
1489
+ id: 'new',
1490
+ label: 'New item',
1491
+ icon: PencilSquareIcon,
1492
+ binding: {mod: true, key: 'n'},
1493
+ },
1494
+ {
1495
+ id: 'save',
1496
+ label: 'Save',
1497
+ icon: ArrowDownOnSquareIcon,
1498
+ binding: {mod: true, key: 's'},
1499
+ },
1500
+ {
1501
+ id: 'settings',
1502
+ label: 'Open settings',
1503
+ icon: Cog6ToothIcon,
1504
+ binding: {mod: true, key: ','},
1505
+ },
1506
+ {
1507
+ id: 'sidebar',
1508
+ label: 'Toggle the sidebar',
1509
+ icon: Bars3BottomLeftIcon,
1510
+ binding: {mod: true, shift: true, key: 'b'},
1511
+ },
1512
+ {
1513
+ id: 'help',
1514
+ label: 'Shortcut cheat sheet',
1515
+ icon: QuestionMarkCircleIcon,
1516
+ binding: {shift: true, key: '?'},
1517
+ },
1518
+ ];
1519
+
1520
+ /**
1521
+ * `KeyboardEvent.key` names `Kbd` does not already understand.
1522
+ *
1523
+ * `Kbd` lowercases each part and falls back to `key.toUpperCase()`, so "k",
1524
+ * "F2" and "?" need no entry. These do: the arrows have short names in `Kbd`'s
1525
+ * own map, and "+" is the character it SPLITS `keys` on — a literal plus has to
1526
+ * take the "plus" escape or the chord renders as two blank keycaps.
1527
+ */
1528
+ const KBD_KEY_NAMES: Record<string, string> = {
1529
+ ArrowUp: 'up',
1530
+ ArrowDown: 'down',
1531
+ ArrowLeft: 'left',
1532
+ ArrowRight: 'right',
1533
+ '+': 'plus',
1534
+ ' ': 'space',
1535
+ };
1536
+
1537
+ /**
1538
+ * A chord as `Kbd`'s `keys` string — the whole thing in one component,
1539
+ * "+"-separated, in modifier order so the chip reads left to right.
1540
+ *
1541
+ * `mod` is handed over UNRESOLVED, because resolving it is `Kbd`'s job and it
1542
+ * does that through `useSyncExternalStore` — so unlike a hand-rolled
1543
+ * `navigator.platform` check it agrees with its own server render.
1544
+ */
1545
+ function chordKeys(chord: Chord): string {
1546
+ const parts: string[] = [];
1547
+ if (chord.mod) {
1548
+ parts.push('mod');
1549
+ }
1550
+ if (chord.shift) {
1551
+ parts.push('shift');
1552
+ }
1553
+ if (chord.alt) {
1554
+ parts.push('alt');
1555
+ }
1556
+ parts.push(KBD_KEY_NAMES[chord.key] ?? chord.key);
1557
+ return parts.join('+');
1558
+ }
1559
+
1560
+ /** The chord as words, for the accessible name behind the keycaps. */
1561
+ function chordLabel(chord: Chord): string {
1562
+ const parts: string[] = [];
1563
+ if (chord.mod) {
1564
+ parts.push('Mod');
1565
+ }
1566
+ if (chord.shift) {
1567
+ parts.push('Shift');
1568
+ }
1569
+ if (chord.alt) {
1570
+ parts.push('Alt');
1571
+ }
1572
+ parts.push(chord.key === ' ' ? 'Space' : chord.key.toUpperCase());
1573
+ return parts.join(' + ');
1574
+ }
1575
+
1576
+ /** Wide enough to hold a chord like ⌘⇧F without the row jumping. */
1577
+ const RECORDER_WIDTH = 96;
1578
+
1579
+ /**
1580
+ * The rebinding capture: an input that swallows every keystroke and reports the
1581
+ * chord instead of typing it.
1582
+ *
1583
+ * It stays an `<input>` on purpose. Shortcut dispatchers skip events whose
1584
+ * target is editable, so a recorder built out of a `div` with a key handler
1585
+ * fires the very shortcut it is trying to record. `TextInput` has no
1586
+ * `isReadOnly` equivalent that keeps focus, so the value is pinned to `""` and
1587
+ * every keydown is `preventDefault`ed — which is what keeps it empty.
1588
+ */
1589
+ function ShortcutRecorder({
1590
+ onCapture,
149
1591
  onCancel,
150
- onSave,
151
- }: ExpandableRowProps) {
1592
+ }: {
1593
+ onCapture: (chord: Chord) => void;
1594
+ onCancel: () => void;
1595
+ }) {
1596
+ const handleKeyDown = useCallback(
1597
+ (event: React.KeyboardEvent) => {
1598
+ event.preventDefault();
1599
+ event.stopPropagation();
1600
+
1601
+ if (event.key === 'Escape') {
1602
+ onCancel();
1603
+ return;
1604
+ }
1605
+ // A modifier alone is not a chord — wait for the key it modifies.
1606
+ if (['Control', 'Meta', 'Alt', 'Shift'].includes(event.key)) {
1607
+ return;
1608
+ }
1609
+
1610
+ onCapture({
1611
+ mod: event.metaKey || event.ctrlKey,
1612
+ shift: event.shiftKey,
1613
+ alt: event.altKey,
1614
+ key: event.key,
1615
+ });
1616
+ },
1617
+ [onCapture, onCancel],
1618
+ );
1619
+
152
1620
  return (
153
- <>
154
- {isExpanded ? (
155
- <VStack gap={4}>
156
- <Text type="body" weight="semibold" display="block">
157
- {label}
158
- </Text>
159
- {children}
160
- <HStack gap={2}>
161
- <Button label="Save" variant="primary" onClick={onSave} />
162
- <Button label="Cancel" variant="ghost" onClick={onCancel} />
1621
+ <TextInput
1622
+ // A real label rather than leaning on the placeholder: the placeholder is
1623
+ // not an accessible name, and this control has no visible one of its own.
1624
+ label="Type a shortcut"
1625
+ isLabelHidden
1626
+ placeholder="Type it"
1627
+ size="sm"
1628
+ width={RECORDER_WIDTH}
1629
+ value=""
1630
+ hasAutoFocus
1631
+ onKeyDown={handleKeyDown}
1632
+ onBlur={onCancel}
1633
+ />
1634
+ );
1635
+ }
1636
+
1637
+ function ShortcutRow({
1638
+ def,
1639
+ chord,
1640
+ isEnabled,
1641
+ isCustom,
1642
+ onRebind,
1643
+ onToggle,
1644
+ onReset,
1645
+ }: {
1646
+ def: ShortcutDef;
1647
+ chord: Chord;
1648
+ isEnabled: boolean;
1649
+ isCustom: boolean;
1650
+ onRebind: (chord: Chord) => void;
1651
+ onToggle: () => void;
1652
+ onReset: () => void;
1653
+ }) {
1654
+ const [isRecording, setIsRecording] = useState(false);
1655
+
1656
+ return (
1657
+ <SettingsRow
1658
+ title={def.label}
1659
+ icon={def.icon}
1660
+ control={
1661
+ <HStack gap={2} align="center">
1662
+ {isCustom && (
1663
+ <IconButton
1664
+ label={`Reset ${def.label} to its default`}
1665
+ icon={<Icon icon={ArrowUturnLeftIcon} size="sm" />}
1666
+ variant="ghost"
1667
+ size="sm"
1668
+ onClick={onReset}
1669
+ />
1670
+ )}
1671
+ {isRecording ? (
1672
+ <ShortcutRecorder
1673
+ onCapture={next => {
1674
+ onRebind(next);
1675
+ setIsRecording(false);
1676
+ }}
1677
+ onCancel={() => {
1678
+ setIsRecording(false);
1679
+ }}
1680
+ />
1681
+ ) : (
1682
+ <Button
1683
+ // `Kbd` goes in as CHILDREN and `label` stays the words: Astryx
1684
+ // makes `label` the `aria-label` as soon as there are children,
1685
+ // so the keycaps are what you see and the chord is what a screen
1686
+ // reader says.
1687
+ label={isEnabled ? chordLabel(chord) : 'No shortcut'}
1688
+ // Ghost, not secondary: `Kbd` fills its own keycaps from the same
1689
+ // neutral token a secondary button fills with, so a filled button
1690
+ // around them is a chip inside a chip in one flat grey. The
1691
+ // keycaps are the chip; the button is the hit target.
1692
+ variant="ghost"
1693
+ size="sm"
1694
+ isDisabled={!isEnabled}
1695
+ tooltip={
1696
+ isEnabled ? 'Click to change' : 'Switch it on to rebind it'
1697
+ }
1698
+ onClick={() => {
1699
+ setIsRecording(true);
1700
+ }}>
1701
+ {isEnabled ? <Kbd keys={chordKeys(chord)} /> : undefined}
1702
+ </Button>
1703
+ )}
1704
+ <Tooltip content={isEnabled ? 'Disable' : 'Enable'}>
1705
+ <Switch
1706
+ size="sm"
1707
+ label={`${def.label} shortcut`}
1708
+ isLabelHidden
1709
+ value={isEnabled}
1710
+ onChange={onToggle}
1711
+ />
1712
+ </Tooltip>
1713
+ </HStack>
1714
+ }
1715
+ />
1716
+ );
1717
+ }
1718
+
1719
+ /**
1720
+ * Every shortcut on one card: the binding as keycaps you can click to rebind,
1721
+ * and a switch to turn it off entirely.
1722
+ *
1723
+ * Overrides are held as a sparse map rather than as a copy of the list, so
1724
+ * "custom" is a fact about the map (the key is present) rather than a
1725
+ * comparison against defaults that has to be kept honest.
1726
+ */
1727
+ function ShortcutsPanel() {
1728
+ const [overrides, setOverrides] = useState<Record<string, Chord | false>>({});
1729
+
1730
+ const resolve = (def: ShortcutDef) => overrides[def.id] ?? def.binding;
1731
+ const hasOverrides = Object.keys(overrides).length > 0;
1732
+
1733
+ const setOverride = (id: string, value: Chord | false) => {
1734
+ setOverrides(current => ({...current, [id]: value}));
1735
+ };
1736
+ const clearOverride = (id: string) => {
1737
+ setOverrides(current => {
1738
+ const next = {...current};
1739
+ Reflect.deleteProperty(next, id);
1740
+ return next;
1741
+ });
1742
+ };
1743
+
1744
+ return (
1745
+ <SettingsCard title="Shortcuts">
1746
+ {SHORTCUTS.map(def => {
1747
+ const resolved = resolve(def);
1748
+ const isEnabled = resolved !== false;
1749
+ return (
1750
+ <ShortcutRow
1751
+ key={def.id}
1752
+ def={def}
1753
+ chord={isEnabled ? resolved : def.binding}
1754
+ isEnabled={isEnabled}
1755
+ // A disabled shortcut is an override too, but its switch already
1756
+ // restores the default — a reset button beside it would be a
1757
+ // second control for the same act.
1758
+ isCustom={overrides[def.id] != null && overrides[def.id] !== false}
1759
+ onRebind={chord => {
1760
+ setOverride(def.id, chord);
1761
+ }}
1762
+ onToggle={() => {
1763
+ if (isEnabled) {
1764
+ setOverride(def.id, false);
1765
+ } else {
1766
+ clearOverride(def.id);
1767
+ }
1768
+ }}
1769
+ onReset={() => {
1770
+ clearOverride(def.id);
1771
+ }}
1772
+ />
1773
+ );
1774
+ })}
1775
+ {hasOverrides && (
1776
+ // A row of its own at the foot of the group, so it takes the card's
1777
+ // divider above it like any other row — the action belongs to the whole
1778
+ // group rather than to the last shortcut in it.
1779
+ <VStack padding={ROW_PADDING}>
1780
+ <HStack>
1781
+ <Button
1782
+ label="Reset all to defaults"
1783
+ variant="ghost"
1784
+ size="sm"
1785
+ icon={<Icon icon={ArrowUturnLeftIcon} size="sm" />}
1786
+ onClick={() => {
1787
+ setOverrides({});
1788
+ }}
1789
+ />
163
1790
  </HStack>
164
1791
  </VStack>
165
- ) : (
166
- <HStack hAlign="between" vAlign="start">
167
- <VStack gap={0}>
168
- <Text type="body" weight="semibold" display="block">
169
- {label}
170
- </Text>
171
- <Text type="supporting" color="secondary" display="block">
172
- {value}
173
- </Text>
174
- </VStack>
175
- <Link
176
- href="#"
177
- onClick={(e: React.MouseEvent) => {
178
- e.preventDefault();
179
- onEdit();
180
- }}>
181
- Edit
182
- </Link>
1792
+ )}
1793
+ </SettingsCard>
1794
+ );
1795
+ }
1796
+
1797
+ function LanguagePanel({settings, update}: PanelProps) {
1798
+ return (
1799
+ <SettingsCard title="Language & region">
1800
+ <SettingsRow
1801
+ title="Language"
1802
+ icon={LanguageIcon}
1803
+ description="Used across menus, buttons, and email."
1804
+ control={
1805
+ <Selector
1806
+ label="Language"
1807
+ isLabelHidden
1808
+ size="sm"
1809
+ width={CONTROL_WIDTH}
1810
+ value={settings.language}
1811
+ onChange={value => update('language', value)}
1812
+ options={[
1813
+ {value: 'en-US', label: 'English (US)'},
1814
+ {value: 'en-GB', label: 'English (UK)'},
1815
+ {value: 'fr', label: 'Français'},
1816
+ {value: 'de', label: 'Deutsch'},
1817
+ {value: 'ja', label: '日本語'},
1818
+ ]}
1819
+ />
1820
+ }
1821
+ />
1822
+ <SettingsRow
1823
+ title="Region format"
1824
+ icon={MapPinIcon}
1825
+ description="Dates, numbers, and currency."
1826
+ control={
1827
+ <Selector
1828
+ label="Region format"
1829
+ isLabelHidden
1830
+ size="sm"
1831
+ width={CONTROL_WIDTH}
1832
+ value={settings.region}
1833
+ onChange={value => update('region', value)}
1834
+ options={[
1835
+ {value: 'US', label: 'United States'},
1836
+ {value: 'GB', label: 'United Kingdom'},
1837
+ {value: 'DE', label: 'Germany'},
1838
+ {value: 'JP', label: 'Japan'},
1839
+ ]}
1840
+ />
1841
+ }
1842
+ />
1843
+ <SettingsRow
1844
+ title="Time zone"
1845
+ icon={GlobeAltIcon}
1846
+ description="Used for scheduling and every timestamp you see."
1847
+ control={
1848
+ <Selector
1849
+ label="Time zone"
1850
+ isLabelHidden
1851
+ size="sm"
1852
+ width={CONTROL_WIDTH}
1853
+ value={settings.timezone}
1854
+ onChange={value => update('timezone', value)}
1855
+ options={[
1856
+ // Zone first, offset second. Led by the offset, every option
1857
+ // starts with the same five characters and the list can only be
1858
+ // read to its end — and the longest of them then truncates
1859
+ // inside the control column.
1860
+ {value: 'PT', label: 'Pacific (GMT-08:00)'},
1861
+ {value: 'MT', label: 'Mountain (GMT-07:00)'},
1862
+ {value: 'CT', label: 'Central (GMT-06:00)'},
1863
+ {value: 'ET', label: 'Eastern (GMT-05:00)'},
1864
+ {value: 'UTC', label: 'UTC (GMT+00:00)'},
1865
+ ]}
1866
+ />
1867
+ }
1868
+ />
1869
+ <SettingsRow
1870
+ title="Start the week on"
1871
+ icon={CalendarDaysIcon}
1872
+ control={
1873
+ <SegmentedControl
1874
+ label="Start the week on"
1875
+ size="sm"
1876
+ value={settings.weekStart}
1877
+ onChange={value => update('weekStart', value)}>
1878
+ <SegmentedControlItem value="sun" label="Sunday" />
1879
+ <SegmentedControlItem value="mon" label="Monday" />
1880
+ </SegmentedControl>
1881
+ }
1882
+ />
1883
+ </SettingsCard>
1884
+ );
1885
+ }
1886
+
1887
+ function PrivacyPanel({settings, update}: PanelProps) {
1888
+ return (
1889
+ <VStack gap={5}>
1890
+ <SettingsCard title="Visibility">
1891
+ <SettingsRow
1892
+ title="Discoverable by email"
1893
+ icon={MagnifyingGlassIcon}
1894
+ description="Let people find your profile by searching your address."
1895
+ control={
1896
+ <Switch
1897
+ label="Discoverable by email"
1898
+ isLabelHidden
1899
+ value={settings.discoverable}
1900
+ onChange={value => update('discoverable', value)}
1901
+ />
1902
+ }
1903
+ />
1904
+ <SettingsRow
1905
+ title="Read receipts"
1906
+ icon={EyeIcon}
1907
+ description="Show people when you have read their message."
1908
+ control={
1909
+ <Switch
1910
+ label="Read receipts"
1911
+ isLabelHidden
1912
+ value={settings.readReceipts}
1913
+ onChange={value => update('readReceipts', value)}
1914
+ />
1915
+ }
1916
+ />
1917
+ <SettingsRow
1918
+ title="Activity status"
1919
+ icon={SignalIcon}
1920
+ description="Show a green dot while you are online."
1921
+ control={
1922
+ <Switch
1923
+ label="Activity status"
1924
+ isLabelHidden
1925
+ value={settings.activityStatus}
1926
+ onChange={value => update('activityStatus', value)}
1927
+ />
1928
+ }
1929
+ />
1930
+ </SettingsCard>
1931
+
1932
+ <SettingsCard title="Your data">
1933
+ <SettingsRow
1934
+ title="Personalized suggestions"
1935
+ description="Use your activity to rank what you see first."
1936
+ icon={SparklesIcon}
1937
+ control={
1938
+ <Switch
1939
+ label="Personalized suggestions"
1940
+ isLabelHidden
1941
+ value={settings.personalization}
1942
+ onChange={value => update('personalization', value)}
1943
+ />
1944
+ }
1945
+ />
1946
+ <SettingsRow
1947
+ title="Export my data"
1948
+ description="We will email a download link within 24 hours."
1949
+ icon={ArrowDownTrayIcon}
1950
+ control={
1951
+ <Button label="Request export" variant="secondary" size="sm" />
1952
+ }
1953
+ />
1954
+ <SettingsRow
1955
+ title="Delete my account"
1956
+ description="Permanent, and it takes the workspace with it."
1957
+ icon={TrashIcon}
1958
+ control={<Button label="Delete" variant="destructive" size="sm" />}
1959
+ />
1960
+ </SettingsCard>
1961
+ </VStack>
1962
+ );
1963
+ }
1964
+
1965
+ function IntegrationsPanel() {
1966
+ return (
1967
+ <SettingsCard title="Connected apps">
1968
+ <SettingsRow
1969
+ title="Calendar"
1970
+ description="Connected as alex@example.com"
1971
+ icon={CalendarDaysIcon}
1972
+ control={<Button label="Disconnect" variant="ghost" size="sm" />}
1973
+ />
1974
+ <SettingsRow
1975
+ title="File storage"
1976
+ description="Attach files straight from your drive."
1977
+ icon={FolderIcon}
1978
+ control={<Button label="Connect" variant="secondary" size="sm" />}
1979
+ />
1980
+ <SettingsRow
1981
+ title="Chat"
1982
+ description="Post updates to a channel when work ships."
1983
+ icon={ChatBubbleLeftRightIcon}
1984
+ control={<Button label="Connect" variant="secondary" size="sm" />}
1985
+ />
1986
+ </SettingsCard>
1987
+ );
1988
+ }
1989
+
1990
+ function DeveloperPanel({settings, update}: PanelProps) {
1991
+ return (
1992
+ <VStack gap={5}>
1993
+ <SettingsCard title="API">
1994
+ <SettingsRow
1995
+ title="API access"
1996
+ description="Issue tokens that act on your behalf."
1997
+ icon={CodeBracketIcon}
1998
+ control={
1999
+ <Switch
2000
+ label="API access"
2001
+ isLabelHidden
2002
+ value={settings.apiAccess}
2003
+ onChange={value => update('apiAccess', value)}
2004
+ />
2005
+ }
2006
+ />
2007
+ <SettingsRow
2008
+ title="Webhook URL"
2009
+ icon={LinkIcon}
2010
+ description="We POST an event here whenever something changes."
2011
+ control={
2012
+ <TextInput
2013
+ label="Webhook URL"
2014
+ isLabelHidden
2015
+ size="sm"
2016
+ width={CONTROL_WIDTH}
2017
+ placeholder="https://example.com/hooks"
2018
+ isDisabled={!settings.apiAccess}
2019
+ value={settings.webhookUrl}
2020
+ onChange={value => update('webhookUrl', value)}
2021
+ />
2022
+ }
2023
+ />
2024
+ </SettingsCard>
2025
+
2026
+ <SettingsCard title="Pre-release">
2027
+ <SettingsRow
2028
+ title="Early access features"
2029
+ icon={BeakerIcon}
2030
+ titleAccessory={<Token label="Beta" size="sm" color="purple" />}
2031
+ description="Unfinished work, turned on for your account only."
2032
+ control={
2033
+ <Switch
2034
+ label="Early access features"
2035
+ isLabelHidden
2036
+ value={settings.earlyAccess}
2037
+ onChange={value => update('earlyAccess', value)}
2038
+ />
2039
+ }
2040
+ />
2041
+ <SettingsRow
2042
+ title="Verbose logging"
2043
+ icon={DocumentMagnifyingGlassIcon}
2044
+ description="Write a detailed trace to the browser console."
2045
+ control={
2046
+ <Switch
2047
+ label="Verbose logging"
2048
+ isLabelHidden
2049
+ value={settings.verboseLogging}
2050
+ onChange={value => update('verboseLogging', value)}
2051
+ />
2052
+ }
2053
+ />
2054
+ </SettingsCard>
2055
+ </VStack>
2056
+ );
2057
+ }
2058
+
2059
+ /**
2060
+ * The body for a panel. Every entry is reachable from the same registry, so a
2061
+ * panel cannot be dropped from the hub by being forgotten here.
2062
+ */
2063
+ function SettingsPanelBody({
2064
+ panel,
2065
+ settings,
2066
+ update,
2067
+ }: {
2068
+ panel: PanelId;
2069
+ } & PanelProps) {
2070
+ switch (panel) {
2071
+ case 'profile':
2072
+ return <ProfilePanel settings={settings} update={update} />;
2073
+ case 'security':
2074
+ return <SecurityPanel settings={settings} update={update} />;
2075
+ case 'billing':
2076
+ return <BillingPanel settings={settings} update={update} />;
2077
+ case 'appearance':
2078
+ return <AppearancePanel settings={settings} update={update} />;
2079
+ case 'notifications':
2080
+ return <NotificationsPanel settings={settings} update={update} />;
2081
+ case 'shortcuts':
2082
+ return <ShortcutsPanel />;
2083
+ case 'language':
2084
+ return <LanguagePanel settings={settings} update={update} />;
2085
+ case 'privacy':
2086
+ return <PrivacyPanel settings={settings} update={update} />;
2087
+ case 'integrations':
2088
+ return <IntegrationsPanel />;
2089
+ case 'developer':
2090
+ return <DeveloperPanel settings={settings} update={update} />;
2091
+ }
2092
+ }
2093
+
2094
+ /* ------------------------------------------------------------------ *
2095
+ * Search
2096
+ * ------------------------------------------------------------------ */
2097
+
2098
+ /**
2099
+ * The query, the filtered results, and the keyboard traversal that drives
2100
+ * `aria-activedescendant`. Split out of the dialog so the hub reads as wiring.
2101
+ */
2102
+ function useSettingsSearch(onSelectPanel: (panel: PanelId) => void) {
2103
+ const [query, setQuery] = useState('');
2104
+ const [activeIndex, setActiveIndex] = useState(0);
2105
+
2106
+ const normalized = query.trim().toLowerCase();
2107
+ const isActive = normalized.length > 0;
2108
+
2109
+ const results = useMemo(
2110
+ () =>
2111
+ isActive
2112
+ ? SEARCHABLE_SETTINGS.filter(setting =>
2113
+ settingMatchesSearch(setting, normalized),
2114
+ )
2115
+ : [],
2116
+ [isActive, normalized],
2117
+ );
2118
+
2119
+ useEffect(() => {
2120
+ setActiveIndex(0);
2121
+ }, [normalized]);
2122
+
2123
+ const onKeyDown: ComponentProps<'input'>['onKeyDown'] = event => {
2124
+ if (event.key === 'Escape' && query) {
2125
+ // Clear before Escape reaches the Dialog, so the first press empties the
2126
+ // box and only the second closes the hub.
2127
+ event.preventDefault();
2128
+ setQuery('');
2129
+ return;
2130
+ }
2131
+ if (!isActive || results.length === 0) {
2132
+ return;
2133
+ }
2134
+ if (event.key === 'ArrowDown') {
2135
+ event.preventDefault();
2136
+ setActiveIndex(index => (index + 1) % results.length);
2137
+ } else if (event.key === 'ArrowUp') {
2138
+ event.preventDefault();
2139
+ setActiveIndex(index => (index - 1 + results.length) % results.length);
2140
+ } else if (event.key === 'Enter') {
2141
+ event.preventDefault();
2142
+ const result = results[activeIndex];
2143
+ if (result) {
2144
+ onSelectPanel(result.panel);
2145
+ }
2146
+ }
2147
+ };
2148
+
2149
+ return {
2150
+ query,
2151
+ setQuery,
2152
+ isActive,
2153
+ results,
2154
+ activeIndex,
2155
+ setActiveIndex,
2156
+ onKeyDown,
2157
+ activeResultId:
2158
+ isActive && results.length > 0
2159
+ ? `settings-search-result-${activeIndex}`
2160
+ : undefined,
2161
+ };
2162
+ }
2163
+
2164
+ type SettingsSearch = ReturnType<typeof useSettingsSearch>;
2165
+
2166
+ function SettingsSearchResults({
2167
+ results,
2168
+ activeIndex,
2169
+ onActiveIndexChange,
2170
+ onSelect,
2171
+ }: {
2172
+ results: SearchableSetting[];
2173
+ activeIndex: number;
2174
+ onActiveIndexChange: (index: number) => void;
2175
+ onSelect: (setting: SearchableSetting) => void;
2176
+ }) {
2177
+ if (results.length === 0) {
2178
+ return (
2179
+ <VStack id="settings-search-results" gap={0.5} padding={2}>
2180
+ <Text type="label">No settings found</Text>
2181
+ <Text type="supporting" color="secondary">
2182
+ Try theme, notifications, or sessions.
2183
+ </Text>
2184
+ </VStack>
2185
+ );
2186
+ }
2187
+
2188
+ // `ListItem` rather than `SideNavItem`: these are listbox options, not
2189
+ // navigation, and a result has to say which panel it lives on — which
2190
+ // `SideNavItem` has no slot for.
2191
+ //
2192
+ // The container is a plain `VStack as="ul"` and NOT `List`, which destructures
2193
+ // a fixed prop set and never spreads the rest: `id`, `role="listbox"` and
2194
+ // `aria-label` would vanish with no type error and no runtime warning, and
2195
+ // the input's `aria-controls` would point at nothing.
2196
+ return (
2197
+ <VStack
2198
+ as="ul"
2199
+ gap={0}
2200
+ id="settings-search-results"
2201
+ role="listbox"
2202
+ aria-label="Search results"
2203
+ isScrollable>
2204
+ {results.map((setting, index) => (
2205
+ <ListItem
2206
+ key={`${setting.panel}-${setting.title}`}
2207
+ id={`settings-search-result-${index}`}
2208
+ role="option"
2209
+ aria-selected={index === activeIndex}
2210
+ label={setting.title}
2211
+ description={`${panelConfig(setting.panel).label} · ${
2212
+ setting.description
2213
+ }`}
2214
+ startContent={
2215
+ <Icon icon={setting.icon} size="sm" color="secondary" />
2216
+ }
2217
+ endContent={
2218
+ <Icon icon={ChevronRightIcon} size="sm" color="secondary" />
2219
+ }
2220
+ isSelected={index === activeIndex}
2221
+ onMouseEnter={() => onActiveIndexChange(index)}
2222
+ onClick={() => onSelect(setting)}
2223
+ />
2224
+ ))}
2225
+ </VStack>
2226
+ );
2227
+ }
2228
+
2229
+ /**
2230
+ * The search box, shared by both shells. `hasClear` gives the clear button and
2231
+ * its live status for free; only the RESULTS list is hand-built.
2232
+ */
2233
+ function SettingsSearchInput({search}: {search: SettingsSearch}) {
2234
+ return (
2235
+ <TextInput
2236
+ label="Search settings"
2237
+ isLabelHidden
2238
+ placeholder="Search settings"
2239
+ size="sm"
2240
+ // Search is a region control, not a settings-row control. The row fields
2241
+ // deliberately share `CONTROL_WIDTH`; applying that same 192px budget
2242
+ // here left a dead strip in SideNav's wider `topContent` region and made
2243
+ // the search look detached from the navigation it filters.
2244
+ width="100%"
2245
+ hasClear
2246
+ // The bare component, NOT `<Icon icon={...} />`: `TextInput` tints an icon
2247
+ // it renders itself, and returns an already-rendered element verbatim —
2248
+ // which leaves the magnifier a step darker than the placeholder beside it.
2249
+ startIcon={MagnifyingGlassIcon}
2250
+ role="combobox"
2251
+ aria-autocomplete="list"
2252
+ aria-controls="settings-search-results"
2253
+ aria-expanded={search.isActive ? search.results.length > 0 : false}
2254
+ aria-activedescendant={search.activeResultId}
2255
+ value={search.query}
2256
+ onChange={search.setQuery}
2257
+ onKeyDown={search.onKeyDown}
2258
+ />
2259
+ );
2260
+ }
2261
+
2262
+ /* ------------------------------------------------------------------ *
2263
+ * Shells
2264
+ * ------------------------------------------------------------------ */
2265
+
2266
+ /**
2267
+ * The whole left column: title, search box, and either results or the nav.
2268
+ * Desktop only — phone width renders `SettingsNarrowShell` instead, so there is
2269
+ * deliberately no collapsed state to configure here.
2270
+ */
2271
+ function SettingsSideNav({
2272
+ titleId,
2273
+ search,
2274
+ activePanel,
2275
+ onSelectPanel,
2276
+ }: {
2277
+ titleId: string;
2278
+ search: SettingsSearch;
2279
+ activePanel: PanelId;
2280
+ onSelectPanel: (panel: PanelId) => void;
2281
+ }) {
2282
+ return (
2283
+ <SideNav
2284
+ aria-label="Settings sections"
2285
+ xstyle={styles.sideNav}
2286
+ // Hand-composed rather than `SideNavHeading`, whose `heading` is typed
2287
+ // `string` and pinned to the large text size — a step above heading-4,
2288
+ // with no prop to reach the heading scale. The inset and row height here
2289
+ // reproduce `SideNavHeading`'s own, so the title stays aligned with the
2290
+ // nav labels below it.
2291
+ header={
2292
+ <HStack gap={2} align="center" paddingInline={2} minHeight={32}>
2293
+ {/* Also the dialog's accessible name (`aria-labelledby`), so the
2294
+ painted title and the announced one cannot drift apart. */}
2295
+ <Heading level={4} id={titleId}>
2296
+ Settings
2297
+ </Heading>
183
2298
  </HStack>
2299
+ }
2300
+ topContent={<SettingsSearchInput search={search} />}>
2301
+ {search.isActive ? (
2302
+ <SettingsSearchResults
2303
+ results={search.results}
2304
+ activeIndex={search.activeIndex}
2305
+ onActiveIndexChange={search.setActiveIndex}
2306
+ onSelect={setting => onSelectPanel(setting.panel)}
2307
+ />
2308
+ ) : (
2309
+ PANEL_GROUPS.map(group => (
2310
+ <SideNavSection
2311
+ key={group.label}
2312
+ title={group.label}
2313
+ xstyle={styles.sideNavSection}>
2314
+ {group.panels.map(panel => (
2315
+ // Single-line by construction: `SideNavItem` has no description.
2316
+ // The same text is the content pane's subtitle, so nothing leaves
2317
+ // the surface.
2318
+ <SideNavItem
2319
+ key={panel.id}
2320
+ label={panel.label}
2321
+ // Pre-rendered, not `icon={panel.icon}`: `SideNavItem` tints an
2322
+ // icon it renders itself `secondary` unless the item is
2323
+ // selected, leaving every unselected glyph a step dimmer than
2324
+ // its own label.
2325
+ icon={<Icon icon={panel.icon} size="sm" color="primary" />}
2326
+ isSelected={panel.id === activePanel}
2327
+ onClick={() => onSelectPanel(panel.id)}
2328
+ />
2329
+ ))}
2330
+ </SideNavSection>
2331
+ ))
184
2332
  )}
185
- <Divider />
186
- </>
2333
+ </SideNav>
187
2334
  );
188
2335
  }
189
2336
 
190
- function InfoRowItem({
191
- label,
192
- value,
193
- action,
2337
+ /** The panel's own heading — the content title, in both shells. */
2338
+ function SettingsPanelHeading({panel}: {panel: PanelId}) {
2339
+ const config = panelConfig(panel);
2340
+ return (
2341
+ <VStack gap={0.5}>
2342
+ <Heading level={2}>{config.label}</Heading>
2343
+ <Text type="supporting" color="secondary">
2344
+ {config.description}
2345
+ </Text>
2346
+ </VStack>
2347
+ );
2348
+ }
2349
+
2350
+ /**
2351
+ * The right column: the close control pinned to the pane's corner, with the
2352
+ * panel's heading and body scrolling under it.
2353
+ */
2354
+ function SettingsContentPane({
2355
+ panel,
2356
+ settings,
2357
+ update,
2358
+ onClose,
194
2359
  }: {
195
- label: string;
196
- value: string;
197
- action: string;
2360
+ panel: PanelId;
2361
+ onClose: () => void;
2362
+ } & PanelProps) {
2363
+ return (
2364
+ <LayoutContent isScrollable padding={4}>
2365
+ {/* `width="100%"` is load-bearing. A vertical Stack is only as wide
2366
+ as its contents in this position; without the width the sticky box
2367
+ collapsed to the close button, so `justify="end"` had no space to
2368
+ traverse and the control landed over the panel title instead of the
2369
+ pane's top-right corner. */}
2370
+ <HStack width="100%" justify="end" xstyle={styles.closeAnchor}>
2371
+ <HStack xstyle={styles.closeBacking}>
2372
+ <IconButton
2373
+ label="Close"
2374
+ variant="ghost"
2375
+ size="sm"
2376
+ icon={<Icon icon="close" size="sm" />}
2377
+ onClick={onClose}
2378
+ />
2379
+ </HStack>
2380
+ </HStack>
2381
+ {/* The panel column: `@container`, so every row inside it stacks on the
2382
+ width the column actually got rather than on the viewport's — see
2383
+ `ROW_STACK`. */}
2384
+ <VStack gap={4} xstyle={row.panel}>
2385
+ <HStack gap={2} align="start">
2386
+ <StackItem size="fill">
2387
+ <SettingsPanelHeading panel={panel} />
2388
+ </StackItem>
2389
+ {/* Holds the column the pinned control floats over, so a long panel
2390
+ description wraps at the button rather than running under it.
2391
+ Presentational only — the control itself is above, in the DOM
2392
+ order a keyboard reaches it in. */}
2393
+ <VStack aria-hidden="true" width={32} />
2394
+ </HStack>
2395
+ <SettingsPanelBody panel={panel} settings={settings} update={update} />
2396
+ </VStack>
2397
+ </LayoutContent>
2398
+ );
2399
+ }
2400
+
2401
+ /**
2402
+ * The narrow-viewport panel picker: the same navigation laid on its side as a
2403
+ * scrolling strip of tabs.
2404
+ */
2405
+ function SettingsPanelTabs({
2406
+ activePanel,
2407
+ onSelectPanel,
2408
+ }: {
2409
+ activePanel: PanelId;
2410
+ onSelectPanel: (panel: PanelId) => void;
198
2411
  }) {
199
2412
  return (
200
- <>
201
- <HStack hAlign="between" vAlign="start">
202
- <VStack gap={0}>
203
- <Text type="body" weight="semibold" display="block">
204
- {label}
205
- </Text>
206
- <Text type="supporting" color="secondary" display="block">
207
- {value}
2413
+ <HStack
2414
+ as="nav"
2415
+ aria-label="Settings sections"
2416
+ gap={1}
2417
+ wrap="nowrap"
2418
+ isScrollable
2419
+ paddingInline={3}
2420
+ paddingBlock={2}>
2421
+ {PANELS.map(panel => (
2422
+ <Button
2423
+ key={panel.id}
2424
+ label={panel.label}
2425
+ icon={<Icon icon={panel.icon} size="sm" />}
2426
+ variant={panel.id === activePanel ? 'secondary' : 'ghost'}
2427
+ size="sm"
2428
+ onClick={() => onSelectPanel(panel.id)}
2429
+ />
2430
+ ))}
2431
+ </HStack>
2432
+ );
2433
+ }
2434
+
2435
+ /**
2436
+ * The whole dialog at phone width: title row, full-width search, the tab strip,
2437
+ * then the panel. One column, because a 351px dialog has no room for two.
2438
+ */
2439
+ function SettingsNarrowShell({
2440
+ titleId,
2441
+ search,
2442
+ activePanel,
2443
+ settings,
2444
+ update,
2445
+ onSelectPanel,
2446
+ onClose,
2447
+ }: {
2448
+ titleId: string;
2449
+ search: SettingsSearch;
2450
+ activePanel: PanelId;
2451
+ onSelectPanel: (panel: PanelId) => void;
2452
+ onClose: () => void;
2453
+ } & PanelProps) {
2454
+ return (
2455
+ <VStack gap={0} height="100%">
2456
+ <HStack gap={2} align="center" paddingInline={3} paddingBlock={3}>
2457
+ <StackItem size="fill">
2458
+ {/* The element `aria-labelledby` points at in this shell. A title row,
2459
+ not the head of a navigation column, so it stays a label — the
2460
+ phone layout's only heading should be the panel's own. */}
2461
+ <Text type="label" id={titleId}>
2462
+ Settings
208
2463
  </Text>
209
- </VStack>
210
- {action && <Link href="#">{action}</Link>}
2464
+ </StackItem>
2465
+ <IconButton
2466
+ label="Close"
2467
+ variant="ghost"
2468
+ size="sm"
2469
+ icon={<Icon icon="close" size="sm" />}
2470
+ onClick={onClose}
2471
+ />
211
2472
  </HStack>
212
2473
  <Divider />
213
- </>
2474
+ <VStack paddingInline={3} paddingBlock={2}>
2475
+ <SettingsSearchInput search={search} />
2476
+ </VStack>
2477
+ {search.isActive ? (
2478
+ <VStack paddingInline={3} isScrollable>
2479
+ <SettingsSearchResults
2480
+ results={search.results}
2481
+ activeIndex={search.activeIndex}
2482
+ onActiveIndexChange={search.setActiveIndex}
2483
+ onSelect={setting => onSelectPanel(setting.panel)}
2484
+ />
2485
+ </VStack>
2486
+ ) : (
2487
+ <>
2488
+ <SettingsPanelTabs
2489
+ activePanel={activePanel}
2490
+ onSelectPanel={onSelectPanel}
2491
+ />
2492
+ <Divider />
2493
+ <StackItem size="fill">
2494
+ {/* Same `@container` as the desktop pane, so a row stacks on the
2495
+ width it was given in either shell. */}
2496
+ <VStack
2497
+ gap={4}
2498
+ padding={4}
2499
+ isScrollable
2500
+ height="100%"
2501
+ xstyle={row.panel}>
2502
+ <SettingsPanelHeading panel={activePanel} />
2503
+ <SettingsPanelBody
2504
+ panel={activePanel}
2505
+ settings={settings}
2506
+ update={update}
2507
+ />
2508
+ </VStack>
2509
+ </StackItem>
2510
+ </>
2511
+ )}
2512
+ </VStack>
214
2513
  );
215
2514
  }
216
2515
 
2516
+ // Remove isInline for production — dialogs should be modal.
217
2517
  export default function SettingsDialogTemplate() {
218
- const [isOpen, setIsOpen] = useState(false);
219
- const [activeNav, setActiveNav] = useState('Login & security');
220
- const [expandedRow, setExpandedRow] = useState<string | null>(null);
221
-
222
- const [language, setLanguage] = useState('en-CA');
223
- const [currency, setCurrency] = useState('CAD');
224
- const [timezone, setTimezone] = useState('ET');
225
- const [activeTab, setActiveTab] = useState('login');
226
-
227
- const [legalName, setLegalName] = useState('Alex Johnson');
228
- const [preferredName, setPreferredName] = useState('');
229
- const [email, setEmail] = useState('a***n@example.com');
230
- const [phone, setPhone] = useState('+1 ***-***-0123');
231
- const [address, setAddress] = useState('');
232
- const [mailingAddress, setMailingAddress] = useState('');
233
- const [emergencyContact, setEmergencyContact] = useState('Provided');
234
- const [readReceipts, setReadReceipts] = useState(true);
235
- const [searchEngines, setSearchEngines] = useState(true);
236
- const [showCity, setShowCity] = useState(true);
237
- const [showTripType, setShowTripType] = useState(true);
238
- const [showStayLength, setShowStayLength] = useState(true);
239
- const [showServices, setShowServices] = useState(true);
240
- const [aiFeatures, setAiFeatures] = useState(true);
241
-
242
- const handleEdit = (row: string) => setExpandedRow(row);
243
- const handleCancel = () => setExpandedRow(null);
244
- const handleSave = () => setExpandedRow(null);
2518
+ const [activePanel, setActivePanel] = useState<PanelId>(DEFAULT_PANEL);
2519
+ const [settings, setSettings] = useState<SettingsState>(INITIAL_SETTINGS);
2520
+ const titleId = useId();
245
2521
 
246
- return (
247
- <>
248
- <Layout
249
- content={
250
- <LayoutContent padding={0}>
251
- <Center height="80vh">
252
- <Button
253
- label="Open settings"
254
- variant="primary"
255
- onClick={() => setIsOpen(true)}
256
- />
257
- </Center>
258
- </LayoutContent>
259
- }
260
- />
2522
+ // Below this the two-column shell is swapped for `SettingsNarrowShell`. NOT a
2523
+ // collapsed rail — `SettingsPanelTabs` records why that does not survive a
2524
+ // phone-width dialog.
2525
+ const isNarrow = useMediaQuery(NARROW_VIEWPORT);
2526
+
2527
+ const update: UpdateSetting = (key, value) => {
2528
+ setSettings(current => ({...current, [key]: value}));
2529
+ };
261
2530
 
2531
+ const search = useSettingsSearch(panel => {
2532
+ selectPanel(panel);
2533
+ });
2534
+
2535
+ const selectPanel = (panel: PanelId) => {
2536
+ setActivePanel(panel);
2537
+ search.setQuery('');
2538
+ };
2539
+
2540
+ return (
2541
+ // `Center` is the page root — the rubric's root for a content page, and
2542
+ // what puts the inline dialog in the middle of the page it stands in for.
2543
+ <Center height="100dvh">
262
2544
  <Dialog
263
- isOpen={isOpen}
264
- onOpenChange={open => setIsOpen(open)}
265
- width={900}
266
- maxHeight="85vh"
267
- padding={0}
2545
+ isOpen
2546
+ // `isInline` renders this same dialog WITHOUT the `<dialog>` element, so
2547
+ // the page can be a thumbnail and a preview. A real modal cannot be
2548
+ // either: it is promoted to the browser's top layer, which no ancestor
2549
+ // transform or `overflow: hidden` can clip — an open one inside a scaled
2550
+ // preview tile paints over the whole gallery instead of inside its tile.
2551
+ // The docsite's own preview is also a Dialog, which a modal here would
2552
+ // then nest inside, and Astryx forbids nesting dialogs.
2553
+ //
2554
+ // Everything else on this page is the composition you want: drop
2555
+ // `isInline`, drive `isOpen` from state, hand `onOpenChange` the setter,
2556
+ // and point the close control at it, and this is a modal.
2557
+ isInline
2558
+ onOpenChange={() => {}}
268
2559
  purpose="form"
269
- style={dialogHeight}>
270
- <Layout
271
- height="fill"
272
- start={
273
- <LayoutPanel width={280} hasDivider role="navigation" padding={3}>
274
- <VStack gap={4}>
275
- <Heading level={2} style={sideNavHeading}>
276
- Account settings
277
- </Heading>
278
- <List density="spacious">
279
- {NAV_ITEMS.map(item => (
280
- <ListItem
281
- key={item.label}
282
- label={item.label}
283
- startContent={<Icon icon={item.icon} />}
284
- isSelected={activeNav === item.label}
285
- onClick={() => {
286
- setActiveNav(item.label);
287
- setExpandedRow(null);
288
- }}
289
- />
290
- ))}
291
- </List>
292
- <Divider />
293
- <List density="spacious">
294
- <ListItem
295
- label="Professional hosting tools"
296
- startContent={<Icon icon={WrenchScrewdriverIcon} />}
297
- onClick={() => {}}
298
- />
299
- </List>
300
- </VStack>
301
- </LayoutPanel>
302
- }
303
- content={
304
- <LayoutContent isScrollable padding={6}>
305
- <VStack gap={6}>
306
- <VStack style={headerSticky}>
307
- <DialogHeader
308
- title={
309
- activeNav === 'Personal information'
310
- ? 'Personal info'
311
- : activeNav
312
- }
313
- onOpenChange={open => setIsOpen(open)}
314
- hasDivider={false}
315
- />
316
- </VStack>
317
- <VStack gap={0} style={contentMaxWidth}>
318
- {activeNav === 'Personal information' && (
319
- <VStack gap={6}>
320
- <VStack gap={4}>
321
- <ExpandableRow
322
- label="Legal name"
323
- value={legalName}
324
- isExpanded={expandedRow === 'legalName'}
325
- onEdit={() => handleEdit('legalName')}
326
- onCancel={handleCancel}
327
- onSave={handleSave}>
328
- <TextInput
329
- label="Legal name"
330
- isLabelHidden
331
- value={legalName}
332
- onChange={setLegalName}
333
- />
334
- </ExpandableRow>
335
- <ExpandableRow
336
- label="Preferred first name"
337
- value={preferredName || 'Not provided'}
338
- isExpanded={expandedRow === 'preferredName'}
339
- onEdit={() => handleEdit('preferredName')}
340
- onCancel={handleCancel}
341
- onSave={handleSave}>
342
- <TextInput
343
- label="Preferred first name"
344
- isLabelHidden
345
- value={preferredName}
346
- onChange={setPreferredName}
347
- />
348
- </ExpandableRow>
349
- <ExpandableRow
350
- label="Email address"
351
- value={email}
352
- isExpanded={expandedRow === 'email'}
353
- onEdit={() => handleEdit('email')}
354
- onCancel={handleCancel}
355
- onSave={handleSave}>
356
- <TextInput
357
- label="Email address"
358
- isLabelHidden
359
- value={email}
360
- onChange={setEmail}
361
- />
362
- </ExpandableRow>
363
- <ExpandableRow
364
- label="Phone number"
365
- value={phone}
366
- isExpanded={expandedRow === 'phone'}
367
- onEdit={() => handleEdit('phone')}
368
- onCancel={handleCancel}
369
- onSave={handleSave}>
370
- <TextInput
371
- label="Phone number"
372
- isLabelHidden
373
- value={phone}
374
- onChange={setPhone}
375
- />
376
- </ExpandableRow>
377
- <InfoRowItem
378
- label="Identity verification"
379
- value="Verified"
380
- action=""
381
- />
382
- <ExpandableRow
383
- label="Residential address"
384
- value={address || 'Not provided'}
385
- isExpanded={expandedRow === 'address'}
386
- onEdit={() => handleEdit('address')}
387
- onCancel={handleCancel}
388
- onSave={handleSave}>
389
- <TextInput
390
- label="Residential address"
391
- isLabelHidden
392
- value={address}
393
- onChange={setAddress}
394
- />
395
- </ExpandableRow>
396
- <ExpandableRow
397
- label="Mailing address"
398
- value={mailingAddress || 'Not provided'}
399
- isExpanded={expandedRow === 'mailingAddress'}
400
- onEdit={() => handleEdit('mailingAddress')}
401
- onCancel={handleCancel}
402
- onSave={handleSave}>
403
- <TextInput
404
- label="Mailing address"
405
- isLabelHidden
406
- value={mailingAddress}
407
- onChange={setMailingAddress}
408
- />
409
- </ExpandableRow>
410
- <ExpandableRow
411
- label="Emergency contact"
412
- value={emergencyContact}
413
- isExpanded={expandedRow === 'emergencyContact'}
414
- onEdit={() => handleEdit('emergencyContact')}
415
- onCancel={handleCancel}
416
- onSave={handleSave}>
417
- <TextInput
418
- label="Emergency contact"
419
- isLabelHidden
420
- value={emergencyContact}
421
- onChange={setEmergencyContact}
422
- />
423
- </ExpandableRow>
424
- </VStack>
425
-
426
- <Card padding={4}>
427
- <VStack gap={4}>
428
- <HStack gap={3} vAlign="start">
429
- <Center width={48} height={48} style={iconBox}>
430
- <Icon icon={LockClosedIcon} />
431
- </Center>
432
- <VStack gap={0}>
433
- <Text
434
- type="body"
435
- weight="semibold"
436
- display="block">
437
- Why isn&apos;t my info shown here?
438
- </Text>
439
- <Text
440
- type="supporting"
441
- color="secondary"
442
- display="block">
443
- We&apos;re hiding some account details to
444
- protect your identity.
445
- </Text>
446
- </VStack>
447
- </HStack>
448
- <Divider />
449
- <HStack gap={3} vAlign="start">
450
- <Center width={48} height={48} style={iconBox}>
451
- <Icon icon={PencilSquareIcon} />
452
- </Center>
453
- <VStack gap={0}>
454
- <Text
455
- type="body"
456
- weight="semibold"
457
- display="block">
458
- Which details can be edited?
459
- </Text>
460
- <Text
461
- type="supporting"
462
- color="secondary"
463
- display="block">
464
- Contact info and personal details can be edited.
465
- If this info was used to verify your identity,
466
- you&apos;ll need to get verified again the next
467
- time you book—or to continue hosting.
468
- </Text>
469
- </VStack>
470
- </HStack>
471
- <Divider />
472
- <HStack gap={3} vAlign="start">
473
- <Center width={48} height={48} style={iconBox}>
474
- <Icon icon={ShareIcon} />
475
- </Center>
476
- <VStack gap={0}>
477
- <Text
478
- type="body"
479
- weight="semibold"
480
- display="block">
481
- What info is shared with others?
482
- </Text>
483
- <Text
484
- type="supporting"
485
- color="secondary"
486
- display="block">
487
- We only release contact information after a
488
- reservation is confirmed.
489
- </Text>
490
- </VStack>
491
- </HStack>
492
- </VStack>
493
- </Card>
494
- </VStack>
495
- )}
496
-
497
- {activeNav === 'Login & security' && (
498
- <VStack gap={6}>
499
- <TabList
500
- value={activeTab}
501
- onChange={setActiveTab}
502
- hasDivider>
503
- <Tab value="login" label="Login" />
504
- <Tab value="shared" label="Shared access" />
505
- </TabList>
506
-
507
- {activeTab === 'login' && (
508
- <VStack gap={8}>
509
- <VStack gap={4}>
510
- <Heading level={3}>Login</Heading>
511
- <Divider />
512
- {LOGIN_ROWS.map(row => (
513
- <InfoRowItem key={row.label} {...row} />
514
- ))}
515
- </VStack>
516
-
517
- <VStack gap={4}>
518
- <Heading level={3}>Social accounts</Heading>
519
- <Divider />
520
- {SOCIAL_ROWS.map(row => (
521
- <InfoRowItem key={row.label} {...row} />
522
- ))}
523
- </VStack>
524
-
525
- <VStack gap={4}>
526
- <Heading level={3}>Device history</Heading>
527
- <Divider />
528
- {DEVICE_ROWS.map((device, i) => (
529
- <React.Fragment key={i}>
530
- <HStack gap={3} vAlign="start">
531
- <Icon icon={ComputerDesktopIcon} />
532
- <StackItem size="fill">
533
- <VStack gap={0}>
534
- <HStack gap={2} vAlign="center">
535
- <Text type="body" weight="semibold">
536
- {device.label}
537
- </Text>
538
- {device.badge && (
539
- <Badge label={device.badge} />
540
- )}
541
- </HStack>
542
- <Text
543
- type="supporting"
544
- color="secondary"
545
- display="block">
546
- {device.location}
547
- </Text>
548
- </VStack>
549
- </StackItem>
550
- {device.action && (
551
- <Link href="#">{device.action}</Link>
552
- )}
553
- </HStack>
554
- <Divider />
555
- </React.Fragment>
556
- ))}
557
- </VStack>
558
-
559
- <VStack gap={4}>
560
- <Heading level={3}>Account</Heading>
561
- <Divider />
562
- <HStack hAlign="between" vAlign="start">
563
- <VStack gap={0}>
564
- <Text
565
- type="body"
566
- weight="semibold"
567
- display="block">
568
- Deactivate your account
569
- </Text>
570
- <Text
571
- type="supporting"
572
- color="secondary"
573
- display="block">
574
- This action cannot be undone
575
- </Text>
576
- </VStack>
577
- <Link href="#">Deactivate</Link>
578
- </HStack>
579
- <Divider />
580
- </VStack>
581
- </VStack>
582
- )}
583
-
584
- {activeTab === 'shared' && (
585
- <VStack gap={8}>
586
- <VStack gap={2}>
587
- <Heading level={3}>Shared access</Heading>
588
- <Divider />
589
- <Text type="body" color="secondary">
590
- Review each request carefully before approving
591
- access. We&apos;ll email your employee or
592
- co-worker a 4-digit code that lets them log into
593
- your account with their trusted device.
594
- </Text>
595
- </VStack>
596
-
597
- <Card variant="muted">
598
- <HStack gap={4} vAlign="start">
599
- <Center width={48} height={48} style={iconBox}>
600
- <Icon icon={LockClosedIcon} />
601
- </Center>
602
- <VStack gap={1}>
603
- <Text type="body" weight="bold">
604
- Adding devices from people you trust
605
- </Text>
606
- <Text type="body" color="secondary">
607
- When you approve a request, you grant someone
608
- full access to your account. They&apos;ll be
609
- able to change reservations and send messages
610
- on your behalf.
611
- </Text>
612
- </VStack>
613
- </HStack>
614
- </Card>
615
- </VStack>
616
- )}
617
- </VStack>
618
- )}
619
-
620
- {activeNav === 'Languages & currency' && (
621
- <VStack gap={6}>
622
- <VStack gap={4}>
623
- <ExpandableRow
624
- label="Preferred language"
625
- value={
626
- LANGUAGES.find(l => l.value === language)?.label ??
627
- language
628
- }
629
- isExpanded={expandedRow === 'language'}
630
- onEdit={() => handleEdit('language')}
631
- onCancel={handleCancel}
632
- onSave={handleSave}>
633
- <Selector
634
- label="Language"
635
- isLabelHidden
636
- size="lg"
637
- value={language}
638
- onChange={setLanguage}
639
- options={LANGUAGES}
640
- />
641
- </ExpandableRow>
642
- <ExpandableRow
643
- label="Preferred currency"
644
- value={
645
- CURRENCIES.find(c => c.value === currency)?.label ??
646
- currency
647
- }
648
- isExpanded={expandedRow === 'currency'}
649
- onEdit={() => handleEdit('currency')}
650
- onCancel={handleCancel}
651
- onSave={handleSave}>
652
- <Selector
653
- label="Currency"
654
- isLabelHidden
655
- size="lg"
656
- value={currency}
657
- onChange={setCurrency}
658
- options={CURRENCIES}
659
- />
660
- </ExpandableRow>
661
- <ExpandableRow
662
- label="Time zone"
663
- value={
664
- TIMEZONES.find(t => t.value === timezone)?.label ??
665
- timezone
666
- }
667
- isExpanded={expandedRow === 'timezone'}
668
- onEdit={() => handleEdit('timezone')}
669
- onCancel={handleCancel}
670
- onSave={handleSave}>
671
- <Selector
672
- label="Time zone"
673
- isLabelHidden
674
- size="lg"
675
- value={timezone}
676
- onChange={setTimezone}
677
- options={TIMEZONES}
678
- />
679
- </ExpandableRow>
680
- </VStack>
681
- </VStack>
682
- )}
683
-
684
- {activeNav === 'Privacy' && (
685
- <VStack gap={6}>
686
- <VStack gap={8}>
687
- <VStack gap={4}>
688
- <Heading level={3}>Messages</Heading>
689
- <Switch
690
- label="Show people when I've read their messages."
691
- value={readReceipts}
692
- onChange={setReadReceipts}
693
- labelPosition="start"
694
- labelSpacing="spread"
695
- />
696
- <HStack hAlign="between" vAlign="center">
697
- <Text type="body" weight="semibold">
698
- Blocked people
699
- </Text>
700
- <Link href="#">View</Link>
701
- </HStack>
702
- <Divider />
703
- </VStack>
704
-
705
- <VStack gap={4}>
706
- <Heading level={3}>Listings</Heading>
707
- <Switch
708
- label="Include my listing(s) in search engines"
709
- description="Turning this on means search engines, like Google, will display your listing page(s) in search results."
710
- value={searchEngines}
711
- onChange={setSearchEngines}
712
- labelPosition="start"
713
- labelSpacing="spread"
714
- />
715
- <Divider />
716
- </VStack>
717
-
718
- <VStack gap={4}>
719
- <Heading level={3}>Reviews</Heading>
720
- <Text type="supporting" color="secondary">
721
- Choose what&apos;s shared when you write a review.{' '}
722
- <Link href="#" type="supporting">
723
- Learn more
724
- </Link>
725
- </Text>
726
- <VStack gap={4}>
727
- <Switch
728
- label="Show my home city and country"
729
- description="Ex: City and country"
730
- value={showCity}
731
- onChange={setShowCity}
732
- labelPosition="start"
733
- labelSpacing="spread"
734
- />
735
- <Switch
736
- label="Show my trip type"
737
- description="Ex: Stayed with kids or pets"
738
- value={showTripType}
739
- onChange={setShowTripType}
740
- labelPosition="start"
741
- labelSpacing="spread"
742
- />
743
- <Switch
744
- label="Show my length of stay"
745
- description="Ex: A few nights, about a week, etc."
746
- value={showStayLength}
747
- onChange={setShowStayLength}
748
- labelPosition="start"
749
- labelSpacing="spread"
750
- />
751
- <Switch
752
- label="Show my booked services"
753
- description="Ex: Gourmet brunch or tasting menu"
754
- value={showServices}
755
- onChange={setShowServices}
756
- labelPosition="start"
757
- labelSpacing="spread"
758
- />
759
- </VStack>
760
- <Divider />
761
- </VStack>
762
-
763
- <VStack gap={4}>
764
- <Heading level={3}>Data privacy</Heading>
765
- <Card>
766
- <HStack hAlign="between" vAlign="center">
767
- <Text type="body">Request my personal data</Text>
768
- <Link href="#">Request</Link>
769
- </HStack>
770
- </Card>
771
- <Switch
772
- label="Help improve AI-powered features"
773
- description="When this is on, we use your data to develop and improve AI models."
774
- value={aiFeatures}
775
- onChange={setAiFeatures}
776
- labelPosition="start"
777
- labelSpacing="spread"
778
- />
779
- <Card>
780
- <HStack hAlign="between" vAlign="center">
781
- <Text type="body">Delete my account</Text>
782
- <Link href="#">Delete</Link>
783
- </HStack>
784
- </Card>
785
- <Card variant="muted">
786
- <HStack gap={4} vAlign="start">
787
- <Center width={48} height={48} style={iconBox}>
788
- <Icon icon={ShieldCheckIcon} />
789
- </Center>
790
- <VStack gap={1}>
791
- <Text type="body" weight="bold">
792
- Committed to privacy
793
- </Text>
794
- <Text type="supporting" color="secondary">
795
- We&apos;re committed to keeping your data
796
- protected. See details in our{' '}
797
- <Link href="#" type="supporting">
798
- Privacy Policy
799
- </Link>
800
- .
801
- </Text>
802
- </VStack>
803
- </HStack>
804
- </Card>
805
- </VStack>
806
- </VStack>
807
- </VStack>
808
- )}
809
- </VStack>
810
- </VStack>
811
- </LayoutContent>
812
- }
813
- />
2560
+ width={1120}
2561
+ maxHeight="min(800px, calc(100dvh - 2rem))"
2562
+ padding={0}
2563
+ aria-labelledby={titleId}>
2564
+ {/* Two shells rather than one responsive one — see the module header.
2565
+ Both are wrapped in the one height, so the frame is the same on
2566
+ every panel and it is the panel that scrolls, not the dialog that
2567
+ grows. */}
2568
+ <VStack xstyle={styles.shell}>
2569
+ {isNarrow ? (
2570
+ <SettingsNarrowShell
2571
+ titleId={titleId}
2572
+ search={search}
2573
+ activePanel={activePanel}
2574
+ settings={settings}
2575
+ update={update}
2576
+ onSelectPanel={selectPanel}
2577
+ onClose={noop}
2578
+ />
2579
+ ) : (
2580
+ <Layout
2581
+ start={
2582
+ <SettingsSideNav
2583
+ titleId={titleId}
2584
+ search={search}
2585
+ activePanel={activePanel}
2586
+ onSelectPanel={selectPanel}
2587
+ />
2588
+ }
2589
+ content={
2590
+ <SettingsContentPane
2591
+ panel={activePanel}
2592
+ settings={settings}
2593
+ update={update}
2594
+ onClose={noop}
2595
+ />
2596
+ }
2597
+ />
2598
+ )}
2599
+ </VStack>
814
2600
  </Dialog>
815
- </>
2601
+ </Center>
816
2602
  );
817
2603
  }