06 — Screen Specifications (Feature Flags Module)
- 1. Flags List Screen
/settings/feature-flags - 2. Flag Detail Screen
/settings/feature-flags/:key - 3. Flag Editor Screen
/settings/feature-flags/:key/edit - 4. Bulk Update Sheet
- 5. Affected-Features Map (detail companion) —
(proposed)
Production-grade specs: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= §2,El= §5,R= §4,Mo= §6. All components reference 00-shared/03.
1. Flags List Screen /settings/feature-flags
Chrome: standard AppBar (surface, El-1) with headlineMedium title "Feature flags";
subtitle count "N toggles · M enabled". Filter row under AppBar (sticky): module FilterChips
(sourced from the loaded data's distinct module values; when none, show "All modules" chip
only) + "Enabled only" toggle (FilterChip). Body: grouped list.
AppBar: Feature flags [menu]
FilterChip: All | biometric | channels | academics | …
FilterChip: [x] Enabled only
[SectionHeader] BIOMETRIC (count)
[AppFlagRow] biometric.enabled ON [switch]
[AppFlagRow] biometric.fingerprint OFF [switch]
[SectionHeader] CHANNELS
[AppFlagRow] channels.sms ON [switch]
[AppFlagRow] channels.whatsapp OFF [switch]
[FAB extended] New flag
Data contract (list source):
- Default:
GET /feature-flags→ array sorted bykeyasc (feature-flags.service.ts:10-12) — not paginated (plain array; nometa). - Module chip:
GET /feature-flags?module=<m>→findByModule(feature-flags.controller.ts:25-27,feature-flag.repository.ts:24-26). - Enabled tab:
GET /feature-flags/enabled→{enabled:true}filter (feature-flag.repository.ts:28-30).
Toggle semantics (the switch):
- Optimistic apply (
00-shared/06 §3.5): on flip → row shows pending state (switch on, row "saving…" label,AppProgressinline) →PUT /feature-flags {key, enabled:newValue, label:<current label>}. - Server truth:
findOneAndUpdate({tenantId,key,isDeleted:false}, {$set:{enabled,label}}, {upsert:true,new:true})(feature-flag.repository.ts:38-42). On 200 → reconcile row with returned doc. On error → rollback switch +AppSnackbar(error); 400 field error shown under row. - Disabled switch state: a switch is disabled when (a) another request in flight on the
same row, (b) offline (writes blocked — no offline queue for flags,
00-shared/07 §10), (c) nofeature-flags.updatepermission (target contract, OQ-5), (d) delete pending. - The switch reflects server doc only after reconcile — the row never claims a state the server didn't confirm.
Propagation timing (what the admin must be told):
- Server has no cache and no event today — the toggle is effective on the next read of
the flag collection; downstream
isEnabled()calls (feature-flags.service.ts:22-25) see it immediately. - Blueprint target: flags cached in Redis
sl:global:featureflag:{name}with poll + cache, 30 s TTL (CACHE_ARCHITECTURE.md:33,48) — once implemented, a toggle can take up to 30 s to be visible to all services. UI copy: after a successful toggle show a subtleAppBanner(info): "Applies to apps within ~30 seconds" only when the cache lands; today it is immediate — keep the banner(planned)-flagged in code (feature toggle for copy,(proposed)). - Client side:
FeatureFlagsCubitTTL (default 30 s,(proposed), mirrorsCACHE_ARCHITECTURE.md:48) — the app re-reads the enabled set at that cadence; aRefreshIndicatorforces an immediate re-read.
Cache-rebuild awareness:
- The
cache-rebuildBullMQ queue exists (queue.constants.ts:13) and is wired to a scheduler (scheduler.service.ts:27,67,158,214) but nothing enqueues it from flag writes — no invalidation path exists in code. UI implication: never show "cache cleared" or "propagated"; the list/detail screens always read the collection directly (server source of truth). When the flag cache lands, the invalidation trigger is(planned)(CACHE_ARCHITECTURE.md §4— permission/dashboard use event-based invalidation; flag writes should follow the same pattern, OQ-7).
States:
- loading →
AppSkeleton(list)(≤ 200 ms budget,00-shared/10 §1). - empty →
AppEmptyState: "No feature flags yet" — fresh tenants have no seeded flags (no seed code in repo); body: "Flags appear here once created — ask your platform team or create one." CTAFilledButton.tonal"New flag" (ifupdate). - error →
AppErrorState(code)with Retry; 401 → silent refresh flow. - offline →
AppOfflineBanner+ last-good cache (client cache keysl:cache:featureflags:all(proposed)); switches disabled with tooltip.
A11y: filter chips Semantics(selected:); each row = one Semantics(button, toggled:, label: "flag <key>"); toggle announced via live region; group headers Semantics(headers:).
Motion: m-entrance stagger 40 ms rows; switch m-fast; filter change re-fade m-slow
(reduced-motion = instant fades).
Responsive: phone single pane; tablet/desktop master-detail (list left ~360 px, detail
right); hover row tint m-fast; Tab order chips → rows → FAB.
2. Flag Detail Screen /settings/feature-flags/:key
[Back] headlineMedium "Flag detail"
AppCard:
key (mono, copy icon) AppFlagKeyChip
module badge (AppBadge) created/updated (bodySmall, tabular figures)
AppSwitch "Enabled" [ON] ← primary toggle (same optimistic semantics as list)
AppInfoRow label: <label or "—">
AppInfoRow description: <description or "—">
AppSectionHeader "Affects"
AppAffectedMapCard (proposed — client registry, may be empty)
[outlined] Edit [text, destructive] Delete
Source: GET /feature-flags/:key → 404 RESOURCE_NOT_FOUND ("Feature flag "feature-flags.service.ts:29) when missing (soft-deleted flags are invisible to
the scoped read too, base.repository.ts:20-30).
States: skeleton → doc; 404 → AppEmptyState "Flag not found — it may have been
deleted" + back; offline → cached doc + banner (writes blocked).
Toggle: same PUT /feature-flags optimistic flow; on success reconcile + snackbar
"Feature
Delete flow (destructive):
AppDialog "Delete this flag?" body: "It will stop appearing everywhere. Re-creating the
same key may fail (the key slot is retained) — prefer disabling instead." → DELETE /feature-flags/:key (feature-flags.controller.ts:54-58). Server: soft delete by _id
(feature-flags.service.ts:47-52); double 404 protected. On 200 → pop to list + snackbar.
On 500 E11000-style duplicate → OQ-4 copy "Couldn't delete — try again". No optimistic
removal (destructive, server-confirm).
A11y: key as code text; switch semantics; dialog focus trap; delete = error colors.
Motion: hero list→detail m-slow (optional); delete dialog m-base; row removal fade.
3. Flag Editor Screen /settings/feature-flags/:key/edit
Chrome: full-screen page (5 fields — 00-shared/05 §5), AppBar with save action.
AppBar: "Edit flag" | "Create flag" [Save]
AppFlagEditorForm:
key AppTextField (mono) — read-only in edit mode; editable in create mode
enabled AppSwitch "Enabled" — required boolean
label AppTextField (optional)
description AppTextField (multiline, optional)
module AppTextField (optional, suggests existing modules)
[AppBanner warning — edit mode]: "Note: description & module are not saved by the server on
update — only enabled & label persist (feature-flag.repository.ts:40)." (OQ-3)
Semantics (server-exact): UpdateFeatureFlagDto = key (string, required), enabled
(boolean, required), label?, description?, module? (update-feature-flag.dto.ts:4-26).
Save = PUT /feature-flags (upsert, feature-flags.controller.ts:42-46). The repository
drops description and module on write (feature-flag.repository.ts:40) — the editor
must (a) show the warning banner above, (b) never render those two fields as "saved" after a
successful response. The module field is written only at first creation of the key
(upsert insert path writes the whole DTO? — no: the insert path writes only what the
repository passes; since the repository only passes enabled/label, even the insert
writes only those three (tenantId,key,enabled,label); description/module are effectively
unwritable through the API today — OQ-3). Client must therefore treat description/module
as read-only display (they can only be populated by seed/migration (planned)
IMPLEMENTATION_PLAN.md:291-307).
Create mode: key empty + placeholder "e.g. channels.whatsapp"; server accepts any string
(no pattern validation, update-feature-flag.dto.ts:5-7); enabling a brand-new key creates
the flag. After save in create mode → navigate to detail of returned doc (server returns the
new doc, {new:true}, feature-flag.repository.ts:41).
States: idle / saving (button spinner, double-submit blocked) / field errors (400
VALIDATION_ERROR per-field details, http-exception.filter.ts:103-107) / success (pop +
snackbar) / offline (writes blocked banner).
Keyboard: key → enabled(switch via Space) → label → description → module; Enter
submits; Esc discards (confirm discard dialog if dirty).
A11y: labels linked; error focus jump to first invalid; warning banner announced once.
4. Bulk Update Sheet
[Title] "Update 3 flags" [drag handle]
mode SegmentedButton: [Enable all] [Disable all]
AppTextField label (optional, applied to all — note server persists only enabled+label)
[Apply] FilledButton (loading while applying)
result: per-key rows ✓ / ✕ (AppFlagKeyChip + reason)
"Retry failed (1)" text button — resends only failed subset (client-tracked)
Semantics (server-exact): body = JSON array of DTOs (feature-flags.controller.ts:50);
service loops await repo.upsert(...) sequentially — no transaction, no rollback
(feature-flags.service.ts:40-44). Class-validator rejects the whole body 400 if any item
is invalid → sheet shows which row is invalid (map details[].message to key). A 5xx
mid-loop → error envelope; client shows applied-so-far vs remaining. No auto-retry of the
full batch (upsert is idempotent per key, so a manual retry is safe but noisy — keep explicit).
Propagation note: same as single toggle — server truth immediate today; 30 s window
when the Redis cache lands (CACHE_ARCHITECTURE.md:48).
States: idle (count) → applying (progress bar, m-base) → done (per-row results,
success secondary, failure error) → error (partial or 400). a11y: live region announces
"3 of 5 applied".
5. Affected-Features Map (detail companion) — (proposed)
AppAffectedMapCard: lists client screens registered against this key in the gating registry
(FeatureFlagsCubit.registry), each row: icon, screen name, "hidden when off". Empty state:
"Nothing in the app maps to this key yet". No server call — pure client composition from
module + registry; (proposed) because no client exists and the server has no
"affected features" endpoint.
Cross-screen micro-interaction & motion notes:
Mo.m-baseroute transitions; cardsEl-e1; sheete-5r-xl.- Snackbar durations: success < 3 s, error ≥ 4 s (
00-shared/03 A). - Switches everywhere:
Mo.m-fast; error rollback =AnimatedSwitcherfade +errorring, no shake on toggle (shake reserved for forms). - Safe areas: bottom-nav height respected; sheets respect insets.