diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md b/.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md
new file mode 100644
index 0000000..f404c23
--- /dev/null
+++ b/.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md
@@ -0,0 +1,685 @@
+---
+phase: 19
+slug: local-auth-no-oidc-mode
+status: draft
+shadcn_initialized: false
+preset: none
+created: 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 `` in the login page — not inline JSX.
+This isolates the seam: Phase 17 replaces `` internals (swap placeholder div for
+``) without touching `` 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 `` internals (image src) + set CSS custom property
+values. No changes to `` 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 `` (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
+- `
` is the app name "FamilySync" in the brand slot (page-level heading);
+ `