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.
25 KiB
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:
- Primary CTA button fill in EventForm ("Save" / "Update Series")
- 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-Nor--color-shared-familyas 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.colorper 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-0fill (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:
meQuery.isLoading→ show spinner + "Signing you in" heading + "Taking you to the sign-in page…" body.meQuery.isError(opaqueredirect / 401), first attempt → triggermaybeRedirectToLogin()immediately; while the redirect is in-flight keep the spinner visible (same view).meQuery.isError, one-shot guard already fired (familysync.loginRedirectAttemptedset) → 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— setborder-radius:4px; color:#FFFFFF; font-weight:600; font-size:var(--text-label-size). - Color source: pass the event's
_familySync.colorto the Schedule-XcalendarIdcolor config (already done viabuildCalendarConfig). No per-event inline style override needed if thecalendarIdcolor 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
startDateorstartTimechange: recalculateendDate/endTimeto preserve the current duration.- Timed:
newEnd = newStart + (oldEnd - oldStart). IfoldEnd <= oldStart(stale state), snap tonewStart + 1h. - All-day:
newEndInclusive = newStartDate + (oldEndInclusive - oldStartDate)in days. If span = 0, keep 0 (same-day). IfoldEnd < oldStart(stale), snapnewEnd = newStart.
- Timed:
- 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 theexclusiveEndToInclusiveDatehelper 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-labelledbypointing 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:
@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 withfill-opacity="0.6"for the individual shapes, then a<clipPath>-intersected shape (or a third overlapping path) atfill-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