Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.8 KiB
phase, slug, status, shadcn_initialized, preset, created
| phase | slug | status | shadcn_initialized | preset | created |
|---|---|---|---|---|---|
| 11 | per-event-reminders | draft | false | none | 2026-06-13 |
Phase 11 — UI Design Contract
Visual and interaction contract for frontend phases. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
Design System
| Property | Value |
|---|---|
| Tool | none — custom CSS tokens (tokens.css), no shadcn |
| Preset | not applicable |
| Component library | none — inline React.CSSProperties style objects |
| Icon library | lucide-react (already in use: X, Loader2) |
| Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif |
Source: apps/pwa/src/styles/tokens.css, apps/pwa/src/components/EventForm.tsx
Spacing Scale
Declared values (must be multiples of 4). Pre-populated from tokens.css.
| Token | Value | Usage |
|---|---|---|
| --space-1 | 4px | Icon gaps, label margin-bottom, helper-text margin-top |
| --space-2 | 8px | Compact element spacing |
| --space-3 | 12px | Select horizontal padding (matching Recurrence picker) |
| --space-4 | 16px | Field margin-bottom (fieldStyle), general element spacing |
| --space-6 | 24px | Section padding |
| --space-8 | 32px | Layout gaps |
| --space-12 | 48px | Major section breaks |
Exceptions: 44px minimum touch target height on the reminder <select> (matches minHeight: '44px' already declared on inputStyle).
Typography
Pre-populated from tokens.css. No new type ramp needed — reminder picker reuses existing roles.
| Role | Size | Weight | Line Height | Usage in this phase |
|---|---|---|---|---|
| Body | 15px (--text-body-size) | 400 (--text-body-weight) | 1.5 | Select option text, helper text values |
| Label | 13px (--text-label-size) | 400 (--text-label-weight) | 1.4 | "Reminder" field label, "Custom (kept)" hint text, error copy |
| Heading | 18px (--text-heading-size) | 600 (--text-heading-weight) | 1.25 | Not directly used by this phase |
| Display | 24px (--text-display-size) | 600 (--text-display-weight) | 1.2 | Not directly used by this phase |
Only 2 weights in use: 400 (regular) and 600 (semibold).
Color
Pre-populated from tokens.css. No new tokens introduced in this phase.
| Role | Value | Usage |
|---|---|---|
| Dominant (60%) | --color-surface: #ffffff | Form background, select background |
| Secondary (30%) | --color-surface-dim: #f7f7f8 | Modal backdrop surface (already used) |
| Accent (10%) | --color-focus-ring: #4a90d9 | Focus ring on reminder select when keyboard-navigated |
| Destructive | --color-destructive: #dc2626 | Validation error copy if picker submission fails |
Accent reserved for: focus ring on the reminder <select> only. Calendar member colors (--color-member-*) and shared family color (--color-shared-family) are unchanged and not used by the reminder picker UI.
Component Inventory
This phase adds exactly one new UI element to EventForm.tsx. All styling follows the established inline React.CSSProperties pattern from the Recurrence picker.
Reminder <select> (new in Phase 11)
- Placement: After the Recurrence
<select>block (~line 885 in EventForm.tsx), before the recurrence-bound control. - Label text:
Reminder - Element ID:
event-reminder - Pattern: Identical to the Recurrence picker —
<div style={fieldStyle}>,<label htmlFor="event-reminder" style={labelStyle}>,<select style={{...inputStyle, padding: '0 var(--space-3)', cursor: 'pointer'}}>
Timed-event options (shown when !allDay)
| Option value | Display label |
|---|---|
null |
None |
5 |
5 minutes before |
10 |
10 minutes before |
15 |
15 minutes before |
30 |
30 minutes before |
60 |
1 hour before |
120 |
2 hours before |
1440 |
1 day before |
2880 |
2 days before |
All-day options (shown when allDay, swapped — D-02/D-03)
| Option value | Display label |
|---|---|
null |
None |
0 |
Same day (9 AM) |
1440 |
1 day before (9 AM) |
2880 |
2 days before (9 AM) |
10080 |
1 week before (9 AM) |
Default: null (None) for both cases — D-01.
Off-preset / Custom alarm handling (D-07/D-08)
- Single simple relative alarm not in preset list: Render a synthetic option
"N min before"or"N hours before"(humanized, see Copywriting table) appended to the option list, selected by default. If user changes selection, the synthetic option is removed. - Absolute-time trigger or multiple alarms: Render a read-only disabled
<option value="__custom__">Custom (kept)</option>selected by default. The<select>itself is NOT disabled — the user can still choose a preset, which replaces the custom alarm. - Preserve on no-change: When picker value remains
"__custom__"or the synthetic off-preset option on save, pass a sentinel (e.g."no-change") in the payload so the outbox worker leaves the original VALARM intact.
Helper text (shown below select, identical style to WR-01 helper)
Style: { fontSize: 'var(--text-label-size)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-1)' }
Shown only when eventFormMode === 'edit' and the event has a custom alarm (either off-preset single or multi/absolute):
"Custom reminder kept — select a preset to replace it."
Notification Push Copy (D-09)
The humanized body replaces the hardcoded Starts in ${minutes} min in reminderScheduler.ts line 157.
Humanized thresholds and wording
| Lead window | Body copy |
|---|---|
| < 60 minutes | Starts in {N} min (e.g. "Starts in 30 min") |
| 60–119 minutes | Starts in 1 hour |
| 120–1439 minutes | Starts in {N} hours (e.g. "Starts in 2 hours") |
| 1440–2879 minutes (1 day) | Starts in 1 day |
| ≥ 2880 minutes | Starts in {N} days (e.g. "Starts in 2 days") |
All-day events with day-granularity leads use the same thresholds (converted from minutes). "1 week before" → 10080 min → "Starts in 7 days".
Push notification title is unchanged from the current implementation (event title).
Copywriting Contract
| Element | Copy |
|---|---|
| Reminder field label | Reminder |
| Picker default option | None |
| Timed preset — 5m | 5 minutes before |
| Timed preset — 10m | 10 minutes before |
| Timed preset — 15m | 15 minutes before |
| Timed preset — 30m | 30 minutes before |
| Timed preset — 1h | 1 hour before |
| Timed preset — 2h | 2 hours before |
| Timed preset — 1d | 1 day before |
| Timed preset — 2d | 2 days before |
| All-day preset — same day | Same day (9 AM) |
| All-day preset — 1d before | 1 day before (9 AM) |
| All-day preset — 2d before | 2 days before (9 AM) |
| All-day preset — 1wk before | 1 week before (9 AM) |
| Off-preset single alarm helper text | Custom reminder kept — select a preset to replace it. |
| Custom (kept) option label | Custom (kept) |
| Push notification — < 60 min | Starts in {N} min |
| Push notification — exactly 1 hour | Starts in 1 hour |
| Push notification — N hours | Starts in {N} hours |
| Push notification — exactly 1 day | Starts in 1 day |
| Push notification — N days | Starts in {N} days |
No empty state: the reminder picker always shows options (default None). No destructive actions in this phase — reminder selection is non-destructive; unsaved changes are discarded on form close (same as all other EventForm fields).
Error state: If the event PUT fails (network/server error), the existing EventForm error toast pattern handles it — no reminder-specific error copy needed.
Interaction Contract
State machine for the picker
allDay = false → show timed presets (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d)
allDay = true → swap to all-day presets (None / Same day / 1d before / 2d before / 1wk before)
allDay toggles → reset picker to None (do not carry over a timed value to all-day or vice versa)
Edit mode: loading existing reminder
reminderLeadMinutes from occurrence:
null → select "None" (default)
matches preset → select matching option
number not in preset list (and single simple relative VALARM) → add synthetic option, select it
absolute/multi alarm → add "Custom (kept)" option (disabled), select it; show helper text
Accessibility
id="event-reminder"on the<select>,htmlFor="event-reminder"on the label (matches Recurrence pattern exactly).<select>participates in the existing focus trap (no special handling needed — it is a native focusable element).- The "Custom (kept)"
<option>usesdisabledattribute to prevent re-selection after the user picks a preset, but the<select>itself remains enabled. - Minimum touch target: 44px height via
minHeight: '44px'oninputStyle(inherited, already declared).
No new UI surfaces
This phase adds no new modals, sheets, toasts, or pages. All changes are:
- One new
<select>field inside the existingEventForm.tsx. - One optional helper text
<div>below the select (edit mode + custom alarm only). - Push notification body copy change in
reminderScheduler.ts(server-side, no UI surface).
Registry Safety
| Registry | Blocks Used | Safety Gate |
|---|---|---|
| shadcn official | none — shadcn not initialized | not applicable |
| third-party | none | not applicable |
No third-party component registry blocks used. Phase 11 adds no new npm dependencies on the frontend. All UI is composed from native HTML elements styled with existing project tokens.
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