---
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 `<input type="checkbox">` styled as a toggle (or a `<button role="switch">` 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 `<div>` with sectionLabelStyle (matches AdminPage pattern) — not `<h3>`, to avoid heading hierarchy issues with the existing `<h2>` 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
