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

35 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created, approved
phase slug status shadcn_initialized preset created approved
19 local-auth-no-oidc-mode approved false none 2026-06-16 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):

: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

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

  • Dimension 1 Copywriting: PASS
  • Dimension 2 Visuals: PASS
  • Dimension 3 Color: PASS
  • Dimension 4 Typography: PASS
  • Dimension 5 Spacing: PASS
  • Dimension 6 Registry Safety: PASS
  • Phase 17 Brand-Slot Readiness: PASS

Approval: approved 2026-06-16