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

254 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-label`s ("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`.