Resolved: light

Events

The STD events kit (src/components/ds/events/) — the presentational layer that formalizes the bespoke portal/public event components. Everything here takes plain props + callbacks (no server actions, no Prisma rows): cards & lists for browsing, the meta chips + viewer-local date formatters, the RSVP/attendance controls, the two-column detail layout with its calendar/share actions, the event-form building blocks, and the staffed-door check-in kiosk. Flip the theme in the top bar to confirm every surface re-skins from the semantic tokens.

EventSummary
The render-ready event projection most pieces accept (./types). Deliberately loose: ids string|number, dates ISO string|Date, enums carried as plain strings — so callers pass DB rows without importing the generated enums.
meta helpers
eventTypeMeta / eventStatusMeta (./event-meta) narrow a loose type/status string to { label, icon, tone } on DS Badge tones (good|warn|bad|neutral|accent). EVENT_TYPE_OPTIONS / EVENT_STATUS_OPTIONS drive filter/option UIs.
presentational
No fetch, no server actions, no router. Interactivity is delegated via callbacks (onSelect / onChange / onSuggest / onMatch / onSubmit). Real wiring lands in a later migration phase.
viewer-tz dates
Date/time components (EventCard, EventHero, EventDateTime, TimeRange, CheckinCounter) format in the VIEWER's timezone — they're client components and suppressHydrationWarning, since the SSR (server tz) and first client pass may legitimately differ.

Cards & lists

The event “chip” in every layout — featured headline, standard card, compact card, and dense row — plus the responsive grid that lays standard cards out for the index, and the compact UpcomingEventsList for a dashboard rail. EventCard delegates its RSVP control + navigation: pass an rsvp slot and an href (or onSelect).

EventCard variant="featured" — headline card, meta overlaid on a scrim
variant="standard" (with cover)
variant="standard" (no cover → accent gradient)
variant="compact" — self-contained small card
variant="row" — borderless dense row

onSelect → last activated: — none yet —

MajorMajor action

March for the Dogs — City Hall Rally

Sep 12, 2026City Hall, 1 Dr Carlton B Goodlett Pl
Training

Saturday Canvass Training

Sep 26, 2026Mission Community Room
Phonebank

Tuesday Phonebank

Sep 15, 2026
Social

Volunteer Happy Hour

Sep 17, 2026Zeitgeist
Online

Welcome Orientation (Zoom)

Sep 20, 2026
MajorProtest

Lab Protest — Downtown

Sep 26, 2026Federal Plaza
EventCardGrid columns='auto' — fluid auto-fill (~18rem cards), 1-col on mobile

Upcoming Events

All events →
UpcomingEventsList — dated rows + per-row (stubbed) RsvpControl
EventCard
event: EventSummary; variant: 'featured'|'standard'|'compact'|'row' (default 'standard'); href (null = no link, else falls back to event.href / portal path); onSelect(event); rsvp (footer/trailing/overlay slot, alias children); showStatus.
EventCardGrid
Layout-only wrapper. columns: 'auto' (default, fluid auto-fill) | 2 | 3 | 4 (responsive ladder). Map an EventSummary[] into <EventCard> children.
UpcomingEventsList
events: EventSummary[]; title / allHref / allLabel / hideAllLink; renderRsvp(event) per-row control; hrefFor / onSelect / showStatus; emptyState. Rows reuse EventCard variant='row' so the chip look stays identical.

Event meta

The small descriptive pieces: the type / status / recurrence / waitlist chips (all on DS Badge tones via the shared meta helpers) and the viewer-local date/time formatters. EventTypeBadge degrades unknown/null types to a neutral “Event” chip; RecurrenceBadge humanizes an iCal FREQ token and renders nothing when blank.

OnlineIn personTrainingProtestCanvassingPhonebankSocialOnboardingOrganizer meetingMajor actionEvent
EventTypeBadge — every EventType
DraftPublishedCancelledProposedRejected
EventStatusBadge — every EventStatus
WeeklyEvery other Tuesday
RecurrenceBadge — FREQ token + free text + blank (renders nothing)
WaitlistedWaitlisted· #3Waitlisted
WaitlistBadge — with + without a position
ProtestPublishedTraining
badge variants — outline / pill / hideIcon

EventDateTime — long date + time

  • Saturday, September 12, 2026 · 1:30 PM CDT
  • Saturday, September 12, 2026 · 1:30 PM CDT4:00 PM CDT
  • Saturday, September 12, 2026, 1:30 PM CDTSaturday, September 26, 2026, 11:00 AM CDT

EventDateRange — compact date range

  • Sep 12, 2026
  • Sep 12, 2026
  • Sep 12 – Sep 26, 2026

TimeRange — time-only (shared meridiem collapses)

  • 1:304:00 PM
  • 1:30 PM
Date formatters — EventDateTime / EventDateRange / TimeRange (viewer tz)

City Hall, 1 Dr Carlton B Goodlett Pl

Meet at the Polk St steps

Directions
with coordinates → static locator map + Directions

Mission Community Room

Buzz #4 for entry

Directions
text only (no coords) → address + Directions, no map
LocationBlock — address + note + Directions, with/without the locator map
EventTypeBadge / EventStatusBadge
type / status (loose string). Forward DS Badge props (outline, pill); hideIcon drops the leading lucide glyph. Tone + label + icon resolve from event-meta.
RecurrenceBadge
recurrence (string|null). Humanizes an iCal FREQ=… token (→ 'Weekly'); passes already-human strings through; renders null when blank. formatRecurrence() is exported for reuse.
WaitlistBadge
Warn-tone 'Waitlisted' chip; optional position ('Waitlisted · #3'); hideIcon. Surfaced when a 'going' RSVP lands past capacity.
EventDateTime / EventDateRange / TimeRange
Viewer-tz formatters (client + suppressHydrationWarning). EventDateTime = long date + time; EventDateRange = compact 'Sep 12 – Sep 26, 2026'; TimeRange = time-only, collapsing a shared meridiem ('2:30 – 4:00 PM'). All take startsAt + optional endsAt (ISO string|Date).
LocationBlock
location / locationNote / latitude / longitude; renders the STD MapView locator (single accent pin) only when both coords are present, and a Google Maps Directions link whenever there's any location. directionsHref() is exported.

RSVP & attendance

The RSVP affordance and the “who's coming” read-outs. RsvpControl is presentational — it owns no server state: the parent holds the authoritative status and reacts to onChange, with pending greying the controls mid-mutation. CapacityMeter escalates tone (accent → warn → bad) as the event fills; the avatar cluster + list both cap with a “+N” overflow.

state: null
Waitlisted
waitlisted "going" — capacity reached
RsvpControl — cycles null → going → maybe → declined (simulated pending)
120 / 250 confirmed
CapacityMeter — filling (accent)
210 / 250 confirmed
CapacityMeter — nearly full (warn)
Full12 waitlisted
CapacityMeter — full + waitlist (bad)
47 going
CapacityMeter — uncapped (plain count, no bar)
+183
188 going
AttendeeAvatars — overflow-aware cluster (+N)
  • Jane Doe7:42 PM
  • Marcus Lee7:39 PM
  • Priya PatelWaitlisted

+39 more

AttendeeList — names + waitlist chips + '+N more'
RsvpControl
status: 'going'|'maybe'|'declined'|null; onChange(nextStatus); waitlisted (shows the chip on a 'going' RSVP); pending (greys/disables mid-mutation). Holds no server state — the parent is authoritative.
CapacityMeter
confirmed + capacity (null/0 = uncapped → plain count); waitlist count surfaced once full; hideLabel. Tone escalates accent (<75%) → warn (<100%) → bad (full).
AttendeeAvatars
attendees: { id, name, avatarUrl? }[]; max caps visible avatars (default 5); total overrides the '+N' when attendees is a sampled head; size; label. Renders nothing when empty.
AttendeeList
attendees: { id, name, waitlisted?, meta? }[]; max (default 8) caps rows into a '+N more'; total drives that count for a truncated head; emptyText.

Detail layout & actions

The event-detail composition: EventHero (the full-bleed banner header, cover photo or accent gradient, title/date/location overlaid) inside EventDetailLayout (a two-column responsive shell — wide main beside a narrower rail), plus the agenda timeline and the calendar/share actions. The combined demo wires the hero, layout, agenda, attendees, map, RSVP, capacity, add-to-calendar, and share together exactly as a real detail page would.

MajorMajor actionPublished

March for the Dogs — City Hall Rally

Saturday, September 12, 2026 · 1:30 PM CDT – 4:00 PM CDTCity Hall, 1 Dr Carlton B Goodlett Pl

Join hundreds of advocates on the City Hall steps for a peaceful rally. Speakers, sign-making, and a march to the Federal Plaza. All ages welcome — leashed dogs encouraged.

Schedule

  1. 9:00 AM

    Doors open · coffee + sign-in

  2. 9:30 AM

    Welcome + safety briefing

  3. 10:00 AM

    Canvass team breakouts

  4. 12:30 PM

    Lunch + debrief

Who’s going

+183
188 going
  • Jane Doe
  • Marcus Lee
  • Priya PatelWaitlisted

+185 more

City Hall, 1 Dr Carlton B Goodlett Pl

Meet at the Polk St steps

Directions
Full composition — EventHero + EventDetailLayout + agenda / attendees / map / RSVP / capacity / calendar / share

Schedule

  1. 9:00 AM

    Doors open · coffee + sign-in

  2. 9:30 AM

    Welcome + safety briefing

  3. 10:00 AM

    Canvass team breakouts

  4. 12:30 PM

    Lunch + debrief

EventAgendaTimeline — the agenda Json as a vertical timeline
AddToCalendar — Google / Apple / Outlook / .ics dropdown
EventShare — public link + copy (with + without ready-to-paste text)
EventHero
event: EventSummary; ended (a 'Past' chip); showStatus; actions (top-right slot, kept outside any link); as ('h1' default | 'h2'). Cover photo behind a scrim, or an accent gradient band. Client (viewer-tz date line).
EventDetailLayout
header (full-width, spans both columns), children (main column), aside (rail), asidePosition ('right' default | 'left'). Stacks (main first) on small screens; splits into main + 20rem rail at lg.
EventAgendaTimeline
agenda: the loose Json array of { time, label } (normalized defensively); heading (default 'Schedule', null to omit). Returns null on empty/malformed data, so it's drop-in safe.
AddToCalendar
title / startsAt / endsAt? / description? / location? build the Google TEMPLATE link; icsHref (the app's existing .ics route) powers Apple / Outlook / Download. Route-agnostic. googleCalendarUrl() is exported.
EventShare
shareUrl (shown inline + opened) + optional shareText (copied; falls back to the URL); copyLabel. Clipboard copy with a transient 'Copied' flash; the link is always visible as a manual fallback.

Form building blocks

The pieces an event-edit form is assembled from. EventFormSection groups related fields under a titled band (the EventForm 'Mini-site' divider look). DateTimeField splits a datetime into a DatePicker + a native HH:MM TimeInput (controlled in either an ISO string or a { date, time } object). AgendaEditor builds a repeatable timeline of rows; EntityMultiSelect is a typeahead multi-select keyed by string id for co-hosts / teams / docs.

When

Split date + time fields, controlled in either ISO or parts mode.

ISO mode — emits a 'YYYY-MM-DDTHH:MM' string.

Parts mode — emits { date, time }.

Start date is required.

starts: 2026-09-12T11:30 · ends: 2026-09-12 14:00 · doors: 09:00

EventFormSection — titled field band + DateTimeField (ISO & parts) + TimeInput

2 rows

AgendaEditor — repeatable { time, label } rows (add / remove / edit)

selected ids: [march]

EntityMultiSelect — typeahead multi-select, id[] contract

Mini-site

Optional public landing page for this event.

Add row

…your fields render here…

EventFormSection — empty band (title + description + action slot)
EventFormSection
title + optional description + optional action (trailing control). Renders an uppercase tracked section label over a top rule, with children in a consistent vertical rhythm. Layout-only.
DateTimeField
Controlled DatePicker + TimeInput pair under one label. mode 'iso' (default; value 'YYYY-MM-DDTHH:MM') or 'parts' (value { date, time }); onChange echoes the same shape. label / hint / error / disabled / required.
TimeInput
Token-styled native <input type='time'>. Controlled: value ('HH:MM', 24h) + onChange(next); invalid for the error ring. Composes with a date input into a datetime (see DateTimeField).
AgendaEditor
value: AgendaRow[] ({ time, label }) + onChange(nextRows). Add / remove / inline-edit; useTimePicker swaps the free-text time cell for an HH:MM TimeInput; emptyHint / addLabel / placeholders.
EntityMultiSelect
options: ComboboxOption[] ({ value: id, label }); value: string[] (ids) + onChange(ids). Built on the DS MultiSelect; resolves ids to chips and drops stale ids not in options. placeholder / emptyText.

Check-in

The staffed-door kiosk. CheckinKioskLayout is the centered shell (event header over a main slot + a side rail); GuestMatcher is the walk-up sign-in form with name typeahead, guest-list matching, an “Is this you?” candidate confirm, and a success reset; CheckinQr renders a real QR for the self-serve sign-in URL; CheckinCounter is the live arrivals read-out. All presentational — the network (suggest / match / submit, the QR target, the poll) is delegated to callbacks/props.

Want to get involved? (optional)

Demo: type “Ada”, then enter ada@example.com and tab out to see the “Is this you?” match. Any other identity falls through to the full form.

GuestMatcher — walk-up sign-in (type 'Ada' + ada@example.com to trigger the match)

Prefer your own phone?

Scan to check in yourself.

app.savethedogs.io/events/demo/checkin

CheckinQr — real QR for the sign-in URL + copy-link

Checked in tonight

42

people checked in

  • Jane Doe1:30 PM
  • Marcus Lee1:42 PM
  • Priya Patel1:39 PM
with arrivals

Checked in tonight

0

people checked in

No one checked in yet.

empty (loaded → empty-state copy)
CheckinKioskLayout side-rail content (QR + counter, shown stacked)

Checked in tonight

42

people checked in

  • Jane Doe1:30 PM
  • Marcus Lee1:42 PM
  • Priya Patel1:39 PM
with arrivals

Checked in tonight

0

people checked in

No one checked in yet.

empty (loaded → empty-state copy)
CheckinCounter — live arrivals read-out (with + empty)

Event check-in

March for the Dogs — City Hall Rally

Saturday, September 12 · 2:30 PMCity Hall
Want to get involved? (optional)

Prefer your own phone?

Scan to check in yourself.

app.savethedogs.io/events/demo/checkin

Checked in tonight

42

people checked in

  • Jane Doe1:30 PM
  • Marcus Lee1:42 PM
CheckinKioskLayout — full kiosk shell: GuestMatcher (main) + CheckinQr & CheckinCounter (rail)
CheckinKioskLayout
title + dateLabel? + location? + eyebrow build the centered event header; children = main slot (the sign-in form), side = rail (QR + counter). Big-touch sizing; the rail sticks on lg and stacks on mobile. Layout-only.
GuestMatcher
Presentational walk-up form. Callbacks own the network: onSuggest(query) → name[]; onMatch({name,email,phone}) → GuestCandidate|null; onConfirm({confirmToken,…}) → result; onSubmit(values) → result. interestOptions / consentCopy. Holds only its own form/UI state.
CheckinQr
url (encoded into the QR, rendered client-side via the qrcode lib + copied by the copy button); title / subtitle / alt / showUrl / copyable. Degrades to the printed URL if the QR fails or clipboard is blocked.
CheckinCounter
count (the big aria-live number) + arrivals: { name, at }[] (newest first; 'at' rendered as a short local time); label; loaded (false → 'Loading…', true → empty-state copy). Owns no polling — the parent supplies + refreshes the data.