Files
familysync/.planning/milestones/v1.0-phases/06-ux-polish/06-UI-SPEC.md
T

473 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 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 140147) 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