06 — Screen Specifications (Settings Module)
- S1 — Settings List
/settings - S2 — Setting Detail / Typed Editors (module core)
- S3 — New Setting (FAB sheet)
- S4 — Delete flow
- S5 — Cross-link banner (list top, dismissible) — context clarity
- Shared micro-interactions & motion (all screens)
Production-grade specs for each Settings screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. All components reference 00-shared/03.
S1 — Settings List /settings
Chrome: standard shell AppBar (Icons.settings, headlineMedium "Settings"); horizontal
padding 16 (phone) / 24 (tablet+); content in ListView.builder (never Column of cards —
keys can number in the hundreds).
Layout (phone):
AppBar: "Settings" headlineMedium
AppSearchBar (client-side) h 56, debounce 300 ms
Group chips (horizontal scroll) FilterChip per enum value + "All"
Group section header (first group) titleMedium "Attendance (12)"
SettingRow (key / value / type chip) AppCard flat, 56–72 h
SettingRow ...
[FAB extended: "New setting"] Icons.add
Layout (desktop ≥840 dp): master-detail — left pane list (max width 480), right pane =
detail S2 for the selected row; selection persists in SettingsCubit.
States:
- loading →
AppSkeleton(list)(≤200 ms target). - success+empty →
AppEmptyStateIcons.tune"No settings yet — add your first key." - group-empty → inline
bodySmall"Nothing in {group} yet." - error →
AppErrorState(code)(401 → refresh flow; 5xx → generic + requestId + Retry). - offline →
AppOfflineBanner+ last-good rows; editors disabled with tooltip. - permission → route guard to
/settings/403(nosettings.read).
Group chips: server-filtered (GET /settings?group=) — tapping a chip triggers a
server refetch (not client filter), because the contract filters server-side
(settings.controller.ts:26-28). The search box filters the current fetch client-side
(no q param exists — 00-shared/07 §5 applies only where controllers support it).
Row (SettingRow):
| Element | Spec |
|---|---|
| Leading | group icon map (school/event_available/grading/notifications/palette/settings) |
| Title | key in mono titleMedium (FontFeature.tabularFigures() for numeric keys) |
| Subtitle | value preview, truncated 1 line: strings quoted, numbers plain, booleans as true/false, objects {…} + count, arrays [… n] |
| Trailing | type chip (AppBadge: STR / NUM / BOOL / JSON), AppMenu (Edit, Duplicate key, Delete) |
| Tap | → detail pane/page |
| Dirty marker | when edited in place: left primary indicator dot + row tinted primaryContainer |
Batch mode: long-press enters selection; AppBottomSheet fixed bottom bar "Save all (N)" —
only dirty keys serialized (03 §3). Per-row saved state clears the dot (Mo.m-fast fade).
Motion: list entrance m-entrance stagger 40 ms; chip indicator slide m-base;
FAB hide on scroll-down, show on scroll-up (00-shared/03 D).
A11y: search Semantics(liveRegion) for result count ("12 of 48"); chips announce
selected state; rows are single Semantics(button) with key label; dirty state announced
via Semantics(label: "unsaved").
Dark/light: token-only; dirty tint primaryContainer on dark = elevated contrast.
S2 — Setting Detail / Typed Editors (module core)
Chrome: detail pane (desktop) or pushed page (phone). Header: key (mono titleLarge),
group dropdown, isEncrypted badge ((planned)), meta footer
(bodySmall: "created {date} · v{version}" — base.schema.ts:30-34; createdBy/updatedBy
when present).
Editor by value type (client inference, 05 §3)
| Type | Editor | Save behavior | Validation |
|---|---|---|---|
string | AppTextField, single line, mono for likely IDs/URLs | explicit Save button (or Enter) | non-empty unless empty allowed by key; no server rule (update-setting.dto.ts:5-10) |
number | AppTextField keyboard numberWithOptions(decimal: true), formatter ^\d*\.?\d*$ | explicit Save | parseable float/int; saved as JSON number (no string coercion) |
boolean | AppSwitch row | instant save on toggle (safe mutation — 00-shared/06 §3.5); server-confirm then reflect; rollback + snackbar on error | n/a |
object / array / null | AppJsonEditor (mono multi-line, min 6 rows, grow) | explicit Save (disabled while invalid) | live JSON parse; save blocked on parse error with inline message |
| unknown type | fall back to JSON editor | same as JSON | — |
Save semantics (from source):
- Full replacement of
value:$set: {value}(setting.repository.ts:37) — never merge. groupis sent with the save and persisted if present (setting.repository.ts:37); omitting it keeps the existing group (onlyvalue+groupare ever written — the DTO'slabel/descriptionare dropped bysettings.service.ts:25).- Upsert idempotent (
{upsert:true, new:true}); server returns the saved doc → detail reconciles to server truth. - No optimistic write for explicit-save editors; boolean toggle is optimistic with
rollback (
00-shared/06 §3.5).
Validation errors (client-first, server shadows):
| Field | Client rule | Server | UI |
|---|---|---|---|
key (create/rename) | non-empty trimmed string | no decorators — missing key → Mongoose required → 500 (OQ-3) | required field error |
group | ∈ enum | @IsEnum(SettingGroup) (400 VALIDATION_ERROR) | dropdown restricts options |
value | any JSON | value: unknown — any JSON accepted | typed editors |
label / description | — | @IsString() optional | hidden (unused by service) |
Save button: loading spinner replaces label (Mo.m-base); disabled while pending
(anti-double-submit 00-shared/08 §6); on success AppSnackbar "Saved" (<3 s) +
HapticFeedback.lightImpact().
Error surfaces: 400 → inline field errors (from details[], http-exception.filter.ts:103-108);
5xx → snackbar generic + requestId, form kept (retry safe — upsert idempotent).
S3 — New Setting (FAB sheet)
AppBottomSheet (full height on phone, centered card ≤520 on desktop):
keyfield (required, autofocus,monohint "e.g. attendance.lateThreshold").- group
AppDropdown(enum, default GENERAL —setting.schema.ts:24-25). - value: type unknown at creation → JSON editor (accepts string/number/bool/object); after first save the row gains its concrete type chip.
- CTA "Create" →
PUT /settings; success → snackbar + list refetch; 500 duplicate-key (recreate-after-delete, OQ-5) → banner explaining the key is soft-deleted.
S4 — Delete flow
AppDialog (destructive): title "Delete {key}?"; body explains soft-delete + that the key
cannot be re-created today (OQ-5 warning). Confirm → DELETE /settings/:key
(settings.controller.ts:49-53); loading on confirm; success → row fade-out (Mo.m-base) +
snackbar; 404 → row removed silently.
S5 — Cross-link banner (list top, dismissible) — context clarity
AppBanner(info): "Attendance rules, theme and working days also live under Organization"
→ /organization (embedded settings surface, organization.schema.ts:96-112). One-time
dismiss persisted in shared_preferences. Rationale: dual-surface confusion is a known
risk (design-docs/organizations/09 §8 — "Which settings apply?").
Shared micro-interactions & motion (all screens)
- Route transitions
Mo.m-base; AppBarEl-1; cardsEl-e1; sheets/dialogse-4/e-5. - Snackbar durations: success < 3 s, error ≥ 4 s (
00-shared/03 A). - Keyboard:
Entersaves editors,Esccloses sheet/confirm,Ctrl+Fsearch (desktop),Ctrl/Cmd+Entersubmits JSON editor (00-shared/08 §2). - Safe areas: bottom
Save allbar respects nav bar height on phone. - Reduced motion: no stagger, no shake — fades/instants only (
00-shared/08 §4).