---
phase: 6
slug: ux-polish
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-10
---

# Phase 6 — UI Design Contract

> Visual and interaction contract for Phase 6: UX Polish.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.

---

## Design System

| Property          | Value                                                                |
| ----------------- | -------------------------------------------------------------------- |
| Tool              | none (custom CSS token layer)                                        |
| Preset            | not applicable                                                       |
| Component library | none — hand-rolled components using CSS custom properties            |
| Icon library      | lucide-react@1.17.0                                                  |
| Font              | system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif |

Source: `apps/pwa/src/styles/tokens.css` — fully established in Phase 2 (D-01/D-02).
No new design-system tooling introduced in this phase.

---

## Spacing Scale

Declared values from `apps/pwa/src/styles/tokens.css` — no changes in this phase:

| Token      | Value | Usage                                     |
| ---------- | ----- | ----------------------------------------- |
| --space-1  | 4px   | Icon gaps, inline padding, dot-label gaps |
| --space-2  | 8px   | Compact element spacing, badge padding    |
| --space-3  | 12px  | Form field internal padding               |
| --space-4  | 16px  | Default element spacing, card padding     |
| --space-6  | 24px  | Section padding, sheet internal padding   |
| --space-8  | 32px  | Layout gaps, modal vertical padding       |
| --space-12 | 48px  | Major section breaks                      |

Exceptions:

- Touch targets: minimum 44px height on all interactive elements (EventForm inputs, recurrence bound control, series-edit prompt buttons). 48px on primary CTAs (per existing PushPermissionPrompt pattern).
- All-day banner row: height is not constrained to spacing scale; it follows the Schedule-X all-day row height. Do not override it.

---

## Typography

From `apps/pwa/src/styles/tokens.css` — no new sizes or weights introduced in this phase:

| Role    | Size | Weight | Line Height | Used in Phase 6                                                                           |
| ------- | ---- | ------ | ----------- | ----------------------------------------------------------------------------------------- |
| Body    | 15px | 400    | 1.5         | Auth splash body text, session-expired interstitial copy, form labels                     |
| Label   | 13px | 400    | 1.4         | Recurrence bound control labels, "repeat until" date caption, series-edit prompt sub-copy |
| Heading | 18px | 600    | 1.25        | Auth splash heading ("Signing you in"), series-edit prompt heading                        |
| Display | 24px | 600    | 1.2         | Not used in Phase 6 new surfaces                                                          |

---

## Color

From `apps/pwa/src/styles/tokens.css` — no new colors introduced in this phase:

| Role            | Value                        | Usage                                                                        |
| --------------- | ---------------------------- | ---------------------------------------------------------------------------- |
| Dominant (60%)  | --color-surface: #FFFFFF     | Auth splash background, modal/sheet backgrounds, EventForm background        |
| Secondary (30%) | --color-surface-dim: #F7F7F8 | Series-edit prompt background band, all-day visual treatment background fill |
| Accent (10%)    | --color-member-0: #4A90D9    | Primary CTA buttons only: "Turn On Notifications", "Save" in event form      |
| Destructive     | --color-destructive: #DC2626 | Delete event, destructive actions only                                       |

Accent reserved for:

1. Primary CTA button fill in EventForm ("Save" / "Update Series")
2. Focus ring (--color-focus-ring: #4A90D9) on interactive inputs

New usage decisions for Phase 6 surfaces:

**All-day event visual treatment (999.6):**

- All-day chips/banners in Schedule-X all-day row: use full-width pill style with the member's `--color-member-N` or `--color-shared-family` as background fill at 100% opacity.
- Timed event chips: keep existing member color fill.
- The visual distinction is achieved through shape and presentation (full-width pill vs. standard event block), not a new color. The existing `_familySync.color` per event drives the fill in both cases.

**Auth splash (999.2) + session-expired interstitial (999.3):**

- Full-screen overlay: `--color-surface` (#FFFFFF) background.
- Spinner: `--color-member-0` (#4A90D9) — reuses the existing Loader2 + spin keyframe pattern.
- Heading: `--color-text-primary` (#111318).
- Body: `--color-text-secondary` (#6B7280).

**Series-edit prompt (999.9):**

- Confirmation sheet / dialog uses same surface + border system as existing DeleteConfirmationDialog.
- No new accent color. Primary confirm action uses `--color-member-0` fill (same as Save CTA).

---

## Copywriting Contract

### Auth splash — unauthenticated cold load (999.2)

| Element                          | Copy                                     |
| -------------------------------- | ---------------------------------------- |
| Heading                          | Signing you in                           |
| Body                             | Taking you to the sign-in page…          |
| Fallback (one-shot guard failed) | Sign-in required. Tap here to try again. |

Rules:

- No punctuation on the heading.
- Body uses an ellipsis (…, U+2026) not three dots.
- The "Sign-in required" fallback is the dead-end only — not the primary unauthenticated path.

### Session-expired interstitial (999.3)

| Element | Copy                 |
| ------- | -------------------- |
| Heading | Session expired      |
| Body    | Signing you back in… |

Rules:

- This interstitial replaces the hanging/generic error state. It is shown for ≤2s before `window.location.href = '/api/login'` fires.
- Do not show a dismiss button — the redirect is automatic.

### EventForm — recurrence bound control (999.8)

| Element                         | Copy                                    |
| ------------------------------- | --------------------------------------- |
| Bound type label                | Ends                                    |
| Option: no bound                | Never                                   |
| Option: repeat until            | On date                                 |
| Option: count                   | After N times                           |
| Date input label                | End date                                |
| Count input label               | Occurrences                             |
| Count input placeholder         | e.g. 10                                 |
| Validation error: count < 1     | Must be at least 1 occurrence           |
| Validation error: until < start | End date must be after the event starts |

### EventForm — series-edit confirmation (999.9)

| Element              | Copy                                            |
| -------------------- | ----------------------------------------------- |
| Sheet/dialog heading | Edit recurring series                           |
| Body                 | This will update all occurrences of this event. |
| Confirm CTA          | Update series                                   |
| Cancel               | Cancel                                          |

Rules:

- "Update series" is the primary action (accent-filled button).
- "Cancel" is a ghost/secondary button.
- No destructive color on confirm — this is an edit, not a delete.

### EventForm — primary CTAs

| Mode                        | CTA label     |
| --------------------------- | ------------- |
| Create (non-recurring)      | Save event    |
| Create (recurring)          | Save event    |
| Edit (non-recurring)        | Save changes  |
| Edit (recurring occurrence) | Update series |

### Sync indicators (D-13)

No copy change. Existing copy is canonical:

- Spinning: "Syncing…"
- Done: "Saved"
- Failed: "Didn't save. Try again." (or conflict variant)
- Dead: "Not saved. Check your connection."
- LiveSyncIndicator reconnecting: "Reconnecting…"
- LiveSyncIndicator disconnected: "Updates paused"

### Empty states

No new empty states introduced in this phase. Existing EmptyState and ListsEmptyState copy is unchanged.

### Destructive actions

| Action                  | Trigger                                                         | Confirmation                                                                                                          |
| ----------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Delete event            | Trash2 icon in EventDetailPopover footer                        | Existing DeleteConfirmationDialog — "Delete event?" / "This can't be undone." / "Delete" (destructive-red) / "Cancel" |
| Delete recurring series | (Not in scope for Phase 6 — whole-series edit only, not delete) | N/A                                                                                                                   |

---

## Surface Contracts

### Surface 1: Auth splash screen (999.2)

**Trigger:** `meQuery.isLoading` on initial mount (before auth state is known).

**Layout:**

- Full-screen centered column: `display:flex; flex-direction:column; align-items:center; justify-content:center; height:100dvh; background:var(--color-surface)`.
- Stack: Loader2 spinner (24px, `--color-member-0`, spinning via global `@keyframes spin`) → 16px gap → Heading (18px/600) → 8px gap → Body (15px/400, `--color-text-secondary`).
- No app chrome (no BottomTabBar, no AppNav).

**States:**

1. `meQuery.isLoading` → show spinner + "Signing you in" heading + "Taking you to the sign-in page…" body.
2. `meQuery.isError` (opaqueredirect / 401), first attempt → trigger `maybeRedirectToLogin()` immediately; while the redirect is in-flight keep the spinner visible (same view).
3. `meQuery.isError`, one-shot guard already fired (`familysync.loginRedirectAttempted` set) → replace body with "Sign-in required. Tap here to try again." with a tap handler that clears the flag and re-triggers login. No spinner in this dead-end state.

**Accessibility:** `role="status"` on the spinner wrapper, `aria-label="Signing you in"`.

### Surface 2: Session-expired interstitial (999.3)

**Trigger:** Any query or mutation returns 401 / opaqueredirect after initial auth succeeds.

**Layout:** Same full-screen centered column as Surface 1, but:

- Heading: "Session expired"
- Body: "Signing you back in…"
- Spinner visible.
- Fires `maybeRedirectToLogin()` after a 1.5s delay (enough for the user to read the message, not long enough to feel broken).

**Implementation note:** Centralized in `apps/pwa/src/api/client.ts` via a typed `SessionExpiredError`. A global TanStack Query `onError` handler intercepts it and sets a Zustand flag that renders this surface above the app tree. The existing one-shot guard in `loginRedirect.ts` is re-armed (clear `familysync.loginRedirectAttempted` before re-firing).

### Surface 3: All-day visual distinction (999.6)

**Schedule-X context:** All-day events appear in the all-day row in week/day views and as full-width banners in month/agenda views. Schedule-X renders them with its own chip CSS.

**Treatment:**

- Override Schedule-X all-day chip styles to render as a full-width rounded pill (border-radius: 4px) spanning the full column width with the event's member color as solid background fill and white (`#FFFFFF`) label text.
- Timed events keep their existing chip appearance (colored left border + lighter background tint, as Schedule-X default renders them with `--sx-color-primary`).
- The visual distinction contract: **all-day = solid filled pill; timed = partial-fill chip with colored border accent**.
- Override selector: `.sx__all-day-event` — set `border-radius:4px; color:#FFFFFF; font-weight:600; font-size:var(--text-label-size)`.
- Color source: pass the event's `_familySync.color` to the Schedule-X `calendarId` color config (already done via `buildCalendarConfig`). No per-event inline style override needed if the `calendarId` color propagates.

**Accessibility:** No additional ARIA needed — Schedule-X all-day row already has date headers. The filled pill provides sufficient contrast (member colors are ≥3:1 on white text at these sizes).

### Surface 4: EventForm — end-tracking + all-day off-by-one fix (999.7)

**Behavior contract:**

- On any `startDate` or `startTime` change: recalculate `endDate`/`endTime` to preserve the current duration.
  - Timed: `newEnd = newStart + (oldEnd - oldStart)`. If `oldEnd <= oldStart` (stale state), snap to `newStart + 1h`.
  - All-day: `newEndInclusive = newStartDate + (oldEndInclusive - oldStartDate)` in days. If span = 0, keep 0 (same-day). If `oldEnd < oldStart` (stale), snap `newEnd = newStart`.
- Floor rule: end must never be before start. If arithmetic would place end before start, snap end = start (timed: same minute; all-day: same day).
- The all-day-edit off-by-one (D-05) is already fixed at `EventForm.tsx:199`. Verify the `exclusiveEndToInclusiveDate` helper still applies correctly in edit pre-fill; do not re-implement.

**Interaction:**

- No toast or indicator when end auto-advances — silent and expected.
- The end date/time fields remain editable after the auto-advance; the user can override further.

### Surface 5: EventForm — recurrence bound control (999.8)

**Placement:** Appears below the frequency `<select>` in the recurrence section, shown only when recurrence ≠ "None".

**Layout:**

- Label: "Ends" (13px/400, `--color-text-secondary`).
- Three-option `<select>` or segmented control:
  - "Never" (default)
  - "On date" → reveals a date `<input type="date">` labeled "End date"
  - "After N times" → reveals a number `<input type="number" min="1">` labeled "Occurrences"
- Both revealed inputs have the same 44px touch-target height as other form fields.
- Validation errors shown inline below the field in 13px/400 `--color-destructive`.

**FREQ persistence fix (D-07):** The `RecurrencePreset` type in `apps/pwa/src/api/client.ts` must map to the correct `FREQ` string in `vevent.ts`. Verify the existing select value is serialized 1:1 into the RRULE FREQ field; if a daily selection persists as weekly, the bug is in the `preset → RRULE` mapping, not the form state.

### Surface 6: Series-edit prompt (999.9)

**Trigger:** User taps "Save" on an EventForm that is editing a recurring occurrence (occurrence has a `uid` whose event has `hasRrule=true`).

**Pattern:** Use the existing bottom-sheet/dialog pattern matching `DeleteConfirmationDialog`:

- Phone (≤767px): bottom sheet sliding up from below.
- Tablet/desktop (≥768px): centered dialog, max-width 480px.

**Layout:**

```
[ Sheet/Dialog ]
  Heading: "Edit recurring series"          (18px/600)
  Body: "This will update all occurrences   (15px/400, --color-text-secondary)
         of this event."
  ─────────────────────────────────────────
  [ Cancel ]        [ Update series ]
  ghost button      accent-filled (--color-member-0)
```

**Accessibility:**

- `role="dialog"`, `aria-modal="true"`, `aria-labelledby` pointing to the heading.
- Focus trap — Tab/Shift+Tab cycle between Cancel and Update series.
- Escape key fires Cancel.

### Surface 7: Spin animation fix (D-13)

**Problem:** `@keyframes spin` is currently defined only inside `PushPermissionPrompt.tsx` inline styles (`:359`). `SyncStateToast` and `LiveSyncIndicator` use `animation: spin …` but the keyframe isn't globally available when that component isn't mounted.

**Fix:** The `@keyframes spin` in `apps/pwa/src/styles/tokens.css` (lines 140–147) IS already defined globally. The bug is that components are referencing it via inline style `animation: 'spin 1s linear infinite'` which works, but the `@keyframes spin` must be confirmed present in the global stylesheet before the component mounts. Executor: verify `tokens.css` exports `@keyframes spin` and that `index.css` imports it before any component referencing the animation mounts. The `@keyframes spin` definition in `PushPermissionPrompt.tsx` is redundant but harmless — remove it after confirming the global definition covers all consumers.

---

## Animation Contract

| Animation | Definition                                                               | Applied to                                                                                 |
| --------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| shimmer   | `@keyframes shimmer` in tokens.css                                       | SkeletonCalendar loading bars                                                              |
| spin      | `@keyframes spin` in tokens.css (global — do not redefine per-component) | Loader2 in SyncStateToast, PushPermissionPrompt, auth splash, session-expired interstitial |
| pulse     | `@keyframes pulse` — confirm presence in tokens.css or add it            | LiveSyncIndicator reconnecting dot                                                         |

Note: If `@keyframes pulse` is not in tokens.css, add it alongside `@keyframes spin`:

```css
@keyframes pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.4;
  }
}
```

---

## Accessibility Contract

All new surfaces must meet:

| Requirement          | Value                                                                                                                                                                                                                          |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Minimum touch target | 44px height (48px on primary CTAs)                                                                                                                                                                                             |
| Focus management     | Focus trap in all dialogs/sheets; Escape closes                                                                                                                                                                                |
| Live regions         | `role="status"` for informational (spinner, sync); `role="alert"` for errors                                                                                                                                                   |
| Color contrast       | 4.5:1 for body text; 3:1 for large text (18px+/bold)                                                                                                                                                                           |
| Motion               | `@keyframes spin` and `pulse` are UI-state feedback — not decorative; acceptable without `prefers-reduced-motion` guard, but a reduced-motion variant (opacity swap instead of spin) is a welcome addition if it fits the plan |

---

## Brand Assets & Iconography

Source: D-BRAND-01, D-BRAND-02 (locked user decisions, 2026-06-10).
In-app UI icons remain lucide-react — this section covers only the brand mark, app icons, and favicons.

### Mark: Glyph + Wordmark

**Concept (D-BRAND-01):** Two overlapping rounded shapes — circles or rounded rectangles — suggesting two members sharing a space (the family) or two overlapping calendar tiles. The overlap region is the accent color at full opacity; each outer shape is the accent color at reduced opacity (≈60%). The wordmark "FamilySync" sits to the right of the glyph in the lockup variant; the glyph alone is the source for all raster icons.

**Geometry / construction:**

- Two circles, each 40px diameter on a 64px × 64px artboard (SVG viewBox="0 0 64 64").
- Circle 1 center: (24, 32). Circle 2 center: (40, 32).
- Overlap: the intersection region formed by both paths.
- Rendering: use two `<circle>` elements with `fill-opacity="0.6"` for the individual shapes, then a `<clipPath>`-intersected shape (or a third overlapping path) at `fill-opacity="1"` for the overlap highlight.
- Wordmark: "FamilySync" set in the system-ui stack at 18px/600 in the lockup; the glyph and wordmark baseline-align.

**Color usage:**

| Surface                           | Glyph fill                                                                             | Wordmark fill                  |
| --------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------ |
| Light background (#FFFFFF)        | #4A90D9 (accent)                                                                       | #111318 (--color-text-primary) |
| Dark background (≥50% dark)       | #FFFFFF                                                                                | #FFFFFF                        |
| Monochrome (print / favicon .ico) | currentColor (#111318 on light, #FFFFFF on dark) — single flat shape, no opacity split |

**Clear space:** Minimum clear space = 1× the glyph diameter (64px on the 64px artboard, i.e. one full glyph-width on all four sides at actual render size).

**Minimum size:**

- Glyph-only: 24px × 24px rendered (below this, detail is lost; use the monochrome flat variant).
- Glyph + wordmark lockup: 120px wide minimum.

**Monochrome fallback:** A single filled shape representing both circles merged (union path), no opacity split. Used for favicon.ico and any single-color context.

### SVG Source-of-Truth Files

| File                                      | Contents                                                                                              | Used as source for                                    |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `apps/pwa/src/assets/logo-glyph.svg`      | Glyph only, viewBox="0 0 64 64", color-variable fills (`currentColor` + CSS custom property override) | All raster icon exports; in-app glyph-only placements |
| `apps/pwa/src/assets/logo-lockup.svg`     | Glyph + "FamilySync" wordmark, viewBox="0 0 240 64"                                                   | Auth splash, header lockup (if present)               |
| `apps/pwa/src/assets/logo-monochrome.svg` | Flat union-path glyph, single fill, viewBox="0 0 64 64"                                               | favicon.ico source layer                              |

All SVG files: no embedded raster data, no `<image>` elements, path-only. Minified with no comments before commit.

### Raster Export Pipeline

Source: `apps/pwa/src/assets/logo-glyph.svg` (light-mode fill: #4A90D9 shapes on transparent background).

Export tool: any SVG-to-PNG renderer that preserves alpha (e.g. `sharp`, `Inkscape --export-png`, or `resvg`). A build-time script at `apps/pwa/scripts/export-icons.ts` (or equivalent Makefile target) must produce the full output list below from the SVG source — no manually-placed PNGs.

**Output list:**

| Output file                             | Size (px)          | Format                                | Notes                                                                                            |
| --------------------------------------- | ------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `apps/pwa/public/icon-192.png`          | 192×192            | PNG, RGBA                             | PWA manifest `any` icon                                                                          |
| `apps/pwa/public/icon-512.png`          | 512×512            | PNG, RGBA                             | PWA manifest `any` icon                                                                          |
| `apps/pwa/public/icon-512-maskable.png` | 512×512            | PNG, RGBA                             | PWA manifest `maskable` icon — glyph centered in safe zone (see below)                           |
| `apps/pwa/public/apple-touch-icon.png`  | 180×180            | PNG, RGB (no alpha), white background | iOS home screen; Apple ignores alpha                                                             |
| `apps/pwa/public/favicon-16.png`        | 16×16              | PNG                                   | favicon.ico source layer                                                                         |
| `apps/pwa/public/favicon-32.png`        | 32×32              | PNG                                   | favicon.ico source layer                                                                         |
| `apps/pwa/public/favicon-48.png`        | 48×48              | PNG                                   | favicon.ico source layer                                                                         |
| `apps/pwa/public/favicon.svg`           | —                  | SVG (copy of logo-glyph.svg)          | Modern browsers; referenced as `<link rel="icon" type="image/svg+xml">`                          |
| `apps/pwa/public/favicon.ico`           | 16+32+48 multi-res | ICO                                   | Legacy browsers; bundle the three PNG layers into a single .ico using `png-to-ico` or equivalent |

Replace the three existing placeholder files (`icon-192.png`, `icon-512.png`, `apple-touch-icon.png`) with the real exports. The script must be idempotent (re-running overwrites all outputs).

### Maskable Safe Zone

The maskable icon specification requires the primary visual to fit within the center 80% of the canvas (the "safe zone"). For a 512×512 canvas, the safe zone is the inner 409×409 px centered region. The glyph export for `icon-512-maskable.png` must:

- Scale the glyph to fit within 409×409 (≈80% of 512).
- Center it on the 512×512 canvas.
- Fill the outer 10% bleed area with the PWA `background_color` (#FFFFFF) so masked shapes (squircle, circle) show a clean white surround rather than transparency artifacts.

Update `vite.config.ts` manifest `icons` array to reference `icon-512-maskable.png` for the `maskable` purpose entry (separate file from the `any` 512px icon):

```ts
{ src: '/icon-192.png',          sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png',          sizes: '512x512', type: 'image/png' },
{ src: '/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
```

### Favicon Set — index.html Changes

Current state: `index.html` has only `<link rel="apple-touch-icon">` and no `<link rel="icon">`. This is a gap.

Add the following tags inside `<head>`, after the existing `apple-touch-icon` line:

```html
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
```

Keep the existing tags unchanged:

```html
<meta name="theme-color" content="#4A90D9" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="FamilySync" />
```

Browser resolution order: SVG favicon first (Chrome 80+, Firefox 41+, Safari 12+), ICO fallback for IE/legacy. No `favicon-32.png` link needed in HTML — the ICO multi-res bundle covers the same use case and reduces link clutter.

### In-App Logo Usage

| Surface                                  | Variant                                     | Size                           | Placement                                                                 |
| ---------------------------------------- | ------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------- |
| Auth splash (Surface 1)                  | Glyph + wordmark lockup (`logo-lockup.svg`) | 120px wide (auto height ~32px) | Centered above the spinner stack; 24px gap below lockup, then spinner     |
| Session-expired interstitial (Surface 2) | Glyph only (`logo-glyph.svg`)               | 32×32px                        | Centered above spinner; same vertical stack as auth splash                |
| App header / nav bar (if present)        | Glyph only (`logo-glyph.svg`)               | 24×24px                        | Leading slot of the top nav bar, 16px from left edge, vertically centered |
| PWA install prompt / about screen        | Glyph + wordmark lockup                     | 160px wide                     | Centered                                                                  |

Spacing tokens used: 24px gap (`--space-6`) between lockup and spinner on auth splash; 16px left inset (`--space-4`) for header placement. These are drawn from the established 8-point scale.

Color: render SVGs using CSS `color` inheritance where possible so light/dark mode automatically applies the correct fill. Set `fill="currentColor"` on all path elements in the SVG source; parent container sets `color: var(--color-text-primary)` (light) or `color: #FFFFFF` (dark overlay contexts such as auth splash over `--color-surface` — use `--color-text-primary` here since the background is white).

### Asset Manifest

| File path                                 | Format              | Size(s)            | Purpose                                | Referenced in                                          |
| ----------------------------------------- | ------------------- | ------------------ | -------------------------------------- | ------------------------------------------------------ |
| `apps/pwa/src/assets/logo-glyph.svg`      | SVG                 | 64×64 viewBox      | SVG source of truth — glyph only       | Export script, in-app `<img>` or inline SVG            |
| `apps/pwa/src/assets/logo-lockup.svg`     | SVG                 | 240×64 viewBox     | SVG source of truth — glyph + wordmark | Auth splash, install prompt                            |
| `apps/pwa/src/assets/logo-monochrome.svg` | SVG                 | 64×64 viewBox      | Monochrome union-path variant          | favicon.ico source                                     |
| `apps/pwa/public/favicon.svg`             | SVG                 | —                  | Modern browser favicon                 | `<link rel="icon" type="image/svg+xml">` in index.html |
| `apps/pwa/public/favicon.ico`             | ICO                 | 16+32+48 multi-res | Legacy browser favicon                 | `<link rel="icon" type="image/x-icon">` in index.html  |
| `apps/pwa/public/icon-192.png`            | PNG (RGBA)          | 192×192            | PWA manifest any icon                  | vite.config.ts manifest `icons`                        |
| `apps/pwa/public/icon-512.png`            | PNG (RGBA)          | 512×512            | PWA manifest any icon                  | vite.config.ts manifest `icons`                        |
| `apps/pwa/public/icon-512-maskable.png`   | PNG (RGBA)          | 512×512            | PWA manifest maskable icon             | vite.config.ts manifest `icons`                        |
| `apps/pwa/public/apple-touch-icon.png`    | PNG (RGB, white bg) | 180×180            | iOS home screen icon                   | `<link rel="apple-touch-icon">` in index.html          |
| `apps/pwa/scripts/export-icons.ts`        | TypeScript          | —                  | Build-time raster export pipeline      | `make icons` or equivalent                             |

---

## Registry Safety

| Registry        | Blocks Used | Safety Gate    |
| --------------- | ----------- | -------------- |
| shadcn official | none        | not applicable |
| Third-party     | none        | not applicable |

No third-party component registries used. All components are hand-rolled using the existing token layer.

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
