Files
familysync/.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md
T
Lucas BergerandClaude Sonnet 4.6 4dd6068dcc docs(19): mark UI-SPEC approved after checker verification
All 6 design dimensions PASS plus Phase 17 brand-slot readiness contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:19:45 -04:00

688 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 19
slug: local-auth-no-oidc-mode
status: approved
shadcn_initialized: false
preset: none
created: 2026-06-16
approved: 2026-06-16
---
# Phase 19 — UI Design Contract: Local Auth (No-OIDC Mode)
> Visual and interaction contract for the local login screen, login-method chooser,
> and admin-surface additions for local account management.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.
---
## Context & Audience
This phase introduces the **first real login UI** in the FamilySync PWA. Today the PWA boots
straight into the authed app (OIDC redirect) or via dev-bypass — there is no login form. Phase 19
builds:
1. A **local login screen** (username + password) — full-viewport, pre-auth, the first surface an
unauthenticated user sees. This is the highest-value branding surface in the app.
2. A **login-method chooser** rendered when OIDC is also configured (D-02) — local form OR
"Login with OIDC" (generic, never says "Authelia" — D-06).
3. Admin-surface additions (in-app shell `/admin` route, extending Phase 10): local member
creation + initial password; self password-change; admin password-reset; per-user
"Link OIDC identity" action.
The login screen is **end-user-facing**, not operator-facing. The non-technical Apple household
member is the primary user — UX must be slick and low-friction (CLAUDE.md hard constraint).
The login screen is a **standalone full-page route**, most closely analogous to the Phase 12
setup wizard (`/setup`). It renders none of the AppNav / BottomTabBar / SetupBanner chrome.
All design tokens are inherited from `apps/pwa/src/styles/tokens.css`. No new tokens are
introduced.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none (existing CSS custom properties) |
| Preset | not applicable |
| Component library | none (hand-rolled inline `React.CSSProperties`, project convention) |
| Icon library | lucide-react (already installed — `Lock`, `User`, `Eye`, `EyeOff`, `Loader2`, `AlertCircle`, `LogIn`, `ShieldCheck`) |
| Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif (var(--font-family-base)) |
Source: `apps/pwa/src/styles/tokens.css` — pre-populated from existing codebase scan.
Pattern baseline: `apps/pwa/src/routes/SetupPage.tsx` (full-viewport standalone page),
`apps/pwa/src/routes/AdminPage.tsx` (admin-surface additions).
---
## Spacing Scale
Uses the existing 4px-based scale. No new tokens.
| Token | Value | Usage in this phase |
|-------|-------|---------------------|
| --space-1 | 4px | Icon gaps, label-to-input gap, helper-text margin-top |
| --space-2 | 8px | Compact element spacing, password show/hide button gap, form field gap within a group |
| --space-3 | 12px | Input padding (vertical), row gaps |
| --space-4 | 16px | Between form fields, button horizontal padding, card horizontal padding |
| --space-6 | 24px | Card padding, section gap, brand slot bottom margin |
| --space-8 | 32px | Between the brand slot and the login card, between major sections |
| --space-12 | 48px | Page top/bottom padding (matches SetupPage pattern) |
Exceptions:
- Login card max-width: 400px (narrower than wizard 540px; a two-field login needs less width).
- All interactive elements: `minHeight: 44px; minWidth: 44px` (WCAG 2.5.5 Touch Target).
- Password show/hide toggle: 44px tap target embedded inside the input row (right-side icon button).
- Brand logo slot: reserved 48px height (aspect-ratio box 1:1); see Brand Slot section.
---
## Typography
All values from `tokens.css`. No new sizes or weights.
| Role | Size | Weight | Line Height | Variable |
|------|------|--------|-------------|----------|
| Body | 15px | 400 | 1.5 | var(--text-body-size) / var(--text-body-weight) / var(--text-body-line-height) |
| Label | 13px | 400 | 1.4 | var(--text-label-size) / var(--text-label-weight) / var(--text-label-line-height) |
| Heading | 18px | 600 | 1.25 | var(--text-heading-size) / var(--text-heading-weight) / var(--text-heading-line-height) |
| Display | 24px | 600 | 1.2 | var(--text-display-size) / var(--text-display-weight) / var(--text-display-line-height) |
Usage in this phase:
- App name "FamilySync" in brand slot: Display (24px/600/1.2) — `var(--color-text-primary)`
- App tagline "Family calendar & lists" in brand slot: Body (15px/400/1.5) — `var(--color-text-secondary)`
- Login card heading ("Sign in"): Heading (18px/600/1.25) — `var(--color-text-primary)`
- Field labels, helper text, divider label ("or"): Label (13px/400/1.4)
- Field labels use weight 600, helper text uses weight 400
- Section labels in admin additions ("LOCAL ACCOUNTS", "OIDC LINK"):
13px/600/uppercase/0.06em letter-spacing (AdminPage `sectionLabelStyle` pattern)
- Error messages: Body (15px/400/1.5) — `var(--color-destructive)`
- Primary CTA label: Label (13px/600)
---
## Color
All values from `tokens.css`. No new hex values.
| Role | Value | Variable | Usage |
|------|-------|----------|-------|
| Dominant (60%) | #ffffff | var(--color-surface) | Page background, card background, input background |
| Secondary (30%) | #f7f7f8 | var(--color-surface-dim) | Divider area between form methods, info banners, rate-limit notice background |
| Accent (10%) | #4a90d9 | var(--color-member-0) | Primary CTA button ("Sign in"), spinner, focus ring, "Login with OIDC" button border |
| Destructive | #dc2626 | var(--color-destructive) | Error message text, error-state input border, lockout notice, rate-limit warning |
Accent reserved for:
- "Sign in" button (filled background)
- "Login with OIDC" button (outlined, `1px solid var(--color-member-0)`, accent text)
- `Loader2` spinner during login submit
- Focus ring on all inputs and buttons (`var(--color-focus-ring)`, 2px outline, 2px offset)
- Text links (e.g., "Forgot password? Ask your admin.")
Additional semantic colors (not new — already in tokens.css):
- `var(--color-border)` #e2e4e9 — card border, input border (default), divider line
- `var(--color-border-subtle)` #eceef2 — section dividers in admin additions
- `var(--color-text-primary)` #111318 — headings, field values, app name
- `var(--color-text-secondary)` #6b7280 — descriptions, helper text, tagline, divider label
- `var(--color-text-muted)` #9ca3af — placeholder text, inactive admin rows
- `var(--color-overlay)` rgba(0,0,0,0.32) — modal backdrop for confirmation dialogs
---
## Brand Slot — Phase 17 Readiness
The login screen is the **highest-value branding surface** in the app — full-viewport,
unauthenticated, the first thing any user sees. A reserved brand slot sits above the login
card and is designed as a **theming/asset seam**: Phase 19 ships a minimal shippable
placeholder; Phase 17 drops in real assets without restructuring the layout.
### Brand slot structure (Phase 19 ships this)
```
[brand-slot]
[--brand-logo placeholder] — 48×48px box, aspect-ratio 1/1, reserved intrinsic dimensions
Placeholder: a 48px circle, background var(--color-member-0),
initials "FS" in white Display (24px/600).
No broken image ref. No layout shift when replaced.
[--brand-app-name] — "FamilySync" text (Display 24px/600, var(--color-text-primary))
Rendered from a CSS custom property / named slot; not hardcoded.
[--brand-tagline] — "Family calendar & lists" (Body 15px/400, var(--color-text-secondary))
```
Layout:
- Centered column, `textAlign: center`
- Logo mark: `width: 48px; height: 48px; borderRadius: 50%; margin: 0 auto var(--space-2)`
- App name: `marginTop: var(--space-2); marginBottom: var(--space-1)`
- Tagline: `marginBottom: var(--space-8)` (32px gap before the login card)
### Asset seam tokens
Define in `tokens.css` (Phase 19 sets placeholder defaults; Phase 17 overrides):
```css
:root {
/* Phase 17 replaces these values — never the component structure */
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
--brand-logo-text: #ffffff; /* placeholder initials color */
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
--brand-app-name: 'FamilySync'; /* not used as CSS content — drives doc only */
}
```
The logo slot renders via a React component `<BrandSlot />` in the login page — not inline JSX.
This isolates the seam: Phase 17 replaces `<BrandSlot>` internals (swap placeholder div for
`<img src="...">`) without touching `<LoginPage>` layout.
### Phase 17 readiness subsection
**Phase 17 contract — what Phase 17 must honor:**
| Slot | Asset Phase 17 provides | Constraints Phase 17 must respect |
|------|-------------------------|-----------------------------------|
| Logo mark | SVG or PNG, favicon-derived | Must fit in 48×48px box at 1x; provide 2x/3x for retina. `alt=""` (decorative — app name already in text) |
| App name text | Same string "FamilySync" or updated display name | Rendered as text, not image — screen readers read it |
| Tagline | Optional; may be removed | If removed, set `--brand-tagline-display: none` — no layout reflow |
| Background hero | Optional — if added, must go behind the entire page, not just the brand slot | `var(--brand-bg): none` default; Phase 17 sets to a CSS gradient or subtle image |
| Aspect-ratio box | Phase 17 MUST keep the 48px height reserve | Prevents layout shift; use `aspect-ratio: 1/1; width: var(--brand-logo-size)` |
Phase 17 asset swap is: update `<BrandSlot>` internals (image src) + set CSS custom property
values. No changes to `<LoginPage>` layout, spacing, or card structure are permitted by this
contract.
---
## Surface Architecture
### Surface 1 — Login Page Shell (`/login`)
A standalone full-page route. No AppNav, no BottomTabBar, no SetupBanner, no
PermissionDeniedBanner at any breakpoint.
- Background: `var(--color-surface)` (#ffffff)
- Layout: `minHeight: 100dvh; display: flex; flexDirection: column; alignItems: center; justifyContent: flex-start`
- Content column: `maxWidth: 400px; width: 100%; margin: 0 auto; padding: var(--space-12) var(--space-6)`
Routing gate:
1. On app load, `GET /api/auth/mode` (pre-auth endpoint — no session required) returns
`{ localEnabled: true, oidcEnabled: boolean }`.
2. If the user already has a valid session (local JWT cookie or OIDC session), they are
redirected to `/calendar` before the login page renders.
3. The `/login` route renders the `<LoginPage>` (full-viewport, no shell).
4. After successful login, navigate to `/` (which redirects to `/calendar`).
### Surface 2 — Brand Slot
Sits at the top of the content column, above the login card. Detailed in "Brand Slot" section.
Not inside the login card — floats above it in the flow.
### Surface 3 — Login Card
The primary login interaction area.
- Background: `var(--color-surface)` (#ffffff)
- Border: `1px solid var(--color-border)` (#e2e4e9)
- Border-radius: 8px
- Padding: `var(--space-6)` (24px) all sides
- Box-shadow: `0 1px 4px rgba(0,0,0,0.06)` (matches SetupPage cardStyle)
- Card heading "Sign in": Heading (18px/600/1.25), `var(--color-text-primary)`,
`marginBottom: var(--space-6)` (24px)
### Surface 4 — Username Field
- Label: "Username" — 13px/600, `var(--color-text-primary)`, `marginBottom: var(--space-1)` (4px)
- Input: `type="text"`, `autoComplete="username"`, `id="login-username"`
- Style: full-width, `padding: var(--space-3) var(--space-4)`, `border: 1px solid var(--color-border)`,
`borderRadius: var(--space-1)`, 15px/400, `var(--color-text-primary)`, `background: var(--color-surface)`
- Error state border: `1px solid var(--color-destructive)`
- `aria-describedby="login-error"` when error state is active
- `spellCheck={false}`, `autoCapitalize="none"`, `autoCorrect="off"`
### Surface 5 — Password Field with Show/Hide Toggle
- Label: "Password" — 13px/600, `var(--color-text-primary)`, `marginBottom: var(--space-1)` (4px)
- Input wrapper: `position: relative`
- Input: `type="password"` (toggled to `"text"` by show/hide button), `autoComplete="current-password"`,
`id="login-password"`, `paddingRight: 44px` (space for toggle)
- Error state border: `1px solid var(--color-destructive)`
- Show/hide toggle button: `position: absolute; right: 0; top: 0; height: 100%; minWidth: 44px;
background: none; border: none; cursor: pointer; color: var(--color-text-muted)` —
renders lucide `Eye` (show) or `EyeOff` (hide), 16px, `aria-label="Show password"` /
`"Hide password"`, `aria-pressed` reflects current state
- Field container `marginBottom: var(--space-4)` (16px)
### Surface 6 — Form Error / Lockout Banner
Shown below the password field, above the submit button. Uses `role="status"` + `aria-live="polite"`.
**Error states in order of severity:**
1. **Invalid credentials** (incorrect username or password):
- Icon: `AlertCircle` (16px, `var(--color-destructive)`) inline
- Copy: "Incorrect username or password." — Body (15px/400), `var(--color-destructive)`
- Both fields remain editable; no field is specifically blamed (timing-safe: do not indicate
which field is wrong)
- Input borders: both switch to `var(--color-destructive)`
2. **Rate limit** (too many attempts, not yet locked):
- Background: `var(--color-surface-dim)` pill/banner, `border-radius: var(--space-1)`,
`padding: var(--space-3) var(--space-4)`
- Icon: `AlertCircle` (16px, `var(--color-destructive)`) inline
- Copy: "Too many attempts. Please wait a moment and try again." — 13px/400,
`var(--color-destructive)`
- Submit button: disabled during rate-limit window
3. **Account locked** (persistent lockout — household scale break-glass is CLI only, D-13):
- Same banner style as rate-limit
- Copy: "This account is temporarily locked. Contact your admin to reset access."
- Submit button: disabled
4. **Generic server error** (5xx / network):
- Copy: "Something went wrong. Please try again." — Body (15px/400), `var(--color-destructive)`
- Submit button: re-enabled after error
### Surface 7 — Primary Submit Button ("Sign in")
- Filled: `background: var(--color-member-0)`, `color: #ffffff`
- Width: 100% (full-width login button — D-04 low-friction for non-technical user)
- Label: 13px/600, `fontFamily: var(--font-family-base)`
- `minHeight: 44px`, `borderRadius: var(--space-1)` (4px), `border: none`
- `transition: background 0.15s ease`
- Disabled state: `background: var(--color-border)`, `cursor: default` (during submission or lockout)
- Loading state: `Loader2` icon (16px, #ffffff, `animation: spin 1s linear infinite`) inline before
label text; label changes to "Signing in…"
- Enabled only when both username and password fields are non-empty
### Surface 8 — Method Divider (OIDC mode only)
Rendered between the local login card and the OIDC button when `oidcEnabled === true` from
`/api/auth/mode`. Not rendered when OIDC is not configured.
- A horizontal rule with centered label "or":
- `display: flex; alignItems: center; gap: var(--space-3); marginTop: var(--space-4); marginBottom: var(--space-4)`
- Left/right lines: `flex: 1; height: 1px; background: var(--color-border)`
- "or" label: 13px/400, `var(--color-text-secondary)`, `flexShrink: 0`
### Surface 9 — OIDC Login Button (OIDC mode only)
Rendered below the method divider when `oidcEnabled === true`. Not rendered when OIDC is not
configured. This is NOT inside the login card — it sits below the card, after the divider.
- Outlined style: `background: transparent; border: 1px solid var(--color-member-0); color: var(--color-member-0)`
- Width: 100% (matches Surface 7 width)
- Label: "Login with OIDC" — 13px/600 (never says "Authelia" — D-06 BYO-Auth principle)
- `minHeight: 44px`, `borderRadius: var(--space-1)`, `cursor: pointer`
- On click: initiates the OIDC authorization-code flow (same as today's redirect)
- `lucide ShieldCheck` (16px) inline before label text — represents "your SSO provider"
- No loading state needed (redirect is instant)
### Surface 10 — Forgot Password Helper
Below Surface 7 (sign-in button), inside the login card.
- A single-line text: "Forgot your password? Ask your admin." — 13px/400,
`var(--color-text-secondary)`, `textAlign: center; marginTop: var(--space-4)`
- No link — password reset is admin-only (D-11), no self-service email reset (D-11, email
out of project scope). The text is informational only; not interactive.
- This copy is non-alarming for the non-technical user: frames it as a quick admin action,
not a problem.
### Surface 11 — Admin Additions: Local Accounts Section
Extends the existing `/admin` route (AdminPage.tsx), below the "MEMBERS" section and "SHARED
CALENDAR" section. New section labeled "LOCAL ACCOUNTS" (section-label style: 13px/600/uppercase/
0.06em letter-spacing, `var(--color-text-muted)`).
**Sub-surface 11A — Create Member / Set Initial Password**
A card/form within the LOCAL ACCOUNTS section:
- Heading (inline, not a card): "Add member" — Body (15px/600/`var(--color-text-primary)`)
- Fields (same input style as CredentialSheet):
- Display name — `type="text"`, label "Display name"
- Username — `type="text"`, label "Username", `autoComplete="off"`, `spellCheck={false}`, `autoCapitalize="none"`
- Initial password — `type="password"`, label "Initial password", `autoComplete="new-password"`
- Confirm password — `type="password"`, label "Confirm password", `autoComplete="new-password"`
- Field error: inline below the specific field, 13px/400, `var(--color-destructive)`, same style as
CredentialSheet validation failure
- Submit: "Add member" — filled accent button (same style as admin Save Credential button),
`minHeight: 44px`, right-aligned in action row. Disabled when any required field is empty or
passwords do not match.
- Success: form clears; member appears in the MEMBERS section above.
- Error copy variants:
- Username already taken: "That username is already in use. Choose a different one."
- Passwords do not match: "Passwords do not match."
- Weak password (if enforced): "Password is too short. Use at least 8 characters."
**Sub-surface 11B — Admin Password Reset (per-member)**
Accessible from each member row in the MEMBERS section via a new "Reset password" action button
(alongside existing "Rotate credential"/"Add credential" buttons — shown only for members who have
a local credential row).
Opens a bottom sheet (mobile) / centered modal (desktop), identical pattern to CredentialSheet
(role="dialog", aria-modal, Escape closes, focus returns to trigger):
- Heading: "Reset password" — 18px/600
- Member subtitle: "{DisplayName}" — 15px/400, `var(--color-text-secondary)`
- Fields:
- New password — `type="password"`, `autoComplete="new-password"`, label "New password"
- Confirm new password — `type="password"`, `autoComplete="new-password"`, label "Confirm new password"
- No current-password field — admin reset does not require knowing the old password
- Action row (right-aligned, gap `var(--space-3)`):
- Cancel: ghost button (same ghostBtnStyle as CredentialSheet)
- "Reset password": filled accent button, disabled while fields empty or mismatch
- Success: sheet closes; no toast (the action is silent — admin-only, not user-visible)
- Error: inline below confirm field in `var(--color-destructive)`, 13px/400
### Surface 12 — Self Password-Change (member self-service)
Accessible from the SettingsSheet (existing Settings bottom sheet the user opens from the avatar
button). A new "Change password" row in SettingsSheet, shown only when the current user has a
local credential (`hasLocalCredential: true` from `/api/me`). Tapping opens a bottom sheet
(same pattern as CredentialSheet):
- Heading: "Change password" — 18px/600
- Fields:
- Current password — `type="password"`, `autoComplete="current-password"`, label "Current password"
- New password — `type="password"`, `autoComplete="new-password"`, label "New password"
- Confirm new password — `type="password"`, `autoComplete="new-password"`, label "Confirm"
- Action row:
- Cancel: ghost button
- "Change password": filled accent, disabled while any field empty or new/confirm mismatch
- Success: sheet closes; no toast (self-service action is low-stakes confirmation)
- Error variants:
- Wrong current password: "Current password is incorrect."
- Passwords do not match: "Passwords do not match."
- Generic error: "Something went wrong. Please try again."
- `aria-describedby` on each field pointing to the specific inline error
### Surface 13 — Link OIDC Identity (per-user action)
Shown in SettingsSheet for the currently authenticated user, only when:
- The user has a local credential (is a local user, not already OIDC-only)
- OIDC is enabled (`oidcEnabled === true` from app state)
Entry point: a "Link OIDC identity" row in SettingsSheet, below "Change password" (if shown).
Tapping opens a **confirmation bottom sheet** (not a form — the actual linking happens via OIDC
redirect, so the sheet just explains consequences):
- Heading: "Link OIDC identity" — 18px/600
- Body (15px/400, `var(--color-text-secondary)`, `lineHeight: 1.5`):
"After linking, you'll sign in with your OIDC provider instead of a username and password.
Your local password will be removed."
- This is informational, not alarming: frame as an upgrade, not a removal.
- Do NOT use the word "delete" or "remove" in the primary copy.
- A secondary note in `var(--color-text-muted)` 13px/400:
"This can't be undone from the app. Contact your admin if you need to revert."
- Action row:
- "Cancel" ghost button
- "Continue with OIDC" filled accent button (D-06: never "Continue with Authelia")
- On "Continue with OIDC": sheet closes; OIDC authorization-code flow initiates.
On callback, backend binds `iss+sub` to the user and deletes the `local_credentials` row (D-12).
User is then redirected to `/calendar` as a now-OIDC-only user.
- If the OIDC `iss+sub` already belongs to another user: the callback returns a 409 error.
The PWA shows a generic error page: "This OIDC identity is already linked to another account.
Please contact your admin." (not shown in the sheet — occurs post-redirect)
---
## Routing & App-Level Gate
1. On app load, `GET /api/auth/mode` is fetched pre-auth (before OIDC middleware, no session
required). Returns: `{ localEnabled: true, oidcEnabled: boolean }`.
2. If the user has a valid session (any method): skip `/login`, proceed to normal app routes.
3. If no valid session AND `localEnabled === true`: render `/login` (Surface 1).
4. If no valid session AND `localEnabled === false` AND `oidcEnabled === true`: initiate OIDC
redirect directly (no login page shown — OIDC-only mode, today's behavior).
5. The `/login` route does NOT render inside the normal App shell — no AppNav, no BottomTabBar.
The existing `AuthSplash` component (spinner + "Signing you in") continues to be shown during
any auth-state loading before the login page is reached.
The Phase 12 setup gate (`/api/setup/status`) takes priority: if `setupComplete === false`, the
app redirects to `/setup` before reaching the login gate.
---
## Interaction Contract
### Login form state machine
```
fields empty → Submit disabled
username OR password empty → Submit disabled
both fields non-empty → Submit enabled
submit tapped → loading state (Loader2 spinner, "Signing in…", submit disabled)
success → navigate to /calendar (cookie set by API)
401 invalid credentials → error state (Surface 6, variant 1); fields remain editable; reset loading
429 rate limit → error state (Surface 6, variant 2); submit temporarily disabled
423 locked → error state (Surface 6, variant 3); submit disabled
5xx / network → error state (Surface 6, variant 4); submit re-enabled
```
### Password show/hide
Toggle button (Surface 5): clicking switches `type` between `"password"` and `"text"`.
The toggle state resets to hidden (`type="password"`) when the field loses focus.
`aria-pressed` reflects current show state.
### OIDC button (Surface 9)
Rendered only when `oidcEnabled === true`. Clicking initiates OIDC authorization-code flow
(same redirect as today). No loading state — the redirect is immediate.
### Focus management
- On page mount, focus moves to the username field (autofocus — login form is the only content)
- On submit error, focus moves to the heading of Surface 6 (`tabIndex={-1}`, `ref` + `.focus()`)
- On Enter key in username field: focus moves to password field
- On Enter key in password field: submit fires (if button not disabled)
### Keyboard-only login
The entire login form is keyboard-navigable. Tab order: username → password → show/hide toggle →
"Sign in" button → "Login with OIDC" button (if shown). No tab traps outside the OIDC
confirmation sheet.
---
## Copywriting Contract
### Login Screen (Surface 110)
| Element | Copy |
|---------|------|
| App name in brand slot | "FamilySync" |
| App tagline in brand slot | "Family calendar & lists" |
| Login card heading | "Sign in" |
| Username field label | "Username" |
| Password field label | "Password" |
| Show password toggle aria-label | "Show password" |
| Hide password toggle aria-label | "Hide password" |
| Primary CTA | "Sign in" |
| Primary CTA loading state | "Signing in…" |
| Forgot password helper | "Forgot your password? Ask your admin." |
| Method divider label | "or" |
| OIDC button label | "Login with OIDC" |
| Error — invalid credentials | "Incorrect username or password." |
| Error — rate limit | "Too many attempts. Please wait a moment and try again." |
| Error — account locked | "This account is temporarily locked. Contact your admin to reset access." |
| Error — server/network | "Something went wrong. Please try again." |
| Empty state | N/A — login form always has explicit content |
### Admin Additions (Surfaces 1113)
| Element | Copy |
|---------|------|
| Section label | "LOCAL ACCOUNTS" |
| Add member form heading | "Add member" |
| Display name field label | "Display name" |
| Username field label | "Username" |
| Initial password field label | "Initial password" |
| Confirm password field label | "Confirm password" |
| Add member submit button | "Add member" |
| Error — username taken | "That username is already in use. Choose a different one." |
| Error — passwords mismatch (create) | "Passwords do not match." |
| Error — password too short | "Password is too short. Use at least 8 characters." |
| Admin reset sheet heading | "Reset password" |
| Admin reset new password label | "New password" |
| Admin reset confirm label | "Confirm new password" |
| Admin reset submit button | "Reset password" |
| SettingsSheet — change password row | "Change password" |
| Self-change sheet heading | "Change password" |
| Self-change current password label | "Current password" |
| Self-change new password label | "New password" |
| Self-change confirm label | "Confirm" |
| Self-change submit button | "Change password" |
| Self-change error — wrong current | "Current password is incorrect." |
| Self-change error — passwords mismatch | "Passwords do not match." |
| SettingsSheet — link OIDC row | "Link OIDC identity" |
| Link OIDC sheet heading | "Link OIDC identity" |
| Link OIDC sheet body | "After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed." |
| Link OIDC secondary note | "This can't be undone from the app. Contact your admin if you need to revert." |
| Link OIDC cancel button | "Cancel" |
| Link OIDC confirm button | "Continue with OIDC" |
| Link OIDC post-redirect error (409) | "This OIDC identity is already linked to another account. Please contact your admin." |
| Admin member row CTA — reset (local user) | "Reset password" |
| Generic admin error | "Something went wrong. Please try again." |
### Copywriting rules (D-06 BYO-Auth principle)
- Never use the word "Authelia" in any user-facing copy. Use "your OIDC provider" or
"Login with OIDC" everywhere.
- Never say "delete" or "remove" when describing the OIDC-link consequence — use
"your local password will be removed" (passive, factual, non-alarming).
- Admin copy ("Reset password") is direct — admins are comfortable with technical vocabulary.
- End-user copy ("Sign in", "Forgot your password? Ask your admin.") is warm and low-friction —
optimized for the non-technical Apple household member.
---
## Destructive Actions
| Action | Trigger | Confirmation approach |
|--------|---------|----------------------|
| Link OIDC identity (removes local credential for that user) | "Link OIDC identity" in SettingsSheet → "Continue with OIDC" tap | Two-step: open confirmation sheet (step 1, explains consequence) + explicit "Continue with OIDC" tap (step 2). The confirmation sheet clearly states "your local password will be removed." No additional modal/dialog beyond this sheet. |
| Admin password reset | "Reset password" in admin member row → sheet submit | Two-step: open reset sheet (step 1) + explicit "Reset password" tap with filled-in new password (step 2). No separate confirmation dialog — the act of filling and submitting a new value is the acknowledgement. |
No hard-delete of local accounts in this phase. Account removal is out of scope.
---
## Accessibility Contract
### Login page (Surfaces 110)
- `role="main"` on the content column
- `<h1>` is the app name "FamilySync" in the brand slot (page-level heading);
`<h2>` is "Sign in" (login card heading)
- Username input: `id="login-username"`, `<label htmlFor="login-username">`, `spellCheck={false}`,
`autoCapitalize="none"`, `autoCorrect="off"`
- Password input: `id="login-password"`, `<label htmlFor="login-password">`, `aria-describedby="login-error"` (when error active)
- Error container: `id="login-error"`, `role="status"`, `aria-live="polite"`, `aria-atomic="true"` —
screen readers announce errors without focus movement
- Show/hide toggle: `aria-pressed`, `aria-label="Show password"` / `"Hide password"`, 44px tap target
- Submit button: `disabled` attribute (not just `pointer-events: none`) when disabled
- Focus on mount: `autoFocus` on username field
- Focus management on error: move focus to error heading (`tabIndex={-1}`, `.focus()`)
- OIDC button: `type="button"`, descriptive label (no ambiguous icon-only)
- Focus ring: `var(--color-focus-ring)` (#4a90d9), 2px outline, 2px offset on all focusable elements
### Admin additions (Surfaces 1113)
- All sheets: `role="dialog"`, `aria-modal="true"`, `aria-label` matching heading, Escape closes,
focus returns to trigger element on close
- All password fields: `type="password"`, correct `autoComplete` values (never cross-contaminate
new-password / current-password)
- Field errors: `aria-describedby` from input to its specific inline error element
- Sheet heading: `<h2>` (heading hierarchy under page `<h1>`)
- Minimum touch targets: `minHeight: 44px; minWidth: 44px` on all buttons
---
## Responsive Behavior
The login page is **phone-first** (the primary user is on mobile — CLAUDE.md hard UX constraint).
- Phone (<768px): card fills viewport minus `var(--space-6)` horizontal padding (12px each side);
brand slot centered; no bottom tab bar; no AppNav
- Desktop (≥768px): card centered at maxWidth 400px; brand slot centered above it
- At all breakpoints: no AppNav, no BottomTabBar rendered on the login page
Admin additions (Surfaces 1113) follow the existing AdminPage responsive pattern:
- Mobile: bottom sheet for all sheets (borderRadius 12px top corners, slides up)
- Desktop: centered modal (maxWidth 480px, same as CredentialSheet)
- Add-member form (Surface 11A) is inline within `/admin` content, not a sheet
---
## Security Display Rules
Hard UI rules — not implementation notes:
- Password fields always render as `type="password"` initially — show/hide is explicit user action
- No password is ever pre-filled, echoed, or returned to the UI after save
- Password values are never written to localStorage, sessionStorage, or any client-side store
- Error messages for invalid credentials do NOT indicate which field is wrong
(timing-safe: same copy for "wrong username" and "wrong password")
- No `dangerouslySetInnerHTML` anywhere on the login page (project convention T-05-24)
- The OIDC button label never contains provider-specific branding that would leak infrastructure
details (D-06)
- The "Link OIDC identity" flow is only accessible to an already-authenticated local user —
never from the unauthenticated login page
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none — not initialized | not applicable |
| third-party | none | not applicable |
No third-party component registries. All components hand-rolled following existing project
convention. No new npm dependencies for UI are required beyond lucide-react (already installed;
new icons needed: `Lock`, `User`, `Eye`, `EyeOff`, `LogIn` — all available in lucide-react).
---
## Pre-Population Sources
| Decision | Source | Value |
|----------|--------|-------|
| Spacing scale | apps/pwa/src/styles/tokens.css | --space-1 through --space-12; no new tokens |
| Typography scale | apps/pwa/src/styles/tokens.css | 4 sizes (13/15/18/24px), 2 weights (400/600) |
| Color palette | apps/pwa/src/styles/tokens.css | All hex values; no new colors |
| Component library | apps/pwa convention | Hand-rolled inline React.CSSProperties; no shadcn |
| Icon library | apps/pwa imports | lucide-react (already installed) |
| Full-page shell layout | SetupPage.tsx | pageStyle, contentColStyle, cardStyle, primaryBtnStyle, ghostBtnStyle, inputStyle, labelStyle, helperStyle |
| Validation row pattern | SetupPage.tsx | ValidationRow component (idle/pending/success/failure) |
| Admin section label style | AdminPage.tsx | sectionLabelStyle (13px/600/uppercase/0.06em) |
| Bottom sheet pattern | CredentialSheet.tsx | role="dialog", aria-modal, Escape, focus-return, borderRadius 12px top |
| Button styles | SetupPage.tsx / AdminPage.tsx | Filled accent + ghost button — exact match |
| Input style | SetupPage.tsx | Same inputStyle(hasError) — border switches to destructive on error |
| No OIDC-specific branding | CONTEXT.md D-06 | Never "Authelia"; use "Login with OIDC" / "your OIDC provider" |
| Local-only + OIDC-optional coexistence | CONTEXT.md D-01/D-02 | localEnabled always true; oidcEnabled from /api/auth/mode |
| OIDC link removes local credential | CONTEXT.md D-12 | Confirmation sheet required; copy non-alarming |
| No email password reset | CONTEXT.md D-11 | "Ask your admin" copy only |
| Admin creates accounts only (no self-signup) | CONTEXT.md D-10 | Add member form is admin-only |
| Stateless JWT session cookie | CONTEXT.md D-05 | No session table UI; logout = clear cookie |
| Break-glass is CLI/env only | CONTEXT.md D-13 | No break-glass UI in scope |
| Phase 17 brand slot seam | cross-phase directive | BrandSlot component + CSS asset tokens defined |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS
- [x] Phase 17 Brand-Slot Readiness: PASS
**Approval:** approved 2026-06-16