| name | settings-system |
| description | Single source of truth for adding, editing, or migrating settings in the Matrx settings system. Use when adding a new preference, creating a settings tab, binding a new Redux slice, wiring a deep link, or touching anything under features/settings, components/official/settings, or the old components/user-preferences folder. Covers primitives, useSetting, the registry, admin gating, persistence tiers, and mobile parity. |
Settings System
Feature doc: features/settings/FEATURE.md — architecture source of truth. Start there for slice map, flows, and invariants.
Everything user-configurable flows through one system: a SettingsShell mounted via WindowPanel on desktop / Vaul drawer on mobile, backed by a registry of tabs that compose exactly one primitives library and read/write state only via useSetting.
Reference implementations:
features/settings/tabs/MessagingTab.tsx — canonical tab (switches + slider + conditional rendering + browser permission).
features/settings/tabs/AppearanceTab.tsx — crosses two slices (theme + userPreferences).
features/settings/tabs/WindowsTab.tsx — action-only writes + live read-only status.
features/settings/tabs/EmailTab.tsx — API-backed tab (the escape hatch).
Golden rules
- Primitives own their layout. No
className prop anywhere in the primitives library. Variations are new enum values on existing props.
- Tabs compose only primitives. Zero
@/components/ui/* imports, zero useSelector/useAppDispatch for writable values. Read-only selectors are acceptable for derived state (see WindowsTab).
useSetting(path) is the only write path. Unless the underlying slice has no write binding yet (new feature day one) — then add a binding to features/settings/slice-bindings.ts.
- Tabs are lazy. Every registry entry uses
lazyTab(() => import("./tabs/XYZ")). Never static-import a tab.
- Icons are Lucide. No emojis in settings UI ever.
- Every tab works on mobile. No
className="hidden md:...". The primitives are already mobile-ready; don't bypass them.
- Admin gating is declarative. Add
requiresAdmin: true to the registry entry — the tree hides it, getVisibleTabs(isAdmin) filters it, and the shell passes isAdmin down automatically.
Decision tree — "I want to add a setting"
Does the value already live in a registered slice?
Yes → call `useSetting("slice.dotted.key")` from your tab. DONE.
No ↓
Is the value new but conceptually a "user preference"?
Yes → extend `UserPreferences` in `lib/redux/slices/userPreferencesSlice.ts`:
• add the field to the right module interface
• add a default in `lib/redux/slices/defaultPreferences.ts`
• call `useSetting("userPreferences.<module>.<field>")`.
(The warm-cache sync policy handles IDB + localStorage + Supabase.)
No ↓
Is it genuinely new state (theme-ish, window-ish, feature-flag-ish)?
Yes → create a slice where it belongs. Then add a binding in
`features/settings/slice-bindings.ts` with `{ read, write, persistence }`.
Flag persistence honestly:
• "synced" — backed by a sync policy (IDB + LS + Supabase)
• "local-only" — Redux only, survives session but NOT reload
• "session" — transient; cleared on reload
What about /api/* endpoints that aren't in Redux?
See EmailTab.tsx. Use local useState + useEffect(fetch), compose primitives as usual, flag persistence: "local-only" in the registry entry until the API is migrated behind a slice+sync policy. Leave a TODO comment pointing to the migration.
Recipe — "Add a new settings tab"
Five files max. Usually three.
Step 1 — Build the tab
features/settings/tabs/MyTab.tsx:
"use client";
import { Sparkles } from "lucide-react";
import {
SettingsSwitch,
SettingsSlider,
SettingsSection,
SettingsSubHeader,
} from "@/components/official/settings";
import { useSetting } from "../hooks/useSetting";
export default function MyTab() {
const [enabled, setEnabled] = useSetting<boolean>("userPreferences.myModule.enabled");
const [level, setLevel] = useSetting<number>("userPreferences.myModule.level");
return (
<>
<SettingsSubHeader title="My feature" description="Short description." icon={Sparkles} />
<SettingsSection title="Behaviour">
<SettingsSwitch label="Enable X" checked={enabled} onCheckedChange={setEnabled} />
<SettingsSlider
label="Level"
=
=
=
=
=
/>
);
}
Step 2 — Register it
features/settings/registry.ts:
const MyTab = lazyTab(() => import("./tabs/MyTab"));
export const settingsRegistry: SettingsTabDef[] = [
{
id: "general.myFeature",
label: "My feature",
icon: Sparkles,
parentId: "general",
description: "Short tooltip/description.",
searchKeywords: ["x", "toggle"],
component: MyTab,
persistence: "synced",
},
];
Step 3 — Done
The tree picks it up. Search picks it up. Admin gating picks it up (no requiresAdmin → visible to everyone). Mobile push-nav picks it up. Breadcrumb picks it up.
Step 4 — (Optional) Add a slice binding if you used a brand-new slice
features/settings/slice-bindings.ts:
export const sliceBindings: Record<string, SliceBinding> = {
myFeature: {
read: (state, key) => readDotted(state.myFeature, key),
write: (key, value) => {
if (key === "enabled") return myFeatureSlice.actions.setEnabled(Boolean(value));
if (key === "level") return myFeatureSlice.actions.setLevel(Number(value));
throw new Error(`myFeature has no writable key "${key}"`);
},
persistence: "synced",
},
};
Primitive catalog
Every primitive accepts the same base props via SettingsCommonProps:
type SettingsCommonProps = {
label: string;
description?: ReactNode;
warning?: ReactNode;
error?: ReactNode;
badge?: SettingsBadge;
icon?: LucideIcon;
disabled?: boolean;
modified?: boolean;
id?: string;
helpText?: ReactNode;
};
Plus last?: boolean on every row primitive (removes trailing border — use on the final row in a SettingsSection).
Form primitives
| Primitive | Use for | Key control props |
|---|
SettingsSwitch | App-level boolean toggle | checked, onCheckedChange |
SettingsCheckbox | Consent / opt-in | checked, onCheckedChange |
SettingsSelect<T> | Enum dropdown | value, onValueChange, options[], size, width, stacked |
SettingsSegmented<T> | 2–4 mutually exclusive | value, onValueChange, options[], size, fullWidth |
SettingsRadioGroup<T> | 2–5 rich-option cards | value, onValueChange, options[], orientation |
SettingsSlider | Numeric range | value, onValueChange, min, max, step, precision, unit, minLabel, midLabel, maxLabel |
SettingsNumberInput | Exact numeric | value, onValueChange, min, max, step, integer, unit, size, width |
SettingsTextInput | Single-line text | value, onValueChange, type, commitOnBlur, maxLength, size, width, |
Layout primitives
| Primitive | Use for | Key props |
|---|
SettingsSubHeader | Top-of-tab title | title, description, icon, divider |
SettingsSection | Titled group of rows | title, description, icon, collapsible, defaultOpen, emphasis, action |
SettingsCallout | Info / warning / error / success banner | tone, title, children, action |
SettingsGrid | 2- or 3-col dense layout | columns, gap |
SettingsReadOnlyValue | Key/value info row | value, copyable, mono |
Anti-patterns
❌ Passing className to a primitive
<SettingsSwitch className="my-4" ... />
✅ If you need spacing, use a SettingsSection. If you need a new variant, add a prop to the primitive.
❌ Direct Redux dispatch in a tab
const dispatch = useAppDispatch();
dispatch(setPreference({ module: "prompts", preference: "temperature", value }));
✅ const [temperature, setTemperature] = useSetting("userPreferences.prompts.defaultTemperature").
❌ Importing shadcn in a tab
import { Switch } from "@/components/ui/switch";
✅ import { SettingsSwitch } from "@/components/official/settings";
❌ Ad-hoc row divs
<div className="flex items-center justify-between px-4 py-3.5 border-b">
<Label>…</Label>
<Switch />
</div> // WRONG
✅ <SettingsSwitch label="…" checked={…} onCheckedChange={…} />
❌ Hardcoding colors
<div className="bg-blue-500 text-white">…</div> // WRONG
✅ <SettingsCallout tone="info">…</SettingsCallout> or use design tokens (bg-primary, text-foreground, etc.).
❌ Adding category-only entries without a parentId-less root
{ id: "foo.child", label: "Child", parentId: "foo", ... }
✅ Every parentId must refer to an existing registry entry. Orphans are silently promoted to roots (bad UX).
❌ Treating persistence as decorative
It drives the badge in the shell and signals migration work. Be honest:
"synced" only when an actual sync policy handles the slice.
"local-only" when the value lives in Redux only and survives the session.
"session" when reload wipes it.
Path syntax for useSetting
useSetting<T>("slice.rest.of.key") — the first segment selects the slice binding, everything after is passed to that binding's read/write.
| Slice | Path shape | Example |
|---|
userPreferences | userPreferences.<module>.<preference> | userPreferences.messaging.notificationVolume |
theme | theme.<key> | theme.mode |
adminPreferences | adminPreferences.<key> | adminPreferences.serverOverride |
layout | layout.<key> | layout.layoutStyle |
windowManager | windowManager.<action> (write-only keys) | windowManager.toggleHidden, windowManager.restoreAll |
Mobile parity
Do nothing special. SettingsShell flips to SettingsDrawerNav under useIsMobile(). The push-nav renders each tab through SettingsTabHost. Because every tab is built from primitives, touch targets, text-base input font-size, and pb-safe are already correct.
Never:
- Add a mobile-only branch inside a tab.
- Render a
<Tabs> component inside a settings tab (stack vertically instead).
- Use
h-screen / min-h-screen inside a tab (primitives + shell handle viewport already).
Deep-linking + the legacy URL
?panels=user_preferences → initUrlHydration opens the overlay.
/settings/preferences?tab=<legacy> → redirects to /dashboard AND dispatches the overlay with the legacy tab id remapped (see LEGACY_TAB_ALIASES).
- The overlay id on the receiving side is always
userPreferencesWindow. The modal id userPreferences is a legacy alias the adapter also listens for.
If you add a new tab with an alternate "short" id for external deep-linking, add it to:
SettingsShellOverlay.LEGACY_TAB_MAP — overlay data mapping.
app/(authenticated)/settings/preferences/page.tsx → LEGACY_TAB_ALIASES — URL query mapping.
Checklist — before you open a PR
When this skill does NOT apply
- Per-agent settings (
features/agents/components/settings-management/*) — that's context-aware agent config, not user preferences. Separate system, separate skill.
- Profile / Organization / Team pages (
app/(authenticated)/settings/profile, /organizations, …) — they're route-based pages, not preference tabs.
- Anything inside
app/(authenticated)/admin/* that manages platform config.