Customizer & Settings
Two twin surfaces for editing the shell configuration — the Layout Customizer slide-in and the full-page Layout Settings — sharing one LayoutContext, the customizer i18n keys, and the option tables.
Overview
Every shell option lives in LayoutContext as a LayoutConfig (persisted to
localStorage('layout_config')). Two UIs edit it:
- Layout Customizer (
src/layout/overlays/LayoutCustomizer.tsx) — a26remright slide-in opened by the headerSettings2gear. Sections stack vertically, each animating in with a per-index delay. It is the fast, always-available editor. - Layout Settings page (
src/pages/LayoutSettings.tsx, route/layout-settings) — a full-page, hero-led twin. A live mini-shell (ShellPreview) sits in the hero, sidebar-mode and skin pickers render as tiles, and the detailed toggles are organised into four tabs.
Both coexist and both call useLayout(), so a change made in one is reflected in the other (and in the
live shell) instantly. They reuse the same customizer: translation keys and the same shared option tables
(src/lib/customizerOptions.ts) rendered through the OptionPills component.
Both also expose presets and a full-factory Reset (guarded by ResetConfirmModal →
clearAppStorage() + reload).
Architecture & files
| File | Responsibility |
|---|---|
src/context/LayoutContext.tsx | LayoutConfig type, defaultLayout, layoutPresets, the SKINS list, and useLayout() (config, update, applyPreset, reset, skin, setSkin, oledDark, setOledDark). Persists config + skin + OLED to localStorage. |
src/layout/overlays/LayoutCustomizer.tsx | The right slide-in panel; all sections. |
src/pages/LayoutSettings.tsx | The full-page twin; hero + sidebar-mode/skin pickers + tabbed preferences. |
src/components/settings/ShellPreview.tsx | Live abstract miniature of the shell (also the skin swatch). |
src/components/settings/ResetConfirmModal.tsx | Confirmation dialog shared by both surfaces. |
src/components/settings/tabs/NavigationTab.tsx | Settings page → Navigation tab. |
src/components/settings/tabs/WidgetsTab.tsx | Settings page → Widgets tab. |
src/components/settings/tabs/AppearanceTab.tsx | Settings page → Appearance tab. |
src/components/settings/tabs/LanguageTab.tsx | Settings page → Language tab. |
src/lib/customizerOptions.ts | Shared id→i18n-key tables: TOAST_POSITIONS, PAGE_TRANSITION_OPTS, PAGE_LOADER_OPTS, SPLASH_FREQ_OPTS, PANEL_HEADER_OPTS, and cap(). |
src/components/ui/OptionPills.tsx | Labelled single-select pill group; labels resolve in the customizer namespace. |
src/components/ui/Switch.tsx | The toggle used for boolean options (with optional icon). |
src/lib/appStorage.ts | clearAppStorage() — wipes every app key except auth_token; the Reset engine. |
Usage
Read and edit config from anywhere via the hook:
import {useLayout} from '@/context/LayoutContext'
function Example() {
const {config, update, applyPreset, reset, skin, setSkin} = useLayout()
return (
<>
<button onClick={() => update({minSidebar: !config.minSidebar})}>Toggle rail</button>
<button onClick={() => applyPreset('minimal')}>Minimal preset</button>
<button onClick={() => setSkin('ocean')}>Ocean skin</button>
</>
)
}The Customizer is opened from the header gear (onCustomizerClick → AppLayout sets customizerOpen). The
Settings page is a normal route (/layout-settings) reached from the sidebar, the ⌘K palette, or the
"Favorites" seed.
useLayout()
| Member | Type | Description |
|---|---|---|
config | LayoutConfig | The current shell configuration. |
update | (partial: Partial<LayoutConfig>) => void | Merge-patches config (the primary write API). |
applyPreset | (name: 'default' | 'minimal' | 'contentFocus') => void | Replaces config with a named preset. |
reset | () => void | Full factory reset: clearAppStorage() then window.location.reload(). |
skin / setSkin | Skin / (s: Skin) => void | Active design skin (persisted to localStorage('design_skin'), reflected on <html data-skin>). |
oledDark / setOledDark | boolean / (v: boolean) => void | True-black (OLED) dark neutrals (localStorage('oled_dark'), <html data-oled>). |
LayoutCustomizerProps
| Prop | Type | Description |
|---|---|---|
open | boolean | Panel visibility (owned by AppLayout). |
onClose | () => void | Close handler (backdrop click + Escape). |
OptionPills props
| Prop | Type | Description |
|---|---|---|
label | string | Section label (already-translated string). |
options | readonly {id: T; key: string}[] | Option list; each key resolves in the customizer namespace. |
value | T | Currently-selected id. |
onChange | (id: T) => void | Selection handler. |
className / groupClassName / pillClassName | string? | Layout/padding hooks so the dense panel and full page render identically to their hand-written originals. |
ResetConfirmModalProps
| Prop | Type | Description |
|---|---|---|
open | boolean | Dialog visibility. |
onClose | () => void | Cancel. |
onConfirm | () => void | Wired to reset. |
The full option set (as rendered by the Customizer)
Every row below maps to a LayoutConfig field via update({...}) (or a dedicated setter). The Customizer
groups them into Sections; the Settings page redistributes the same options across tabs (noted in the last
column).
| Section | Control | Config field | Type / options | Mutual exclusion | Settings tab |
|---|---|---|---|---|---|
| Design | Skin tiles | skin (setSkin) | one of SKINS (13) | — | Design section (page-level) |
| Preset | Preset buttons | (whole config) applyPreset | default / minimal / contentFocus | — | Hero buttons |
| Navigation | Show primary nav | showPrimaryNav | Switch | — | Navigation |
| Navigation | Fixed nav | fixedNav | Switch | — | Navigation |
| Navigation | Auto-hide nav | autoHideNav | Switch | ⟂ stickyPageHeader | Navigation |
| Content | Sticky page header | stickyPageHeader | Switch | ⟂ autoHideNav | Navigation |
| Sidebar | Minified sidebar | minSidebar | Switch | clears dualSidebar + headerOnly | Sidebar-mode picker |
| Sidebar | Dual sidebar | dualSidebar | Switch | clears minSidebar + headerOnly | Sidebar-mode picker |
| Sidebar | Header-only | headerOnly | Switch | clears minSidebar + dualSidebar | Sidebar-mode picker |
| Sidebar | Accordion menu | accordionMenu | Switch | — | Navigation |
| Footer | Fixed footer | fixedFooter | Switch | — | Widgets |
| Widgets | Chat bubble | chatBubble | Switch | ⟂ chatRail | Widgets |
| Widgets | Chat rail | chatRail | Switch | ⟂ chatBubble | Widgets |
| Widgets | Cursor glow | cursorGlow | Switch | — | Widgets |
| Widgets | Toast position | toastPosition | OptionPills (TOAST_POSITIONS, 5) | — | Widgets |
| Widgets | Page transition | pageTransition | OptionPills (PAGE_TRANSITION_OPTS) | — | Widgets |
| Widgets | Page loader | pageLoader | OptionPills (PAGE_LOADER_OPTS, 3) | — | Widgets |
| Widgets | Loader frequency | splashFrequency | OptionPills (SPLASH_FREQ_OPTS: always/once) | — | Widgets |
| Widgets | Route loader | routeLoader | Switch | — | Widgets |
| Panels | Panel header | panelHeaderVariant | OptionPills (PANEL_HEADER_OPTS: default/muted/dark/black/skin) | — | Appearance |
| Panels | Equal-height panels | panelEqualHeight | Switch | — | Appearance |
| Language | Language tiles | (i18n) setLanguage | one per LANGUAGES | — | Language |
| Appearance | Theme mode | useTheme().setMode | light / dark / system | — | Appearance |
| Appearance | True black (OLED) | oledDark (setOledDark) | Switch (forces dark when on) | — | Appearance |
| Appearance | Dark sidebar | darkSidebar | Switch | clears oledSidebar when off | Appearance |
| Appearance | OLED sidebar | oledSidebar | Switch | forces darkSidebar on | Appearance |
Mutually-exclusive groups (enforced in the onChange handlers)
- Sidebar mode — enabling
minSidebar/dualSidebar/headerOnlyclears the other two:onChange={(v) => update({minSidebar: v, dualSidebar: false, headerOnly: false})} - Widgets —
chatBubbleandchatRailare exclusive:onChange={(v) => update(v ? {chatRail: true, chatBubble: false} : {chatRail: false})} - Header/scroll —
autoHideNav⟂stickyPageHeader(a hidden header would leave a gap above a stuck page header):onChange={(v) => update(v ? {autoHideNav: true, stickyPageHeader: false} : {autoHideNav: false})} - OLED coupling — turning OLED-dark on forces theme to
dark; the OLED sidebar toggle forces the dark sidebar on, and turning the dark sidebar off clears the OLED sidebar.
The Settings page collapses the three sidebar booleans into one single-select mode via
activeSidebarMode(config) and sidebarModePatch(mode) — so the picker tiles are naturally exclusive.
Shared option tables + OptionPills
The toast/transition/loader/splash/panel-header pickers appear in both surfaces, so their id→i18n-key
lists live once in src/lib/customizerOptions.ts and render through OptionPills. All labels resolve in the
customizer i18n namespace — OptionPills calls useTranslation('customizer') internally, so you pass
the raw key (e.g. toastTopCenter) and it resolves the label. The transition and loader tables are derived
from the motion kind arrays:
export const PAGE_TRANSITION_OPTS = PAGE_TRANSITION_KINDS.map((id) => ({id, key: `pageTransition${cap(id)}`}))
export const PAGE_LOADER_OPTS = PAGE_LOADER_KINDS.map((id) => ({id, key: `pageLoader${cap(id)}`}))Adding a transition/loader kind therefore surfaces automatically in both pickers once you add the kind to
src/lib/motion.ts and its customizer:pageTransition<Cap> / pageLoader<Cap> i18n key. See
Motion & Effects.
The live mini-shell (ShellPreview)
ShellPreview is a pure, presentational abstract of the shell: every region is a token-coloured rectangle
that morphs (motion layout + spring) as its config changes. It takes a PreviewConfig (a Pick of
minSidebar, dualSidebar, headerOnly, showPrimaryNav, fixedFooter, chatRail, stickyPageHeader)
plus optional darkSidebar / oledSidebar. Because it is pure and token-driven, the Settings page reuses it
three ways: the hero live preview (bound to the real config), the sidebar-mode picker tiles (each
with a representative modePreview(mode)), and the skin picker tiles (wrapped in data-skin={id} so
each renders in that skin's tokens).
Layout Settings page structure
LayoutSettings.tsx renders, top to bottom:
Hero
Eyebrow/title/subtitle, the preset buttons + Reset, and the live ShellPreview labelled with the active
skin + sidebar mode.
Sidebar layout
Four mode tiles (default / minified / dual / headerOnly).
Design / skins
One tile per skin, active tile ringed with a Check.
Preferences
A two-pane Card: a tab rail (navigation / widgets / appearance / language, sliding
layoutId="ls-tab-active" highlight) and the tab body. Only the body remounts on tab switch (a plain
opacity fade), so the hero/tiles don't reproject their layout animations.
Presets (layoutPresets)
Three presets in LayoutContext:
- default — the shipped defaults (
darkSidebar,chatRail,accordionMenu,toastPosition: 'top-center',pageTransition: 'none',pageLoader: 'branded',splashFrequency: 'once'). - minimal — nav chrome off, no widgets,
pageLoader: 'minimal'. - contentFocus — minified sidebar + chat rail,
pageTransition: 'slide',splashFrequency: 'always'.
applyPreset(name) replaces the whole config with the preset object.
The full-factory Reset flow
Reset is intentionally destructive and is gated by ResetConfirmModal in both surfaces. On confirm it calls
reset():
// src/context/LayoutContext.tsx
const reset = useCallback(() => {
clearAppStorage() // wipes every app localStorage/sessionStorage key EXCEPT auth_token
window.location.reload() // all stores re-hydrate from defaults + seed
}, [])Register every persisted key
clearAppStorage() (src/lib/appStorage.ts) is the canonical list of app storage keys — config, skin,
OLED, theme, favorites, presence, recent routes, scrumboard/calendar/contacts/chat state, etc. — so a reset
returns the whole app to first-run state while keeping the user signed in. Any new persisted key must be
registered there, or Reset won't clear it.
Examples
Add a new boolean option end-to-end:
// 1) src/context/LayoutContext.tsx — extend the type + defaults
export interface LayoutConfig {
// ...
compactCards: boolean
}
export const defaultLayout: LayoutConfig = {
// ...
compactCards: false,
}
// 2) src/layout/overlays/LayoutCustomizer.tsx — a Switch in the right Section
<Switch
id="compactCards"
label={t('customizer:compactCards')}
description={t('customizer:compactCardsDesc')}
checked={config.compactCards}
onChange={(v) => update({compactCards: v})}
/>
// 3) src/components/settings/tabs/AppearanceTab.tsx — mirror it on the page
// 4) add customizer:compactCards / compactCardsDesc to src/locales/en/customizer.json
// 5) consume config.compactCards wherever the shell/pages need itAdd a picker option (e.g. a toast position):
// src/lib/customizerOptions.ts
export const TOAST_POSITIONS = [
// ...
{id: 'top-left', key: 'toastTopLeft'},
]
// then add customizer:toastTopLeft, and make sure ToastPosition includes 'top-left'Because OptionPills is shared, the new pill appears in the Customizer panel and the Widgets tab at once.
Programmatically apply a preset and skin:
const {applyPreset, setSkin} = useLayout()
applyPreset('contentFocus')
setSkin('midnight')Best practices
- Write through
update({...})with the smallest patch; don't rebuild the whole config object unless you're applying a preset. - Preserve the mutual-exclusion patterns. When adding a sidebar/widget/scroll option that conflicts with
an existing one, clear the counterpart in the same
updatecall (follow the existing handlers). - Register every persisted key in
src/lib/appStorage.tsso Reset stays complete. Prefer an exportedSTORAGE_KEYconstant over an inline string. - Keep the two surfaces in parity. A new option should appear in both the Customizer section and the
matching Settings tab, reusing the same
customizer:keys and (for pickers) the same shared table. - Use
OptionPillsfor single-select andSwitchfor booleans — don't hand-roll new controls. - Don't reproject layout on tab switches — the Settings page fades the tab body only; wrapping switched
content in a keyed
layoutnode would flicker the sliding indicators. See Motion & Effects.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Toggling one option silently turns off another | Intended mutual exclusion | Sidebar modes, chat widgets, and auto-hide/sticky-header are exclusive by design. |
| A picker pill shows its raw key instead of a label | Missing/mis-namespaced translation | The key must exist in src/locales/<lng>/customizer.json; OptionPills resolves in the customizer namespace only. |
| Customizer and Settings page disagree | A control bypassed useLayout | Route all writes through update/applyPreset/setSkin; both surfaces share the one context. |
| Reset didn't clear my new feature's state | Key not registered | Add it to APP_LOCAL_KEYS / APP_SESSION_KEYS in src/lib/appStorage.ts. |
| OLED-dark toggle doesn't darken | Theme wasn't dark | Turning OLED on forces setTheme('dark'); check useTheme isn't overridden. |
| Skin picker tile shows the active skin's colours instead of its own | Missing data-skin wrapper | Each skin tile wraps ShellPreview in <div data-skin={id}>. |
| Config not persisting across reloads | localStorage blocked / cleared | LayoutContext writes layout_config in an effect; a reset or private-mode storage block clears it. |
FAQ
Do I have to open the panel to change settings? No — the Settings page (/layout-settings) edits the
exact same config, and the ⌘K palette exposes a few quick toggles. All three share useLayout.
What's the difference between a preset and Reset? A preset replaces the live config with a curated set
(non-destructive to other stores). Reset is a full factory reset — it wipes all app storage (except
auth_token) and reloads.
Where do skins fit in? Skin is orthogonal to layout config; it's stored separately (design_skin) and
applied via <html data-skin>. The Customizer's "Design" section and the Settings "Design" grid both call
setSkin. See Design Skins.
Is theme mode part of LayoutConfig? No — theme (light/dark/system) is owned by useTheme (a
separate module store), but it's edited from the Appearance section/tab alongside layout options.
Why do the panel and page render identical pickers? They import the same customizerOptions tables and
the same OptionPills component; only the layout/padding class props differ.
Notes for designers & content editors
- All labels are
customizer:i18n keys. To rename an option or its help text, editsrc/locales/en/customizer.json(and translate). Never hardcode strings in the components. - Section titles, preset names, skin labels/hints, mode labels all follow the
customizer:+cap(id)convention (e.g.skinOceanLabel,presetMinimal,modeSystem). - The mini-shell is abstract — it's coloured blocks, not the real UI. It reads tokens/skins, so it updates automatically when you add a skin; no per-skin artwork is needed.
- Icons on the Settings switches/tabs are
lucide-react; keep theh-[1.125rem]sizing used by the tabs for visual consistency. - Reset is destructive — its warning copy lives in the
commonnamespace (resetConfirmTitle/resetConfirmDesc/resetConfirmConfirm). Keep it clear.
Related
Layout & Shell
LayoutContext, the configurable shell, route presentation, presets.
Page Layouts
Sidebar modes edited here — minified / dual / header-only.
Design Skins
The skin mechanism behind the Design pickers.
Design Tokens & CSS
Theme mode, OLED true-black, and the token system.
Motion & Effects
Page transitions, splash/loaders, reduced-motion.
Was this page helpful?
