Skip to main content

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.

ScopeOwnerSurfaceExamples
User preferencesIndividual userUtility PanelTheme, density, preferred view
School settingsActive schoolDedicated school routesProfile, branding, academic policy
Platform settingsPlatform operatorDedicated platform routesProvisioning 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.

TabEntry pointContent
Quick actionsTop-bar quick-actions buttonPermission-trimmed shortcuts
NotificationsTop-bar notification entryNotification list or empty state
PreferencesUser menu → PreferencesPersonal 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, and Moon icons 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 system is 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.

  1. Before React hydrates, the local cached value determines the first paint.
  2. system resolves through prefers-color-scheme and follows OS changes live.
  3. Explicit light or dark choices ignore subsequent OS theme changes.
  4. After authentication, the database preference is canonical and replaces a stale local cached value.
  5. A user selection updates the UI and local cache immediately, then saves to the API.
  6. 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 1 understands appearance.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:

  1. Apply the theme and update local storage optimistically.
  2. Send PATCH /preferences.
  3. Retry once automatically for a transient failure.
  4. On success, accept the normalized server response.
  5. On final failure, retain the local appearance and show Could not sync with a manual Retry action.

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 Tab and 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:

  1. Add user_preferences to the Drizzle schema.
  2. Regenerate the single baseline migration and its metadata.
  3. Drop and recreate local PostgreSQL.
  4. Apply the baseline with db:migrate.
  5. Run db:seed for authorization data.
  6. Run db:bootstrap:platform-admin for 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, and dark behave 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.