PVR Tech Studio

Customizer & Layout Settings

Two twin surfaces for editing the shell configuration: the Layout Customizer (a right slide-in panel opened by the header gear) and the Layout Settings page (/layout-settings, a full-page tabbed twin).

11 min read
Updated August 13, 2026

LayoutContext, share the customizer i18n keys and the customizerOptions tables, and stay in sync live.

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) — a 26rem right slide-in opened by the header Settings2 gear. 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

FileResponsibility
src/context/LayoutContext.tsxLayoutConfig type, defaultLayout, layoutPresets, a re-export of the SKINS list (defined in src/config/skins.ts), and useLayout() (config, update, applyPreset, reset, skin, setSkin, oledDark, setOledDark). Persists config + skin + OLED to localStorage.
src/layout/overlays/LayoutCustomizer.tsxThe right slide-in panel; all sections.
src/pages/LayoutSettings.tsxThe full-page twin; hero + sidebar-mode/skin pickers + tabbed preferences.
src/components/settings/ShellPreview.tsxLive abstract miniature of the shell (also the skin swatch).
src/components/settings/ResetConfirmModal.tsxConfirmation dialog shared by both surfaces.
src/components/settings/tabs/NavigationTab.tsxSettings page → Navigation tab.
src/components/settings/tabs/WidgetsTab.tsxSettings page → Widgets tab.
src/components/settings/tabs/AppearanceTab.tsxSettings page → Appearance tab.
src/components/settings/tabs/LanguageTab.tsxSettings page → Language tab.
src/lib/customizerOptions.tsShared id→i18n-key tables: TOAST_POSITIONS, PAGE_TRANSITION_OPTS, PAGE_LOADER_OPTS, SPLASH_FREQ_OPTS, PANEL_HEADER_OPTS, and cap().
src/components/ui/OptionPills.tsxLabelled single-select pill group; labels resolve in the customizer namespace.
src/components/ui/Switch.tsxThe toggle used for boolean options (with optional icon).
src/lib/appStorage.tsclearAppStorage() — 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.

API / Props

useLayout()

MemberTypeDescription
configLayoutConfigThe current shell configuration.
update(partial: Partial<LayoutConfig>) => voidMerge-patches config (the primary write API).
applyPreset(name: 'default' | 'minimal' | 'contentFocus') => voidReplaces config with a named preset.
reset() => voidFull factory reset: clearAppStorage() then window.location.reload().
skin / setSkinSkin / (s: Skin) => voidActive design skin (persisted to localStorage('design_skin'), reflected on <html data-skin>).
oledDark / setOledDarkboolean / (v: boolean) => voidTrue-black (OLED) dark neutrals (localStorage('oled_dark'), <html data-oled>).

LayoutCustomizerProps

PropTypeDescription
openbooleanPanel visibility (owned by AppLayout).
onClose() => voidClose handler (backdrop click + Escape).

OptionPills props

PropTypeDescription
labelstringSection label (already-translated string).
optionsreadonly {id: T; key: string}[]Option list; each key resolves in the customizer namespace.
valueTCurrently-selected id.
onChange(id: T) => voidSelection handler.
className / groupClassName / pillClassNamestring?Layout/padding hooks so the dense panel and full page render identically to their hand-written originals.

ResetConfirmModalProps

PropTypeDescription
openbooleanDialog visibility.
onClose() => voidCancel.
onConfirm() => voidWired to reset.

Configuration & customization

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).

SectionControlConfig fieldType / optionsMutual exclusionSettings tab
DesignSkin tilesskin (setSkin)one of SKINS (13)—Design section (page-level)
PresetPreset buttons(whole config) applyPresetdefault / minimal / contentFocus—Hero buttons
NavigationShow primary navshowPrimaryNavSwitch—Navigation
NavigationFixed navfixedNavSwitch—Navigation
NavigationAuto-hide navautoHideNavSwitch⟂ stickyPageHeaderNavigation
ContentSticky page headerstickyPageHeaderSwitch⟂ autoHideNavNavigation
SidebarMinified sidebarminSidebarSwitchclears dualSidebar + headerOnlySidebar-mode picker
SidebarDual sidebardualSidebarSwitchclears minSidebar + headerOnlySidebar-mode picker
SidebarHeader-onlyheaderOnlySwitchclears minSidebar + dualSidebarSidebar-mode picker
SidebarAccordion menuaccordionMenuSwitch—Navigation
FooterFixed footerfixedFooterSwitch—Widgets
WidgetsChat bubblechatBubbleSwitch⟂ chatRailWidgets
WidgetsChat railchatRailSwitch⟂ chatBubbleWidgets
WidgetsCursor glowcursorGlowSwitch—Widgets
WidgetsToast positiontoastPositionOptionPills (TOAST_POSITIONS, 5)—Widgets
WidgetsPage transitionpageTransitionOptionPills (PAGE_TRANSITION_OPTS)—Widgets
WidgetsPage loaderpageLoaderOptionPills (PAGE_LOADER_OPTS, 3)—Widgets
WidgetsLoader frequencysplashFrequencyOptionPills (SPLASH_FREQ_OPTS: always/once)—Widgets
WidgetsRoute loaderrouteLoaderSwitch—Widgets
PanelsPanel headerpanelHeaderVariantOptionPills (PANEL_HEADER_OPTS: default/muted/dark/black/skin)—Appearance
PanelsEqual-height panelspanelEqualHeightSwitch—Appearance
LanguageLanguage tiles(i18n) setLanguageone per LANGUAGES—Language
AppearanceTheme modeuseTheme().setModelight / dark / system—Appearance
AppearanceTrue black (OLED)oledDark (setOledDark)Switch (forces dark when on)—Appearance
AppearanceDark sidebardarkSidebarSwitchclears oledSidebar when offAppearance
AppearanceOLED sidebaroledSidebarSwitchforces darkSidebar onAppearance

Mutually-exclusive groups (enforced in the onChange handlers)

  • Sidebar mode — enabling minSidebar / dualSidebar / headerOnly clears the other two:
    onChange={(v) => update({minSidebar: v, dualSidebar: false, headerOnly: false})}
  • Widgets — chatBubble and chatRail are 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 Animation & 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:

  1. Hero — eyebrow/title/subtitle, the preset buttons + Reset, and the live ShellPreview labelled with the active skin + sidebar mode.
  2. Sidebar layout — four mode tiles (default / minified / dual / headerOnly).
  3. Design / skins — one tile per skin, active tile ringed with a Check.
  4. 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
}, [])

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 it

Add 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 update call (follow the existing handlers).
  • Register every persisted key in src/lib/appStorage.ts so Reset stays complete. Prefer an exported STORAGE_KEY constant 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 OptionPills for single-select and Switch for 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 layout node would flicker the sliding indicators. See Animation & Effects.

Troubleshooting

SymptomLikely causeFix
Toggling one option silently turns off anotherIntended mutual exclusionSidebar modes, chat widgets, and auto-hide/sticky-header are exclusive by design.
A picker pill shows its raw key instead of a labelMissing/mis-namespaced translationThe key must exist in src/locales/<lng>/customizer.json; OptionPills resolves in the customizer namespace only.
Customizer and Settings page disagreeA control bypassed useLayoutRoute all writes through update/applyPreset/setSkin; both surfaces share the one context.
Reset didn't clear my new feature's stateKey not registeredAdd it to APP_LOCAL_KEYS / APP_SESSION_KEYS in src/lib/appStorage.ts.
OLED-dark toggle doesn't darkenTheme wasn't darkTurning OLED on forces setTheme('dark'); check useTheme isn't overridden.
Skin picker tile shows the active skin's colours instead of its ownMissing data-skin wrapperEach skin tile wraps ShellPreview in <div data-skin={id}>.
Config not persisting across reloadslocalStorage blocked / clearedLayoutContext 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, edit src/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 the h-[1.125rem] sizing used by the tabs for visual consistency.
  • Reset is destructive — its warning copy lives in the common namespace (resetConfirmTitle / resetConfirmDesc / resetConfirmConfirm). Keep it clear.

Was this page helpful?