PVR Tech Studio
Dev customizing

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.

11 min read
Updated July 15, 2026

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 ResetConfirmModalclearAppStorage() + reload).

Architecture & files

FileResponsibility
src/context/LayoutContext.tsxLayoutConfig 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.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 (onCustomizerClickAppLayout sets customizerOpen). The Settings page is a normal route (/layout-settings) reached from the sidebar, the ⌘K palette, or the "Favorites" seed.

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.

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 / contentFocusHero buttons
NavigationShow primary navshowPrimaryNavSwitchNavigation
NavigationFixed navfixedNavSwitchNavigation
NavigationAuto-hide navautoHideNavSwitchstickyPageHeaderNavigation
ContentSticky page headerstickyPageHeaderSwitchautoHideNavNavigation
SidebarMinified sidebarminSidebarSwitchclears dualSidebar + headerOnlySidebar-mode picker
SidebarDual sidebardualSidebarSwitchclears minSidebar + headerOnlySidebar-mode picker
SidebarHeader-onlyheaderOnlySwitchclears minSidebar + dualSidebarSidebar-mode picker
SidebarAccordion menuaccordionMenuSwitchNavigation
FooterFixed footerfixedFooterSwitchWidgets
WidgetsChat bubblechatBubbleSwitchchatRailWidgets
WidgetsChat railchatRailSwitchchatBubbleWidgets
WidgetsCursor glowcursorGlowSwitchWidgets
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 loaderrouteLoaderSwitchWidgets
PanelsPanel headerpanelHeaderVariantOptionPills (PANEL_HEADER_OPTS: default/muted/dark/black/skin)Appearance
PanelsEqual-height panelspanelEqualHeightSwitchAppearance
LanguageLanguage tiles(i18n) setLanguageone per LANGUAGESLanguage
AppearanceTheme modeuseTheme().setModelight / dark / systemAppearance
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})}
  • WidgetschatBubble and chatRail are exclusive:
    onChange={(v) => update(v ? {chatRail: true, chatBubble: false} : {chatRail: false})}
  • Header/scrollautoHideNavstickyPageHeader (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
}, [])

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 Motion & 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?