User Preferences
How personal UI preferences are owned, stored, synchronized, and presented. This specification covers the first preference, appearance theme, and establishes the contract for adding preferences without mixing them with school or platform configuration.
Settings scopes
Settings are separated by owner and blast radius.
| Scope | Owner | Surface | Examples |
|---|---|---|---|
| User preferences | Individual user | Utility Panel | Theme, density, preferred view |
| School settings | Active school | Dedicated school routes | Profile, branding, academic policy |
| Platform settings | Platform operator | Dedicated platform routes | Provisioning policy, modules, plan limits |
A preference changes only the current user's experience. It must never grant access, alter school policy, or change platform behavior.
India is the only supported market initially. INR, en-IN, and
Asia/Kolkata are application defaults rather than editable preferences or
settings. UI language may become a user preference independently later.
First release
The first release adds one global preference:
type ThemePreference = 'system' | 'light' | 'dark';
system is the default. Future preferences are added only when a concrete
product requirement exists; the first release does not display empty or planned
categories.
Utility Panel
The existing right flyout becomes the Utility Panel. This is its internal and architectural name; the visible title follows the active tab.
| Tab | Entry point | Content |
|---|---|---|
| Quick actions | Top-bar quick-actions button | Permission-trimmed shortcuts |
| Notifications | Top-bar notification entry | Notification list or empty state |
| Preferences | User menu → Preferences | Personal preference controls |
Each entry point opens its designated tab. Closing the panel discards the active tab selection; reopening through an entry point always selects that entry point's tab. Closing the panel does not cancel an in-flight preference update.
School and platform settings never appear in the Utility Panel. They remain route-level administrative screens in their respective contexts.
Preferences tab
The initial Preferences tab contains a single unframed Appearance section:
Preferences
Appearance
Theme
[ System ] [ Light ] [ Dark ]
Saving… | Could not sync Retry
The theme control:
- Uses
Monitor,Sun, andMoonicons with visible labels. - Behaves as an accessible radio group.
- Applies the selected theme immediately.
- Has no Save or Apply button.
- Shows synchronization status next to the setting.
- Optionally states the resolved light/dark appearance when
systemis active.
The panel is 380 px wide on desktop and uses the existing full-height sheet on mobile. Content scrolls if preferences grow; it does not use nested cards or a second navigation surface.
Theme lifecycle
Use next-themes with class-based themes and system as the fallback.
- Before React hydrates, the local cached value determines the first paint.
systemresolves throughprefers-color-schemeand follows OS changes live.- Explicit
lightordarkchoices ignore subsequent OS theme changes. - After authentication, the database preference is canonical and replaces a stale local cached value.
- A user selection updates the UI and local cache immediately, then saves to the API.
- Signing out retains the local theme cache so the public sign-in screen keeps the same appearance.
The effective theme applies to public and authenticated pages. Native controls
use the matching color-scheme. Theme changes do not use a global color
transition.
Persistence
Preferences use one optional row per internal user.
user_preferences
----------------
user_id UUID PRIMARY KEY, FK → users.id ON DELETE CASCADE
preferences JSONB NOT NULL DEFAULT '{}'
schema_version INTEGER NOT NULL DEFAULT 1
created_at TIMESTAMP NOT NULL
updated_at TIMESTAMP NOT NULL
The initial stored document is sparse:
{
"appearance": {
"theme": "dark"
}
}
No row, {}, or a missing appearance.theme all resolve to system. Empty
rows are not created by seed data; the first successful update creates the row.
JSON is validated against a typed schema. Authorization, roles, feature access, school policy, infrastructure configuration, and secrets are never stored in preferences.
Schema version
schema_version identifies the shape and meaning of the JSON document. It is
independent of PostgreSQL and Drizzle migration numbers.
- Version
1understandsappearance.theme. - Adding a compatible optional field does not require a version increment.
- Renaming, moving, removing, changing a type, or changing the meaning of a field requires a new version and an explicit converter.
- Older documents are validated and upgraded through converters.
- A document newer than the running application is not overwritten.
API contract
Preferences are global to the authenticated user and independent of school or
platform context. They do not require X-Tenant-Code.
Read preferences
GET /api/preferences
The API returns the current normalized document even when no row exists:
{
"appearance": {
"theme": "system"
},
"schemaVersion": 1,
"updatedAt": null
}
Update preferences
PATCH /api/preferences
Content-Type: application/json
{
"appearance": {
"theme": "dark"
}
}
The API:
- Accepts only known fields and values.
- Rejects unknown keys and invalid values with
400. - Merges the patch without deleting unrelated preference categories.
- Validates the merged document before saving it atomically.
- Creates or updates only the authenticated user's row.
- Returns the complete normalized preference response.
The web client calls the same-origin BFF path /preferences; the BFF attaches
the server-held access token.
Synchronization and failure
The database is canonical; local storage is a first-paint cache.
On selection:
- Apply the theme and update local storage optimistically.
- Send
PATCH /preferences. - Retry once automatically for a transient failure.
- On success, accept the normalized server response.
- On final failure, retain the local appearance and show
Could not syncwith a manualRetryaction.
A failed save never causes an abrupt visual rollback. On a later successful preference fetch, the canonical server value replaces the local cache. Session expiry follows the normal authentication flow rather than appearing as a preference synchronization error.
Visual direction
Dark mode extends the existing token system rather than creating another visual identity.
- Preserve blue for primary actions and focus.
- Use neutral charcoal surfaces rather than pure black or dark blue.
- Separate elevation through surface luminance and restrained borders rather than heavy shadows.
- Keep layout, spacing, typography, and component dimensions identical between themes.
- Preserve semantic success, warning, and error meaning with appropriate contrast in each theme.
- Future school branding may affect constrained accent tokens, not structural background or text tokens.
Review the sign-in page, sidebar, top bar, content, footer, Utility Panel, context switcher, user menu, cards, forms, sheets, dialogs, tooltips, and all interactive states in both themes.
Accessibility
- Normal text meets WCAG AA contrast of at least 4.5:1.
- Large text and meaningful UI boundaries meet at least 3:1.
- Theme selection is conveyed by radio state, icon, and text, not color alone.
- Keyboard users enter the group with
Taband change options with arrow keys. - Focus indicators remain visible in both themes.
- Browser zoom and text scaling do not overflow the control or panel.
- Forced-colors mode remains operable even when its appearance differs.
- Reduced-motion users do not receive decorative theme-change animation.
Development database workflow
While no persistent environment data must be retained:
- Add
user_preferencesto the Drizzle schema. - Regenerate the single baseline migration and its metadata.
- Drop and recreate local PostgreSQL.
- Apply the baseline with
db:migrate. - Run
db:seedfor authorization data. - Run
db:bootstrap:platform-adminfor the pre-provisioned Keycloak admin.
The bootstrap does not create a preferences row. Once valuable or shared data exists, the baseline becomes immutable and all changes use forward-only migrations.
Acceptance criteria
API and persistence
- Missing preference data resolves to normalized defaults.
- The first valid update creates a row; later updates modify the same row.
- Invalid values and unknown keys return
400. - Updating theme preserves unrelated preference keys.
- Endpoints require authentication but no tenant context.
- Users can read and update only their own preferences.
- An unsupported newer schema version is not overwritten.
Theme behavior
system,light, anddarkbehave as defined above.- Server data replaces stale local cache after loading.
- Selection applies immediately and survives refresh.
- Failed synchronization retains the visible selection and offers Retry.
- The cache remains after sign-out.
Rendering and interaction
- First paint uses the cached effective theme without an incorrect-theme flash.
- Public and authenticated surfaces use the same effective theme.
- Hydration produces no theme warnings.
- Every Utility Panel entry point opens its designated tab.
- Closing the panel does not cancel pending saves.
- The radio group is keyboard and screen-reader operable.
Visual verification
Verify desktop and mobile in both themes, including menus, sheets, inputs, loading, empty, error, hover, focus, selected, and disabled states. Automated behavior tests are supplemented by browser screenshots and contrast review.