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

+190
View File
@@ -0,0 +1,190 @@
# API Contract — Legislative Dashboard UI (Spec §5)
**Audience:** dashboard build lane (`t_b38e9af2`), correlation lane (`t_2736f40d`), LLM lane (`t_950780e0`). One shared contract so no lane diverges.
**Principle:** *auth-agnostic, field-name-stable, degradeable.* The preferences/personalization shape does not depend on which auth method is chosen.
**Base record = `scraper/schema.py::BillAction`** (flat `daily_file_records`) **+** the correlation lane's relational tables (bills, committees, members, bill↔committee joins) **+** the LLM lane's per-bill `tags`.
**Status:** `draft v0.1` — field names are the frozen part; *values* and *endpoints* are ratifiable. Auth method = Open Decision D1; ZIP model = Open Decision D2; scoring weights = D3.
---
## 0. Conventions
- **Dates:** ISO `YYYY-MM-DD` (`date`). Times are strings (`"9:30am"`, `"Upon Call of the Chair"`) — do NOT canonicalize to RFC3339 in the list layer; keep source form, add a derived `hearing_sort` field (see §2) for stable ordering.
- **Enums:** `chamber_origin` ∈ `{ASSEMBLY, SENATE}`. `source` ∈ `{ASSEMBLY_DAILY_FILE, SENATE_DAILY_FILE_PDF, LEGINFO_BILL_RESULTS}`.
- **Nullability:** fields a daily-file event may legitimately lack (`hearing_time`, `location`, `committee` for floor items, `tags` pre-LLM) are nullable. Consumers must render a `TBD`/omitted state, never crash on null.
- **IDs:** `bill_id` = leginfo canonical (`"202520260AB28"`). `legislator_id`, `district_id`, `tag_id`, `user_id` = system-owned opaque IDs (snake_case, stable).
- **Auth:** every `/person`/`/prefs`/`/watch`/`/topics` and the `/bills` personal view requires a bearer token (method per D1). `/bills`, `/tags`, `/legislators`, `/districts/resolve` are public.
- **Errors** (§6): HTTP status + `{ "error": { "code", "message", "field?" } }`. `field` on 400.
- **Versioning:** contract at `/api/v1/*`. Breaking changes → new major; add fields, never rename/delete (the frozen field names).
---
## 1. Shared record: `Bill` (what the UI consumes)
```json
{
"bill_id": "202520260AB28",
"bill_number": "AB 28",
"chamber_origin": "ASSEMBLY",
"author": "Schiavo et al.",
"short_title": "Waste: requires recycling standards…",
"description": "Same as short_title for daily-file; enrichment may expand.",
"committee": "Resources and Water",
"hearing_time": "9:30am",
"hearing_sort": 55800,
"location": "State Capitol, Room 444",
"date": "2026-09-07",
"session": "2025-2026",
"item_number": 14,
"supplement": 2,
"source": "ASSEMBLY_DAILY_FILE",
"tags": ["waste", "environment"],
"relevance": {
"score": 5,
"matched": [
{ "type": "watched-legislator", "value": "Leg. Schiavo (A-14, chair)" },
{ "type": "tag", "value": "waste" }
]
}
}
```
- **`hearing_sort`** (derived, backend-owned): seconds-from-midnight for HH:MMam/pm; `"Upon Call of the Chair"` / `"Call of the Chair"` → **max** (sorts last); null → **max** (with `hearing_time: null`). Enables the within-day sort in DESIGN_DASHBOARD §4.
- **`relevance`** present for **logged-in** requests only; omitted/`score:0` for anon. `matched[]` powers the `Why is this here?` transparency UI. Each entry: `type` ∈ `{watched-legislator, committee-of-watched-legislator, tag}`, `value` = human-readable reason.
- **`tags`** may be `null` (no LLM analysis yet) or `[]` → UI renders no chips, ranking degrades to legislator-match only.
### Bill list envelope
```json
{
"session": "2025-2026",
"as_of": "2026-09-05T18:00:00Z",
"chamber_availability": { "ASSEMBLY": "available", "SENATE": "pending" },
"items": [ /* Bill[] */ ],
"groups": {
"2026-09-07": 14,
"2026-09-08": 31
}
}
```
- `chamber_availability` drives the "Senate unavailable" note — never surface Senate rows when `"pending"`.
- Personal view returns `items` in **floated-first order** with the `PersonalBand` slice (top N) derivable by `relevance.score > 0` then `hearing_sort`.
---
## 2. Endpoint `GET /api/v1/bills`
**Query:** `?upcoming=1&from=2026-09-01&to=2026-09-30&chamber=ASSEMBLY&tags=waste,health&author=…&q=recycling&sort=hearing_limit&personal=1`
- `upcoming=1` → only future-dated events (`date >= as_of` and/or not yet acted on) — see §5 "upcoming" definition.
- `personal=1` (logged in) → apply the float-to-top ranking (§4 of DESIGN_DASHBOARD); response carries `relevance` on items.
- Public without token; token *upgrades* the view (adds `relevance`, enables `personal=1`).
**Response:** the Bill-list envelope (§1). HTTP 200. Empty → `items: []` + `groups: {}` (UI renders `/bills` empty-state, not an error).
---
## 3. Personalization endpoints (logged-in)
### 3.1 `POST /api/v1/districts/resolve` — ZIP/address → districts → legislators
```json
// request (one of the input modes; Open Decision D2 sets which is primary)
{ "zip": "90210" } // fast path (may be ambiguous)
{ "address": "…" } // authoritative (Legislature full-address tool)
{ "mode": "manual", "district": "A-14" } // fallback
// response
{
"resolved_from": "zip", // | "address" | "manual"
"confidence": "single" | "ambiguous" | "zero" | "out-of-state",
"districts": [
{
"district_id": "A-14",
"type": "ASSEMBLY", // | "SENATE"
"number": 14,
"assembly_member": { "legislator_id": "L-asm-14", "name": "M. Schiavo", "party": "D" },
"senate_member": { "legislator_id": "L-sen-08", "name": "R. Riu", "party": "R" }
}
],
"suggested_topics": ["waste", "housing"], // derived from member committees ∩ session tag cloud
"suggested_watch": ["L-asm-14", "L-sen-08"]
}
```
- **Confidence semantics (must match §8 of DESIGN_DASHBOARD):** `single` → UI auto-advances to the candidate/legislator list. `ambiguous`/`zero`/`out-of-state` → UI shows the §6.4 state (candidates to confirm / manual picker / "not in CA"), **never auto-watches.**
- **Source:** `findyourrep.legislature.ca.gov` (by address) + `data.ca.gov` boundary datasets (by zip). Re-verify freshness at redistricting.
- **Resolve does not persist** — it *proposes*. Persisting watches happens in §3.3. (Trusting-resolve = trust requirement; see DESIGN §8.)
### 3.2 `GET /api/v1/legislators?q=…` — browse / find legislators
```json
{ "items": [
{ "legislator_id": "L-asm-14", "name": "M. Schiavo", "party": "D",
"chamber": "ASSEMBLY", "district": 14,
"committees": ["Resources and Water"],
"domains": ["waste", "water"] } ] }
```
### 3.3 `GET / PUT /api/v1/prefs` — the personalization store
```json
{
"user_id": "u_001",
"zip": "90210", // optional (address lookup may leave null)
"address": null, // optional, only if D2 makes address the input
"home_chamber": "ASSEMBLY", // optional hint
"districts": [ { "district_id": "A-14", "type": "ASSEMBLY", "number": 14 } ],
"watched_legislators": [ "L-asm-14", "L-sen-08" ],
"opted_in_tags": [ "waste", "health" ],
"updated_at": "2026-09-05T18:00:00Z"
}
```
- `GET` returns current prefs (or `404` if none → UI shows setup card). `PUT` replaces the whole blob. Granular helpers `POST /prefs/districts/confirm` etc. allowed but not required.
- **This shape is auth-method-independent** — D1 doesn't change it.
### 3.4 `POST /api/v1/prefs/watch` & `DELETE /api/v1/prefs/watch/:legislator_id`
Toggle a single watch (optimistic-update friendly; returns the updated `watched_legislators`). Same for topics:
### 3.5 `POST /api/v1/prefs/topics` & `DELETE /api/v1/prefs/topics/:tag_id`
- 201 on add / 200 on remove; 400 with `field` on unknown id. Idempotent.
---
## 4. Reference data endpoints (public)
### 4.1 `GET /api/v1/tags` — the tag cloud (from the LLM lane)
```json
{
"as_of": "2026-09-05T18:00:00Z",
"tags": [
{ "tag_id": "tag:health", "label": "Health", "count": 214 },
{ "tag_id": "tag:education","label": "Education", "count": 188 },
{ "tag_id": "tag:waste", "label": "Waste", "count": 42 }
]
}
```
- `count` = number of bills carrying the tag (drives the `· 42 bills` UI affordance and the "top topics" teaser). May be sparse early (LLM lane warming) — UI shows the "filling in" hint, never a 0-count dead chip.
- **Ownership note:** the *taxonomy/labels* are produced by the LLM lane (`t_950780e0`); this contract fixes their *shape*, not their values. A future `curator` role (out of scope) would own label normalization.
### 4.2 `GET /api/v1/legislators` & `GET /api/v1/districts` — public browse (from correlation lane)
Same shapes as §3.2 / the `districts[]` items; no token needed.
---
## 5. "Upcoming" — the exact, testable definition
`upcoming` = a bill event satisfying **all**:
1. `event_date = COALESCE(date, hearing date)` and `event_date >= as_of` (future-dated), **and**
2. not in a terminal "acted/closed" state the source marks (a committee that already passed it out of committee, or floor passage) — i.e., the event is an *open* future moment, not a historical action.
3. For a bill with **multiple future events** (committee hearing then floor on different days), each event is its own upcoming row; dedupe only *within* a `date`.
- If the source can't confirm state #2, **default to including** (false-negative risk: dropping a real hearing is worse than showing a closed one) and mark `possibly_closed: true` (future field; not required for v0.1).
- **"This week" / "This month"** filters are just `from`/`to` windows over `event_date`.
---
## 6. Errors (all endpoints)
```json
{ "error": { "code": "invalid_zip" | "ambiguous_district" | "district_not_found"
| "out_of_state" | "tag_unknown" | "legislator_unknown"
| "not_authenticated" | "prefs_not_set" | "chamber_unavailable",
"message": "human-readable", "field": "zip" } }
```
- `400` invalid/ambiguous/out-of-state (with `field`). `401` not authenticated. `404` unknown id / no prefs. `409` race on concurrent watch add (client retries idempotently). `503` chamber unavailable (e.g. Senate antibot) — **not a hard client error**; UI renders "Senate unavailable" and continues on Assembly. `503` also when the LLM tag service is down → UI degrades, no tag chips, ranking by legislator only.
---
## 7. Contract notes for each lane
- **Correlation lane (`t_2736f40d`):** owns §3.1 resolve + §4.2 legislators/districts from `correlation_sources.member_rosters` (findyourrep + data.ca.gov). Must produce the `districts[]` + `assembly_member`/`senate_member` join to members.
- **LLM lane (`t_950780e0`):** owns `tags[]` on `Bill` (§1) and §4.1 `GET /tags`. Must tolerate the **deterministic fallback tagger** (configurable ollama) so `tags` degrades to a known small set when ollama is down — v0.1 `tags` may be `[]`/null until warmed.
- **Dashboard build (`t_b38e9af2`):** implements the float-to-top ranking from `relevance.score` (§1) per DESIGN_DASHBOARD §4; consumes this contract verbatim. Auth method per D1 is chosen *here* but the shapes above are frozen regardless.
- **QA:** assert `hearing_sort` ordering, `upcoming` boundary (a T-1 day is excluded), `chamber_availability` gates Senate rows, and the 6 empty states each return `200 items:[]` (not 4xx/5xx).
---
*Companion docs:* `design/DESIGN_DASHBOARD.md` (IA + flows + visual system), `design/wireframes/*.html` (3 mockups). Open Decisions: **D1 auth method, D2 ZIP vs address primary input + data-source provenance, D3 scoring weights.**
+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`.
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Legislative Dashboard — Design deliverables (index)</title>
<style>
:root{--ink:#1a1a1a;--muted:#5b5b5b;--paper:#fff;--surface:#f4f5f7;--border:#d5d9df;
--accent:#0b5394;--accent-ink:#08345f;
--ring:0 0 0 2px #fff,0 0 0 4px var(--accent);
--font-ui:-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--radius:4px;--shadow:0 1px 2px rgba(0,0,0,.06),0 2px 8px rgba(0,0,0,.05);--maxw:900px}
*{box-sizing:border-box}html,body{margin:0}
body{font-family:var(--font-ui);color:var(--ink);background:var(--surface);font-size:15px;line-height:1.55;text-wrap:pretty}
a{color:var(--accent);text-underline-offset:2px}h1,h2{line-height:1.2;margin:0}
:where(a,button):focus-visible{outline:none;box-shadow:var(--ring);border-radius:var(--radius)}
.wrap{max-width:var(--maxw);margin:0 auto;padding:32px 20px 64px}
.eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);font-weight:600}
h1{font-size:26px;margin:6px 0 4px}
.lede{color:var(--muted);max-width:64ch;margin:0 0 22px}
.card{display:block;background:var(--paper);border:1px solid var(--border);border-radius:var(--radius);
padding:16px 18px;margin-bottom:12px;box-shadow:var(--shadow);text-decoration:none;color:inherit}
.card:hover{border-color:var(--accent)}
.card .t{font-weight:600;font-size:16px}
.card .d{color:var(--muted);font-size:14px;margin-top:3px}
.card .k{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--accent);margin-top:8px;display:block}
h2{font-size:14px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:26px 0 10px}
.open{background:var(--paper);border:1px solid var(--warn,#8a5a00);border-radius:var(--radius);padding:14px 16px}
.open b{color:#8a5a00}
.open ul{margin:8px 0 0;padding-left:18px}.open li{margin:4px 0}
footer{color:var(--muted);font-size:12px;border-top:1px solid var(--border);padding-top:14px;margin-top:24px}
</style>
</head>
<body>
<div class="wrap">
<div class="eyebrow">Spec §5 · UI lane · wireframes + contract</div>
<h1>Legislative Dashboard — Design deliverables</h1>
<p class="lede">The UX design for the anonymous + personalized California-legislature dashboard. One surface, two personas: anonymous sees a chronological overview of <em>upcoming</em> legislation; logged-in users set a ZIP → find legislators → watch them + opt into topics, and matching bills float to the top. This is the design a backend/frontend engineer implements — not the build itself.</p>
<h2>Read first</h2>
<a class="card" href="DESIGN_DASHBOARD.md">
<span class="t">DESIGN_DASHBOARD.md — the design</span>
<span class="d">Information architecture, the two flows (anonymous vs. logged-in), the "float to top" ranking model, the "upcoming" definition, layouts, all six empty states, the ZIP→legislator resolution model, and the visual/interaction system.</span>
<span class="k">design/DESIGN_DASHBOARD.md</span>
</a>
<a class="card" href="API_CONTRACT.md">
<span class="t">API_CONTRACT.md — what the UI consumes</span>
<span class="d">Shared JSON shapes (bill list, watched-legislator / preference blob, tag cloud, ZIP-district resolve, error envelope) so the build, correlation, and LLM lanes share one contract. Auth-agnostic, field-name-stable, degradeable.</span>
<span class="k">design/API_CONTRACT.md</span>
</a>
<h2>Wireframes (open in a browser)</h2>
<a class="card" href="wireframes/anon-dashboard.html">
<span class="t">01 · Anonymous dashboard</span>
<span class="d">Chronological "upcoming legislation" queue grouped by day, personalization-lite filters (no login), a non-annoying "unlock" affordance that names the value, top-topics teaser.</span>
<span class="k">design/wireframes/anon-dashboard.html</span>
</a>
<a class="card" href="wireframes/logged-in-dashboard.html">
<span class="t">02 · Logged-in dashboard</span>
<span class="d">The floated "Relevant to you" band with meaning-cued indicators + "why is this here?" transparency, the ZIP-setup empty state (with all four resolve outcomes), and live watch/opt-in toggles that re-rank. Top toggle switches between personalized / setup / anon states.</span>
<span class="k">design/wireframes/logged-in-dashboard.html</span>
</a>
<a class="card" href="wireframes/watch-and-opt-in-controls.html">
<span class="t">03 · Watch / opt-in controls</span>
<span class="d">Every control and every state (default, checked, focus, disabled, pending) for legislator-watch toggles and topic opt-in chips, plus the zoomed-up legend for the float-reason indicators.</span>
<span class="k">design/wireframes/watch-and-opt-in-controls.html</span>
</a>
<h2>Open decisions (for the orchestrator / owner — not silently assumed)</h2>
<div class="open">
<b>D1 · Auth method.</b>
<ul>
<li><b>Recommended:</b> minimal email + local account (D1a), with OAuth (Google/Apple) offered as an alternative for residents who don't want a new password (D1b).</li>
<li>The <em>preferences/personalization shape is identical under either</em> — that's the point of keeping auth out of the data contract. <code>/person</code>, <code>/prefs</code>, <code>/watch</code>, <code>/topics</code> just need a bearer token.</li>
</ul>
<b>D2 · ZIP vs. full address as the primary input + data-source provenance/freshness.</b>
<ul>
<li><b>Design position (flagged, not decided):</b> a ZIP <em>alone cannot reliably resolve a California district</em> — zips straddle Assembly/Senate boundaries (e.g., 95602 Auburn ≈ 31% A-1 / 69% A-5). The authoritative path is <code>findyourrep.legislature.ca.gov</code> by full address; ZIP-district boundaries come from published datasets (Legislature / <code>data.ca.gov</code>, Esri zip-derived).</li>
<li><b>Recommended:</b> ZIP as the fast path with an automatic "verify by address / pick a district manually" fallback on ambiguous/zero/out-of-state; consider making address the <em>primary</em> input if we don't want to ship "possibly wrong district" personalization. Owner must decide which is primary and <em>ratify the data source + its redistricting refresh cadence</em> re: the correlation lane.</li>
</ul>
<b>D3 · Ranking weights.</b>
<ul>
<li>Design default: <code>score = 3·(author watched) + 2·(sits-in watched legislator's committee) + 2·(opted-in tag matches) + 1·(home-chamber)</code>. Weights are tunable; owner may adjust, but the band-membership rule (<code>score &gt; 0</code> = relevant) is the contract the build must keep stable.</li>
</ul>
</div>
<footer>Design by the UI/UX lane · 2026-09-05 · Companion to scraper/schema.py (BillAction) + LLM tag lane (per-bill tags) + correlation lane (districts/members/rosters). Wireframes are HTML mockups, not the implementation.</footer>
</div>
</body>
</html>
+286
View File
@@ -0,0 +1,286 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Legislative Dashboard — Anonymous overview (wireframe)</title>
<style>
/* --- Design tokens: government-appropriate, calm, high contrast (WCAG AA) --- */
:root{
--ink:#1a1a1a; --muted:#5b5b5b; --paper:#ffffff; --surface:#f4f5f7;
--border:#d5d9df; --border-strong:#b8bfc9;
--accent:#0b5394; --accent-ink:#08345f;
--success:#1e7e34; --warn:#8a5a00; --danger:#b3261e;
--asm:#2f5aa7; --sen:#7a3b8f; /* chip tints only, never page fill */
--ring:0 0 0 2px #fff, 0 0 0 4px var(--accent);
--font-ui: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
--radius:4px; /* restrained, not rounded-everywhere */
--shadow:0 1px 2px rgba(0,0,0,.06), 0 2px 8px rgba(0,0,0,.05);
--maxw:1160px;
}
*{box-sizing:border-box}
html,body{margin:0}
body{
font-family:var(--font-ui); color:var(--ink); background:var(--surface);
font-size:15px; line-height:1.5; text-wrap:pretty;
}
a{color:var(--accent); text-decoration:underline; text-underline-offset:2px}
a:hover{color:var(--accent-ink)}
h1,h2,h3{line-height:1.2; margin:0}
:where(a,button,[tabindex]):focus-visible{outline:none; box-shadow:var(--ring); border-radius:var(--radius)}
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
.skip{position:absolute;left:-999px;top:0;background:var(--accent);color:#fff;padding:.6em 1em;z-index:10}
.skip:focus{left:8px;top:8px}
/* --- Layout scaffold --- */
.top{position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:16px;
background:var(--paper);border-bottom:1px solid var(--border);padding:10px 20px}
.wordmark{font-weight:700;font-size:17px;letter-spacing:.2px;white-space:nowrap}
.wordmark small{display:block;font-weight:500;font-size:11px;color:var(--muted);letter-spacing:.4px;text-transform:uppercase}
nav.main{display:flex;gap:18px;margin-left:4px}
nav.main a{color:var(--ink);text-decoration:none;font-weight:500}
nav.main a:hover{text-decoration:underline}
.spacer{flex:1}
.signin{background:var(--accent);color:#fff;font-weight:600;border:none;border-radius:var(--radius);
padding:8px 14px;font-size:14px;cursor:pointer;min-height:36px}
.signin:hover{background:var(--accent-ink)}
.shell{max-width:var(--maxw);margin:0 auto;padding:18px 20px 64px}
/* --- Status / freshness band --- */
.fresh{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;background:var(--paper);
border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px;box-shadow:var(--shadow)}
.fresh .badge{font-size:12px;font-weight:600;background:var(--surface);border:1px solid var(--border);
border-radius:999px;padding:2px 10px}
.fresh .ok{color:var(--success)} .fresh .pend{color:var(--warn)}
.fresh .dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px;vertical-align:middle}
.dot.g{background:var(--success)} .dot.y{background:var(--warn)}
.refresh{margin-left:auto;font-size:13px;color:var(--muted)}
h1.page{font-size:22px;margin:18px 0 4px}
.lede{color:var(--muted);max-width:70ch;margin:0 0 16px}
/* --- Controls bar --- */
.controls{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;
background:var(--paper);border:1px solid var(--border);border-radius:var(--radius);
padding:10px 12px;margin-bottom:16px;box-shadow:var(--shadow)}
.controls label{font-size:12px;color:var(--muted);display:flex;flex-direction:column;gap:2px}
.controls input[type=search]{min-width:280px;padding:7px 10px;font-size:14px;
border:1px solid var(--border-strong);border-radius:var(--radius)}
.controls select{padding:7px 8px;font-size:14px;border:1px solid var(--border-strong);
border-radius:var(--radius);background:#fff}
.chipfilter{display:flex;flex-wrap:wrap;gap:6px}
.fchip{display:inline-flex;align-items:center;gap:6px;font-size:13px;border:1px solid var(--border-strong);
background:#fff;border-radius:999px;padding:4px 10px;cursor:pointer;color:var(--ink)}
.fchip:hover{border-color:var(--accent)}
.fchip .n{color:var(--muted);font-variant-numeric:tabular-nums}
/* --- Grid: main + aside --- */
.grid{display:grid;grid-template-columns:1fr 340px;gap:20px;align-items:start}
@media(max-width:860px){.grid{grid-template-columns:1fr}}
/* --- Day group + bill rows (the Monitor list) --- */
.dayhead{display:flex;align-items:baseline;gap:10px;margin:22px 0 8px}
.dayhead h2{font-size:16px}
.dayhead .count{color:var(--muted);font-size:13px}
.rows{border:1px solid var(--border);border-radius:var(--radius);background:var(--paper);
overflow:hidden;box-shadow:var(--shadow)}
.row{display:grid;grid-template-columns:30px 88px 1fr auto;gap:12px;align-items:start;
padding:12px 14px;border-top:1px solid var(--border)}
.row:first-child{border-top:0}
.chamber{text-align:center;font-size:10px;font-weight:700;padding:2px 0;border-radius:3px;letter-spacing:.5px}
.chamber.asm{background:rgba(47,90,167,.12);color:var(--asm)}
.chamber.sen{background:rgba(122,59,143,.12);color:var(--sen)}
.blnum{font-family:var(--font-mono);font-weight:600;font-size:14px;color:var(--ink)}
.blnum a{color:inherit;text-decoration:none;display:block}
.blnum small{display:block;color:var(--muted);font-family:var(--font-ui);font-weight:500;font-size:12px}
.body .title{font-weight:600;margin:0 0 2px}
.meta{color:var(--muted);font-size:13px;display:flex;flex-wrap:wrap;gap:3px 10px;align-items:center}
.meta time{font-variant-numeric:tabular-nums}
.tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
.tag{font-size:11px;color:var(--ink);background:var(--surface);border:1px solid var(--border);
border-radius:999px;padding:2px 8px}
.tags .n{color:var(--muted)}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:4px;white-space:nowrap}
.side .where{font-size:12px;color:var(--muted)}
.view{display:inline-block;font-size:13px;font-weight:600;text-decoration:none}
@media(max-width:560px){
.row{grid-template-columns:30px 1fr;gap:8px}
.chamber span{display:none}
.side{grid-column:2/3;flex-direction:row;justify-content:flex-start;flex-wrap:wrap;gap:8px;white-space:normal}
}
/* --- Unlock affordance (anon only) — value-stated, non-annoying, not a modal --- */
.unlock{border:1px dashed var(--border-strong);border-radius:var(--radius);
padding:14px 16px;margin:0 0 16px;background:#fbf7ef}
.unlock h3{font-size:15px;margin:0 0 4px}
.unlock p{margin:0 0 10px;color:var(--muted);font-size:14px;max-width:62ch}
.unlock .cta{background:#fff;border:1px solid var(--accent);color:var(--accent);
font-weight:600;border-radius:var(--radius);padding:7px 14px;cursor:pointer;min-height:36px}
.unlock .cta:hover{background:var(--accent);color:#fff}
/* --- Aside cards --- */
.card{background:var(--paper);border:1px solid var(--border);border-radius:var(--radius);
padding:14px;box-shadow:var(--shadow);margin-bottom:16px}
.card h3{font-size:14px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:0 0 10px}
.topline{display:flex;justify-content:space-between;align-items:baseline;
padding:7px 0;border-top:1px solid var(--border);font-size:14px}
.topline:first-of-type{border-top:0}
.topline .n{font-variant-numeric:tabular-nums;color:var(--muted)}
footer{max-width:var(--maxw);margin:0 auto;padding:20px;color:var(--muted);font-size:12px;
border-top:1px solid var(--border)}
.wf-note{position:fixed;right:12px;bottom:12px;background:var(--ink);color:#fff;
font-size:11px;padding:5px 9px;border-radius:3px;opacity:.9;font-family:var(--font-mono)}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="top">
<div class="wordmark">CA Legislature<small>Upcoming legislation</small></div>
<nav class="main" aria-label="Primary">
<a href="#main">Dashboard</a><a href="#">Legislators</a><a href="#">Bills</a>
</nav>
<div class="spacer"></div>
<button class="signin" type="button">Sign in / register</button>
</header>
<main id="main" class="shell">
<!-- Freshness / status band -->
<section class="fresh" aria-label="Data freshness">
<span><span class="dot g"></span><b style="font-weight:600">Assembly</b> <span class="ok">updated 2026-09-05</span></span>
<span class="badge"><span class="pend">Senate</span> — pending today</span>
<span class="badge">Session 2025–2026</span>
<span class="refresh">Source: leginfo / daily files · <a href="https://leginfo.legislature.ca.gov" target="_blank" rel="noopener">source</a></span>
</section>
<h1 class="page">Upcoming legislation</h1>
<p class="lede">A chronological view of what comes up next on the California Assembly and Senate calendars — committee hearings and floor action, with the subject matter of each bill. Set your ZIP to pin your legislators and topics to the top.</p>
<!-- Unlock affordance: names the value, no dark pattern, no modal-on-arrival -->
<section class="unlock" aria-labelledby="unlock-h">
<h3 id="unlock-h">Make this dashboard work for you</h3>
<p>Enter your ZIP code to find your State Senate and Assembly members, then mark them “watched” and choose the topics you care about. Bills they’re involved in float to the top of your personal view. No account required to browse — sign in only to save it.</p>
<button class="cta" type="button">Set my ZIP →</button>
</section>
<!-- Controls bar (personalization-lite for anon) -->
<section class="controls" aria-label="Filters">
<label>Search
<input type="search" placeholder="bill title, author, keyword… ⌘K" aria-label="Search bills">
</label>
<label>Chamber
<select aria-label="Chamber"><option>Both</option><option>Assembly</option><option>Senate</option></select>
</label>
<label>Date range
<select aria-label="Date range"><option>Upcoming</option><option>This week</option><option>This month</option><option>All</option></select>
</label>
<label>Sort
<select aria-label="Sort"><option>Earliest</option><option>Latest</option><option>Relevance</option></select>
</label>
<div class="chipfilter" style="align-self:flex-end;gap:6px">
<button class="fchip" type="button">Waste <span class="n">42</span></button>
<button class="fchip" type="button">Education <span class="n">188</span></button>
<button class="fchip" type="button">Health <span class="n">214</span></button>
<button class="fchip" type="button">Housing <span class="n">97</span></button>
</div>
</section>
<div class="grid">
<!-- MAIN: the Monitor list, grouped by day -->
<div>
<section aria-label="Events on September 7, 2026">
<div class="dayhead"><h2>Mon, Sep 7, 2026</h2><span class="count">14 items · 6 committee hearings · 8 floor items</span></div>
<div class="rows">
<article class="row">
<div class="chamber asm" title="Assembly"><span class="sr">Assembly</span>A<br>AS</div>
<div class="blnum"><a href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB28" target="_blank" rel="noopener">AB&nbsp;28</a><small>A · Schiavo et al.</small></div>
<div class="body">
<p class="title">Waste: requires statewide recycling standards and reporting</p>
<div class="meta"><time datetime="09:30">9:30am</time> · <span>Committee of Resources and Water</span> · <span>S. Capitol, Rm 444</span></div>
<div class="tags"><span class="tag">Waste <span class="n">· 42</span></span><span class="tag">Environment</span></div>
</div>
<div class="side"><span class="where">Rm 444</span><a class="view" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB28" target="_blank" rel="noopener">View →</a></div>
</article>
<article class="row">
<div class="chamber sen" title="Senate"><span class="sr">Senate</span>S<br>SE</div>
<div class="blnum"><a href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260SB795" target="_blank" rel="noopener">SB&nbsp;795</a><small>S · Riu</small></div>
<div class="body">
<p class="title">Health: establishes a community health worker program</p>
<div class="meta"><time>10:00am</time> · <span>Committee of Health</span> · <span>S. Capitol, Rm 1340</span></div>
<div class="tags"><span class="tag">Health <span class="n">· 214</span></span><span class="tag">Housing</span></div>
</div>
<div class="side"><span class="where">Rm 1340</span><a class="view" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260SB795" target="_blank" rel="noopener">View →</a></div>
</article>
<article class="row">
<div class="chamber asm" title="Assembly"><span class="sr">Assembly</span>A<br>AS</div>
<div class="blnum"><a target="_blank" rel="noopener" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB300">AB&nbsp;300</a><small>A · Chen</small></div>
<div class="body">
<p class="title">Education: modernizes K-12 career-technical standards</p>
<div class="meta"><time>1:30pm</time> · <span>Committee of Education</span> · <span>S. Capitol, Rm 1080</span></div>
<div class="tags"><span class="tag">Education <span class="n">· 188</span></span></div>
</div>
<div class="side"><span class="where">Rm 1080</span><a class="view" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB300" target="_blank" rel="noopener">View →</a></div>
</article>
<article class="row">
<div class="chamber asm" title="Assembly"><span class="sr">Assembly</span>A<br>AS</div>
<div class="blnum">—<small>Floor action</small></div>
<div class="body">
<p class="title">Assembly Floor — item 14, supplement 2</p>
<div class="meta"><time>Upon Call of the Chair</time> · <span>A-SAC Assembly Chamber</span></div>
<div class="tags"><span class="tag">Fiscal</span></div>
</div>
<div class="side"><span class="where">Floor</span><a class="view" href="#" >View →</a></div>
</article>
</div>
</section>
<section aria-label="Events on September 8, 2026">
<div class="dayhead"><h2>Tue, Sep 8, 2026</h2><span class="count">31 items</span></div>
<div class="rows">
<article class="row">
<div class="chamber sen" title="Senate"><span class="sr">Senate</span>S<br>SE</div>
<div class="blnum"><a target="_blank" rel="noopener" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260SB42">SB&nbsp;42</a><small>S · Nguyen</small></div>
<div class="body">
<p class="title">Housing: expands affordable housing bond authority</p>
<div class="meta"><time>9:00am</time> · <span>Committee of Housing</span> · <span>S. Capitol, Rm 1012</span></div>
<div class="tags"><span class="tag">Housing <span class="n">· 97</span></span><span class="tag">Taxation</span></div>
</div>
<div class="side"><span class="where">Rm 1012</span><a class="view" href="#" >View →</a></div>
</article>
</div>
</section>
</div>
<!-- ASIDE: secondary, opt-in teaser -->
<aside aria-label="Top topics this session">
<section class="card">
<h3>Top subject-matter this session</h3>
<div class="topline"><button type="button">Health</button><span class="n">214 bills</span></div>
<div class="topline"><button type="button">Education</button><span class="n">188 bills</span></div>
<div class="topline"><button type="button">Housing</button><span class="n">97 bills</span></div>
<div class="topline"><button type="button">Waste</button><span class="n">42 bills</span></div>
<p style="margin:10px 0 0;font-size:12px;color:var(--muted)">Click a topic to filter. <a href="#">Sign in to pin topics to your dashboard →</a></p>
</section>
<section class="card">
<h3>About this data</h3>
<p style="margin:0;font-size:13px;color:var(--muted)">Heard from the California Assembly &amp; Senate daily files via <a href="https://leginfo.legislature.ca.gov" target="_blank" rel="noopener">leginfo</a>. “Upcoming” = future-dated, not-yet-acted events. <a href="#">How is this computed? →</a></p>
</section>
</aside>
</div>
</main>
<footer>
CA Legislature · Upcoming legislation — a free public service. Senate data pending today. · <a href="#">Accessibility</a> · <a href="#">Privacy</a> · <a href="#">How “upcoming” is computed</a>
</footer>
<span class="wf-note">wireframe · 01 anonymous dashboard</span>
</body>
</html>
+445
View File
@@ -0,0 +1,445 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Legislative Dashboard — Logged-in / personalized (wireframe)</title>
<style>
:root{
--ink:#1a1a1a; --muted:#5b5b5b; --paper:#fff; --surface:#f4f5f7;
--border:#d5d9df; --border-strong:#b8bfc9;
--accent:#0b5394; --accent-ink:#08345f;
--success:#1e7e34; --warn:#8a5a00; --danger:#b3261e;
--asm:#2f5aa7; --sen:#7a3b8f;
--personal:#08345f; --personal-band:#eaf1f8;
--ring:0 0 0 2px #fff,0 0 0 4px var(--accent);
--font-ui:-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SFMono-Regular","Menlo","Consolas",monospace;
--radius:4px; --shadow:0 1px 2px rgba(0,0,0,.06),0 2px 8px rgba(0,0,0,.05); --maxw:1160px;
}
*{box-sizing:border-box}
html,body{margin:0}
body{font-family:var(--font-ui);color:var(--ink);background:var(--surface);font-size:15px;line-height:1.5;text-wrap:pretty}
a{color:var(--accent);text-underline-offset:2px} a:hover{color:var(--accent-ink)}
h1,h2,h3{line-height:1.2;margin:0}
:where(a,button,[tabindex]):focus-visible{outline:none;box-shadow:var(--ring);border-radius:var(--radius)}
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
.skip{position:absolute;left:-999px;top:0;background:var(--accent);color:#fff;padding:.6em 1em;z-index:10}
.skip:focus{left:8px;top:8px}
.top{position:sticky;top:0;z-index:6;display:flex;align-items:center;gap:16px;background:var(--paper);border-bottom:1px solid var(--border);padding:10px 20px}
.wordmark{font-weight:700;font-size:17px;white-space:nowrap}
.wordmark small{display:block;font-weight:500;font-size:11px;color:var(--muted);letter-spacing:.4px;text-transform:uppercase}
nav.main{display:flex;gap:18px;margin-left:4px}
nav.main a{color:var(--ink);text-decoration:none;font-weight:500}
nav.main a:hover{text-decoration:underline}
.spacer{flex:1}
/* User band — the personalization summary, always visible */
.userband{display:flex;align-items:center;gap:8px;background:var(--personal-band);border:1px solid #cdddec;
border-radius:999px;padding:5px 13px;font-size:13px;white-space:nowrap}
.userband .mono{font-family:var(--font-mono);font-weight:600}
.userband .sep{color:var(--border-strong)}
.usermenu{position:relative}
.usermenu > button{background:#fff;border:1px solid var(--border);border-radius:999px;padding:5px 10px;cursor:pointer;font-size:13px;display:flex;gap:8px;align-items:center}
.usermenu .menu{position:absolute;right:0;top:110%;background:#fff;border:1px solid var(--border);border-radius:6px;box-shadow:var(--shadow);min-width:190px;display:none;padding:4px}
.usermenu:hover .menu,.usermenu:focus-within .menu{display:block}
.usermenu .menu a{display:block;padding:8px 10px;text-decoration:none;font-size:14px;border-radius:4px}
.usermenu .menu a:hover{background:var(--surface)}
.shell{max-width:var(--maxw);margin:0 auto;padding:18px 20px 64px}
.fresh{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;background:var(--paper);border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px;box-shadow:var(--shadow)}
.fresh .badge{font-size:12px;font-weight:600;background:var(--surface);border:1px solid var(--border);border-radius:999px;padding:2px 10px}
.fresh .ok{color:var(--success)} .fresh .pend{color:var(--warn)}
.fresh .refresh{margin-left:auto;font-size:13px;color:var(--muted)}
.live{min-height:20px;font-size:13px;color:var(--muted);margin:8px 2px}
h1.page{font-size:22px;margin:10px 0 2px}
.controls{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;background:var(--paper);border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;margin:14px 0 18px;box-shadow:var(--shadow)}
.controls label{font-size:12px;color:var(--muted);display:flex;flex-direction:column;gap:2px}
.controls input[type=search]{min-width:260px;padding:7px 10px;font-size:14px;border:1px solid var(--border-strong);border-radius:var(--radius)}
.controls select{padding:7px 8px;font-size:14px;border:1px solid var(--border-strong);border-radius:var(--radius);background:#fff}
/* Personal band — the floated, promoted region */
.band{border:1px solid #cdddec;background:var(--personal-band);border-radius:var(--radius);padding:14px 16px;margin-bottom:18px}
.band .bandhead{display:flex;align-items:center;gap:10px;margin-bottom:4px}
.band .bandhead h2{font-size:16px;color:var(--personal)}
.band .count{font-size:13px;color:var(--muted)}
.band .allpill{margin-left:auto;font-size:13px;font-weight:600;cursor:pointer;background:#fff;border:1px solid #cdddec;color:var(--accent);border-radius:999px;padding:4px 12px}
/* Meaning-cued indicator: NOT decoration — it encodes WHY this row is here. */
.relevant{border-left:3px solid var(--accent);padding-left:11px}
.relevant.leg{border-left-color:var(--success)}
.legmark{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;color:var(--success);
background:rgba(30,126,52,.1);border-radius:3px;padding:1px 7px}
.tagmark{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;color:var(--accent);
background:rgba(11,83,148,.09);border-radius:3px;padding:1px 7px}
/* Why popover */
.whywrap{position:relative;display:inline-block}
.whybtn{font-size:12px;color:var(--muted);background:none;border:none;cursor:pointer;padding:2px 0;text-decoration:underline;text-underline-offset:2px}
.why-pop{position:absolute;left:0;top:118%;z-index:8;width:290px;background:#fff;border:1px solid var(--border);
border-radius:6px;box-shadow:var(--shadow);padding:12px;display:none;font-size:13px;text-align:left}
.why-pop[open],.whywrap:hover .why-pop,.whywrap:focus-within .why-pop{display:block}
.why-pop h4{margin:0 0 6px;font-size:13px}
.why-pop ul{margin:6px 0 0;padding-left:16px}
.why-pop code{font-family:var(--font-mono);font-size:12px;background:var(--surface);padding:1px 5px;border-radius:3px}
.grid{display:grid;grid-template-columns:1fr 340px;gap:20px;align-items:start}
@media(max-width:860px){.grid{grid-template-columns:1fr}}
.dayhead{display:flex;align-items:baseline;gap:10px;margin:22px 0 8px}
.dayhead h3{font-size:16px}
.dayhead .count{color:var(--muted);font-size:13px}
.rows{border:1px solid var(--border);border-radius:var(--radius);background:var(--paper);overflow:hidden;box-shadow:var(--shadow)}
.row{display:grid;grid-template-columns:30px 88px 1fr auto;gap:12px;align-items:start;padding:12px 14px;border-top:1px solid var(--border)}
.row:first-child{border-top:0}
.chamber{text-align:center;font-size:10px;font-weight:700;padding:2px 0;border-radius:3px;letter-spacing:.5px;line-height:1.05}
.chamber.asm{background:rgba(47,90,167,.12);color:var(--asm)} .chamber.sen{background:rgba(122,59,143,.12);color:var(--sen)}
.blnum{font-family:var(--font-mono);font-weight:600;font-size:14px}
.blnum a{color:inherit;text-decoration:none;display:block}
.blnum small{display:block;color:var(--muted);font-family:var(--font-ui);font-weight:500;font-size:12px}
.title{font-weight:600;margin:0 0 2px}
.meta{color:var(--muted);font-size:13px;display:flex;flex-wrap:wrap;gap:3px 10px;align-items:center}
.meta time{font-variant-numeric:tabular-nums}
.tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
.tag{font-size:11px;background:var(--surface);border:1px solid var(--border);border-radius:999px;padding:2px 8px}
.tags .n{color:var(--muted)}
.tag.on{background:var(--personal-band);border-color:#cdddec}
.reason{margin-top:6px;font-size:12px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:4px;white-space:nowrap}
.side .where{font-size:12px;color:var(--muted)}
.view{display:inline-block;font-size:13px;font-weight:600;text-decoration:none}
@media(max-width:560px){.row{grid-template-columns:30px 1fr;gap:8px}.chamber span{display:none}.side{grid-column:2/3;flex-direction:row;justify-content:flex-start;flex-wrap:wrap;white-space:normal}}
.card{background:var(--paper);border:1px solid var(--border);border-radius:var(--radius);padding:14px;box-shadow:var(--shadow);margin-bottom:16px}
.card h3{font-size:14px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:0 0 4px}
.card .sub{font-size:12px;color:var(--muted);margin:0 0 10px}
/* Watch toggle */
.watch{display:flex;align-items:center;gap:10px;padding:8px 0;border-top:1px solid var(--border)}
.watch:first-child{border-top:0}
.watch .who{flex:1}
.watch .who b{font-weight:600}.watch .who small{display:block;color:var(--muted);font-size:12px}
.toggle{position:relative;width:40px;height:22px;flex:none}
.toggle input{opacity:0;width:100%;height:100%;margin:0;cursor:pointer}
.toggle .knob{position:absolute;inset:0;background:var(--border-strong);border-radius:999px;transition:background .12s}
.toggle .knob::after{content:"";position:absolute;width:16px;height:16px;background:#fff;border-radius:50%;top:3px;left:3px;transition:left .12s}
.toggle input:checked + .knob{background:var(--success)}
.toggle input:checked + .knob::after{left:21px}
.watch .stat{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px}
.stat.on{color:var(--success)}.stat.off{color:var(--muted)}
/* Topic opt-in chips */
.opt{display:flex;flex-wrap:wrap;gap:7px}
.optchip{display:inline-flex;align-items:center;gap:7px;font-size:13px;border:1px solid var(--border-strong);
background:#fff;border-radius:999px;padding:5px 11px;cursor:pointer;color:var(--ink)}
.optchip:hover{border-color:var(--accent)}
.optchip.on{background:var(--personal-band);border-color:var(--accent);font-weight:600}
.optchip .n{color:var(--muted);font-variant-numeric:tabular-nums}
.optchip .box{width:14px;height:14px;border:1.5px solid var(--border-strong);border-radius:3px;display:inline-flex;align-items:center;justify-content:center;font-size:11px}
.optchip.on .box{background:var(--accent);border-color:var(--accent);color:#fff}
footer{max-width:var(--maxw);margin:0 auto;padding:20px;color:var(--muted);font-size:12px;border-top:1px solid var(--border)}
/* ZIP setup empty-state (toggled on via #state-setup) — shown when a user has no prefs */
#setup{border:1px dashed var(--border-strong);border-radius:var(--radius);background:#fbf7ef;padding:16px;margin:14px 0}
.setup-row{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap}
.setup-row label{display:flex;flex-direction:column;font-size:12px;color:var(--muted);gap:3px}
.setup-row input{padding:8px 10px;border:1px solid var(--border-strong);border-radius:var(--radius);font-size:14px}
.setup-row input.zip{max-width:130px;font-family:var(--font-mono)}
#btn-find{background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:8px 14px;min-height:36px;font-weight:600;cursor:pointer}
#btn-find:hover{background:var(--accent-ink)}
.candidates{margin-top:12px;border-top:1px solid #eadfca;padding-top:10px;font-size:13px;color:var(--muted)}
.modebar{display:flex;gap:8px;align-items:center;margin:0 0 12px;background:var(--ink);color:#fff;border-radius:6px;padding:8px 12px}
.modebar b{font-family:var(--font-mono);font-size:12px}
.modebar button{background:transparent;color:#fff;border:1px solid #5a6270;border-radius:4px;padding:4px 10px;font-size:12px;cursor:pointer}
.modebar button[aria-pressed=true]{background:#fff;color:var(--ink);font-weight:600}
.wf-note{position:fixed;right:12px;bottom:12px;background:var(--ink);color:#fff;font-size:11px;padding:5px 9px;border-radius:3px;opacity:.9;font-family:var(--font-mono)}
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body data-state="personal">
<a class="skip" href="#main">Skip to content</a>
<header class="top">
<div class="wordmark">CA Legislature<small>Upcoming legislation</small></div>
<nav class="main" aria-label="Primary"><a href="#main">Dashboard</a><a href="#">Legislators</a><a href="#">Bills</a></nav>
<div class="spacer"></div>
<!-- User band reflects the personalization summary at a glance -->
<div class="userband" id="ub-personal">
<span class="mono">ZIP&nbsp;90210</span><span class="sep">·</span>Districts <b>A-14 &nbsp;S-08</b>
<span class="sep">·</span><b id="ub-watch">2</b> watching <span class="sep">·</span><b id="ub-topics">3</b> topics
</div>
<div class="usermenu">
<button type="button" aria-haspopup="true">A. R. ▾</button>
<div class="menu" role="menu">
<a role="menuitem" href="#prefs">Preferences</a>
<a role="menuitem" href="#prefs">Watched &amp; topics</a>
<a role="menuitem" href="#">Signed out</a>
</div>
</div>
</header>
<!-- Demo control only: not part of the product. Toggles the two personal states. -->
<div class="modebar" role="group" aria-label="Demo state selector (wireframe only)">
Wireframe demo — show state:
<button type="button" aria-pressed="true" data-set="personal">Personalized (configured)</button>
<button type="button" aria-pressed="false" data-set="setup">ZIP setup (empty)</button>
<button type="button" aria-pressed="false" data-set="anon">No login (anon)</button>
</div>
<main id="main" class="shell">
<section class="fresh" aria-label="Data freshness">
<span><b style="font-weight:600">Assembly</b> <span class="ok">updated 2026-09-05</span></span>
<span class="badge"><span class="pend">Senate</span> — pending today</span>
<span class="badge">Session 2025–2026</span>
<span class="refresh">Source: leginfo / daily files · <a href="https://leginfo.legislature.ca.gov" target="_blank" rel="noopener">source</a></span>
</section>
<h1 class="page">Your dashboard</h1>
<p class="live" role="status" aria-live="polite"></p>
<!-- =========== ZIP / ADDRESS setup empty-state (state=setup) =========== -->
<section id="setup" hidden aria-labelledby="setup-h">
<h2 id="setup-h"><span style="font-size:17px;font-weight:600">Pin your view</span></h2>
<p style="margin:6px 0 12px;color:#6a5a3a;max-width:62ch">Enter your ZIP code and we’ll find your State Senate and Assembly representatives, then surface their bills and the topics you care about at the top. Prefer exact results? You can enter a full address instead — a ZIP alone can’t always pin a district.</p>
<form class="setup-row" onsubmit="return false">
<label>ZIP code
<input class="zip" inputmode="numeric" maxlength="5" placeholder="90210" aria-label="ZIP code">
</label>
<button id="btn-find" type="submit">Find my legislators <span style="opacity:.75;font-weight:400;font-size:11px">(demo: cycles the 4 outcome panels)</span></button>
<span style="flex:1"></span>
<a href="#" style="align-self:flex-end">Use a full address instead →</a>
</form>
<div class="candidates" role="note">
<p style="margin:0 0 8px;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Resolve outcome on ZIP 90210 — <b style="font-weight:600;color:var(--ink)">single district ✓</b></p>
<b>We found your district → <span style="color:var(--accent)">District 14 (Assembly) · District 08 (Senate)</span></b>
<p style="margin:6px 0 8px">Confirm which representatives to watch and what topics to follow. Nothing is saved until you choose.</p>
<div class="watch"><div class="who"><b>M. Schiavo</b> <small>Assembly District 14 · D</small></div><label class="toggle"><input type="checkbox" checked aria-label="Watch M. Schiavo"><span class="knob"></span></label><span class="stat on">Watching</span></div>
<div class="watch"><div class="who"><b>R. Riu</b> <small>Senate District 08 · R</small></div><label class="toggle"><input type="checkbox" aria-label="Watch R. Riu"><span class="knob"></span></label><span class="stat off">Not watched</span></div>
<p style="margin:8px 0 4px;color:var(--muted)">Suggested topics for these representatives:</p>
<div class="opt">
<button class="optchip on" type="button"><span class="box" aria-hidden="true">✓</span>Waste <span class="n">· 42</span></button>
<button class="optchip on" type="button"><span class="box" aria-hidden="true">✓</span>Water <span class="n">· 63</span></button>
<button class="optchip" type="button"><span class="box"></span>Housing <span class="n">· 97</span></button>
<button class="optchip" type="button"><span class="box"></span>Transportation <span class="n">· 51</span></button>
</div>
</div>
<!-- Other resolve outcomes (§6.4 of DESIGN_DASHBOARD) — shown as alternative panels, hidden by default -->
<div class="candidates" role="note" hidden data-outcome="ambiguous">
<p style="margin:0 0 8px;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Outcome — <b style="font-weight:600;color:var(--warn)">ambiguous ZIP</b></p>
<p style="margin:0 0 8px">ZIP <code style="font-family:var(--font-mono)">95602</code> spans more than one district — which one is yours?</p>
<div class="watch"><div class="who"><b>District 5 (Assembly)</b> <small>69% of residents in this ZIP</small></div><a href="#" class="view">Use this →</a></div>
<div class="watch"><div class="who"><b>District 1 (Assembly)</b> <small>31% of residents in this ZIP</small></div><a href="#" class="view">Use this →</a></div>
</div>
<div class="candidates" role="note" hidden data-outcome="zero">
<p style="margin:0 0 8px;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Outcome — <b style="font-weight:600;color:var(--warn)">no match</b></p>
<p style="margin:0 0 8px">We couldn’t match ZIP <code style="font-family:var(--font-mono)">89001</code> to a California district.</p>
<a href="#" class="view">Pick a district manually →</a> · <a href="#" class="view">Try a full address →</a>
</div>
<div class="candidates" role="note" hidden data-outcome="out-of-state">
<p style="margin:0 0 8px;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">Outcome — <b style="font-weight:600;color:var(--danger)">out of state</b></p>
<p style="margin:0">This ZIP isn’t in California. This tool covers only the CA state legislature.</p>
</div>
</section>
<!-- =========== Personalized view (state=personal / anon) =========== -->
<div id="personal-view">
<!-- Controls: same as anon; personal ranking is on by default for logged-in -->
<section class="controls" aria-label="Filters">
<label>Search
<input type="search" placeholder="bill title, author, keyword… ⌘K" aria-label="Search bills">
</label>
<label>Chamber<select><option>Both</option><option>Assembly</option><option>Senate</option></select></label>
<label>Date range<select><option>Upcoming</option><option>This week</option><option>This month</option><option>All</option></select></label>
<label>Sort<select id="sort"><option>Earliest</option><option>Latest</option><option>Relevance</option></select></label>
</section>
<!-- PERSONAL BAND: floated, top N, meaning-cued, with reveal-why -->
<section class="band" id="band" aria-label="Relevant to you">
<div class="bandhead">
<h2>Relevant to you</h2>
<span class="count" id="band-count">6 bills</span>
<button type="button" class="allpill" id="showall">Show all 6</button>
</div>
<!-- A floated-by-legislator bill (success-cued left bar) -->
<article class="row relevant leg">
<div class="chamber asm" title="Assembly"><span class="sr">Assembly</span>A<br>AS</div>
<div class="blnum"><a target="_blank" rel="noopener" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB28">AB 28</a><small>A · Schiavo</small></div>
<div class="body">
<p class="title">Waste: requires statewide recycling standards and reporting</p>
<div class="meta"><time datetime="09:30">9:30am</time> · <span>Committee of Resources and Water</span> · <span>S. Capitol, Rm 444</span></div>
<div class="tags"><span class="tag on">Waste <span class="n">· 42</span></span><span class="tag">Environment</span></div>
<div class="reason">
<span class="legmark">● Your legislator</span>
<span class="tagmark">● Topic: Waste</span>
<span class="whywrap">
<button class="whybtn" type="button">Why is this here? ▸</button>
<span class="why-pop" role="dialog" aria-label="Why this bill is relevant">
<h4>Why this surfaced</h4>
<ul>
<li>Your watched legislator: <code>M. Schiavo (A-14)</code> — author of this bill.</li>
<li>Topic match: <code>Waste</code> is one of your opted-in topics.</li>
</ul>
<p style="margin:8px 0 0;color:var(--muted)">Either match alone floats a bill; both raises its position.</p>
</span>
</span>
</div>
</div>
<div class="side"><span class="where">Rm 444</span><a class="view" target="_blank" rel="noopener" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB28">View →</a></div>
</article>
<!-- A floated-by-topic-only bill (accent-cued left bar = tag match, no legislator match) -->
<article class="row relevant">
<div class="chamber sen" title="Senate"><span class="sr">Senate</span>S<br>SE</div>
<div class="blnum"><a target="_blank" rel="noopener" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260SB795">SB 795</a><small>S · Riu</small></div>
<div class="body">
<p class="title">Health: establishes a community health worker program</p>
<div class="meta"><time>10:00am</time> · <span>Committee of Health</span> · <span>S. Capitol, Rm 1340</span></div>
<div class="tags"><span class="tag on">Health <span class="n">· 214</span></span><span class="tag">Housing</span></div>
<div class="reason">
<span class="tagmark">● Topic: Health</span>
<span class="whywrap">
<button class="whybtn" type="button">Why is this here? ▸</button>
<span class="why-pop" role="dialog" aria-label="Why this bill is relevant">
<h4>Why this surfaced</h4>
<ul><li>Topic match: <code>Health</code> is one of your opted-in topics.</li></ul>
<p style="margin:8px 0 0;color:var(--muted)">No legislator match — floated by topic only.</p>
</span>
</span>
</div>
</div>
<div class="side"><span class="where">Rm 1340</span><a class="view" target="_blank" rel="noopener" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260SB795">View →</a></div>
</article>
</section>
<div class="grid">
<!-- MAIN: full queue, grouped by day; floated bills keep a "Relevant" marker -->
<div>
<section aria-label="Events on September 7, 2026">
<div class="dayhead"><h3>Mon, Sep 7, 2026</h3><span class="count">14 items</span></div>
<div class="rows">
<article class="row">
<div class="chamber asm" title="Assembly"><span class="sr">Assembly</span>A<br>AS</div>
<div class="blnum"><a target="_blank" rel="noopener" href="https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202520260AB28">AB 28</a><small>A · Schiavo</small></div>
<div class="body">
<p class="title">Waste: requires statewide recycling standards</p>
<div class="meta"><time datetime="09:30">9:30am</time> · <span>Committee of Resources and Water</span> · <span>Rm 444</span></div>
<div class="reason"><span class="legmark">● Your legislator</span>
<span class="whywrap"><button class="whybtn" type="button">Why?</button>
<span class="why-pop"><ul><li>Author <code>M. Schiavo</code> is watched</li></ul></span></span></div>
<div class="tags"><span class="tag on">Waste</span></div>
</div>
<div class="side"><span class="where">Rm 444</span><a class="view" href="#" target="_blank">View →</a></div>
</article>
<article class="row">
<div class="chamber asm" title="Assembly"><span class="sr">Assembly</span>A<br>AS</div>
<div class="blnum"><a target="_blank" rel="noopener" href="#">AB 300</a><small>A · Chen</small></div>
<div class="body">
<p class="title">Education: modernizes K-12 career-technical standards</p>
<div class="meta"><time>1:30pm</time> · <span>Committee of Education</span> · <span>Rm 1080</span></div>
<div class="tags"><span class="tag">Education <span class="n">· 188</span></span></div>
</div>
<div class="side"><span class="where">Rm 1080</span><a class="view" href="#" target="_blank">View →</a></div>
</article>
</div>
</section>
</div>
<!-- ASIDE: manage personalization -->
<aside id="prefs" aria-label="Your preferences">
<section class="card">
<h3>Watchlist</h3><p class="sub">Legislators whose authorship or committee activity surfaces their bills.</p>
<div class="watch">
<div class="who"><b>M. Schiavo</b><small>Assembly D-14 · D · Resources &amp; Water</small></div>
<label class="toggle"><input type="checkbox" checked data-leg aria-label="Watch M. Schiavo"><span class="knob"></span></label>
<span class="stat on">Watching</span>
</div>
<div class="watch">
<div class="who"><b>R. Riu</b><small>Senate D-08 · R · Health</small></div>
<label class="toggle"><input type="checkbox" data-leg aria-label="Watch R. Riu"><span class="knob"></span></label>
<span class="stat off">Not watched</span>
</div>
<a href="#" style="display:inline-block;margin-top:8px;font-size:13px">+ Find another legislator</a>
</section>
<section class="card">
<h3>Topics you follow</h3><p class="sub">Subject-matter (from bill analysis). Bills in these topics float up.</p>
<div class="opt" role="group" aria-label="Topics you follow">
<button class="optchip on" type="button" data-topic><span class="box" aria-hidden="true">✓</span>Waste <span class="n">· 42</span></button>
<button class="optchip on" type="button" data-topic><span class="box" aria-hidden="true">✓</span>Water <span class="n">· 63</span></button>
<button class="optchip on" type="button" data-topic><span class="box" aria-hidden="true">✓</span>Health <span class="n">· 214</span></button>
<button class="optchip" type="button" data-topic><span class="box"></span>Housing <span class="n">· 97</span></button>
<button class="optchip" type="button" data-topic><span class="box"></span>Education <span class="n">· 188</span></button>
</div>
<a href="#" style="display:inline-block;margin-top:8px;font-size:12px">See all 206 topics →</a>
</section>
<section class="card">
<h3>Zone</h3>
<p class="sub" style="margin:0">ZIP <b>90210</b> → Assembly D-14 · Senate D-08. A ZIP can span more than one district — <a href="#">verify by address →</a></p>
</section>
</aside>
</div>
</div>
</main>
<footer>
CA Legislature · Your dashboard — a free public service. Senate data pending today. · <a href="#">Accessibility</a> · <a href="#">Privacy</a> · <a href="#">How “upcoming” is computed</a>
</footer>
<span class="wf-note">wireframe · 02 logged-in dashboard</span>
<script>
// --- Wireframe-only demo interactivity: proves the states & the "float on change" behavior.
const body=document.body, personal=document.getElementById('personal-view');
const setup=document.getElementById('setup');
const live=document.querySelector('.live');
const modeBtns=[...document.querySelectorAll('.modebar button')];
function setState(s){
body.dataset.state=s;
modeBtns.forEach(b=>b.setAttribute('aria-pressed', b.dataset.set===s));
const isSetup=s==='setup';
setup.hidden=!isSetup;
personal.hidden=isSetup;
document.getElementById('ub-personal').style.display = s==='anon' ? 'none':'';
document.querySelector('.usermenu').style.display = s==='anon' ? 'none':'';
document.querySelector('h1.page').textContent = s==='anon' ? 'Upcoming legislation' : 'Your dashboard';
live.textContent = s==='anon' ? 'Anonymous — personal view is off. Sign in for a personalized dashboard.'
: s==='setup' ? 'Set your ZIP to find your legislators and topics.'
: '6 bills relevant to you are showing above the full list.';
}
modeBtns.forEach(b=>b.addEventListener('click',()=>setState(b.dataset.set)));
// Opt-in / watch toggles: optimistic + announce + "re-rank" feedback (the float model in miniature)
function refresh(){
const w=[...document.querySelectorAll('[data-leg]').filter(i=>i.checked)].length;
const t=document.querySelectorAll('[data-topic].on').length;
document.getElementById('ub-watch').textContent=w;
document.getElementById('ub-topics').textContent=t;
document.getElementById('band-count').textContent=(3+t)+' bills';
}
document.querySelectorAll('[data-leg]').forEach(i=>i.addEventListener('change',e=>{
e.currentTarget.closest('.watch').querySelector('.stat').textContent = e.currentTarget.checked?'Watching':'Not watched';
e.currentTarget.closest('.watch').querySelector('.stat').className='stat '+(e.currentTarget.checked?'on':'off');
refresh(); live.textContent='Re-ranked — watchlist change applied.';
}));
document.querySelectorAll('[data-topic].on, [data-topic]:not(.on)').forEach(c=>c.addEventListener('click',()=>{
const on=c.classList.toggle('on');
c.querySelector('.box').textContent=on?'✓':'';
c.querySelector('.box').setAttribute('aria-hidden', on?'true':'false');
refresh(); live.textContent='Re-ranked — topic change applied.';
}));
// Wireframe-only: cycle the 4 ZIP resolve outcomes on the ZIP form.
(() => {
const btn = document.getElementById('btn-find');
if (!btn) return;
const panels = [document.querySelector('.candidates:not([data-outcome])'),
...document.querySelectorAll('[data-outcome]')];
let i = 0;
btn.addEventListener('click', () => {
panels.forEach((p, j) => { if (p) p.hidden = j !== i; });
const cur = panels[i];
if (cur) cur.scrollIntoView({behavior:'smooth', block:'nearest'});
i = (i + 1) % panels.length;
});
})();
refresh(); setState('personal');
</script>
</body>
</html>
@@ -0,0 +1,192 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Watch a legislator / Opt into a topic — controls (wireframe)</title>
<style>
:root{
--ink:#1a1a1a;--muted:#5b5b5b;--paper:#fff;--surface:#f4f5f7;
--border:#d5d9df;--border-strong:#b8bfc9;
--accent:#0b5394;--accent-ink:#08345f;--success:#1e7e34;--warn:#8a5a00;--danger:#b3261e;
--personal-band:#eaf1f8;
--ring:0 0 0 2px #fff,0 0 0 4px var(--accent);
--font-ui:-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SFMono-Regular","Menlo",Consolas,monospace;
--radius:4px;--shadow:0 1px 2px rgba(0,0,0,.06),0 2px 8px rgba(0,0,0,.05);--maxw:1080px;
}
*{box-sizing:border-box}html,body{margin:0}
body{font-family:var(--font-ui);color:var(--ink);background:var(--surface);font-size:15px;line-height:1.5;text-wrap:pretty}
a{color:var(--accent);text-underline-offset:2px}h1,h2,h3{line-height:1.2;margin:0}
:where(a,button,[tabindex]):focus-visible{outline:none;box-shadow:var(--ring);border-radius:var(--radius)}
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
.skip{position:absolute;left:-999px;top:0;background:var(--accent);color:#fff;padding:.6em 1em;z-index:10}.skip:focus{left:8px;top:8px}
.wrap{max-width:var(--maxw);margin:0 auto;padding:22px 20px 64px}
h1{font-size:20px;margin-bottom:2px}.sub{color:var(--muted);margin:0 0 18px;font-size:14px}
.panel{background:var(--paper);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin-bottom:16px;box-shadow:var(--shadow)}
.panel h2{font-size:14px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:0 0 4px}
.panel .desc{font-size:14px;color:var(--ink);margin:0 0 12px}
.states{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px}
.state{border:1px solid var(--border);border-radius:6px;padding:12px;background:var(--surface)}
.state .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:8px;font-weight:600}
.state .note{font-size:11px;color:var(--muted);margin-top:8px}
/* toggle */
.toggle{position:relative;width:40px;height:22px;flex:none}
.toggle input{opacity:0;width:100%;height:100%;margin:0;cursor:pointer}
.toggle .knob{position:absolute;inset:0;background:var(--border-strong);border-radius:999px;transition:background .12s}
.toggle .knob::after{content:"";position:absolute;width:16px;height:16px;background:#fff;border-radius:50%;top:3px;left:3px;transition:left .12s}
.toggle input:checked+.knob{background:var(--success)}.toggle input:checked+.knob::after{left:21px}
.toggle input:disabled+.knob{background:#e6e8ec}.toggle input:disabled+.knob::after{background:#fff}
.stat{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px}
.stat.on{color:var(--success)}.stat.off{color:var(--muted)}
/* opt-in chip */
.optchip{display:inline-flex;align-items:center;gap:7px;font-size:14px;border:1px solid var(--border-strong);
background:#fff;border-radius:999px;padding:6px 12px;cursor:pointer;color:var(--ink);min-height:40px}
.optchip:hover{border-color:var(--accent)}.optchip:disabled{opacity:.55;cursor:not-allowed;border-color:var(--border)}
.optchip.on{background:var(--personal-band);border-color:var(--accent);font-weight:600}
.optchip .box{width:15px;height:15px;border:1.5px solid var(--border-strong);border-radius:3px;display:inline-flex;align-items:center;justify-content:center;font-size:11px}
.optchip.on .box{background:var(--accent);border-color:var(--accent);color:#fff}
.optchip .n{color:var(--muted);font-variant-numeric:tabular-nums}
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.who{flex:1;min-width:150px}.who b{font-weight:600}.who small{display:block;color:var(--muted);font-size:12px}
/* legend for the meaning-cued indicators */
.legend{display:flex;gap:18px;flex-wrap:wrap;font-size:13px;color:var(--muted);margin-top:6px}
.legend i{display:inline-block;width:3px;height:14px;border-radius:2px;vertical-align:middle;margin-right:6px}
.leg-l{background:var(--success)}.leg-t{background:var(--accent)}
/* buttons */
.btn{background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:8px 14px;min-height:36px;font-weight:600;cursor:pointer}
.btn:hover{background:var(--accent-ink)}.btn.ghost{background:#fff;border:1px solid var(--accent);color:var(--accent)}
.btn.ghost:hover{background:var(--accent);color:#fff}
.pillrow{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.pill{border:1px solid var(--border-strong);border-radius:999px;padding:4px 10px;font-size:12px;background:#fff;cursor:pointer}
.wf-note{position:fixed;right:12px;bottom:12px;background:var(--ink);color:#fff;font-size:11px;padding:5px 9px;border-radius:3px;opacity:.9;font-family:var(--font-mono)}
@media(max-width:640px){.states{grid-template-columns:1fr 1fr}}
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<main id="main" class="wrap">
<h1>Control states — “Watch a legislator” &amp; “Opt into a topic”</h1>
<p class="sub">Every control, every state, with the meaning-cued indicators that tell the user *why* a bill floated. These are the pieces of §5B / §10 of the design spec, shown in isolation for the build lane.</p>
<!-- 1. ZIP / address resolution -->
<section class="panel" aria-labelledby="z">
<h2 id="z">1 · Resolve ZIP / address → legislators</h2>
<p class="desc">Fast path first; falls back to address or manual because a ZIP can span more than one district.</p>
<div class="row" style="gap:10px;align-items:flex-end;flex-wrap:wrap">
<label style="display:flex;flex-direction:column;gap:3px;font-size:12px;color:var(--muted)">ZIP
<input inputmode="numeric" maxlength="5" placeholder="90210" style="max-width:130px;padding:8px 10px;border:1px solid var(--border-strong);border-radius:var(--radius);font-family:var(--font-mono)" aria-label="ZIP code"></label>
<button class="btn" type="button">Find my legislators</button>
<a href="#">Use a full address instead →</a>
</div>
<div class="pillrow" aria-label="Result states">
<span style="font-size:12px;color:var(--muted);align-self:center">Result states:</span>
<button class="pill" type="button">single district ✓</button>
<button class="pill" type="button">ambiguous (2–3) → confirm</button>
<button class="pill" type="button">zero → manual picker</button>
<button class="pill" type="button">out-of-state</button>
</div>
</section>
<!-- 2. Watch toggle: all states -->
<section class="panel" aria-labelledby="w">
<h2 id="w">2 · Watch toggle (legislator)</h2>
<p class="desc">Watches a legislator by authorship or committee membership. Optimistic update on toggle; re-ranks the personal band instantly.</p>
<div class="states">
<div class="state">
<div class="lbl">Default · not watched</div>
<div class="row">
<div class="who"><b>R. Riu</b><small>Senate D-08 · R · Health</small></div>
<label class="toggle"><input type="checkbox" aria-label="Watch R. Riu"><span class="knob"></span></label>
<span class="stat off">Not watched</span>
</div>
<div class="note">Off. No effect on ranking.</div>
</div>
<div class="state">
<div class="lbl">Checked · watching</div>
<div class="row">
<div class="who"><b>M. Schiavo</b><small>Assembly D-14 · D · Resources &amp; Water</small></div>
<label class="toggle"><input type="checkbox" checked aria-label="Watch M. Schiavo"><span class="knob"></span></label>
<span class="stat on">Watching</span>
</div>
<div class="note">On. Bills float to top. Green bar.</div>
</div>
<div class="state">
<div class="lbl">Focus</div>
<div class="row">
<div class="who"><b>T. Vega</b><small>Assembly D-14 · D</small></div>
<label class="toggle"><input type="checkbox" aria-label="Watch T. Vega" style="outline:none;box-shadow:var(--ring)"><span class="knob"></span></label>
<span class="stat off">Not watched</span>
</div>
<div class="note">2px focus ring visible.</div>
</div>
<div class="state">
<div class="lbl">Disabled · off-rolodex</div>
<div class="row">
<div class="who"><b>(term-end)</b><small>Not on current roster</small></div>
<label class="toggle"><input type="checkbox" disabled aria-label="Watch (unavailable)"><span class="knob"></span></label>
<span class="stat off">Unavailable</span>
</div>
<div class="note">Past legislator; locked out.</div>
</div>
</div>
</section>
<!-- 3. Topic opt-in: a live chip with its states -->
<section class="panel" aria-labelledby="t">
<h2 id="t">3 · Topic opt-in chip</h2>
<p class="desc">Opt into a subject-matter tag (from the LLM lane). Count shows how many bills carry it. Toggling re-ranks.</p>
<div class="states">
<div class="state"><div class="lbl">Off</div>
<button class="optchip" type="button" data-toggle><span class="box"></span>Housing <span class="n">· 97</span></button>
<div class="note">Not followed. No effect.</div></div>
<div class="state"><div class="lbl">On (followed)</div>
<button class="optchip on" type="button" data-toggle><span class="box" aria-hidden="true">✓</span>Waste <span class="n">· 42</span></button>
<div class="note">Followed. Bills float up.</div></div>
<div class="state"><div class="lbl">Focus</div>
<button class="optchip" type="button" data-toggle style="outline:none;box-shadow:var(--ring)"><span class="box"></span>Transportation <span class="n">· 51</span></button>
<div class="note">Keyboard target.</div></div>
<div class="state"><div class="lbl">Unavailable · no tags yet</div>
<button class="optchip" type="button" disabled><span class="box"></span>Topic analysis pending <span class="n">—</span></button>
<div class="note">LLM lane warming; not offered yet.</div></div>
</div>
</section>
<!-- 4. Meaning-cued indicator legend -->
<section class="panel" aria-labelledby="m">
<h2 id="m">4 · “Why this here” indicators (the float reason)</h2>
<p class="desc">The left bar + chip tell the user <em>why</em> a bill floated — a trust requirement, not decoration.</p>
<div class="row" style="align-items:stretch;gap:14px;flex-wrap:wrap">
<div style="flex:1;min-width:280px;border:1px solid var(--border);border-left:3px solid var(--success);border-radius:4px;padding:12px">
<div style="font-weight:600">AB 28 — Waste standards</div>
<div style="font-size:13px;color:var(--muted);margin-top:4px">Schiavo · Resources &amp; Water · 9:30am</div>
<div style="font-size:12px;color:var(--success);font-weight:600;margin-top:6px">● Your legislator · Topic: Waste</div>
<a href="#" style="font-size:12px">Why is this here? →</a>
</div>
<div style="flex:1;min-width:280px;border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:4px;padding:12px">
<div style="font-weight:600">SB 795 — Health program</div>
<div style="font-size:13px;color:var(--muted);margin-top:4px">Riu · Health · 10:00am</div>
<div style="font-size:12px;color:var(--accent);font-weight:600">● Topic: Health</div>
<a href="#" style="font-size:12px">Why is this here? →</a>
</div>
</div>
<div class="legend">
<span><i class="leg-l"></i>green bar = watched-legislator match</span>
<span><i class="leg-t"></i>blue bar = opted-in-topic match</span>
<span>color never the only signal — each pair carries text too</span>
</div>
</section>
</main>
<span class="wf-note">wireframe · 03 watch / opt-in controls</span>
<script>
document.querySelectorAll('[data-toggle]').forEach(c=>c.addEventListener('click',()=>{
const on=c.classList.toggle('on');
const box=c.querySelector('.box');
box.textContent=on?'✓':'';
box.setAttribute('aria-hidden', on?'true':'false');
}));
</script>
</body>
</html>