---
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 1–10)

| 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 11–13)

| 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 1–10)
- `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 11–13)
- 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 11–13) 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
