--- phase: 20 slug: admin-member-editor-form-declutter status: draft shadcn_initialized: false preset: none created: 2026-06-18 --- # Phase 20 — UI Design Contract > Visual and interaction contract for the Admin Member Editor & Form Declutter phase. > Generated by gsd-ui-researcher, verified by gsd-ui-checker. --- ## Design System | Property | Value | |----------|-------| | Tool | none — custom CSS custom properties | | Preset | not applicable | | Component library | none — inline styles referencing CSS tokens | | Icon library | lucide-react (existing: CheckCircle, AlertCircle, Loader2; add ChevronRight) | | Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif | No `components.json` detected. Project uses a hand-rolled design token system declared in `apps/pwa/src/styles/tokens.css`. All new components must reference those CSS custom properties (never hard-code hex/px). This is an established project convention — do not introduce shadcn or any third-party component library in this phase. --- ## Spacing Scale All tokens sourced from `apps/pwa/src/styles/tokens.css` (pre-populated — no change needed): | Token | Value | Usage | |-------|-------|-------| | `--space-1` | 4px | Icon gaps, label-to-input margin | | `--space-2` | 8px | Badge icon gap, toast icon gap, button row gap | | `--space-3` | 12px | Field gap inside editor sections, tab padding | | `--space-4` | 16px | Default field padding, section heading margin-bottom | | `--space-6` | 24px | Sheet padding, section gap between Profile / Password / App-password | | `--space-8` | 32px | Section bottom margin in Members panel | | `--space-12` | 48px | Page top/bottom padding | Exceptions: - Member row minimum height: 44px (touch target floor — not a spacing token) - Sheet border-radius: 12px phone bottom-sheet top corners / 12px desktop modal all corners (matches existing CredentialSheet / ResetPasswordSheet) - MemberRow add-member trigger button bottom margin: `--space-4` below member list, above the trigger --- ## Typography All values sourced from `tokens.css` (pre-populated — no change): | Role | Size | Weight | Line Height | Usage in this phase | |------|------|--------|-------------|---------------------| | Body | 15px (`--text-body-size`) | 400 (`--text-body-weight`) | 1.5 (`--text-body-line-height`) | Member display name in row; section description text; subtitle line under sheet heading | | Label | 13px (`--text-label-size`) | 400 or 600 | 1.4 (`--text-label-line-height`) | Form field labels (600), credential status badges (400), toast copy (400), section MEMBERS heading (600 + uppercase) | | Heading | 18px (`--text-heading-size`) | 600 (`--text-heading-weight`) | 1.25 (`--text-heading-line-height`) | Sheet h2 ("Edit member", "Add member"), editor section dividers | | Display | 24px (`--text-display-size`) | 600 (`--text-display-weight`) | 1.2 (`--text-display-line-height`) | Not used in this phase | --- ## Color All values sourced from `tokens.css` (pre-populated): | Role | Value | Usage | |------|-------|-------| | Dominant (60%) | `--color-surface` (#ffffff) | Page background, sheet background, input background | | Secondary (30%) | `--color-surface-dim` (#f7f7f8) / `--color-border-subtle` (#eceef2) | MemberRow bottom border, section dividers, "Add member" trigger button border | | Accent (10%) | `--color-member-0` (#e8915a) | Primary action buttons only (see reserved-for list below) | | Destructive | `--color-destructive` (#dc2626) | Inline error text, error-state input border | Accent (`--color-member-0`) reserved for: 1. Primary save/submit button background (enabled state) in the Member editor — each section's "Save" button 2. Active tab underline indicator in the Members & Accounts / Settings tab strip 3. "Currently shared" label text in the calendar picker (existing pattern, unchanged) 4. Toast success icon (`CheckCircle`) 5. "Use detected:" timezone affordance link (existing pattern, unchanged) Accent is NOT used on: - Cancel buttons (use `--color-text-secondary`, no background) - Disabled buttons (use `--color-border` as background) - The "Add member" trigger button (ghost button: `--color-surface` background, `--color-surface-dim` hover — matches Surface A; not an accent CTA) - The row chevron/edit icon (use `--color-text-muted`) Member avatar swatches use `--color-member-{colorIndex}` (index-derived, unchanged from existing MemberRow). --- ## Surface Inventory This phase introduces or reworks the following surfaces: ### Surface A — Member list (Members tab, existing panel body) The current inline "Add member" form and the per-row action-button cluster are removed. The panel body becomes: ``` [Section label: MEMBERS] [MemberRow] × N ← whole-row tap opens editor sheet; trailing ChevronRight icon [Divider] [+ Add member button] ← single trigger at bottom; opens MemberEditorSheet in create mode ``` MemberRow (reworked): - Layout: avatar swatch (32px circle) → name + status badge → flex spacer → ChevronRight icon (16px, `--color-text-muted`) - The entire row is the tap/click target (min-height 44px, `cursor: pointer`) - `role="button"` on the row div, `aria-label="Edit {displayName}"`, `tabIndex={0}`, keyboard: Enter/Space opens editor - Trailing admin badge: when `member.isAdmin === true`, show a small inline badge — label "Admin", 12px/600, `--color-member-0` text on `--color-surface-dim` background, border-radius 4px, padding 2px 6px — placed between the status badge and the chevron - Remove: "Rotate" button, "Add credential" button, "Reset password" button — all removed from MemberRow "Add member" trigger button: - Full-width or left-aligned ghost button with `+` icon prefix (use lucide `Plus`, 16px) - Label: "Add member" - Border: `1px solid var(--color-border)`, border-radius 8px, padding `--space-3` vertical / `--space-4` horizontal - Background: `--color-surface`; hover/focus: `--color-surface-dim` - Min-height: 44px - Opens MemberEditorSheet in create mode ### Surface B — Member editor sheet (MemberEditorSheet, new component) Single sheet component with `mode` prop: `'edit'` (id present) or `'create'` (no id). Sheet chrome (identical to CredentialSheet / ResetPasswordSheet): - Phone: fixed bottom, `border-radius 12px 12px 0 0`, `box-shadow: 0 -4px 24px rgba(0,0,0,0.15)`, `padding: var(--space-6)`, zIndex 301 - Desktop: centered modal, `max-width 480px`, `border-radius 12px`, `box-shadow: 0 8px 32px rgba(0,0,0,0.18)`, `padding: var(--space-6)`, zIndex 301 - Backdrop: `rgba(0,0,0,0.32)` (`--color-overlay`), zIndex 300, click closes - Escape closes; focus returns to trigger row on close - `role="dialog"`, `aria-modal="true"`, `aria-label` = sheet heading - `useFocusTrap` on dialog div - On open: focus the h2 heading (`tabIndex={-1}`) - Desktop: `max-height: calc(100dvh - var(--space-8))`, `overflow-y: auto` Sheet heading: - `h2`, 18px/600/1.25, `--color-text-primary` - Edit mode: "Edit member" - Create mode: "Add member" Member subtitle (edit mode only): - 15px/400, `--color-text-secondary`, `margin-bottom: var(--space-6)` - Displays `member.displayName` **Edit mode — three collapsible sections rendered as labeled card blocks:** Section divider style (between sections): - `border-top: 1px solid var(--color-border-subtle)`, `margin: var(--space-6) 0` **Section 1 — Profile** (always shown in edit mode): - Section heading: "Profile", 13px/600/uppercase/letterspacing 0.06em, `--color-text-muted` - Field: "Display name" — text input, 15px, min-height 44px, border `--color-border`, border-radius 4px, padding `--space-3 --space-4` - Field: "Admin" — toggle/checkbox row: - Label: "Admin", 15px/400, `--color-text-primary` - Sub-label: "Can access admin settings", 13px/400, `--color-text-secondary` - Control: native `` styled as a toggle (or a `` with `aria-checked`) — use whichever is cleaner; visually: pill shape, 44px×24px, `--color-member-0` when checked, `--color-border` when unchecked, white thumb circle - Initial state: `member.isAdmin` from API - Last-admin guard: if saving would demote the last admin, show inline error below the toggle: "Cannot remove admin — at least one admin must remain." (red, 13px) - Save button for this section: label "Save", right-aligned, same primary button style (accent background, 13px/600, min-height 44px, border-radius 4px) - Error state: inline below Save button, `--color-destructive`, 13px **Section 2 — Set new password** (edit mode only; only shown when `member.hasLocalCredential === true`): - Section heading: "Set new password", same style as above - Helper: "Leave blank to keep the current password." — 13px/400, `--color-text-secondary`, below heading - Field: "New password" — `type="password"`, `autoComplete="new-password"`, min-height 44px - Field: "Confirm new password" — same - Password never pre-filled. `autoComplete="new-password"` on both fields. - Save button: label "Set password", right-aligned, primary style - Validation: if fields differ, inline error "Passwords do not match." If new password < 8 chars, "Password must be at least 8 characters." - In-flight: show `Loader2` 14px inline in the button (existing pattern) **Section 3 — App password** (edit mode only): - Section heading: "App password", same style - Helper text: "Fastmail app password scoped to Calendars & Contacts (CalDAV)." - Include inline link: "Get an app password" → `https://app.fastmail.com/settings/security/devicetokens`, `target="_blank" rel="noopener noreferrer"`, `--color-member-0` color, underline - Field: "Fastmail email" — `type="email"`, `autoComplete="email"`, min-height 44px - In edit mode: if the stored `fastmailEmail` is available from the credential, prefill this field with the stored email (read-convenience; the user can correct it). The app password field is never prefilled. - Field: "App password" — `type="password"`, `autoComplete="new-password"`, never prefilled - "Validating against CalDAV…" — shown during in-flight mutation, using `Loader2` 16px + text (matches CredentialSheet pattern) - CalDAV failure error: "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." - Save button: label "Save app password", right-aligned, primary style **Create mode — single unified form (no section dividers):** - Fields: Display name, Username, Initial password, Confirm password - Matches today's inline Add-member form fields exactly - Save button: label "Add member", right-aligned, primary style - Client validation: passwords match + ≥ 8 chars; username conflict 409 → "That username is already in use." --- ## Copywriting Contract | Element | Copy | |---------|------| | Editor sheet heading (edit) | "Edit member" | | Editor sheet heading (create) | "Add member" | | Profile section heading | "Profile" | | Password section heading | "Set new password" | | Password section helper | "Leave blank to keep the current password." | | App password section heading | "App password" | | App password helper | "Fastmail app password scoped to Calendars & Contacts (CalDAV)." | | App password link text | "Get an app password" | | Profile Save CTA | "Save" | | Password Save CTA | "Set password" | | App password Save CTA | "Save app password" | | Create mode Save CTA | "Add member" | | Add member trigger (panel) | "Add member" | | Cancel (all sheets) | "Cancel" | | Admin badge label | "Admin" | | Admin toggle label | "Admin" | | Admin toggle sub-label | "Can access admin settings" | | Credential status — set | "Credential set" | | Credential status — missing | "No credential" | | Validating state | "Validating against CalDAV…" | | CalDAV failure error | "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." | | Last-admin guard error | "Cannot remove admin — at least one admin must remain." | | Password mismatch error | "Passwords do not match." | | Password too short error | "Password must be at least 8 characters." | | Username conflict error | "That username is already in use. Choose a different one." | | Generic save error | "Something went wrong. Please try again." | | Profile saved toast | "Profile saved." | | Password reset toast | "Password updated." | | App password saved toast | "App password saved." | | Member added toast | "Member added." | | Loading members state | "Loading members…" | | Members load error | "Could not load members." | | Empty members state heading | "No members yet" | | Empty members state body | "Add a member to get started." | **Retired copy (must not appear anywhere after this phase):** - "Rotate" — remove from all button labels, headings, and aria-labels - "Add credential" — remove from MemberRow button - "Reset password" — remove as standalone button; replaced by "Set new password" section inside editor --- ## Interaction Contracts ### Row tap → editor open - Tap/click anywhere on a MemberRow opens MemberEditorSheet in edit mode for that member - Keyboard: Enter or Space on the row (when `role="button"`) opens the sheet - Focus returns to the tapped row on sheet close ### "Add member" trigger → create sheet - Tap/click the "Add member" button opens MemberEditorSheet in create mode - On success: sheet closes, member list refreshes, toast "Member added." fires - Focus returns to the "Add member" button on cancel/close ### Per-section saves (edit mode) - Each section (Profile / Set new password / App password) has its own Save button - Save is independent — one section save does not affect other sections - Disabled state: button uses `--color-border` background, `cursor: default` - In-flight: Loader2 spinner inline in button label (existing pattern), button disabled - Success: toast fires, sheet stays open (user may save another section) - Error: inline error below the section's save button ### Admin toggle - Toggle flips `is_admin` optimistically on the client - On save (Profile section Save): sends to `PATCH /api/admin/members/:id` - If server returns 409/422 (last-admin guard): show inline error "Cannot remove admin — at least one admin must remain." Revert toggle to previous state. ### Sheet dismissal - Backdrop click: closes sheet, discards unsaved changes, focus returns to trigger - Escape key: same as backdrop click - Cancel button: same as backdrop click - No confirmation dialog for unsaved changes (forms are short; data loss is minimal) ### Toast positioning - Phone: `bottom: calc(var(--bottom-chrome-h) + var(--space-4))` (clears BottomTabBar) - Desktop: `bottom: var(--space-6)` - Auto-dismiss: 3000ms (existing pattern, unchanged) --- ## Accessibility Contract - MemberRow: `role="button"`, `aria-label="Edit {displayName}"`, `tabIndex={0}`, keyboard Enter/Space - MemberEditorSheet: `role="dialog"`, `aria-modal="true"`, `aria-label` matches sheet h2 - Focus management: on open → h2 (`tabIndex={-1}`); on close → trigger element - `useFocusTrap` traps Tab/Shift+Tab within dialog - Admin toggle: `role="switch"`, `aria-checked={isAdmin}`, `aria-label="Admin"` - Inline errors: `aria-describedby` on the relevant input(s) pointing to the error id - Password fields: `autoComplete="new-password"`, never prefilled - Fastmail email field (app password section): `autoComplete="email"` - Toast: `role="status"`, `aria-live="polite"`, `aria-atomic="true"` - 44px minimum touch target on all interactive elements - Section headings inside sheet: use `` with sectionLabelStyle (matches AdminPage pattern) — not ``, to avoid heading hierarchy issues with the existing `` sheet title --- ## Registry Safety | Registry | Blocks Used | Safety Gate | |----------|-------------|-------------| | shadcn official | none | not applicable — shadcn not initialized | | Third-party | none | not applicable | No third-party component registries. All components are hand-authored using project CSS tokens. Icon additions from `lucide-react` (already a project dependency): `ChevronRight`, `Plus`. --- ## Checker Sign-Off - [ ] Dimension 1 Copywriting: PASS - [ ] Dimension 2 Visuals: PASS - [ ] Dimension 3 Color: PASS - [ ] Dimension 4 Typography: PASS - [ ] Dimension 5 Spacing: PASS - [ ] Dimension 6 Registry Safety: PASS **Approval:** pending