Initial commit

This commit is contained in:
kfj001 committed 2026-09-06 10:18:26 -07:00
commit 5f2db08b43
100 files changed
+16985

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
# 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`.