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

+657
View File
@@ -0,0 +1,657 @@
/* ============================================================================
Legislative Dashboard — frontend (spec §5, DESIGN §3/§5/§10).
Vanilla JS + fetch against the stdlib API. Two personas on one surface:
anonymous (overview) and logged-in (personal band + watch/opt-in). No
frameworks, no build step.
========================================================================== */
(function () {
"use strict";
var API = "/api/v1";
var BAND_N = 10;
var state = {
token: localStorage.getItem("dash_token") || null,
user: null,
prefs: null,
filters: { upcoming: true, chamber: "", q: "", tags: [] },
personal: false,
showReason: false,
allItems: [],
tags: [],
};
/* ---------- tiny fetch client ---------- */
function apiFetch(path, opts) {
opts = opts || {};
var headers = { "Content-Type": "application/json" };
if (state.token) headers["Authorization"] = "Bearer " + state.token;
return fetch(API + path, {
method: opts.method || "GET",
headers: headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
}).then(function (r) {
return r.json().then(function (j) {
return { status: r.status, body: j };
});
});
}
function el(id) { return document.getElementById(id); }
/* ---------- render helpers ---------- */
function chip(chamber) {
var c = (chamber || "ASSEMBLY").toLowerCase();
var label = c === "senate" ? "Senate" : "Assembly";
return '<span class="chip ' + (c === "senate" ? "sen" : "asm") + '">'
+ label + "</span>";
}
function legMatches(ev) {
var m = (ev.relevance && ev.relevance.matched) || [];
return m.some(function (x) { return x.type !== "tag"; });
}
function tagMatches(ev) {
var m = (ev.relevance && ev.relevance.matched) || [];
return m.some(function (x) { return x.type === "tag"; });
}
function floatClass(ev) {
var l = legMatches(ev), t = tagMatches(ev);
if (t && l) return "both";
if (t) return "tag-match";
if (l) return "leg-match";
return "";
}
function rowHTML(ev, opts) {
opts = opts || {};
var rel = ev.relevance && ev.relevance.score > 0;
var cls = "row";
if (rel) cls += " relevant " + floatClass(ev);
var time = ev.hearing_time || '<span class="meta">time TBD</span>';
var meta = [time, ev.committee, ev.location, ev.session]
.filter(Boolean).join(" · ");
var tags = (ev.tags || []).slice(0, 6)
.map(function (tg) {
var on = state.prefs &&
(state.prefs.opted_in_tags || []).indexOf(tg) >= 0;
return '<span class="tag ' +
(on ? 'on"' : '"') + ">" + esc(tg) + "</span>";
}).join("");
var marker = rel
? '<span class="chip marker" title="Floated: relevant to you">'
+ "Relevant to you</span>"
: "";
var reasons = "";
if (rel) {
var parts = (ev.relevance.matched || []).map(function (x) {
return esc(x.value);
});
reasons = '<div class="reason' +
(state.showReason ? " show" : "") + '">'
+ "Floated: " + parts.join("; ") + "</div>";
}
var url = "https://leginfo.legislature.ca.gov/bill/" +
(ev.session || "2025-2026") + "/AB" + billNum(ev);
return '<div class="' + cls + '" data-bill="' +
esc(ev.bill_id) + '">'
+ '<span class="num">' + chip(ev.chamber_origin) +
'<br>' + esc(ev.bill_number) + "</span>"
+ '<div class="main"><span class="title">'
+ esc(ev.short_title || ev.bill_number) + "</span>"
+ "<div class='meta'>" + esc(meta) + "</div>"
+ "<a href='" + url + "' target='_blank'"
+ " rel='noopener'>View →</a> " + marker
+ tags
+ reasons
+ '</div>'
+ '<div class="actions">'
+ (state.prefs
? '<button class="why-btn" type="button">Why▸</button>'
: "")
+ "</div>"
+ "</div>";
}
function billNum(ev) {
var m = (ev.bill_number || "").match(/(\d+)/);
return m ? m[1] : "";
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/* ---------- band rendering ---------- */
function groupByDay(items) {
var groups = {};
items.forEach(function (it) {
var key = it.date || "TBD";
if (!groups[key]) groups[key] = { items: [], key: key };
groups[key].items.push(it);
});
return groups;
}
function renderGroups(container, items) {
var byDate = groupByDay(items);
var html = "";
Object.keys(byDate).sort().forEach(function (d) {
var g = byDate[d];
var label = g.key === "TBD" ? "Date TBD"
: niceDate(g.key) + " · " + g.items.length +
" item" + (g.items.length > 1 ? "s" : "");
html += '<div class="day-heading"><b>' + esc(label) +
"</b></div>";
g.items.forEach(function (ev) {
html += rowHTML(ev);
});
});
container.innerHTML = html;
wireRows(container);
}
function niceDate(iso) {
try {
var d = new Date(iso + "T00:00:00");
return d.toLocaleDateString(undefined, {
weekday: "short", month: "short", day: "numeric",
year: "numeric" });
} catch (e) { return iso; }
}
/* ---------- empty states (DESIGN §6) ---------- */
function showEmpty(kind, container) {
var el = el(container);
var h = "";
if (kind === "no-next") {
h = "<h3>No hearings scheduled</h3>" +
"<p>No upcoming daily-file items in this window.</p>" +
"<button type='button' class='escape' id='see-past'>" +
"See recent actions instead</button>";
} else if (kind === "filter") {
h = "<h3>No upcoming items match your filters</h3>" +
"<p><button type='button' class='escape' id='clear'>" +
"Clear filters</button> · " +
"<button type='button' class='escape' id='loosen'>" +
"Loosen to all topics</button> · " +
"<button type='button' class='escape' id='widen'>" +
"Widen date window</button></p>";
} else if (kind === "setup") {
el.innerHTML = "";
el.hidden = false;
el.parentElement.querySelector("#band-setup").hidden = false;
return;
} else if (kind === "no-tags") {
h = "<h3>Topic analysis is filling in</h3>" +
"<p>Tag chips appear as the topic service warms. " +
"You can still watch legislators above.</p>";
}
el.innerHTML = h;
el.hidden = false;
wire(el, kind);
}
function clearEmpty(container) {
var e = el(container);
e.hidden = true; e.innerHTML = "";
}
/* ---------- load + render ---------- */
function load() {
var q = new URLSearchParams();
if (state.filters.upcoming) q.set("upcoming", "1");
if (state.filters.chamber) q.set("chamber", state.filters.chamber);
if (state.filters.q) q.set("q", state.filters.q);
if (state.filters.tags.length)
q.set("tags", state.filters.tags.join(","));
if (state.personal && state.token) q.set("personal", "1");
q.set("full", "1");
apiFetch("/bills?" + q.toString()).then(function (r) {
if (r.status !== 200) { return; }
render(r.body);
});
}
function render(env) {
// freshness / context band
el("freshness-updated").textContent =
"Updated " + (env.as_of || "").slice(0, 10);
el("freshness-session").textContent = "Session " + (env.session || "");
var c = env.chamber_availability || {};
el("freshness-chamber").textContent =
c.SENATE === "available"
? "Senate available"
: "Senate unavailable today";
el("freshness-chamber").style.color =
c.SENATE === "available" ? "var(--muted)" : "var(--warn)";
var items = env.items || [];
state.allItems = items;
var band = items.filter(function (i) {
return i.relevance && i.relevance.score > 0;
});
var bandFull = band;
var shown = bandFull.slice(0, BAND_N);
// personal band
var bandCount = el("band-count");
bandCount.textContent = bandFull.length
? bandFull.length + (bandFull.length > BAND_N
? " (showing " + shown.length + ")" : "") : "";
var list = el("band-list");
var setup = el("band-setup");
if (!state.prefs || (state.prefs.watched_legislators || []).length
=== 0 && (state.prefs.opted_in_tags || []).length === 0) {
list.innerHTML = "";
setup.hidden = false;
} else {
setup.hidden = true;
if (!shown.length) {
list.innerHTML = "<p class='meta'>No bills match your " +
"watches/topics yet. Add more on the right, or " +
"<button type='button' class='escape' id='clear-watch'>" +
"Clear filters</button>.</p>";
} else {
renderGroups(list, shown);
}
var btn = el("btn-show-all");
btn.hidden = bandFull.length <= BAND_N;
}
// all band
var allCount = el("all-count");
allCount.textContent = items.length +
" upcoming" + (items.length === 1 ? " item" : " items");
var allList = el("all-list");
var allEmpty = el("all-empty");
if (!items.length) {
allList.innerHTML = "";
showEmpty("no-next", "all-empty");
} else {
clearEmpty("all-empty");
renderGroups(allList, items);
}
// aside teaser
renderTeaser();
loadTags();
}
function renderTeaser() {
var ul = el("topics-teaser");
ul.innerHTML = "";
(state.tags || []).slice(0, 6).forEach(function (t) {
var li = document.createElement("li");
li.innerHTML = "<span class='n'>" + t.count + "</span> " +
esc(t.label);
var b = document.createElement("button");
b.type = "button";
b.textContent = "view";
b.onclick = function () {
el("f-tags").value = t.tag_id.split(":")[1];
state.filters.tags = [el("f-tags").value];
load();
};
li.appendChild(b);
ul.appendChild(li);
});
}
function loadTags() {
apiFetch("/tags").then(function (r) {
if (r.status === 200) {
state.tags = r.body.tags || [];
var sel = el("f-tags");
sel.innerHTML = state.tags
.map(function (t) {
var name = t.tag_id.split(":")[1];
return '<option value="' + esc(name) + '">' +
esc(t.label) + " (" + t.count +
")</option>";
}).join("");
// rehydrate current selection
Array.prototype.forEach.call(sel.options,
function (o) {
o.selected =
state.filters.tags.indexOf(o.value) >= 0;
});
renderTeaser();
} else {
// "no tagged bills yet" — degrade, no crash
el("topics-teaser").innerHTML =
"<li class='meta'>Topic analysis is filling in…</li>";
}
});
}
/* ---------- auth (D1a local account, dev) ---------- */
function signIn(email) {
apiFetch("/auth/register", { method: "POST",
body: { email: email } }).then(function (r) {
if (r.status !== 201) { alert("Sign-in failed"); return; }
state.token = r.body.token;
localStorage.setItem("dash_token", r.body.token);
apiFetch("/prefs", {}).then(function (pr) {
state.prefs = pr.status === 200 ? pr.body : null;
refreshUI();
load();
});
});
}
function signOut() {
state.token = null;
state.prefs = null;
state.personal = false;
localStorage.removeItem("dash_token");
refreshUI();
load();
}
function refreshUI() {
var has = state.token;
el("user-band").hidden = !has;
el("anon-cta").hidden = !!has;
el("personal-controls").hidden = !has;
el("aside-title").textContent = has
? "Your watchlist" : "Top topics this session";
if (has) {
var p = state.prefs || {};
el("user-band-text").textContent =
"ZIP " + (p.zip || "—") + " · " +
((p.watched_legislators || []).length) +
" legislators · " +
((p.opted_in_tags || []).length) + " topics";
renderWatch();
renderTopics();
}
}
/* ---------- watch / topic toggles ---------- */
function renderWatch() {
var c = el("resolve-result");
var p = state.prefs || {};
var wl = p.watched_legislators || [];
var html = "";
wl.forEach(function (id) {
html += "<div class='candidate'>" +
"<button type='button' class='watch-btn on' " +
"data-leg='" + esc(id) + "'>✓ watching " +
esc(pretty(id)) + "</button>" +
"<button type='button' class='unwatch-btn ghost' " +
"data-leg='" + esc(id) + "'>remove</button>" +
"</div>";
});
c.innerHTML = html || "";
wire(c, "watch");
}
function renderTopics() {
var c = el("topic-list");
var p = state.prefs || {};
var opted = p.opted_in_tags || [];
c.innerHTML = (state.tags || []).map(function (t) {
var name = t.tag_id.split(":")[1];
var on = opted.indexOf(name) >= 0;
return "<button type='button' class='topic-chip" +
(on ? " on" : "") + "' data-tag='" + esc(name) +
"'>" + (on ? "✓ " : "") + esc(t.label) +
" <span class='count'>· " + t.count + " bills</span>" +
"</button>";
}).join("");
wire(c, "topic");
}
function toggleWatch(id, on) {
var path = on
? "/prefs/watch"
: "/prefs/watch/" + encodeURIComponent(id);
var method = on ? "POST" : "DELETE";
var body = on ? { legislator_id: id } : null;
apiFetch(path, { method: method, body: body }).then(function (r) {
if (r.status === 400 || r.status === 404) {
alert(r.body.error && r.body.error.message);
}
apiFetch("/prefs", {}).then(function (pr) {
state.prefs = pr.status === 200 ? pr.body : null;
refreshUI();
load();
announce("Ranking updated");
});
});
}
function toggleTopic(tag, on) {
var path = on ? "/prefs/topics"
: "/prefs/topics/" + encodeURIComponent(tag);
var method = on ? "POST" : "DELETE";
var body = on ? { tag: tag } : null;
apiFetch(path, { method: method, body: body }).then(function (r) {
if (r.status === 400) {
alert(r.body.error && r.body.error.message);
}
apiFetch("/prefs", {}).then(function (pr) {
state.prefs = pr.status === 200 ? pr.body : null;
refreshUI();
load();
announce("Ranking updated");
});
});
}
function announce(msg) {
var a = el("freshness-updated");
var old = a.textContent;
a.textContent = old + " · " + msg;
}
/* ---------- ZIP resolve (DESIGN §8, §6.4) ---------- */
function resolve(zip) {
apiFetch("/districts/resolve", { method: "POST",
body: { zip: zip } }).then(function (r) {
var c = el("resolve-result");
var p = state.prefs || {};
if (r.status === 200 && r.body.confidence === "single") {
p.zip = zip;
var d = r.body.districts[0];
var mem = d.assembly_member || d.senate_member;
p.districts = [d];
if (mem && (p.watched_legislators || []).indexOf(
mem.legislator_id) === -1) {
toggleWatch(mem.legislator_id, true);
}
(r.body.suggested_topics || []).forEach(function (t) {
if ((p.opted_in_tags || []).indexOf(t) === -1)
toggleTopic(t, true);
});
c.innerHTML = "<div class='candidate'>" +
"<span class='who'>Resolved: " + esc(d.district_id) +
" — " + esc((mem && mem.name) || "") + "</span>" +
"</div>" +
"<p class='meta'>Watched " +
esc((mem && mem.name) || "n/a") +
"; suggested topics opted in. " +
"Nothing is auto-picked without a confirm here — " +
"you can remove any on the right.</p>";
load();
} else if (r.status === 200 && r.body.confidence ===
"ambiguous") {
c.innerHTML = "<p class='warning'>" + esc(r.body.note ||
"Multiple districts match this ZIP. Confirm one:") +
"</p>" +
r.body.districts.map(function (d) {
var mem = d.assembly_member || d.senate_member;
return "<div class='candidate'>" +
"<span class='who'>" + esc(d.district_id) +
" · " + esc((mem && mem.name) || "unknown") +
" <span class='dc'>" +
esc((mem && mem.party) || "") + "</span></span>" +
"<button type='button' class='confirm-btn ghost' " +
"data-dc='" + esc(d.district_id) +
"' data-leg='" +
esc((mem && mem.legislator_id) || "") + "'>" +
"Watch " + esc(d.district_id) + "</button></div>";
}).join("");
wire(c, "resolve-ambiguous");
} else {
// zero / out-of-state / other → §6.4 manual fallback
var msg = r.body && r.body.error
? r.body.error.message
: "Could not match that ZIP.";
c.innerHTML =
"<p class='warning'>" + esc(msg) + "</p>" +
"<input type='text' id='manual-dc' placeholder=" +
"'Pick a district by hand, e.g. A-14'>" +
"<button type='button' id='btn-manual' " +
"class='ghost'>Confirm</button>";
el("btn-manual").onclick = function () {
var dc = el("manual-dc").value.trim();
if (dc) resolveManual(dc);
};
}
});
state.prefs = state.prefs || {};
}
function resolveManual(dc) {
apiFetch("/districts/resolve", { method: "POST",
body: { district: dc } }).then(function (r) {
if (r.status === 200) {
var p = state.prefs || {};
p.districts = r.body.districts;
var mem = r.body.districts[0].assembly_member ||
r.body.districts[0].senate_member;
if (mem) toggleWatch(mem.legislator_id, true);
load();
}
});
}
function pretty(id) {
var m = id.match(/^(A|S)-(\d+)$/i);
return m ? m[1] + m[2] : id;
}
/* ---------- wiring ---------- */
function wireRows(container) {
Array.prototype.forEach.call(
container.querySelectorAll(".why-btn"),
function (b) {
b.onclick = function () {
var r = b.closest(".row").querySelector(".reason");
if (r) r.classList.toggle("show");
};
}
);
}
function wire(container, kind) {
if (kind === "watch") {
Array.prototype.forEach.call(
container.querySelectorAll(".unwatch-btn"),
function (b) {
b.onclick = function () {
toggleWatch(b.getAttribute("data-leg"), false);
};
}
);
}
if (kind === "topic") {
Array.prototype.forEach.call(
container.querySelectorAll(".topic-chip"),
function (b) {
b.onclick = function () {
var on = b.classList.contains("on");
toggleTopic(b.getAttribute("data-tag"), !on);
};
}
);
}
if (kind === "resolve-ambiguous") {
Array.prototype.forEach.call(
container.querySelectorAll(".confirm-btn"),
function (b) {
b.onclick = function () {
var lid = b.getAttribute("data-leg");
if (lid) toggleWatch(lid, true);
var name = b.closest(".candidate")
.querySelector(".who").textContent;
el("resolve-result").innerHTML =
"<div class='candidate'><span class='who'>" +
esc(name.trim()) +
"</span><span class='dc'>confirmed</span>" +
"</div>";
};
}
);
}
}
function boot() {
// controls
el("f-chamber").onchange = function () {
state.filters.chamber = el("f-chamber").value; load();
};
el("f-date").onchange = function () {
var v = el("f-date").value;
state.filters.upcoming = (v !== "all");
load();
};
var st;
el("f-search").oninput = function () {
clearTimeout(st);
st = setTimeout(function () {
state.filters.q = el("f-search").value; load();
}, 300);
};
el("f-tags").onchange = function () {
state.filters.tags = Array.prototype.slice.call(
el("f-tags").selectedOptions).map(function (o) {
return o.value;
});
load();
};
el("f-reason").onchange = function () {
state.showReason = el("f-reason").checked;
Array.prototype.forEach.call(
document.querySelectorAll(".reason"),
function (r) { r.classList.toggle("show", state.showReason); });
};
el("btn-refresh").onclick = load;
el("btn-signout").onclick = signOut;
el("btn-resolve").onclick = function () {
var z = el("zip-input").value.trim();
if (z) resolve(z);
};
el("btn-setup").onclick = function () {
var z = el("zip-setup").value.trim();
if (z) { signIn(z + "@resident.gov"); }
};
// unlock CTA
var u = el("unlocked");
if (u) u.onclick = function () {
var em = prompt("Sign in with a resident email (D1a demo):");
if (em) signIn(em);
};
// restore session
if (state.token) {
state.personal = true;
apiFetch("/prefs").then(function (r) {
if (r.status === 200) {
state.prefs = r.body;
state.personal = true;
} else {
state.prefs = null;
}
refreshUI();
load();
});
}
refreshUI();
loadTags();
load();
}
document.addEventListener("DOMContentLoaded", boot);
})();
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>California Legislature Dashboard — Upcoming Legislation</title>
<link rel="stylesheet" href="style.css">
<meta name="description"
content="A monitor of upcoming California Assembly &amp; Senate daily-file
hearings. Anonymous overview; a logged-in resident pins their
legislators &amp; topics to the top.">
</head>
<body>
<!-- sticky header: wordmark + nav + (logged-in) user band -->
<header class="topbar" id="topbar">
<a class="wordmark" href="/">CA&nbsp;Legislature<span>Dashboard</span></a>
<nav class="nav" aria-label="Primary">
<a href="#dashboard" id="nav-dashboard" class="active">Dashboard</a>
<a href="#legislators">Legislators</a>
<a href="#bills">Bills</a>
</nav>
<div class="user-band" id="user-band" hidden>
<span id="user-band-text">ZIP — · 0 legislators · 0 topics</span>
<a href="#prefs" id="nav-prefs">Preferences</a>
<button type="button" id="btn-signout">Sign out</button>
</div>
<div class="anon-cta" id="anon-cta">
<a href="#unlocked">Sign in</a>
<span class="cta-value">Save your interests → sign in</span>
</div>
</header>
<main id="app">
<!-- §9 status / context band -->
<section class="freshness" id="freshness" aria-live="polite">
<span id="freshness-updated">Loading…</span>
<span id="freshness-session" class="badge"></span>
<span id="freshness-chamber" class="note"></span>
<button type="button" id="btn-refresh" class="ghost">Refresh</button>
</section>
<!-- Controls bar -->
<section class="controls" id="controls">
<input type="search" id="f-search" class="search"
placeholder="Search bills… (title, author, committee)"
aria-label="Search bills">
<label>Chamber
<select id="f-chamber">
<option value="">All</option>
<option value="ASSEMBLY">Assembly</option>
<option value="SENATE">Senate</option>
</select>
</label>
<label>Date
<select id="f-date">
<option value="upcoming">Upcoming</option>
<option value="week">This week</option>
<option value="all">All</option>
</select>
</label>
<select id="f-tags" multiple size="1" aria-label="Filter by topic"
class="tags-filter"></select>
<label>Why is this here?
<input type="checkbox" id="f-reason" title="Show why each
floated bill is in your view">
</label>
</section>
<!-- Logged-in personal controls (ZIP + watch/opt-in) -->
<section class="personal-controls" id="personal-controls" hidden>
<div class="zip-lookup">
<label for="zip-input">Where do you live?</label>
<input type="text" id="zip-input" inputmode="numeric"
pattern="[0-9]{5}" placeholder="ZIP (e.g. 90210)">
<button type="button" id="btn-resolve">Find
my legislators</button>
</div>
<div id="resolve-result" class="resolve-result"></div>
<div class="watch-list" id="watch-list"></div>
<div class="topic-list" id="topic-list"></div>
</section>
<!-- Personal band: floored "Relevant to you" (top N) + setup empty state -->
<section class="band" id="personal-band" aria-label="Relevant to you">
<h2 class="band-title">Relevant to you
<span id="band-count" class="count"></span>
<button type="button" id="btn-show-all" class="ghost"
hidden>Show all N</button></h2>
<div id="band-setup" class="empty-state" hidden>
<h3>Pin your view</h3>
<p>Enter your ZIP and we'll surface your legislators and the
bills that matter to you.</p>
<input type="text" id="zip-setup" inputmode="numeric"
placeholder="ZIP code">
<button type="button" id="btn-setup">Find my legislators</button>
</div>
<div id="band-list" class="list"></div>
</section>
<!-- All upcoming legislation: the full queue, floated bills marked -->
<section class="band" id="all-band">
<h2 class="band-title">All upcoming legislation
<span id="all-count" class="count"></span></h2>
<div id="all-list" class="list"></div>
<div id="all-empty" class="empty-state" hidden></div>
</section>
<!-- aside: watchlist summary / top topics this session -->
<aside class="aside" id="aside">
<h3 id="aside-title">Top topics this session</h3>
<ul id="topics-teaser" class="teaser"></ul>
<a id="unlocked" class="unlock">Unlock your view — pin your
legislators &amp; topics to the top →</a>
</aside>
<footer class="footer">
<span>Provenance: CA Assembly &amp; Senate daily file ·
<a href="#about">About</a> · <a href="#help">Help</a> ·
<a href="#privacy">Privacy</a> ·
<a href="#accessibility">Accessibility</a></span>
</footer>
</main>
<script src="app.js"></script>
</body>
</html>
+222
View File
@@ -0,0 +1,222 @@
/* ============================================================================
Legislative Dashboard — visual system (DESIGN_DASHBOARD §9).
Government-appropriate, high-contrast, accessible. One accent. No
gradients, glass, emoji, or hero. Rhythm via scale + spacing + rules.
============================================================================ */
:root {
--ink: #1a1a1a;
--muted: #5b5b5b;
--paper: #ffffff;
--surface: #f4f5f7;
--border: #d5d9df;
--accent: #0b5394; /* sober blue, AA on paper */
--accent-ink: #0a3f6f;
--success: #1e7e34; /* watched / on */
--warn: #8a5a00;
--danger: #b3261e;
--asm: #2f5aa7; /* chamber chip tint only */
--sen: #7a3b8f;
--reason-tag: #0b5394; /* 3px bar: tag match */
--reason-leg: #1e7e34; /* 3px bar: legislator match */
--focus-ring: #0b5394;
--radius: 3px;
--gap: 14px;
--mono: ui-monospace, "SF Mono", "Menlo", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--ink);
background: var(--paper);
line-height: 1.5;
font-size: 15px;
}
/* ---- focus: visible 2px ring, never the only signal ---- */
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ---- topbar ---- */
.topbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 18px;
padding: 10px 18px;
background: var(--paper);
border-bottom: 1px solid var(--border);
}
.wordmark { text-decoration: none; color: var(--ink);
font-weight: 700; letter-spacing: .02em; font-size: 15px; }
.wordmark span { color: var(--accent); }
.nav { display: flex; gap: 14px; }
.nav a { color: var(--muted); text-decoration: none; font-size: 14px;
padding: 6px 2px; border-bottom: 2px solid transparent; }
.nav a:hover { color: var(--ink); }
.nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
.user-band { display: flex; align-items: center; gap: 12px;
margin-left: auto; font-size: 13px; color: var(--muted); }
.user-band button { font-size: 13px; }
.anon-cta { margin-left: auto; text-align: right; }
.anon-cta a { color: var(--accent); font-weight: 600; }
.cta-value { display: block; color: var(--muted); font-size: 12px; }
/* ---- freshness / status band ---- */
.freshness {
display: flex; align-items: center; gap: 10px;
padding: 8px 18px; background: var(--surface);
border-bottom: 1px solid var(--border); font-size: 13px;
color: var(--muted);
}
.badge { background: var(--paper); border: 1px solid var(--border);
padding: 1px 8px; border-radius: 10px; }
.note { color: var(--warn); }
/* ---- controls ---- */
.controls {
display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
padding: 12px 18px; border-bottom: 1px solid var(--border);
font-size: 13px;
}
.controls input, .controls select {
font: inherit; font-size: 13px; padding: 6px 8px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--paper); color: var(--ink);
}
.controls .search { min-width: 240px; flex: 1 1 auto; }
.controls label { display: inline-flex; align-items: center; gap: 5px;
color: var(--muted); }
/* ---- layout: main + aside ---- */
main { max-width: 1100px; margin: 0 auto; }
main .controls, main .freshness { max-width: none; }
.wrap-grid { display: grid; grid-template-columns: 1fr 360px;
gap: 20px; padding: 16px 18px; }
@media (max-width: 860px) {
.wrap-grid { grid-template-columns: 1fr; }
}
/* ---- personal controls ---- */
.personal-controls {
padding: 14px 18px; border-bottom: 1px solid var(--border);
background: var(--surface);
}
.zip-lookup { display: flex; align-items: end; gap: 10px; flex-wrap: wrap; }
.zip-lookup label { display: block; font-size: 12px; color: var(--muted);
margin-bottom: 3px; }
.zip-lookup input { font: inherit; padding: 8px 10px; width: 160px;
border: 1px solid var(--border); border-radius: var(--radius); }
.resolve-result { margin-top: 10px; }
.candidate { display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border: 1px solid var(--border);
border-radius: var(--radius); margin: 4px 0; background: var(--paper); }
.candidate .who { font-weight: 600; }
.candidate .dc { color: var(--muted); font-size: 12px; }
.warning { color: var(--danger); font-size: 13px; margin: 6px 0; }
.watch-list, .topic-list {
display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.watch-item, .topic-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border: 1px solid var(--border);
border-radius: 14px; font-size: 13px; background: var(--paper);
}
.watch-item.on, .topic-chip.on {
border-color: var(--success); color: var(--success);
background: #eef7f0; }
.topic-chip .count { color: var(--muted); font-size: 11px; }
button.chip-toggle { min-width: 120px; }
/* ---- bands ---- */
.band { padding: 16px 18px; border-bottom: 1px solid var(--border); }
.band-title { font-size: 14px; text-transform: uppercase; letter-spacing:
0.06em; color: var(--muted); margin: 0 0 10px; }
.band-title .count { color: var(--ink); font-weight: 700; }
#personal-band .band-title { color: var(--accent); }
.day-heading {
font-size: 13px; color: var(--muted); margin: 14px 0 6px;
padding-bottom: 4px; border-bottom: 1px solid var(--border); }
.day-heading b { color: var(--ink); }
/* ---- bill row ---- */
.row {
display: grid; grid-template-columns: 22px 1fr auto;
gap: 10px; align-items: start;
padding: 10px 10px 10px 0; border-bottom: 1px solid var(--border);
border-left: 3px solid transparent; padding-left: 8px;
}
.row.relevant { background: #f7fafd; }
.row.relevant.tag-match { border-left-color: var(--reason-tag); }
.row.relevant.leg-match { border-left-color: var(--reason-leg); }
.row.relevant.both { border-left-color: var(--accent); }
.row .title { font-weight: 600; }
.row .meta { color: var(--muted); font-size: 13px; margin-top: 2px; }
.row .num { font-family: var(--mono); font-size: 13px; }
.row .why { color: var(--accent); font-size: 12px; cursor: pointer; }
.row .reason { font-size: 12px; color: var(--muted); margin-top: 4px;
display: none; }
.row .reason.show { display: block; }
.chip { display: inline-block; font-size: 11px; padding: 1px 7px;
border-radius: 10px; border: 1px solid var(--border);
margin-right: 5px; }
.chip.asm { background: #eef3fb; color: var(--asm); border-color:#cdd9ee; }
.chip.sen { background: #f5eef9; color: var(--sen); border-color:#e2d2ea; }
.chip.marker { background: var(--accent); color: #fff; border-color:
var(--accent); }
.tag { display: inline-block; font-size: 11px; color: var(--muted);
border: 1px solid var(--border); border-radius: 12px;
padding: 0 6px; margin: 0 4px 0 0; }
.row a { color: var(--accent); text-decoration: none; }
.row a:hover { text-decoration: underline; }
/* ---- empty states ---- */
.empty-state { padding: 22px; text-align: center; color: var(--muted);
border: 1px dashed var(--border); border-radius: var(--radius);
background: var(--surface); }
.empty-state h3 { color: var(--ink); margin-top: 0; }
.empty-state input { margin: 8px 0; padding: 8px; font: inherit;
border: 1px solid var(--border); border-radius: var(--radius); }
.escape { color: var(--accent); cursor: pointer; }
/* ---- aside ---- */
.aside { padding: 16px 0; font-size: 13px; }
.aside h3 { font-size: 13px; text-transform: uppercase; letter-spacing:
0.06em; color: var(--muted); }
.teaser { list-style: none; padding: 0; margin: 0 0 14px; }
.teaser li { padding: 4px 0; }
.teaser .n { color: var(--muted); }
.teaser button { margin-left: 6px; font-size: 12px; }
.unlock { display: block; margin-top: 10px; padding: 10px;
border: 1px solid var(--accent); color: var(--accent);
border-radius: var(--radius); text-decoration: none; }
.unlock:hover { background: #f7fafd; }
/* ---- footer ---- */
.footer { padding: 18px; font-size: 12px; color: var(--muted);
border-top: 1px solid var(--border); }
.footer a { color: var(--muted); }
/* ---- buttons ---- */
button { font: inherit; font-size: 13px; padding: 6px 12px;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--paper); color: var(--ink); cursor: pointer; }
button:hover { background: var(--surface); }
button.ghost { background: transparent; border-color: var(--border);
color: var(--muted); }
button.primary { background: var(--accent); color: #fff;
border-color: var(--accent); }
button.on { background: var(--success); color: #fff;
border-color: var(--success); }
/* motion: minimal. a floated row may fade in once. */
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
.row.relevant { animation: fadein 140ms ease-out; }
@media (prefers-reduced-motion: reduce) {
.row.relevant { animation: none; }
}