docs(11): UI design contract for per-event reminders
This commit is contained in:
@@ -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") |
|
||||
| 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>` 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
|
||||
Reference in New Issue
Block a user