23 KiB
Legislative Dashboard — UX Design (Spec §5, UI Lane)
Lane: UX design (no implementation — this defines look, feel, flow; the build lane implements it).
Parent: t_21cd58a9 — "New project — California Legislature Scraper"
Design surface: Monitor (a dashboard showing upcoming legislative state) with an Operate secondary (watch / opt-in controls). Not a marketing surface — no hero, no "feature three cards."
Design date: 2026-09-05 · Designer: ui_ux_designer · Status: for orchestrator review + one round of decisions (see Open Decisions).
0. What this is, in one line
One dashboard, two personas: an anonymous visitor sees a chronological overview of upcoming California legislation; a logged-in resident sets a ZIP (→ districts → legislators), watches legislators and opts into subject-matter tags, and those matching bills float to the top of the same dashboard.
1. Frozen data contract this design consumes (do not change — implement against it)
Per-bill record shape = scraper/schema.py::BillAction → flat daily_file_records, plus the correlation lane's relational tables (bills, committees, members, bill↔committee joins) and the LLM lane's per-bill subject-matter tag cloud.
| Field the UI uses | Type | Notes / caveats |
|---|---|---|
bill_number |
str | Canonical display, e.g. "AB 28", "SB 795" |
bill_id |
str | leginfo id, e.g. "202520260AB28" — join to detail/committee history |
date |
str ISO | "2026-08-31" — the daily-file date (event day) |
hearing_time |
str | "9:30am", "Upon Call of the Chair" — the moment-of-action; nullable |
location |
str | "State Capitol, Room 444" |
chamber_origin |
ASSEMBLY/SENATE |
Bill's origin chamber |
author |
str | Lead author string, e.g. "Schiavo et al." |
short_title / description |
str | Subject / short title |
committee |
str | null | null for floor items (join to committee roster → members → legislators) |
tags |
str[] | From the LLM lane — subject-matter cloud, e.g. ["waste","education","health"]. Nullable / may be empty for a bill with no analysis yet. |
item_number, supplement, session, source, id |
— | Provenance; surfaced in detail view, not in list rows |
Key caveat that shapes the whole design: the record is a daily-file action/event, not a canonical bill object. The same bill_number appears on multiple dates (one row per action). The dashboard is a calendar/queue ("what happens, when, where"), not a catalog of statutes. This drives the grouping, the "upcoming" model, and the dedupe rules below.
Two join paths make a bill relevant to a logged-in user (either one floats it — see §4):
- By legislator:
bill.author == a watched legislatoror the bill is in a committee chaired/membership-included by a watched legislator (viacommittee → members). - By topic:
bill.tags ∩ opted_in_tags ≠ ∅.
2. Goals, non-goals, success
Goals
- Glanceability: a resident (and a curious anonymous visitor) answers "what happens next, and what's relevant to me?" in < 3 seconds.
- One coherent surface: anonymous and logged-in are the same layout; login only adds a personal layer on top (not a different app).
- Trustworthy, government-appropriate, accessible: plain language, high contrast, WCAG AA, no dark-patterns, no jargon without tooltips, clear provenance.
- Graceful degradation: works with a subset of data present (e.g., Assembly-only Senate-unavailable; tags not yet computed).
Non-goals (out of this UI lane; call them out so engineers don't build them)
- Auth system implementation — only the account model (see §7 + Open Decision D1). Auth method decision is the owner's.
- ZIP→district ingestion/ETL — owned by the correlation lane (
daily_file_sources.yaml::correlation_sources.member_rosters); this lane only consumes it. - Bill full-text / vote/analysis views beyond the link-out to
leginfo— the dashboard floats and links; it does not host full legislative text. - Notifications/push, email digests, mobile native apps.
- Multi-session/multi-jurisdiction beyond CA state legislature (Assembly + Senate).
Success metrics (for the build/QA lane to instrument)
- Time-to-first-relevant-bill on logged-in dashboard < 3 s (first viewport).
- "Floated to top" precision: ≥ 90% of the user's top-N matches a stated watch/tag (no unrelated bills in the floated band).
- No user stuck in a dead-end empty state without an actionable next step.
3. Information architecture (IA)
Routes / screens
| Screen | Who | Purpose |
|---|---|---|
/ → Dashboard |
anon + user | The one surface; personal layer conditional on login. |
/sign-in , /register |
user | Auth (behavior per Open Decision D1). Not designed in detail. |
/me → Profile / Preferences |
user | Set ZIP/address, manage watched legislators, manage opted-in tags, view resolved districts. |
/bill/:bill_id |
anyone | Detail: status, author, committee history, votes, analyses — links out to leginfo (leginfo_api.bill_nav). Not built here; a thin shell that deep-links. |
/legislator/:id |
anyone | Legislator activity: the bills they author / sit on committees for, upcoming hearings. Thin shell. |
/about, /help, /privacy |
anon + user | Provenance, data sources, "how 'upcoming' is computed," privacy. |
Global nav (persistent, top bar)
Logo/wordmark · Dashboard · [Legislators] · [Bills] · [Sign in] / [avatar ▸ menu: Preferences · Watched · Signed out]
Legislators/Billslinks are discoverability entry points (Explore surface), secondary to the dashboard.- On anon: right side shows
Sign in+ one contextual CTA (Save your interests → sign in). - The personal controls (ZIP, watch, opt-in) live in the logged-in dashboard header band + in
/me. They never appear to anon (anon sees an "unlock" affordance instead — see §5).
Content hierarchy on the Dashboard (top→bottom)
- Status / context band — data freshness ("Updated 2026-09-05 · Assembly daily file · Senate pending"), session badge (
2025-2026), user band (if logged in). - Controls — search, chamber filter, tag filter, date/"upcoming" filter, sort.
- Primary list — the upcoming-events queue, grouped by day, sorted (see §4). Personal band at top when logged in.
- Side / secondary — "My watchlist summary" (logged in), "Popular topics this week" (anon, opt-in teaser).
- Footer — provenance, sources, link to
/about.
4. The ranking model — "float to top" (the heart of §5)
Rule (design spec; the build lane implements the SQL/aggregate accordingly):
A bill is floated to the top of a logged-in user's dashboard if and only if
author ∩ watched_legislators≢ ∅ ORbill.tags ∩ opted_in_tags≢ ∅.(Equivalently: watched-legislator match OR opted-in-tag match. A single match suffices; both is stronger.)
Two bands, not two lists. The dashboard is one ordered list split visually into:
Personal / Relevant to you(logged-in only): floated bills, grouped by day, sorted within by §4.2. Bounded to top N = 10 by default; aShow all Ncontrol reveals the rest.All upcoming legislation: the full queue (same sort), with a subtleRelevant to youmarker on any floated bill that also sits in the general list (so nothing is hidden — floated bills are promoted, not removed).
Scoring (for ordering within the floated band, tie-breaks, and a future "relevance score" badge — keep it interpretable):
score = 3·(author is watched)
+ 2·(author sits-in a watched legislator's committee) // via committee→members join
+ 2·( |tags ∩ opted_in_tags| )
+ 1·(chamber == user's home chamber hint, optional)
- Band membership =
score > 0. Intra-band sort by descendingscore, then §4.2. - Weights are a design default, not frozen — flag as tunable (Open Decision D3).
Dedupe / grouping
- Group the queue by
date(the daily-file date). Heading:Mon, Sep 7, 2026 · 14 items. - Within a day: sort by
hearing_timeascending ("Upon Call of the Chair" / "Call of Chair" sorts last; missing time sinks to end of day with atime TBDnote). - Collapse duplicate
bill_idacross days only within the same day; across days they are distinct upcoming events (a bill moves Assembly→committee→floor over weeks).
Empty / degraded variants (§6) and ranking transparency: a toggle Why is this here? on each floated row reveals the matched rule ("Floated: Committee of your watched legisl. — Sen. Schiavo (SB 795)" / "Floated: Topic: Education (tag match)"). Interpretability is a trust requirement, not a nicety.
5. Two flows (anonymous vs logged-in)
5A. Anonymous overview
- Lands on
/. Default filter: upcoming only, all chambers, sort = date→time. - Sees the
All upcoming legislationqueue grouped by day. Each row: chip[ASSEMBLY|SENATE]·AB 28· short_title ·Committee of … · 9:30am · State Capitol Rm 444· tag chips ·View →(deep-link to leginfo). - No account, no personal band. Instead: a persistent, non-annoying "Unlock your view" affordance that names the value — "Set your ZIP to pin your legislators and topics to the top." No dark patterns, no paywall, no modal-on-arrival.
- Anon can still filter by tag / chamber / search — personalization-lite without login. This is the on-ramp: it demonstrates value, then offers to save.
- Opt-in teaser (secondary): "Top subject-matter this session" list (a few tags with counts) → clicking one filters; a small
Sign in to pin these topics to your dashboardlink.
5B. Logged-in personalized
- Lands on
/. Personal band renders at top (if a ZIP + ≥1 watch or tag exists; else the setup empty-state in §6.3). - Setup-on-first-run (if no ZIP): a single step — "Where do you live? ZIP: [____] [Find my legislators]" — or the richer address variant (see §8, D2). On submit: resolve ZIP→district(s)→legislator(s); show them with Watch toggles already exposed and suggested topics (derived from the legislators' committee domains / the session tag cloud) as opt-in chips pre-selected-but-editable.
- User watches legislators (toggle, max sensible N, say 6) and opts into tags. Each action re-runs the ranking on the server and re-renders the personal band instantly (or on next load — no animation required, see motion posture §9).
- Floated bills appear in the personal band with the
Why is this here?transparency toggle. - Preference management always reachable from the top band and
/me.
State model (frontend, see §10 + API_CONTRACT.md §3):
- Anonymous session =
{ filters: {chamber, tags[], search, upcoming_only} }. - User session = anon filters +
{ zip, districts[], home_chamber?, watched_legislators[], opted_in_tags[] }.
6. Empty states (a dashboard lives and dies by these)
6.1 No upcoming items (e.g., recess / non-legislative day) — not a blank screen:
- Message: "No hearings scheduled. Next legislative day: Oct 1." + a calm link to "See recent actions instead" (toggle to past) + a "Set a reminder" CTA.
6.2 Filter returns nothing (user filtered to a tag/chamber with no upcoming hits):
- "No upcoming items match
Education+Senate." →Clear filters+Loosen to all tags+Widen the date window. Always an escape hatch.
6.3 Logged-in, no personalization yet (the most important empty state — it's the setup funnel):
- Reuse the 5B.2 single-step ZIP entry in place of a dead personal band. Headline: "Pin your view. Enter your ZIP and we'll surface your legislators and the bills that matter to you." Suggest: pre-fill nothing; no fake data. If the user signs in but sets nothing, the personal band shows this setup card, and the
All upcominglist stays fully functional below it (degradation, not a wall).
6.4 ZIP resolves to 0 / ambiguous / out-of-state districts (see §8):
0: "We couldn't match that ZIP to a California district. Try a full address, or pick a district manually." → manual district picker +/help.ambiguous (1–3 districts): list the candidate districts, each with a Watch toggle; user confirms. Never auto-pick.out-of-state: "That ZIP isn't in California. This tool covers the CA state legislature." → offer the manual-pick fallback or end.
6.5 Loading / stale data: skeleton rows for the list (3–5), a Last updated <date> + Refresh control; if Senate is pending/antiblocked (see §11 known risk), a small inline note "Senate unavailable today," never a hard error.
6.6 No tagged bills yet (LLM lane not warmed / a bill has no analysis): tag chips render only when present; the "opt into topics" step shows a hint "Topic analysis is filling in — try again soon" with the session tag cloud as the pick list. Ranked personal band degrades to author/committee watch only when a bill has no tags.
7. Account & personalization model (UI-facing; auth method = Open Decision D1)
- Account = one ZIP/address + a preference blob (
watched_legislators[],opted_in_tags[],districts[], optionalhome_chamber). - Login is optional. The anonymous dashboard is fully usable. Login unlocks (a) personal ranking, (b) persistence of watches/tags across devices, (c) a shareable personal view.
- No required PII beyond what the user enters. ZIP is the minimum; address only if the user chooses the address-based lookup (D2). Privacy note visible on
/meand/privacy: "We store your ZIP and your watch/topic choices; we don't sell them; we don't tie them to your identity beyond your account." - Auth decision: see Open Decision D1. The UI contract is auth-agnostic — the frontend holds a bearer token; the backend owns the method. Two shapes are sketched so the build lane can pick either without redesign:
- D1a — minimal: email + local account (no external IdP).
- D1b — federated: OAuth (Google/Apple) preferred for residents who don't want a new password. The preferences shape (§10 / API_CONTRACT §3) is identical under either — that's the point of keeping auth out of the data contract.
- Roles: single role, Resident. No admin UI in scope. (Future:
curatorfor tag taxonomy ownership — out of scope, note it.)
8. ZIP → legislator resolution model
Consumes daily_file_sources.yaml::correlation_sources.member_rosters (assembly.ca.gov/.../find-my-rep, findyourrep.legislature.ca.gov, committee + member rosters). Design does not implement the ETL — it consumes a /districts/resolve endpoint (API_CONTRACT §3).
Critical real-world fact (grounded, not assumed): A ZIP code alone does not reliably resolve a California district — zips straddle Assembly/Senate boundaries (e.g., a single ZIP can be ~31% one district / ~69% another). The authoritative resolution is by full address via the Legislature's own tool / county voter files; ZIP is a fast path that can be ambiguous.
Design decision (proposed; flagged D2): two-tier lookup —
- ZIP fast-path:
POST /districts/resolve {zip}→ returns[district...](0, 1, or several) with a confidence hint. On a single clean district, auto-advance to legislator list. On ambiguous/0, drop to step 2. - Address path (authoritative):
POST /districts/resolve {address}→ single district(s). Offered as a link on the ambiguous/zero state, and optionally as the primary input if D2 decides so. - Manual fallback:
/meexposes "pick a district by hand" for all edge cases (P.O. boxes, cross-border zips, recent redistricting).
Resolved district → legislators: each district → {assembly_member, senate_member} (from member rosters). The two become candidate legislators presented with Watch toggles and pre-derived suggested topics from the committees they sit on (committee → tags of that committee's bills, or committee-domain → tag mapping). The user confirms which to watch and which topics to opt into. Nothing is auto-watched on resolve — resolve suggests, the user commits (prevents surprise personalization; a trust requirement).
Data-source authority: findyourrep.legislature.ca.gov (official, by full address), with zip→district boundary data from the state's published datasets (data.ca.gov "CA State Senate/Assembly Districts and Membership," Esri US-zip-derived; refreshed on each redistricting — re-verify provenance/freshness at build time — flagged D2).
9. Layout, visual system, motion posture
Layout (desktop-first, responsive down to 360px):
- Header band (sticky): wordmark; nav; (logged-in) user band =
ZIP 90210 · 2 legislators · 3 topics ▾;Refresh+Last updated. - Status line: freshness + session + chamber-availability note.
- Controls bar: search (⌘K-able),
Chamber [All▾],Topics [chips],Date [Upcoming ▾ | This week ▾ | All],Sort [Earliest ▾],Why is this here? [▸]. - Main column (list) + aside (360–420px, collapses under main on mobile): "Your watchlist" (logged in:
AB 28 · Sen. Schiavo · Sep 7 · Watch ✓) and "Top topics this session" (anon opt-in teaser; logged-in = quick opt-in chips). - Footer: provenance, data sources w/ last-scraper date,
About/Help/Privacy/Accessibility.
Visual system — government-appropriate, accessible, plain:
- Palette (neutral, high-contrast; one accent only):
--ink #1a1a1a,--muted #5b5b5b,--paper #ffffff,--surface #f4f5f7,--border #d5d9df--accent #0b5394(a sober blue, AA on paper) — used for links + primary buttons only.- State:
--success #1e7e34(watched/on) ·--warn #8a5a00·--danger #b3261e. - Chamber tints:
--asm #2f5aa7,--sen #7a3b8fused only as small chip tints, never as a page fill.
- Type: humanist sans for UI, serif headline optional but discouraged (government tone, not editorial) — body
system-ui / -apple-system, "Segoe UI", Robotowith a real fallback, not raw Inter-by-default (a flagged slop tell). Numeric/mono for bill numbers + times (ui-monospace). - No gradient fills, no glassmorphism, no emoji, no stock-photo hero, no "3 equal feature cards," no accent-rail cards, no monument-stats (see
claude-designslop audit — a Monitor surface). Rhythm via scale + spacing + rules, not color noise. - Row treatment: dense but scannable. A
--relevantsubtle left indicator on floated rows is allowed because it carries meaning (it's the "why is this here?" affordance), but it is a 3px meaning-coded bar (accentfor tag-match, a second weight for legislator-match), not decoration. - Controls (watch / opt-in): 44px-min hit targets;
Watch= a labeled toggle ([ ] Sen. Schiavo↔[✓] Sen. Schiavo · watching);Topics= pressable chips that show a✓when opted in. Clear pressed/hover/focus rings (2px,--accent).prefers-reduced-motionhonored.
Motion posture: minimal. No load animation beyond a short skeleton. A floated row may fade-in once when ranking updates (≤150ms). No micro-bounce, no scroll-linked effect. Rationale: government tool = predictability over delight.
10. Component / state catalog (for the engineer to build)
Each has states the mockups show: default / hover / focus / pressed / disabled / empty / loading / error.
BillRow— chip(chamber) +bill_number(mono) +short_title+author+committee+hearing_time+location+tag chips+Why▸toggle +View →link. Variants:.relevant(floated),.past(faded, struck date when toggled to history, no watch affordance).DayGroup— adateheading (Mon, Sep 7, 2026 · N items) + itsBillRow[] sorted §4.WatchToggle— a legislator row: monogram/name + district + chamber +[ ]/[✓] Watch+ "remove watchlist item" affordance. States: unwatched / watching / off (past legislator, disabled).TopicOptChip— a tag in the opt-in set:[ ] Waste↔[✓] Waste+ usage count· 42 bills. Pressed = opted in.ZipLookup—{ZIP input | Address input} + [Find my legislators], with the §6.4 result states (single/ambiguous/zero/out-of-state) and the legislator candidate list it feeds.Controls bar— search, chamber, topics, date, sort, reveal-why; all keyboard-operable, each has an empty-state escape.EmptyState— the six variants in §6, all with an actionable next step.FreshnessBar—Last updated <date>+ chamber-availability notes +Refresh.PersonalBand— the floated list container +Show all N+ the setup card when unconfigured (§6.3).RevealWhy— the transparency popover on aBillRow: shows the matched rule + the specific watch/tag that triggered it.
Accessibility targets: WCAG AA. Keyboard: full tab order on controls + rows (Enter opens RevealWhy/View); visible :focus-visible rings; aria-live on the "ranking updated" message; color never the only signal (chip tint + text label + the meaning-cued bar all coexist). Screen-reader: rows get aria-labels ("AB 28, Assembly, hearing Sep 7, 9:30am, relevant to you"). Contrast verified for all text/borders against paper.
11. Degradation & known risks carried from upstream
- Senate unavailable (antibot 403 on senate.ca.gov): design the dashboard to run on Assembly-only cleanly; a small inline "Senate unavailable today" note on
FreshnessBar, Senate chips/rows simply absent. Never a hard error. - Tags not yet computed (LLM lane lagging): tag chips render only when present; ranking degrades to author/committee-watch only; "topics" step shows the "filling in" hint. Never show an empty tag row where
tagsis null. - Data freshness / "watch" anomaly: the QA lane already pairs a count-drop watch with an alert — the UI shows
Last updatedso a stale personal band is legible, not silent-wrong. - Cross-chamber + session boundary: a bill may appear under
chamber_originSenate while on the Assembly file; label origin explicitly; when the session rolls (Jan, renumber),sessionis surfaced so a Jan-1 jump doesn't look like data loss. - Provenance: every bill links to its
leginfosource;About/Helpcite the scraper + sources + last-scrape date. No data without a provenance link.
12. Interaction flow (happy path, condensed)
Anonymous: open / → glance All upcoming grouped by day → optionally filter by tag/chamber/search (no login) → (optional) "Unlock your view" → sign in.
Logged-in: open / → personal band auto-renders (watches + topics) → Show all N / RevealWhy / add more in aside → Watch a new legislator or opt in a topic → ranking re-runs, band updates → manage in /me any time.
13. Open decisions (surfaced for the orchestrator/owner — not silently assumed)
See Open Decisions table in §14; the authoritative list is also emitted in the task handoff. Nothing here is resolved by the designer; each needs a decision before the build lane implements it (or the UI ships with the documented default and the decision is ratified later).
Companion files: design/API_CONTRACT.md (JSON shapes the build + correlation + LLM lanes share), design/wireframes/anon-dashboard.html, design/wireframes/logged-in-dashboard.html, design/wireframes/watch-and-opt-in-controls.html.