Files
cali_scraper/design/DESIGN_DASHBOARD.md
2026-09-06 10:18:26 -07:00

23 KiB
Raw Permalink Blame History

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):

  1. By legislator: bill.author == a watched legislator or the bill is in a committee chaired/membership-included by a watched legislator (via committee → members).
  2. 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 / Bills links 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)

  1. Status / context band — data freshness ("Updated 2026-09-05 · Assembly daily file · Senate pending"), session badge (2025-2026), user band (if logged in).
  2. Controls — search, chamber filter, tag filter, date/"upcoming" filter, sort.
  3. Primary list — the upcoming-events queue, grouped by day, sorted (see §4). Personal band at top when logged in.
  4. Side / secondary — "My watchlist summary" (logged in), "Popular topics this week" (anon, opt-in teaser).
  5. 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 ≢ ∅ OR bill.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; a Show all N control reveals the rest.
  • All upcoming legislation: the full queue (same sort), with a subtle Relevant to you marker 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 descending score, 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_time ascending ("Upon Call of the Chair" / "Call of Chair" sorts last; missing time sinks to end of day with a time TBD note).
  • Collapse duplicate bill_id across 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

  1. Lands on /. Default filter: upcoming only, all chambers, sort = date→time.
  2. Sees the All upcoming legislation queue 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).
  3. 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.
  4. Anon can still filter by tag / chamber / search — personalization-lite without login. This is the on-ramp: it demonstrates value, then offers to save.
  5. 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 dashboard link.

5B. Logged-in personalized

  1. Lands on /. Personal band renders at top (if a ZIP + ≥1 watch or tag exists; else the setup empty-state in §6.3).
  2. 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.
  3. 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).
  4. Floated bills appear in the personal band with the Why is this here? transparency toggle.
  5. 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 upcoming list 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[], optional home_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 /me and /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: curator for 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 —

  1. 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.
  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.
  3. Manual fallback: /me exposes "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 #7a3b8f used 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", Roboto with 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-design slop audit — a Monitor surface). Rhythm via scale + spacing + rules, not color noise.
  • Row treatment: dense but scannable. A --relevant subtle 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 (accent for 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-motion honored.

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 — a date heading (Mon, Sep 7, 2026 · N items) + its BillRow[] 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 a BillRow: 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 tags is null.
  • Data freshness / "watch" anomaly: the QA lane already pairs a count-drop watch with an alert — the UI shows Last updated so a stale personal band is legible, not silent-wrong.
  • Cross-chamber + session boundary: a bill may appear under chamber_origin Senate while on the Assembly file; label origin explicitly; when the session rolls (Jan, renumber), session is surfaced so a Jan-1 jump doesn't look like data loss.
  • Provenance: every bill links to its leginfo source; About/Help cite 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.