Phase 20: Admin Member Editor & Form Declutter #25
@@ -0,0 +1,317 @@
|
|||||||
|
---
|
||||||
|
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 (use `--color-member-0` background — it is the primary CTA for the panel)
|
||||||
|
- 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
|
||||||
Reference in New Issue
Block a user