Files
familysync/.planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md
T

16 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
20 admin-member-editor-form-declutter draft false none 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