Resolved: light

Calendar & dates

Everything time-shaped: entering a date, entering a date range, rendering a live “time ago”, and laying events out on a month grid. Date entry is the DS <DatePicker> — a thin, token-driven wrapper over a NATIVE <input type='date'>, so it inherits the OS picker, keyboard entry and locale for free (no date-fns, no JS calendar widget). The month grid is the app's own EventsCalendar, shown here on a static fixture. Flip the theme in the top bar to confirm every surface re-skins via the semantic tokens.

DatePicker
The date-entry component (@phauna/ds/overlays). A styled native <input type='date'> — takes the usual input attrs (value / onChange / min / max / disabled / required …) plus invalid?: boolean. Controlled: own the value in client state.
DateRangePicker
Two coupled native date inputs (@phauna/ds/overlays). Controlled via start / end (strings) + onStartChange / onEndChange; min / max / invalid forward through to both.
RelativeTime
A self-updating <time> element (@phauna/ds/overlays) — date (Date|string|number) + optional intervalMs. Re-renders on its own, so it is client-only.
DateInput (TYPE)
NOT a component — in @phauna/ds it is the type alias Date | string | number | null | undefined that the fmt* helpers accept. Don't reach for a <DateInput>; reach for <DatePicker>.
fmt helpers
fmtDate / fmtDateRange / fmtRelative (from @phauna/ds) turn a DateInput into display text. The native input emits a 'YYYY-MM-DD' string; pass that straight to fmtDate for the human label.
EventsCalendar
The app's month grid (src/components/portal/EventsCalendar.tsx). Props: events: CalendarEvent[]. Static & client (holds the month cursor); here it runs on an in-memory fixture.

DatePicker — date entry

The single date field. It is a controlled native <input type='date'>: pass value (a 'YYYY-MM-DD' string) and an onChange that reads e.target.value. Because it is native, the calendar dropdown, keyboard typing and the locale-aware display all come from the browser — the DS only supplies the token-driven chrome (border, radius, focus ring, the invalid state). Always give it an accessible name (a <label htmlFor>, or aria-label as in these isolated demos).

empty (placeholder shows the locale date format)
pre-filled value

value 2026-07-04fmtDate July 3, 2026

value → fmtDate(): the raw input string, formatted for display
value / onChange
Controlled like any input: value is a 'YYYY-MM-DD' string; onChange={(e) => setValue(e.target.value)}. An empty string clears it.
type is fixed
The component omits & hard-sets type='date' — you can't (and don't) pass type. Everything else from InputHTMLAttributes spreads through.
display vs value
The browser shows the date in the user's locale; the value it emits is always ISO 'YYYY-MM-DD'. Format for display elsewhere with fmtDate(value, …).
accessible name
Pair with a visible <label htmlFor> in real forms (or the DS FormRow). These standalone demos use aria-label so each field is still named for AT.

DatePicker — states & constraints

The states you'll actually wire: invalid for a failed validation (red ring, token-driven — never an inline color), disabled for an inert field, required for a must-fill, and min / max to clamp the selectable range (the native picker greys out everything outside the window). Each is a plain prop forwarded to the underlying input.

default — idle, valid
invalid — failed validation (token-driven ring)
disabled — inert, not editable
required — must be filled to submit
min / max — only July 2026 is selectable (try the picker)
invalid
boolean. Switches the field to the error treatment (red focus/border ring) via data-attributes + tokens. Pair with an external error message + aria-describedby.
disabled
Standard input disable — greyed, not focusable, not editable. The value is still rendered but locked.
required
Native required: the field participates in form validation and the browser blocks submit if empty.
min / max
ISO 'YYYY-MM-DD' bounds. The native picker disables dates outside [min, max] and rejects out-of-range typing.

DateRangePicker — a date range

Two coupled DatePickers for a start → end span. Controlled with the explicit start / end strings and onStartChange / onEndChange callbacks (note: distinct from the single picker's e.target.value handler). min / max and invalid apply to both ends. Use it for an event window, a report range, an availability span — anywhere one field would be ambiguous.

Pick a start and an end date.

empty range

Jul 3, 2026 Jul 10, 2026

pre-filled range → fmtDate echo

Jul 10, 2026 Jul 3, 2026

invalid — both ends show the error treatment
start / end
Two controlled 'YYYY-MM-DD' strings — the component does NOT use value/onChange. Keep both in state.
onStartChange / onEndChange
Each receives the new string value (already unwrapped — not an event). Wire your own start ≤ end check; the component won't enforce ordering.
min / max / invalid
Apply to both ends. Set invalid yourself when start > end (or your own rule fails) — the demo's invalid case shows the reversed-range treatment.

RelativeTime — live “time ago”

A <time> element that renders a relative phrase (“12 minutes ago”, “in 2 days”) and re-computes itself on an interval, so it stays fresh without a re-fetch. Because it ticks on its own it is client-only. Under the hood it is the same logic as the fmtRelative() helper — use the component when you want it to keep updating, the helper when you just need a one-shot string.

four offsets from now — each updates on its own interval
date
Date | string | number — the instant to describe. Past → “… ago”, future → “in …”.
intervalMs
How often to re-render (default is a sensible cadence). The element re-computes the phrase so it never goes stale on a long-open page.
renders <time>
Outputs a semantic <time> element; pass-through <time> attrs are accepted (dateTime is managed for you).
vs fmtRelative
fmtRelative(d) (from @phauna/ds) returns the same phrase as a plain string — no ticking. Use it in non-reactive contexts (CSV, a server render, a one-off label).

Month calendar (EventsCalendar)

The app's own month grid — src/components/portal/EventsCalendar.tsx — rendered here on a static in-memory fixture (no fetch, no DB). It takes events: CalendarEvent[], buckets each by its local calendar day from the ISO startsAt, pads the leading blanks so day 1 lands under the right weekday, marks today with an accent pill, and links each event to its detail page. Major events (isMajor) get a full-width red badge; everything else is a compact accent chip, optionally tagged with its EventTypeBadge. The Today / ‹ / › controls shift the month cursor (client state).

EventsCalendar events={DEMO_EVENTS} — 8 sample events on the current month
events: CalendarEvent[]
The only prop. CalendarEvent = { id, slug, title, startsAt (ISO string), isMajor, type (EventType | null) }. Here it's a static fixture; in the portal it's loaded server-side and passed in.
day bucketing
Each event lands on the LOCAL calendar day of its startsAt (viewer timezone). Multiple events per day stack within the cell.
isMajor
true → a full-width red Badge (a headline action like a rally/protest). false → a compact accent chip. Red is reserved for genuinely major actions, never decoration.
type → EventTypeBadge
A non-null EventType renders the matching EventTypeBadge (icon + tone) beneath the title, so the kind of event reads at a glance.
navigation
Each event links to /portal/events/{slug ?? id}. The header's ‹ / › step the month and Today snaps back — all local client state, no refetch.
static demo
EventsCalendar is a client component; this page mounts it through a colocated 'use client' island on a hardcoded CalendarEvent[]. Do not fetch in the styleguide.

Calendar engine — unified <Calendar>

The STD calendar engine (src/components/ds/calendar/). One <Calendar> composes the CalendarToolbar over four interchangeable views — Month, Week, Day, Agenda — all speaking the SAME CalendarEvent shape. Use the segmented control to switch views; ‹ / Today / › step the cursor by that view's natural period (month / week / day / agenda window) and the period label updates to match. Both view and cursor are controlled-OR-uncontrolled; this demo runs uncontrolled. The Week and Day views lay timed events over a 6am–11pm axis with side-by-side overlap packing; the Agenda view lists the next 30 days grouped by day. Every event here has no href, so activation flows through onSelectEvent (echoed below the calendar) rather than navigation.

Calendar events={ENGINE_EVENTS} — defaultView='month'; switch views in the toolbar
Calendar defaultView='week' — same data, time-grid week view (note the 12th's overlap)
Calendar defaultView='agenda' — same data, chronological list of the next 30 days
events: CalendarEvent[]
The shared engine shape: { id, title, startsAt (ISO|Date), endsAt? , href?, type? (free-form tag), isMajor?, tone? ('accent'|'good'|'warn'|'bad'|'neutral') }. Distinct from the portal EventsCalendar's CalendarEvent — the engine's is generic. ISO strings are parsed to viewer-LOCAL Dates via date-fns.
view / defaultView
Active layout ('month'|'week'|'day'|'agenda'). Controlled via view + onViewChange, or uncontrolled from defaultView (default 'month'). The toolbar's segmented control switches it.
cursor / onCursorChange
The focused date. Controlled, or uncontrolled (seeded at the start of today's month). prev/next step by the active view's period — ±month / ±week / ±day / ±agendaDays — and Today snaps to today.
onSelectEvent
Fires when an event is activated (click / Enter) in any view. Events with an href navigate; otherwise this is the only selection hook. The demo echoes the most-recent selection.
today / agendaDays
today injects what counts as 'now' (defaults to new Date()) for deterministic 'today' marking. agendaDays sets the agenda window length AND the agenda prev/next step (default 30).
accessibility
Month/Week/Day grids are role=grid with per-cell aria-labels, roving tabindex, and arrow-key navigation; today + selected are conveyed via aria-current / aria-selected, not color alone. Agenda is a linear role=list. All views are client components (render in viewer-local tz).

MiniMonth — compact date picker

The engine's compact month date-picker (src/components/ds/calendar/MiniMonth.tsx) — a 7-column month grid for pairing with date fields. Click a day or arrow-key to it and press Enter to pick; ‹ / › page the month. Days in markedDays get a dot ('has something' — here, every day with a sample event). It is a role=grid with per-cell aria-label, roving tabindex, and aria-selected / aria-current, so the picked + current day never rely on color alone. Controlled via selected + onSelect (and optionally month + onMonthChange); this demo owns the selection in state and echoes it.

MiniMonth selected/onSelect — pick a day; event days are dotted
selected / onSelect
selected (Date | null) is the picked day (highlighted + aria-selected); onSelect(d) fires on click or Enter/Space. Own the value in client state.
month / onMonthChange
Optional controlled visible month. Omit both and MiniMonth self-manages which month is shown (paging stays internal); supply them to drive it externally.
markedDays
Date[] to dot under the day number ('has events' / 'has something'). Here it's every start day in the engine fixture.
vs DatePicker
DatePicker is a native <input type='date'> (OS picker, no JS grid). MiniMonth is a custom JS grid — reach for it when you need an always-visible calendar surface, marked days, or full control over the look; reach for DatePicker for plain date entry in a form.