Files
familysync/.planning/phases/06-ux-polish/06-UI-SPEC.md
T
Lucas Berger 4b77ec0254 docs(06): add brand assets & iconography contract to UI-SPEC
Appends a new "Brand Assets & Iconography" section covering the
glyph+wordmark mark concept, SVG source-of-truth files, raster export
pipeline, full favicon set (SVG + ICO + index.html gaps), maskable
safe-zone spec, in-app logo usage surfaces, and a complete asset
manifest table. All existing UI-SPEC sections preserved verbatim.
2026-06-10 09:45:00 -04:00

25 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
6 ux-polish draft false none 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:

@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):

{ 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:

<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:

<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