Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 KiB
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-4below 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:
- Primary save/submit button background (enabled state) in the Member editor — each section's "Save" button
- Active tab underline indicator in the Members & Accounts / Settings tab strip
- "Currently shared" label text in the calendar picker (existing pattern, unchanged)
- Toast success icon (
CheckCircle) - "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-borderas background) - The "Add member" trigger button (ghost button:
--color-surfacebackground,--color-surface-dimhover — 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-0text on--color-surface-dimbackground, 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 lucidePlus, 16px) - Label: "Add member"
- Border:
1px solid var(--color-border), border-radius 8px, padding--space-3vertical /--space-4horizontal - 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 headinguseFocusTrapon 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">witharia-checked) — use whichever is cleaner; visually: pill shape, 44px×24px,--color-member-0when checked,--color-borderwhen unchecked, white thumb circle - Initial state:
member.isAdminfrom 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)
- Label: "Admin", 15px/400,
- 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
Loader214px 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-0color, underline
- Include inline link: "Get an app password" →
- Field: "Fastmail email" —
type="email",autoComplete="email", min-height 44px- In edit mode: if the stored
fastmailEmailis 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.
- In edit mode: if the stored
- Field: "App password" —
type="password",autoComplete="new-password", never prefilled - "Validating against CalDAV…" — shown during in-flight mutation, using
Loader216px + 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-borderbackground,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_adminoptimistically 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-labelmatches sheet h2 - Focus management: on open → h2 (
tabIndex={-1}); on close → trigger element useFocusTraptraps Tab/Shift+Tab within dialog- Admin toggle:
role="switch",aria-checked={isAdmin},aria-label="Admin" - Inline errors:
aria-describedbyon 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