docs(11): UI design contract for per-event reminders

This commit is contained in:
Lucas Berger
2026-06-13 20:52:33 -04:00
parent 771608d3d4
commit ecdd94a907
@@ -0,0 +1,239 @@
---
phase: 11
slug: per-event-reminders
status: draft
shadcn_initialized: false
preset: none
created: 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") |
| 60119 minutes | `Starts in 1 hour` |
| 1201439 minutes | `Starts in {N} hours` (e.g. "Starts in 2 hours") |
| 14402879 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>` uses `disabled` attribute to prevent re-selection after the user picks a preset, but the `<select>` itself remains enabled.
- Minimum touch target: 44px height via `minHeight: '44px'` on `inputStyle` (inherited, already declared).
### No new UI surfaces
This phase adds no new modals, sheets, toasts, or pages. All changes are:
1. One new `<select>` field inside the existing `EventForm.tsx`.
2. One optional helper text `<div>` below the select (edit mode + custom alarm only).
3. 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