● Zion Boggan zionboggan.com ↗

WIP: partial mobile overhaul

1f694f1   Zion Boggan committed on Jun 26, 2026 (1 month ago)
_ops/glm-f5.txt +34 -0
@@ -0,0 +1,34 @@
+You are GLM-5.2, frontend lead for Docket (neutral US gov transparency app, LIVE at usdocket.org). Work in /opt/docket. The site is desktop-only and BREAKS on mobile. This wave = a thorough MOBILE-FIRST RESPONSIVE OVERHAUL + bug fixes. Be RAG-first; read ONLY: docs/BRAND.md, docs/ACCESSIBILITY.md, docs/NEUTRALITY.md, docs/API.md. Then audit your own app/ + components/.
+
+REAL PROBLEMS (verified at 390px iPhone width via _ops/qa/report.json + owner screenshots):
+- Every data TABLE overflows the viewport: /bills table = 3032px, /members/[id] = 1971px (overflows whole doc), /register & / = 828px, /votes 585, /spending 836, /executive 661, /calendar 657. Columns collide; right-side columns (Action/Result/Source) get clipped.
+- The header crams the display-mode toggle (DARK/READABLE/PLAIN) AND the page-nav (REGISTER/CALENDAR/SCHEDULE/VOTES/MEMBERS/BILLS) onto ONE line that runs off-screen - "VOTES" is half-cut. They look like one confusing strip.
+- React hydration error #418 on /register (server/client mismatch - likely the live-ticker RegisterHero and/or date formatting differing SSR vs client).
+- Display headlines are huge on mobile.
+- Tap targets <40px across nav + table links (fails senior accessibility; need >=44px).
+- Calendar uses a fixed min-w-[40rem] table → overflows mobile AND leaves big dead space on desktop.
+
+FIX ALL OF THIS:
+
+1. RESPONSIVE TABLES → CARDS. No data table may overflow at 390px. Pattern: at >=md render the table as-is; below md, render each row as a STACKED CARD (labeled field rows: Date, Body, Item, Action, Result, Source link). Apply to EVERY list/table: register, votes, votes/[id] roster, bills, bill/[id], spending (awards + apportionments), executive, members/[id] vote history (the year-by-year - render per-year cards, NOT a 1971px table), states confidence list. Zero horizontal page scroll on any route at 390px.
+
+2. NAV RESTRUCTURE (this is the cohesion fix). Cleanly SEPARATE two things:
+ (a) Page navigation: Register · Calendar · Schedule · Votes · Members · Bills · Spending · Executive · States · Search. On mobile use a hamburger menu OR a non-clipping horizontal-scroll tab bar; show a clear ACTIVE-page indicator; >=44px targets. On desktop a clean nav bar.
+ (b) Display-mode toggle (Dark/Readable/Plain): a COMPACT control (e.g. a small labeled segmented control or a dropdown), visually distinct from the page-nav, not on the same crammed line.
+ Routing must feel seamless: consistent header on every page, active state, obvious back affordances.
+
+3. FIX HYDRATION #418 on /register: make the RegisterHero ticker hydration-safe - render identical initial markup on server and client, only start the prepend/animation in a useEffect after mount. Ensure date/time formatting is deterministic (no SSR-UTC vs client-local divergence). NOTE: lib/format.ts formatEt was just fixed to return "" for date-only items so the DATE shows instead of a bogus "20:00 ET" - also RENAME the register table column header "TIME" -> "DATE" (the live hero may keep its time-of-day theater).
+
+4. RESPONSIVE TYPOGRAPHY: display headlines use clamp() so they're reasonable on phones; body >=16px (>=18px dark mode per ACCESSIBILITY.md); line-heights comfortable.
+
+5. CALENDAR: make it responsive - fill the container width on desktop (kill the dead space), and on mobile switch to an AGENDA/LIST view (day-grouped events) instead of a fixed-width month grid. No overflow.
+
+6. STATES PAGE: it currently shows "56 states" all "Missing"/red (state data isn't ingested yet) - reads as broken. Fix the framing: label it "50 states + DC & territories" (not "56 states"), and present un-ingested coverage as a calm "coverage rolling out" state (muted, NOT a wall of alarming red). Keep the honesty (gaps shown) but don't imply the site is broken.
+
+7. TAP TARGETS >=44x44px for all interactive elements; underline+color links; no hover-only.
+
+8. FEED DIVERSITY: the register is a monotonous wall of "FEDERAL REGISTER". In lib/feed.ts getFeedItems (or the register page), INTERLEAVE sources so bills/votes/spending/executive mix instead of all same-source same-day FR clustering at the top. Keep it ranked by official action (neutrality rule #3) but ensure variety in what's visible.
+
+KEEP: brand (BRAND.md), neutrality gate (source link every fact, no adjectives, muted vote colors, no comments), and all 3 display modes working on every page incl the new mobile layouts.
+
+BUILD + SELF-VERIFY: `npm run build` MUST pass. Then re-run the mobile QA crawl to PROVE you fixed overflow: `node /opt/docket/_ops/qa-crawl.js` and read /opt/docket/_ops/qa/report.json - every route must show NO table/element exceeding ~390px (offenders list empty or within viewport) and NO React #418 error. Iterate until the crawl is clean. Do NOT restart the production server (handled separately). Update FRONTEND_NOTES.md. Definition of done: QA crawl clean (no overflow, no hydration error), nav separated + mobile-friendly, calendar responsive, states reframed, all 3 modes intact.
app/bill/[id]/page.tsx +44 -62
@@ -2,12 +2,13 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { StatusPill, classifyStatus } from "@/components/status-pill";
import { PartyTag } from "@/components/party-tag";
import { MemberAvatar } from "@/components/member-avatar";
import { RiderFlag, ChangeTag } from "@/components/rider-flag";
-import { getBillDetail, type BillDiffChange } from "@/lib/feed";
+import { getBillDetail, type BillDiffChange, type BillAmendment } from "@/lib/feed";
import { formatDate } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -40,7 +41,7 @@ export default function BillPage({
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<Link
href="/search"
- className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper"
>
← Search
</Link>
@@ -301,46 +302,7 @@ export default function BillPage({
</p>
</Card>
) : (
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <thead>
- <tr className="border-b border-line">
- <Th>Amendment</Th>
- <Th>Sponsor</Th>
- <Th>Status</Th>
- <Th className="text-right">Source</Th>
- </tr>
- </thead>
- <tbody>
- {bill.amendments.map((a) => (
- <tr
- key={a.id}
- className="border-b border-line font-mono text-[12px] last:border-0"
- >
- <Td className="text-paper">
- {a.type} {a.number}
- </Td>
- <Td className="text-ash">
- {a.sponsor ? (
- <Link
- href={a.sponsor.href}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- {a.sponsor.name}
- </Link>
- ) : (
- "-"
- )}
- </Td>
- <Td className="uppercase text-ash-2">{a.status || "Filed"}</Td>
- <Td className="text-right">
- <SourceLink url={a.sourceUrl} />
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
- </Card>
+ <BillAmendmentsTable amendments={bill.amendments} />
)}
</div>
@@ -367,6 +329,46 @@ export default function BillPage({
);
}
+function BillAmendmentsTable({
+ amendments,
+}: {
+ amendments: BillAmendment[];
+}) {
+ const columns: Column[] = [
+ { key: "amdt", header: "Amendment", cardTitle: true },
+ { key: "sponsor", header: "Sponsor" },
+ { key: "status", header: "Status" },
+ { key: "source", header: "Source", align: "right" },
+ ];
+ const rows: Row[] = amendments.map((a) => ({
+ key: a.id,
+ cells: {
+ amdt: <span className="text-paper">{a.type} {a.number}</span>,
+ sponsor:
+ a.sponsor ? (
+ <Link
+ href={a.sponsor.href}
+ className="focus-ring rounded-sm text-ash underline underline-offset-4 hover:text-paper"
+ >
+ {a.sponsor.name}
+ </Link>
+ ) : (
+ <span className="text-ash-2">-</span>
+ ),
+ status: <span className="uppercase text-ash-2">{a.status || "Filed"}</span>,
+ source: <SourceLink url={a.sourceUrl} />,
+ },
+ }));
+ return (
+ <DataTable
+ caption="Amendments filed on this bill: amendment, sponsor, status, source."
+ cardLabel="Amendments"
+ columns={columns}
+ rows={rows}
+ />
+ );
+}
+
function DiffEntry({ d }: { d: BillDiffChange }) {
return (
<div className="border-l-2 pl-3" style={{ borderColor: "var(--dk-line)" }}>
@@ -387,23 +389,3 @@ function DiffEntry({ d }: { d: BillDiffChange }) {
</div>
);
}
-
-function Th({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
-}
-
-function Td({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
-}
app/bills/page.tsx +59 -78
@@ -1,9 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { StatusPill, classifyStatus } from "@/components/status-pill";
-import { getBills, getPolicyAreaLabels, getBillCongresses } from "@/lib/feed";
+import { getBills, getPolicyAreaLabels, getBillCongresses, type BillListItem } from "@/lib/feed";
import { formatDate } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -49,7 +50,7 @@ export default function BillsPage({
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<Link
href={policyArea ? "/categories" : "/register"}
- className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper"
>
← {policyArea ? "Topics" : "Register"}
</Link>
@@ -141,62 +142,7 @@ export default function BillsPage({
</p>
</Card>
) : (
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- Bills on record: number, title, policy area, status, last action.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>Bill</Th>
- <Th>Policy area</Th>
- <Th>Status</Th>
- <Th>Last action</Th>
- <Th className="text-right">Source</Th>
- </tr>
- </thead>
- <tbody>
- {bills.map((b) => (
- <tr
- key={b.id}
- className="border-b border-line font-mono text-[12px] last:border-0 hover:bg-graphite/60"
- >
- <Td className="text-paper">
- <Link
- href={b.href}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- {b.number}
- </Link>
- <span className="block text-ash-2">{b.congress}th</span>
- </Td>
- <Td className="align-middle">
- {b.policyArea ? (
- <Link
- href={`/bills?policyArea=${encodeURIComponent(b.policyArea)}`}
- className="focus-ring rounded-sm text-ash underline underline-offset-4 hover:text-paper"
- >
- {b.policyArea}
- </Link>
- ) : (
- <span className="text-ash-2">-</span>
- )}
- <span className="block max-w-[24rem] truncate text-ash-2">{b.title}</span>
- </Td>
- <Td>
- <StatusPill variant={classifyStatus(b.status)}>
- {b.status || "Introduced"}
- </StatusPill>
- </Td>
- <Td className="tabular-nums text-ash">{formatDate(b.updatedAt)}</Td>
- <Td className="text-right">
- <SourceLink url={b.sourceUrl} />
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
- </Card>
+ <BillsTable bills={bills} />
)}
</div>
@@ -210,6 +156,61 @@ export default function BillsPage({
);
}
+/** Bills as a responsive table (>=md) / stacked cards (<md). */
+function BillsTable({ bills }: { bills: BillListItem[] }) {
+ const columns: Column[] = [
+ { key: "bill", header: "Bill", cardTitle: true },
+ { key: "area", header: "Policy area" },
+ { key: "status", header: "Status" },
+ { key: "updated", header: "Last action" },
+ { key: "source", header: "Source", align: "right" },
+ ];
+ const rows: Row[] = bills.map((b) => ({
+ key: b.id,
+ cells: {
+ bill: (
+ <span className="text-paper">
+ <Link
+ href={b.href}
+ className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
+ >
+ {b.number}
+ </Link>
+ <span className="block text-ash-2">
+ {b.congress}th · {b.title}
+ </span>
+ </span>
+ ),
+ area:
+ b.policyArea ? (
+ <Link
+ href={`/bills?policyArea=${encodeURIComponent(b.policyArea)}`}
+ className="focus-ring rounded-sm text-ash underline underline-offset-4 hover:text-paper"
+ >
+ {b.policyArea}
+ </Link>
+ ) : (
+ <span className="text-ash-2">-</span>
+ ),
+ status: (
+ <StatusPill variant={classifyStatus(b.status)}>
+ {b.status || "Introduced"}
+ </StatusPill>
+ ),
+ updated: <span className="tabular-nums text-ash">{formatDate(b.updatedAt)}</span>,
+ source: <SourceLink url={b.sourceUrl} />,
+ },
+ }));
+ return (
+ <DataTable
+ caption="Bills on record: number, title, policy area, status, last action."
+ cardLabel="Bills on record"
+ columns={columns}
+ rows={rows}
+ />
+ );
+}
+
function dropParam(
sp: Record<string, string | string[] | undefined>,
key: string
@@ -224,23 +225,3 @@ function dropParam(
const s = params.toString();
return s ? `/bills?${s}` : "/bills";
}
-
-function Th({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
-}
-
-function Td({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
-}
app/calendar/page.tsx +111 -10
@@ -115,7 +115,7 @@ export default function CalendarPage({
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<Link
href="/register"
- className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper"
>
← Register
</Link>
@@ -137,8 +137,9 @@ export default function CalendarPage({
← {MONTH_NAMES[prev.month - 1].slice(0, 3)}
</Link>
<p
- className="display px-2 text-2xl text-paper sm:text-3xl"
+ className="display px-2 text-paper"
aria-live="polite"
+ style={{ fontSize: "clamp(1.25rem, 5vw, 1.875rem)" }}
>
{MONTH_NAMES[month - 1]} {year}
</p>
@@ -198,10 +199,12 @@ export default function CalendarPage({
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[1.5fr_1fr]">
- {/* MONTH GRID */}
- <Card className="overflow-hidden">
- <div className="overflow-x-auto">
- <table className="w-full min-w-[40rem] border-collapse text-left">
+ {/* LEFT: month grid (>=md) · agenda list (<md). Both fill the container
+ width - no fixed min-width, so no overflow and no desktop dead space. */}
+ <div>
+ {/* DESKTOP MONTH GRID */}
+ <Card className="hidden overflow-hidden md:block">
+ <table className="w-full table-fixed border-collapse text-left">
<caption className="sr-only">
Calendar grid for {MONTH_NAMES[month - 1]} {year}. Each cell is a
day; select a day to read its events. Rows are weeks, columns
@@ -284,11 +287,16 @@ export default function CalendarPage({
))}
</tbody>
</table>
+ <p className="label border-t border-line px-3 py-2 text-ash-2">
+ {monthEvents.length} event{monthEvents.length === 1 ? "" : "s"} this month. Select a day to read them.
+ </p>
+ </Card>
+
+ {/* MOBILE AGENDA - day-grouped list, replaces the grid on phones. */}
+ <div className="md:hidden">
+ <MobileAgenda events={monthEvents} kindByValue={KIND_BY_VALUE} />
</div>
- <p className="label border-t border-line px-3 py-2 text-ash-2">
- {monthEvents.length} event{monthEvents.length === 1 ? "" : "s"} this month. Select a day to read them.
- </p>
- </Card>
+ </div>
{/* SIDE: legend + selected day */}
<div className="space-y-6">
@@ -447,3 +455,96 @@ function formatLong(iso: string): string {
day: "numeric",
});
}
+
+/**
+ * Mobile agenda - day-grouped list of the month's events, used in place of the
+ * month grid on phones (<md). Each day is a header; each event is a compact
+ * card (kind mark + refType, title link, source). Empty when the month has no
+ * events. Reuses the same kind metadata + source links as the grid.
+ */
+function MobileAgenda({
+ events,
+ kindByValue,
+}: {
+ events: CalendarEvent[];
+ kindByValue: Map<CalendarKind, KindMeta>;
+}) {
+ if (events.length === 0) {
+ return (
+ <Card className="p-5">
+ <p className="font-mono text-[13px] text-ash-2">
+ No events on record for this month. Month navigation above moves to
+ other months; each event will link to its official source.
+ </p>
+ </Card>
+ );
+ }
+ // Group by date (asc); events already come back ordered by date.
+ const byDate = new Map<string, CalendarEvent[]>();
+ for (const e of events) {
+ const arr = byDate.get(e.date) ?? [];
+ arr.push(e);
+ byDate.set(e.date, arr);
+ }
+ const days = Array.from(byDate.keys()).sort();
+ return (
+ <div className="space-y-6">
+ {days.map((d) => {
+ const evs = byDate.get(d)!;
+ return (
+ <section key={d} aria-label={formatLong(d)}>
+ <div className="mb-2 flex items-baseline justify-between gap-3 border-b border-line pb-2">
+ <h3 className="font-mono text-[13px] text-paper">{formatLong(d)}</h3>
+ <span className="label text-ash-2">
+ {evs.length} event{evs.length === 1 ? "" : "s"}
+ </span>
+ </div>
+ <div className="space-y-2">
+ {evs.map((e) => {
+ const k = kindByValue.get(e.kind);
+ return (
+ <article
+ key={e.id}
+ className="rounded-[3px] border border-line bg-surface p-3"
+ >
+ <div className="flex flex-wrap items-center gap-2">
+ {k ? (
+ <span
+ className="pill !py-0.5"
+ style={{
+ color: k.color,
+ borderColor: `color-mix(in srgb, ${k.color} 45%, transparent)`,
+ }}
+ >
+ {k.plain}
+ </span>
+ ) : (
+ <span className="pill !py-0.5 text-ash">{e.kind}</span>
+ )}
+ <span className="label text-ash-2">{e.refType}</span>
+ </div>
+ <p className="mt-2 break-words font-mono text-[12px] leading-relaxed text-paper">
+ {e.href ? (
+ <Link
+ href={e.href}
+ className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
+ >
+ {e.title}
+ </Link>
+ ) : (
+ e.title
+ )}
+ </p>
+ <div className="mt-2">
+ <SourceLink url={e.sourceUrl} />
+ </div>
+ </article>
+ );
+ })}
+ </div>
+ </section>
+ );
+ })}
+ </div>
+ );
+}
app/categories/page.tsx +1 -1
@@ -23,7 +23,7 @@ export default function CategoriesPage() {
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<Link
href="/register"
- className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper"
>
← Register
</Link>
app/globals.css +21 -0
@@ -165,17 +165,38 @@
input[type="checkbox"],
input[type="radio"],
input[type="search"],
+ input[type="text"],
select {
min-height: 44px;
min-width: 44px;
}
+ /* Mobile overflow guard: no element should ever push the page wider than the
+ viewport. Tables are replaced by cards <md via the DataTable component; this
+ is belt-and-suspenders so a stray long token can never cause horizontal
+ scroll (mobile-first overhaul). */
+ body {
+ overflow-x: hidden;
+ }
+
+ /* Structural links (nav / back / action affordances) carry a >=44px target so
+ they pass the a11y #3 target-size rule even though plain <a> is exempt from
+ the button rule above. */
+ .tap {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ }
+
/* Skip-to-content link - visible only when focused (keyboard-first). */
.skip-link {
position: absolute;
left: 0.5rem;
top: -3rem;
z-index: 100;
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
background: var(--dk-surface);
color: var(--dk-paper);
border: 1px solid var(--dk-line);
app/members/[id]/page.tsx +93 -130
@@ -2,12 +2,17 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Card, SectionHeader } from "@/components/ui";
+import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { PartyTag } from "@/components/party-tag";
import { PositionTag } from "@/components/position-tag";
import { MemberAvatar } from "@/components/member-avatar";
import { StatusPill, classifyStatus } from "@/components/status-pill";
-import { getMember } from "@/lib/feed";
+import {
+ getMember,
+ type MemberVote,
+ type MemberAmendment,
+} from "@/lib/feed";
import { formatDate } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -37,7 +42,7 @@ export default function MemberDetailPage({
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<Link
href="/members"
- className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper"
>
← Members
</Link>
@@ -58,7 +63,12 @@ export default function MemberDetailPage({
{member.chamber === "senate" ? "U.S. Senate" : "U.S. House"} · {member.state}
{member.district != null ? ` · district ${member.district}` : ""}
</p>
- <h1 className="display text-4xl text-paper sm:text-5xl">{member.name}</h1>
+ <h1
+ className="display text-paper"
+ style={{ fontSize: "clamp(1.875rem, 7vw, 3rem)", lineHeight: 1 }}
+ >
+ {member.name}
+ </h1>
<div className="mt-4 flex flex-wrap items-center gap-3">
<PartyTag party={member.partyCode} />
<span className="font-mono text-[13px] text-ash">
@@ -119,44 +129,7 @@ export default function MemberDetailPage({
</p>
</Card>
) : (
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- This member&apos;s recent recorded votes: date, chamber, result, position.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>Date</Th>
- <Th>Chamber</Th>
- <Th>Result</Th>
- <Th>Voted</Th>
- </tr>
- </thead>
- <tbody>
- {member.votes.map((v) => (
- <tr
- key={v.voteId}
- className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
- >
- <Td className="tabular-nums text-ash">{formatDate(v.date)}</Td>
- <Td className="uppercase text-ash-2">{v.chamber}</Td>
- <Td className="text-paper">
- <Link
- href={v.voteHref}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- {v.question || v.result}
- </Link>
- <span className="block text-ash-2">{v.result}</span>
- </Td>
- <Td>
- <PositionTag position={v.position} />
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
- </Card>
+ <MemberVotesTable votes={member.votes} />
)}
</div>
@@ -239,43 +212,7 @@ export default function MemberDetailPage({
</span>
</summary>
<div className="border-t border-line">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- {yg.year ?? "Undated"} votes for {member.name}: date,
- chamber, question, result, and this member&apos;s position.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>Date</Th>
- <Th>Chamber</Th>
- <Th>Question</Th>
- <Th>Voted</Th>
- </tr>
- </thead>
- <tbody>
- {yg.votes.map((v) => (
- <tr
- key={v.voteId}
- className="border-b border-line font-mono text-[12px] last:border-0 hover:bg-graphite/60"
- >
- <Td className="tabular-nums text-ash">{formatDate(v.date)}</Td>
- <Td className="uppercase text-ash-2">{v.chamber}</Td>
- <Td className="text-paper">
- <Link
- href={v.voteHref}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- {v.question || v.result}
- </Link>
- <span className="block text-ash-2">{v.result}</span>
- </Td>
- <Td>
- <PositionTag position={v.position} />
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
+ <MemberVotesTable votes={yg.votes} dense />
</div>
</details>
</Card>
@@ -292,46 +229,7 @@ export default function MemberDetailPage({
eyebrow={`Amendments · ${member.amendments.length}`}
title="Amendments offered"
/>
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <thead>
- <tr className="border-b border-line">
- <Th>Amendment</Th>
- <Th>Bill</Th>
- <Th>Status</Th>
- <Th className="text-right">Source</Th>
- </tr>
- </thead>
- <tbody>
- {member.amendments.map((a) => (
- <tr
- key={a.amendmentId}
- className="border-b border-line font-mono text-[12px] last:border-0"
- >
- <Td className="text-paper">
- {a.type} {a.number}
- </Td>
- <Td className="text-ash">
- {a.billHref ? (
- <Link
- href={a.billHref}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- Bill {a.billId}
- </Link>
- ) : (
- "-"
- )}
- </Td>
- <Td className="uppercase text-ash-2">{a.status || "-"}</Td>
- <Td className="text-right">
- <SourceLink url={a.sourceUrl} />
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
- </Card>
+ <MemberAmendmentsTable amendments={member.amendments} />
</div>
) : null}
@@ -344,22 +242,87 @@ export default function MemberDetailPage({
);
}
-function Th({
- children,
- className = "",
+/** A member's votes as a responsive table (>=md) / stacked cards (<md).
+ * `dense` collapses the chamber column for the year-by-year sections. */
+function MemberVotesTable({
+ votes,
+ dense = false,
}: {
- children: React.ReactNode;
- className?: string;
+ votes: MemberVote[];
+ dense?: boolean;
}) {
- return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
+ const columns: Column[] = [
+ { key: "date", header: "Date" },
+ ...(dense ? [] : [{ key: "chamber", header: "Chamber", hideOnCard: true } as Column]),
+ { key: "result", header: "Question", cardTitle: true },
+ { key: "voted", header: "Voted" },
+ ];
+ const rows: Row[] = votes.map((v) => ({
+ key: v.voteId,
+ cells: {
+ date: <span className="tabular-nums text-ash">{formatDate(v.date)}</span>,
+ chamber: <span className="uppercase text-ash-2">{v.chamber}</span>,
+ result: (
+ <span className="text-paper">
+ <Link
+ href={v.voteHref}
+ className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
+ >
+ {v.question || v.result}
+ </Link>
+ <span className="block text-ash-2">{v.result}</span>
+ </span>
+ ),
+ voted: <PositionTag position={v.position} />,
+ },
+ }));
+ return (
+ <DataTable
+ caption="This member's recorded votes: date, chamber, question, result, position."
+ cardLabel="Recorded votes"
+ columns={columns}
+ rows={rows}
+ />
+ );
}
-function Td({
- children,
- className = "",
+/** A member's amendments as a responsive table (>=md) / stacked cards (<md). */
+function MemberAmendmentsTable({
+ amendments,
}: {
- children: React.ReactNode;
- className?: string;
+ amendments: MemberAmendment[];
}) {
- return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
+ const columns: Column[] = [
+ { key: "amdt", header: "Amendment", cardTitle: true },
+ { key: "bill", header: "Bill" },
+ { key: "status", header: "Status" },
+ { key: "source", header: "Source", align: "right" },
+ ];
+ const rows: Row[] = amendments.map((a) => ({
+ key: a.amendmentId,
+ cells: {
+ amdt: <span className="text-paper">{a.type} {a.number}</span>,
+ bill:
+ a.billHref ? (
+ <Link
+ href={a.billHref}
+ className="focus-ring rounded-sm text-ash underline underline-offset-4 hover:text-paper"
+ >
+ Bill {a.billId}
+ </Link>
+ ) : (
+ <span className="text-ash-2">-</span>
+ ),
+ status: <span className="uppercase text-ash-2">{a.status || "-"}</span>,
+ source: <SourceLink url={a.sourceUrl} />,
+ },
+ }));
+ return (
+ <DataTable
+ caption="Amendments offered by this member: amendment, bill, status, source."
+ cardLabel="Amendments"
+ columns={columns}
+ rows={rows}
+ />
+ );
}
app/not-found.tsx +1 -1
@@ -10,7 +10,7 @@ export default function NotFound() {
</p>
<Link
href="/register"
- className="focus-ring label mt-8 inline-flex items-center gap-1 text-paper underline-offset-4 hover:underline"
+ className="focus-ring tap label mt-8 gap-1 text-paper underline-offset-4 hover:underline"
>
← Back to the Register
</Link>
app/register/page.tsx +56 -74
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { RegisterHero } from "@/components/register-hero";
import { Card, SectionHeader } from "@/components/ui";
+import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { ChangesFeed } from "@/components/changes-feed";
import {
@@ -69,59 +70,7 @@ export default async function RegisterPage() {
</p>
</Card>
) : (
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- Official actions in ranked order: time, body, item, action,
- result, and source.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>Date</Th>
- <Th>Body</Th>
- <Th>Item</Th>
- <Th>Action</Th>
- <Th className="text-right">Result</Th>
- <Th className="text-right">Source</Th>
- </tr>
- </thead>
- <tbody>
- {shown.map((r) => (
- <tr
- key={r.id}
- className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
- >
- <Td className="tabular-nums text-ash">
- {formatEt(r.actionDate) || formatDate(r.actionDate)}
- </Td>
- <Td className="uppercase text-ash-2">{r.chamber}</Td>
- <Td className="text-paper">
- {r.href ? (
- <Link
- href={r.href}
- className="focus-ring rounded-sm underline underline-offset-4"
- >
- {r.title}
- </Link>
- ) : (
- r.title
- )}
- {r.summary ? (
- <span className="block text-ash-2">{r.summary}</span>
- ) : null}
- </Td>
- <Td className="uppercase text-ash">{r.actionType}</Td>
- <Td className="text-right tabular-nums text-paper">
- {r.amountText ?? "-"}
- </Td>
- <Td className="text-right">
- <SourceLink url={r.sourceUrl} />
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
- </Card>
+ <RegisterTable items={shown} />
)}
{/* Composition by kind - proportional to official-action volume (rule #3). */}
@@ -166,7 +115,7 @@ export default async function RegisterPage() {
</p>
<Link
href="/spending"
- className="focus-ring label mt-5 inline-flex items-center gap-1 text-ash underline underline-offset-4 hover:text-paper"
+ className="focus-ring tap label mt-5 gap-1 text-ash underline underline-offset-4 hover:text-paper"
>
Spending register →
</Link>
@@ -180,7 +129,7 @@ export default async function RegisterPage() {
<p className="label">Recently changed</p>
<Link
href="/calendar"
- className="focus-ring label text-ash underline underline-offset-4 hover:text-paper"
+ className="focus-ring tap label gap-1 text-ash underline underline-offset-4 hover:text-paper"
>
Calendar →
</Link>
@@ -197,7 +146,7 @@ export default async function RegisterPage() {
<p className="label">Browse by topic</p>
<Link
href="/categories"
- className="focus-ring label text-ash underline underline-offset-4 hover:text-paper"
+ className="focus-ring tap label gap-1 text-ash underline underline-offset-4 hover:text-paper"
>
All topics →
</Link>
@@ -233,22 +182,55 @@ function byKind(items: FeedItem[]) {
);
}
-function Th({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
-}
-
-function Td({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
+/** Ranked register as a responsive table (>=md) / stacked cards (<md). */
+function RegisterTable({ items }: { items: FeedItem[] }) {
+ const columns: Column[] = [
+ { key: "date", header: "Date" },
+ { key: "body", header: "Body", hideOnCard: true },
+ { key: "item", header: "Item", cardTitle: true },
+ { key: "action", header: "Action" },
+ { key: "result", header: "Result", align: "right" },
+ { key: "source", header: "Source", align: "right" },
+ ];
+ const rows: Row[] = items.map((r) => ({
+ key: r.id,
+ cells: {
+ date: (
+ <span className="tabular-nums text-ash">
+ {formatEt(r.actionDate) || formatDate(r.actionDate)}
+ </span>
+ ),
+ body: <span className="uppercase text-ash-2">{r.chamber}</span>,
+ item: (
+ <span className="text-paper">
+ {r.href ? (
+ <Link
+ href={r.href}
+ className="focus-ring rounded-sm underline underline-offset-4"
+ >
+ {r.title}
+ </Link>
+ ) : (
+ r.title
+ )}
+ {r.summary ? (
+ <span className="block text-ash-2">{r.summary}</span>
+ ) : null}
+ </span>
+ ),
+ action: <span className="uppercase text-ash">{r.actionType}</span>,
+ result: (
+ <span className="tabular-nums text-paper">{r.amountText ?? "-"}</span>
+ ),
+ source: <SourceLink url={r.sourceUrl} />,
+ },
+ }));
+ return (
+ <DataTable
+ caption="Official actions in ranked order: date, body, item, action, result, and source."
+ cardLabel="Official actions in ranked order"
+ columns={columns}
+ rows={rows}
+ />
+ );
}
app/schedule/page.tsx +1 -1
@@ -34,7 +34,7 @@ export default function SchedulePage() {
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<Link
href="/register"
- className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper"
>
← Register
</Link>
app/spending/page.tsx +90 -112
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
-import { getAwards, getFiscal, getApportionments } from "@/lib/feed";
+import { getAwards, getFiscal, getApportionments, type Award, type Apportionment } from "@/lib/feed";
import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { formatUsd, formatDate } from "@/lib/format";
@@ -94,44 +95,7 @@ export default function SpendingPage() {
</p>
</Card>
) : (
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- Federal awards: agency, recipient, program, date, amount, source.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>Agency</Th>
- <Th>Recipient</Th>
- <Th>Program</Th>
- <Th>Date</Th>
- <Th className="text-right">Amount</Th>
- <Th className="text-right">Source</Th>
- </tr>
- </thead>
- <tbody>
- {awards.map((a) => (
- <tr
- key={a.id}
- className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
- >
- <Td className="text-paper">{a.agency}</Td>
- <Td className="text-ash">{a.recipient}</Td>
- <Td className="text-ash">{a.program}</Td>
- <Td className="tabular-nums text-ash-2">
- {formatDate(a.actionDate)}
- </Td>
- <Td className="text-right tabular-nums text-paper">
- {a.amountText}
- </Td>
- <Td className="text-right">
- <SourceLink url={a.sourceUrl} />
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
- </Card>
+ <AwardsTable awards={awards} />
)}
<p className="label mt-4 text-ash-2">
Source: USAspending.gov. Figures reflect recorded award obligations;
@@ -168,59 +132,7 @@ export default function SpendingPage() {
</p>
</Card>
) : (
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- OMB apportionments: agency, account, appropriated, apportioned, status, source.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>Agency</Th>
- <Th>Account</Th>
- <Th className="text-right">Appropriated</Th>
- <Th className="text-right">Apportioned</Th>
- <Th>Status</Th>
- <Th className="text-right">Source</Th>
- </tr>
- </thead>
- <tbody>
- {apportionments.map((a) => {
- const color = apportionmentColor(a.status);
- return (
- <tr
- key={a.id}
- className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
- >
- <Td className="text-paper">{a.agency}</Td>
- <Td className="text-ash">{a.account}</Td>
- <Td className="text-right tabular-nums text-paper">
- {a.appropriated == null ? "-" : formatUsd(a.appropriated)}
- </Td>
- <Td className="text-right tabular-nums text-paper">
- {a.apportioned == null ? "-" : formatUsd(a.apportioned)}
- </Td>
- <Td>
- <span
- className="inline-flex items-center gap-1.5 uppercase"
- style={{ color }}
- >
- <span
- aria-hidden="true"
- className="inline-block h-1.5 w-1.5 rounded-full"
- style={{ backgroundColor: color }}
- />
- {a.status}
- </span>
- </Td>
- <Td className="text-right">
- <SourceLink url={a.sourceUrl} label="OMB" />
- </Td>
- </tr>
- );
- })}
- </tbody>
- </table>
- </Card>
+ <ApportionmentsTable apportionments={apportionments} color={apportionmentColor} />
)}
<p className="label mt-4 text-ash-2">
Source: apportionment-public.max.gov (OMB). Appropriated and apportioned are recorded figures; the gap is shown where one exists.
@@ -230,6 +142,92 @@ export default function SpendingPage() {
);
}
+function AwardsTable({ awards }: { awards: Award[] }) {
+ const columns: Column[] = [
+ { key: "agency", header: "Agency", cardTitle: true },
+ { key: "recipient", header: "Recipient" },
+ { key: "program", header: "Program", hideOnCard: true },
+ { key: "date", header: "Date" },
+ { key: "amount", header: "Amount", align: "right" },
+ { key: "source", header: "Source", align: "right" },
+ ];
+ const rows: Row[] = awards.map((a) => ({
+ key: a.id,
+ cells: {
+ agency: <span className="text-paper">{a.agency}</span>,
+ recipient: <span className="text-ash">{a.recipient}</span>,
+ program: <span className="text-ash">{a.program}</span>,
+ date: <span className="tabular-nums text-ash-2">{formatDate(a.actionDate)}</span>,
+ amount: <span className="tabular-nums text-paper">{a.amountText}</span>,
+ source: <SourceLink url={a.sourceUrl} />,
+ },
+ }));
+ return (
+ <DataTable
+ caption="Federal awards: agency, recipient, program, date, amount, source."
+ cardLabel="Federal awards"
+ columns={columns}
+ rows={rows}
+ />
+ );
+}
+
+function ApportionmentsTable({
+ apportionments,
+ color,
+}: {
+ apportionments: Apportionment[];
+ color: (s: string) => string;
+}) {
+ const columns: Column[] = [
+ { key: "agency", header: "Agency", cardTitle: true },
+ { key: "account", header: "Account" },
+ { key: "appropriated", header: "Appropriated", align: "right", hideOnCard: true },
+ { key: "apportioned", header: "Apportioned", align: "right", hideOnCard: true },
+ { key: "status", header: "Status" },
+ { key: "source", header: "Source", align: "right" },
+ ];
+ const rows: Row[] = apportionments.map((a) => {
+ const c = color(a.status);
+ return {
+ key: a.id,
+ cells: {
+ agency: <span className="text-paper">{a.agency}</span>,
+ account: <span className="text-ash">{a.account}</span>,
+ appropriated: (
+ <span className="tabular-nums text-paper">
+ {a.appropriated == null ? "-" : formatUsd(a.appropriated)}
+ </span>
+ ),
+ apportioned: (
+ <span className="tabular-nums text-paper">
+ {a.apportioned == null ? "-" : formatUsd(a.apportioned)}
+ </span>
+ ),
+ status: (
+ <span className="inline-flex items-center gap-1.5 uppercase" style={{ color: c }}>
+ <span
+ aria-hidden="true"
+ className="inline-block h-1.5 w-1.5 rounded-full"
+ style={{ backgroundColor: c }}
+ />
+ {a.status}
+ </span>
+ ),
+ source: <SourceLink url={a.sourceUrl} label="OMB" />,
+ },
+ };
+ });
+ return (
+ <DataTable
+ caption="OMB apportionments: agency, account, appropriated, apportioned, status, source."
+ cardLabel="OMB apportionments"
+ columns={columns}
+ rows={rows}
+ />
+ );
+}
+
function ImpoundCard({
label,
count,
@@ -259,23 +257,3 @@ function ImpoundCard({
</Card>
);
}
-
-function Th({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
-}
-
-function Td({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
-}
app/states/[code]/page.tsx +8 -7
@@ -20,7 +20,12 @@ export function generateMetadata({
}
function confidenceColor(c: "ok" | "stale" | "missing"): string {
- return c === "ok" ? "var(--dk-yea)" : c === "stale" ? "var(--dk-hold)" : "var(--dk-nay)";
+ // Un-ingested reads as a calm ash "rolling out", not red (states reframe).
+ return c === "ok" ? "var(--dk-yea)" : c === "stale" ? "var(--dk-hold)" : "var(--dk-ash)";
+}
+
+function confidenceLabel(c: "ok" | "stale" | "missing"): string {
+ return c === "ok" ? "Current" : c === "stale" ? "Stale" : "Coverage rolling out";
}
export default function StateDetailPage({
@@ -35,7 +40,7 @@ export default function StateDetailPage({
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<Link
href="/states"
- className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper"
>
← States
</Link>
@@ -60,11 +65,7 @@ export default function StateDetailPage({
style={{ backgroundColor: confidenceColor(detail.confidence) }}
/>
<span className="label">
- {detail.confidence === "ok"
- ? "Current"
- : detail.confidence === "stale"
- ? "Stale"
- : "Missing"}
+ {confidenceLabel(detail.confidence)}
</span>
</span>
<span className="label text-ash-2">
app/states/page.tsx +108 -97
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { Card, PageHeader } from "@/components/ui";
+import { DataTable, type Column, type Row } from "@/components/data-table";
import { getStates, type StateCoverage } from "@/lib/feed";
import { clsx } from "@/lib/cx";
import { formatDate } from "@/lib/format";
@@ -8,108 +9,132 @@ import { formatDate } from "@/lib/format";
export const metadata: Metadata = {
title: "States",
description:
- "Per-state coverage and confidence. Gaps are recorded, not hidden. Open a state for its delegation and sponsored bills.",
+ "Coverage for the 50 states, DC, and U.S. territories. State-legislative coverage is rolling out; gaps are recorded, not hidden. Open a state for its delegation and sponsored bills.",
};
export const dynamic = "force-dynamic";
-function confidenceColor(c: StateCoverage["confidence"]): string {
- // muted semantic tones - equal weight (neutrality §Viz)
- return c === "ok" ? "var(--dk-yea)" : c === "stale" ? "var(--dk-hold)" : "var(--dk-nay)";
-}
-
-function confidenceLabel(c: StateCoverage["confidence"]): string {
- return c === "ok" ? "Current" : c === "stale" ? "Stale" : "Missing";
+/**
+ * Coverage framing. Federal data (congress.gov delegation + bill sponsorships)
+ * is on record for every state; STATE-legislative coverage is rolling out and
+ * is shown honestly but calmly - never a wall of alarming red. Un-ingested
+ * states read as "Coverage rolling out" in a muted tone, not "Missing"/red.
+ * ok → Current (sage)
+ * stale → Stale (hold)
+ * missing → Coverage rolling out (ash, NOT red)
+ */
+function coverageMeta(c: StateCoverage["confidence"]): {
+ label: string;
+ color: string;
+} {
+ if (c === "ok") return { label: "Current", color: "var(--dk-yea)" };
+ if (c === "stale") return { label: "Stale", color: "var(--dk-hold)" };
+ return { label: "Coverage rolling out", color: "var(--dk-ash)" };
}
export default function StatesPage() {
const states = getStates();
const ok = states.filter((s) => s.confidence === "ok").length;
const stale = states.filter((s) => s.confidence === "stale").length;
- const missing = states.filter((s) => s.confidence === "missing").length;
+ const rolling = states.filter((s) => s.confidence === "missing").length;
+
+ // 50 states + DC + territories (PR, AS, GU, MP, VI). Shown honestly as
+ // jurisdictions, never as a bogus "56 states".
+ const territories = states.length - 50;
+
+ const columns: Column[] = [
+ { key: "state", header: "State", cardTitle: true },
+ { key: "confidence", header: "Coverage" },
+ { key: "seen", header: "Last seen", hideOnCard: true },
+ { key: "missing", header: "Not yet in record" },
+ ];
+ const rows: Row[] = states.map((s) => {
+ const m = coverageMeta(s.confidence);
+ return {
+ key: s.code,
+ cells: {
+ state: (
+ <Link
+ href={`/states/${s.code}`}
+ className="focus-ring rounded-sm text-paper underline underline-offset-4 hover:text-paper"
+ >
+ <span className="mr-2 text-ash-2">{s.code}</span>
+ {s.name}
+ </Link>
+ ),
+ confidence: (
+ <span className="inline-flex items-center gap-1.5" style={{ color: m.color }}>
+ <span
+ aria-hidden="true"
+ className="inline-block h-1.5 w-1.5 rounded-full"
+ style={{ backgroundColor: m.color }}
+ />
+ {m.label}
+ </span>
+ ),
+ seen: (
+ <span className="tabular-nums text-ash-2">
+ {s.lastScrape ? formatDate(s.lastScrape) : "-"}
+ </span>
+ ),
+ missing: (
+ <span className="uppercase text-ash-2">
+ {s.missingTypes.length ? s.missingTypes.join(", ") : "-"}
+ </span>
+ ),
+ },
+ };
+ });
return (
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<PageHeader
eyebrow="State register"
- title="Coverage, by state"
- lead="State legislative activity via OpenStates and official state legislatures. Freshness and completeness vary; this map records the gaps rather than hiding them. Federal delegation and sponsored bills are shown per state."
+ title="50 states + DC & territories"
+ lead="Federal delegation and sponsored bills are on record for every state, DC, and U.S. territory. State-level legislative coverage is rolling out - gaps are recorded here, not hidden, and never imply the site is broken."
/>
- {/* confidence summary - muted, equal weight */}
+ {/* Coverage summary - muted, calm. "Rolling out" uses an ash tone, not red. */}
<div className="mt-8 grid gap-4 sm:grid-cols-3">
- <ConfidenceCard label="Current" count={ok} level="ok" />
- <ConfidenceCard label="Stale" count={stale} level="stale" />
- <ConfidenceCard label="Missing" count={missing} level="missing" />
+ <CoverageCard label="Current" count={ok} level="ok" />
+ <CoverageCard label="Stale" count={stale} level="stale" />
+ <CoverageCard label="Coverage rolling out" count={rolling} level="missing" />
</div>
<div className="mt-12">
- <SectionHeader
- eyebrow={`Per-state · ${states.length}`}
- title="Confidence map"
- description="Each state's worst-case coverage across bills, votes, and sponsors. Open a state for its congressional delegation."
+ <div className="mb-6">
+ <p className="label mb-2">Per-state · {states.length} jurisdictions</p>
+ <h2
+ className="display text-paper"
+ style={{ fontSize: "clamp(1.25rem, 4vw, 1.875rem)" }}
+ >
+ Coverage by state
+ </h2>
+ <p className="prose-lead mt-3 max-w-2xl text-ash">
+ Each jurisdiction&apos;s worst-case state-legislative coverage across bills, votes,
+ and sponsors. Federal delegation and bill sponsorships (from congress.gov) are
+ shown regardless. Open a state for its congressional delegation.
+ </p>
+ </div>
+
+ <DataTable
+ caption="Per-state coverage: state, coverage level, last scrape, data not yet in record."
+ cardLabel="Per-state coverage"
+ columns={columns}
+ rows={rows}
/>
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- Per-state coverage confidence: state, level, last scrape, missing data types.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>State</Th>
- <Th>Confidence</Th>
- <Th>Last seen</Th>
- <Th>Missing</Th>
- </tr>
- </thead>
- <tbody>
- {states.map((s) => (
- <tr
- key={s.code}
- className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
- >
- <Td className="text-paper">
- <Link
- href={`/states/${s.code}`}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- <span className="mr-2 text-ash-2">{s.code}</span>
- {s.name}
- </Link>
- </Td>
- <Td>
- <span
- className="inline-flex items-center gap-1.5"
- style={{ color: confidenceColor(s.confidence) }}
- >
- <span
- aria-hidden="true"
- className="inline-block h-1.5 w-1.5 rounded-full"
- style={{ backgroundColor: confidenceColor(s.confidence) }}
- />
- {confidenceLabel(s.confidence)}
- </span>
- </Td>
- <Td className="tabular-nums text-ash-2">
- {s.lastScrape ? formatDate(s.lastScrape) : "-"}
- </Td>
- <Td className="uppercase text-ash-2">
- {s.missingTypes.length ? s.missingTypes.join(", ") : "-"}
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
- </Card>
+
<p className="label mt-4 text-ash-2">
- State legislative coverage requires per-state polling. Federal delegation and bill sponsorships (from congress.gov) are shown regardless of state-leg coverage.
+ {territories} of these jurisdictions are DC &amp; U.S. territories. State
+ legislative coverage requires per-state polling and rolls out over time; the
+ federal delegation is on record now.
</p>
</div>
</div>
);
}
-function ConfidenceCard({
+function CoverageCard({
label,
count,
level,
@@ -118,38 +143,24 @@ function ConfidenceCard({
count: number;
level: StateCoverage["confidence"];
}) {
+ const m = coverageMeta(level);
return (
<Card className="p-5">
<p className="label mb-2">{label}</p>
<div className="flex items-baseline gap-2">
- <span className="display text-3xl text-paper">{count}</span>
- <span className="label text-ash-2">states</span>
+ <span
+ className="display text-paper"
+ style={{ fontSize: "clamp(1.75rem, 6vw, 1.875rem)" }}
+ >
+ {count}
+ </span>
+ <span className="label text-ash-2">jurisdictions</span>
</div>
<span
className={clsx("mt-3 inline-block h-0.5 w-12 rounded-full")}
- style={{ backgroundColor: confidenceColor(level) }}
+ style={{ backgroundColor: m.color }}
aria-hidden="true"
/>
</Card>
);
}
-
-function Th({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
-}
-
-function Td({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
-}
app/votes/[id]/page.tsx +49 -70
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { StatusPill, classifyStatus } from "@/components/status-pill";
import { VoteTally } from "@/components/vote-tally";
@@ -66,7 +67,7 @@ export default function VoteDetailPage({
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
<Link
href="/votes"
- className="focus-ring label mb-6 inline-flex items-center gap-1 text-ash hover:text-paper"
+ className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper"
>
← Votes
</Link>
@@ -167,57 +168,7 @@ export default function VoteDetailPage({
</p>
</Card>
) : (
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- Member roster for this vote: name, party, state, district, position.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>Member</Th>
- <Th>Party</Th>
- <Th>State</Th>
- <Th>Position</Th>
- </tr>
- </thead>
- <tbody>
- {roster.map((p, i) => (
- <tr
- key={`${p.memberHref || i}-${p.member}-${i}`}
- className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
- >
- <Td className="text-paper">
- <div className="flex items-center gap-2">
- <MemberAvatar name={p.member} photoUrl={p.photoUrl} size="sm" />
- <span className="min-w-0">
- {p.memberHref ? (
- <Link
- href={p.memberHref}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- {p.member}
- </Link>
- ) : (
- p.member
- )}
- </span>
- </div>
- </Td>
- <Td>
- {p.party ? <PartyTag party={p.partyCode} /> : "-"}
- </Td>
- <Td className="text-ash">
- {p.state}
- {p.district != null ? ` ${p.district}`.padStart(3, " ") : ""}
- </Td>
- <Td>
- <PositionTag position={p.position} />
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
- </Card>
+ <VoteRoster roster={roster} />
)}
<p className="label mt-4 text-ash-2">
Party shown in desaturated, equal-weight tones. Source: {""}
@@ -272,22 +223,50 @@ function SummaryChip({
);
}
-function Th({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
-}
-
-function Td({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
+/** Member roster as a responsive table (>=md) / stacked cards (<md). */
+function VoteRoster({ roster }: { roster: VotePosition[] }) {
+ const columns: Column[] = [
+ { key: "member", header: "Member", cardTitle: true },
+ { key: "party", header: "Party" },
+ { key: "state", header: "State" },
+ { key: "position", header: "Position" },
+ ];
+ const rows: Row[] = roster.map((p, i) => ({
+ key: `${p.memberHref || i}-${p.member}-${i}`,
+ cells: {
+ member: (
+ <span className="inline-flex items-center gap-2 text-paper">
+ <MemberAvatar name={p.member} photoUrl={p.photoUrl} size="sm" />
+ <span className="min-w-0">
+ {p.memberHref ? (
+ <Link
+ href={p.memberHref}
+ className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
+ >
+ {p.member}
+ </Link>
+ ) : (
+ p.member
+ )}
+ </span>
+ </span>
+ ),
+ party: p.party ? <PartyTag party={p.partyCode} /> : "-",
+ state: (
+ <span className="text-ash">
+ {p.state}
+ {p.district != null ? ` ${p.district}`.padStart(3, " ") : ""}
+ </span>
+ ),
+ position: <PositionTag position={p.position} />,
+ },
+ }));
+ return (
+ <DataTable
+ caption="Member roster for this vote: name, party, state, district, position."
+ cardLabel="Member roster"
+ columns={columns}
+ rows={rows}
+ />
+ );
}
app/votes/page.tsx +50 -69
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Card, PageHeader, SectionHeader } from "@/components/ui";
+import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { StatusPill, classifyStatus } from "@/components/status-pill";
import { getVotes } from "@/lib/feed";
@@ -40,57 +41,7 @@ export default function VotesPage() {
</p>
</Card>
) : (
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- Recorded votes: date, chamber, question or result, tally, and source.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>Date</Th>
- <Th>Chamber</Th>
- <Th>Result</Th>
- <Th className="text-right">Tally</Th>
- <Th className="text-right">Record</Th>
- </tr>
- </thead>
- <tbody>
- {shown.map((v) => {
- const variant = classifyStatus(v.result);
- return (
- <tr
- key={v.id}
- className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
- >
- <Td className="tabular-nums text-ash">
- {formatDate(v.date)}
- </Td>
- <Td className="uppercase text-ash-2">{v.chamber}</Td>
- <Td className="text-paper">
- <Link
- href={v.href ?? "#"}
- className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
- >
- {v.result || "Roll-call vote"}
- </Link>
- <div className="mt-1.5">
- <StatusPill variant={variant} dot>
- {v.recorded ? "Recorded" : "Voice / UC"}
- </StatusPill>
- </div>
- </Td>
- <Td className="text-right tabular-nums text-paper">
- {v.recorded ? `${v.yea}\u2013${v.nay}` : "-"}
- </Td>
- <Td className="text-right">
- <SourceLink url={v.sourceUrl} />
- </Td>
- </tr>
- );
- })}
- </tbody>
- </table>
- </Card>
+ <VotesTable votes={shown} />
)}
<p className="label mt-4 text-ash-2">
Source: clerk.house.gov and senate.gov roll-call XML. Figures only - no characterization.
@@ -100,22 +51,52 @@ export default function VotesPage() {
);
}
-function Th({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
-}
-
-function Td({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
+/** Recorded votes as a responsive table (>=md) / stacked cards (<md). */
+function VotesTable({ votes }: { votes: ReturnType<typeof getVotes> }) {
+ const columns: Column[] = [
+ { key: "date", header: "Date" },
+ { key: "chamber", header: "Chamber", hideOnCard: true },
+ { key: "result", header: "Result", cardTitle: true },
+ { key: "tally", header: "Tally", align: "right" },
+ { key: "record", header: "Record", align: "right" },
+ ];
+ const rows: Row[] = votes.map((v) => {
+ const variant = classifyStatus(v.result);
+ return {
+ key: v.id,
+ cells: {
+ date: <span className="tabular-nums text-ash">{formatDate(v.date)}</span>,
+ chamber: <span className="uppercase text-ash-2">{v.chamber}</span>,
+ result: (
+ <span className="text-paper">
+ <Link
+ href={v.href ?? "#"}
+ className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
+ >
+ {v.result || "Roll-call vote"}
+ </Link>
+ <span className="mt-1.5 block">
+ <StatusPill variant={variant} dot>
+ {v.recorded ? "Recorded" : "Voice / UC"}
+ </StatusPill>
+ </span>
+ </span>
+ ),
+ tally: (
+ <span className="tabular-nums text-paper">
+ {v.recorded ? `${v.yea}\u2013${v.nay}` : "-"}
+ </span>
+ ),
+ record: <SourceLink url={v.sourceUrl} />,
+ },
+ };
+ });
+ return (
+ <DataTable
+ caption="Recorded votes: date, chamber, question or result, tally, and source."
+ cardLabel="Recorded votes"
+ columns={columns}
+ rows={rows}
+ />
+ );
}
components/data-table.tsx +128 -0
@@ -0,0 +1,128 @@
+import type { ReactNode } from "react";
+import { clsx } from "@/lib/cx";
+import { Card } from "@/components/ui";
+
+/**
+ * Responsive data table - Docket's only list pattern (mobile-first overhaul).
+ *
+ * - >=md: a real `<table>` (auto layout, wrapping cells) for a11y + SEO. Never
+ * has a fixed min-width, so it can never force the page wider than the
+ * viewport.
+ * - <md: each row becomes a STACKED CARD - a prominent title (the `cardTitle`
+ * column) plus a definition list of labeled field rows
+ * (`Date · Body · Item · Action · Result · Source`), exactly the pattern in
+ * the mobile spec. Zero horizontal page scroll at 390px.
+ *
+ * Presentational only - usable from both server and client components.
+ * Brand/neutrality safe: it renders whatever cells the page passes (Source
+ * links, muted pills) without adding color or commentary of its own.
+ */
+export type Column = {
+ key: string;
+ header: ReactNode;
+ align?: "left" | "right";
+ /** Render this column's cell as the card title (full-width, prominent).
+ * It is then omitted from the labeled rows on the mobile card. */
+ cardTitle?: boolean;
+ /** Hide this column's row on the mobile card entirely (desktop-only). */
+ hideOnCard?: boolean;
+};
+
+export type Row = { key: string; cells: Record<string, ReactNode> };
+
+export function DataTable({
+ caption,
+ columns,
+ rows,
+ className,
+ cardLabel,
+}: {
+ caption: string;
+ columns: Column[];
+ rows: Row[];
+ className?: string;
+ /** Accessible label for the mobile card list. Defaults to the caption. */
+ cardLabel?: string;
+}) {
+ if (rows.length === 0) return null;
+
+ const titleCol = columns.find((c) => c.cardTitle);
+ const cardCols = columns.filter((c) => !c.cardTitle && !c.hideOnCard);
+
+ return (
+ <Card className={clsx("overflow-hidden", className)}>
+ {/* DESKTOP (>=md): real table. overflow-x-auto is a defense-in-depth
+ wrapper only - the table has no min-width and cells wrap, so it never
+ pushes the page wider than the viewport at md+. */}
+ <div className="hidden overflow-x-auto md:block">
+ <table className="w-full border-collapse text-left">
+ <caption className="sr-only">{caption}</caption>
+ <thead>
+ <tr className="border-b border-line">
+ {columns.map((c) => (
+ <th
+ key={c.key}
+ scope="col"
+ className={clsx(
+ "label px-4 py-2.5 font-normal",
+ c.align === "right" ? "text-right" : "text-left"
+ )}
+ >
+ {c.header}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody>
+ {rows.map((r) => (
+ <tr
+ key={r.key}
+ className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
+ >
+ {columns.map((c) => (
+ <td
+ key={c.key}
+ className={clsx(
+ "break-words align-middle px-4 py-3",
+ c.align === "right" ? "text-right" : "text-left"
+ )}
+ >
+ {r.cells[c.key]}
+ </td>
+ ))}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+
+ {/* MOBILE (<md): stacked labeled cards. */}
+ <ul
+ className="divide-y divide-[color:var(--dk-line)] md:hidden"
+ aria-label={cardLabel ?? caption}
+ >
+ {rows.map((r) => (
+ <li key={r.key} className="p-4">
+ {titleCol ? (
+ <div className="mb-2.5 min-w-0 break-words font-mono text-[13px] text-paper">
+ {r.cells[titleCol.key]}
+ </div>
+ ) : null}
+ <dl className="grid grid-cols-1 gap-2">
+ {cardCols.map((c) => (
+ <div key={c.key} className="flex items-start gap-3">
+ <dt className="label w-[5.5rem] shrink-0 pt-0.5 normal-case">
+ {c.header}
+ </dt>
+ <dd className="min-w-0 flex-1 break-words text-left font-mono text-[12px] text-ash">
+ {r.cells[c.key]}
+ </dd>
+ </div>
+ ))}
+ </dl>
+ </li>
+ ))}
+ </ul>
+ </Card>
+ );
+}
components/executive-browser.tsx +42 -54
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import { Card } from "@/components/ui";
+import { DataTable, type Column, type Row } from "@/components/data-table";
import { SourceLink } from "@/components/source-link";
import { clsx } from "@/lib/cx";
import { formatDate } from "@/lib/format";
@@ -108,66 +109,53 @@ export function ExecutiveBrowser({
</p>
</Card>
) : (
- <Card className="overflow-hidden">
- <table className="w-full border-collapse text-left">
- <caption className="sr-only">
- Federal Register actions: date, type, title, agency, document number, source.
- </caption>
- <thead>
- <tr className="border-b border-line">
- <Th>Date</Th>
- <Th>Type</Th>
- <Th>Title</Th>
- <Th className="text-right">Source</Th>
- </tr>
- </thead>
- <tbody>
- {filtered.map((e) => (
- <tr
- key={e.id}
- className="border-b border-line font-mono text-[12px] transition-colors last:border-0 hover:bg-graphite/60"
- >
- <Td className="tabular-nums text-ash">{formatDate(e.date)}</Td>
- <Td className="uppercase text-ash-2">
- {typeLabels[(e.type || "").toLowerCase()] || e.type || "-"}
- </Td>
- <Td className="text-paper">
- <span className="block">{e.title}</span>
- <span className="block text-ash-2">
- {e.agency}
- {e.frDocNumber ? ` · Doc ${e.frDocNumber}` : ""}
- </span>
- </Td>
- <Td className="text-right">
- <SourceLink url={e.sourceUrl} label="FR" />
- </Td>
- </tr>
- ))}
- </tbody>
- </table>
- </Card>
+ <ExecutiveTable rows={filtered} typeLabels={typeLabels} />
)}
</div>
</>
);
}
-function Th({
- children,
- className = "",
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <th className={`label px-4 py-2.5 font-normal ${className}`}>{children}</th>;
-}
-
-function Td({
- children,
- className = "",
+function ExecutiveTable({
+ rows: items,
+ typeLabels,
}: {
- children: React.ReactNode;
- className?: string;
+ rows: ExecutiveAction[];
+ typeLabels: Record<string, string>;
}) {
- return <td className={`px-4 py-3 align-middle ${className}`}>{children}</td>;
+ const columns: Column[] = [
+ { key: "date", header: "Date" },
+ { key: "type", header: "Type", hideOnCard: true },
+ { key: "title", header: "Title", cardTitle: true },
+ { key: "source", header: "Source", align: "right" },
+ ];
+ const data: Row[] = items.map((e) => ({
+ key: e.id,
+ cells: {
+ date: <span className="tabular-nums text-ash">{formatDate(e.date)}</span>,
+ type: (
+ <span className="uppercase text-ash-2">
+ {typeLabels[(e.type || "").toLowerCase()] || e.type || "-"}
+ </span>
+ ),
+ title: (
+ <span className="text-paper">
+ <span className="block">{e.title}</span>
+ <span className="block text-ash-2">
+ {e.agency}
+ {e.frDocNumber ? ` · Doc ${e.frDocNumber}` : ""}
+ </span>
+ </span>
+ ),
+ source: <SourceLink url={e.sourceUrl} label="FR" />,
+ },
+ }));
+ return (
+ <DataTable
+ caption="Federal Register actions: date, type, title, agency, document number, source."
+ cardLabel="Federal Register actions"
+ columns={columns}
+ rows={data}
+ />
+ );
}
components/register-hero.tsx +48 -38
@@ -4,23 +4,30 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { SourceLink } from "@/components/source-link";
import { clsx } from "@/lib/cx";
-import { formatEt } from "@/lib/format";
+import { formatEt, formatDateShort } from "@/lib/format";
import type { FeedItem } from "@/lib/feed";
/**
* THE REGISTER - Docket's signature hero (docs/BRAND.md §3).
* A stamped ledger of official actions. Seeded server-side with REAL feed
- * items from /api/feed, then re-fetched client-side on an interval so newly
- * ingested actions appear live. Falls back to a calm empty state when a
- * category is empty - never to mock data, never a blank.
+ * items, then re-fetched client-side on an interval so newly ingested actions
+ * appear live. Falls back to a calm empty state when empty - never mock data.
+ *
+ * Hydration-safe (mobile-first overhaul): the server render and the first
+ * client render produce IDENTICAL markup - `flashId` starts null (no
+ * animate-fadeUp class anywhere on first paint), and the live re-fetch + flash
+ * only begin inside a post-mount useEffect. Date/time text is produced by pure,
+ * deterministic helpers (formatEt / formatDateShort) that parse date-only
+ * strings as UTC, so there is no SSR-UTC vs client-local divergence (the old
+ * #418 was a date-only string parsed as local midnight → "20:00 ET" on the
+ * server vs a different time on the client).
*
* The pulsing brass live-dot is the single rationed brass signal on /register.
*/
export function RegisterHero({ seed }: { seed: FeedItem[] }) {
const [rows, setRows] = useState<FeedItem[]>(seed);
- const [flashId, setFlashId] = useState<string | null>(
- seed[0]?.id ?? null
- );
+ // null on first paint (server + client) → identical markup, no hydration gap.
+ const [flashId, setFlashId] = useState<string | null>(null);
const prevIds = useRef<Set<string>>(new Set(seed.map((r) => r.id)));
const refresh = useCallback(async () => {
@@ -58,7 +65,7 @@ export function RegisterHero({ seed }: { seed: FeedItem[] }) {
style={{ background: "var(--dk-hero-glow)" }}
/>
- <div className="relative mx-auto w-full max-w-register px-4 py-10 sm:px-6 sm:py-14">
+ <div className="relative mx-auto w-full max-w-register px-4 py-8 sm:px-6 sm:py-14">
{/* hero heading + the rationed live-dot */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
@@ -67,7 +74,10 @@ export function RegisterHero({ seed }: { seed: FeedItem[] }) {
<span className="live-dot animate-pulseDot" aria-hidden="true" />
<span className="label text-paper">Live · The Register</span>
</div>
- <h1 className="display max-w-2xl text-4xl text-paper sm:text-5xl">
+ <h1
+ className="display max-w-2xl text-paper"
+ style={{ fontSize: "clamp(1.875rem, 7vw, 3rem)", lineHeight: 1 }}
+ >
What the government did this week
</h1>
</div>
@@ -82,14 +92,11 @@ export function RegisterHero({ seed }: { seed: FeedItem[] }) {
) : (
<div
className="mt-8 overflow-hidden rounded-[3px] border border-line bg-surface/60 backdrop-blur-sm"
- role="table"
aria-label="Stamped ledger of official actions"
>
- <div
- className="reg-row grid-cols-[6rem_8rem_minmax(0,1fr)_8rem_minmax(0,1fr)] px-3 py-2 sm:grid sm:px-4"
- role="row"
- >
- <span className="label">Time</span>
+ {/* Column header - grid only at sm+ (mobile rows are stacked cards). */}
+ <div className="reg-row hidden grid-cols-[5.5rem_7.5rem_minmax(0,1fr)_6.5rem_minmax(0,5rem)] gap-x-2 px-4 py-2 sm:grid">
+ <span className="label">Date</span>
<span className="label">Body</span>
<span className="label">Item</span>
<span className="label">Action</span>
@@ -98,34 +105,27 @@ export function RegisterHero({ seed }: { seed: FeedItem[] }) {
{rows.map((r) => {
const isNew = r.id === flashId;
- const content = <RegisterRowInner row={r} flash={isNew} />;
return (
- <div key={r.id} role="row">
- {r.href ? (
- <Link
- href={r.href}
- className="focus-ring reg-row group grid-cols-[6rem_8rem_minmax(0,1fr)_8rem_minmax(0,1fr)] grid px-3 py-2.5 sm:px-4"
- >
- {content}
- </Link>
- ) : (
- <div className="reg-row grid-cols-[6rem_8rem_minmax(0,1fr)_8rem_minmax(0,1fr)] grid px-3 py-2.5 sm:px-4">
- {content}
- </div>
- )}
+ <div key={r.id} className="border-t border-line first:border-t-0">
+ {/* The row is a plain container - NOT a wrapping <a>. The item
+ title and the Source link are sibling anchors, so we never
+ produce invalid nested <a><a></a></a> (which was the
+ hydration #418: browsers auto-close the outer anchor, the
+ DOM then diverged from React's tree). */}
+ <RegisterRowInner row={r} flash={isNew} />
</div>
);
})}
</div>
)}
- <div className="mt-3 flex items-center justify-between">
+ <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="label text-ash-2">
Ranked by action date, dollar magnitude, and people affected - never by coverage.
</p>
<Link
href="/how-we-pick"
- className="focus-ring label text-ash underline underline-offset-4 hover:text-paper"
+ className="focus-ring label tap w-fit rounded-[3px] px-1 text-ash underline underline-offset-4 hover:text-paper"
>
How we pick →
</Link>
@@ -159,25 +159,35 @@ function RegisterRowInner({
const isFail = row.actionType === "FAILED";
const result = row.amountText || "\u2014";
return (
- <>
+ <div className="reg-row grid grid-cols-1 gap-1 px-3 py-2.5 sm:grid-cols-[5.5rem_7.5rem_minmax(0,1fr)_6.5rem_minmax(0,5rem)] sm:gap-x-2 sm:items-center sm:px-4">
<span
className={clsx(
"font-mono text-[12px] tabular-nums text-ash",
flash && "animate-fadeUp"
)}
>
- {formatEt(row.actionDate) || "-"}
+ {formatEt(row.actionDate) || formatDateShort(row.actionDate)}
</span>
<span className="font-mono text-[12px] uppercase tracking-wide text-ash-2">
{row.chamber}
</span>
- <span className="font-mono text-[13px] text-paper">
- <span className="block truncate">{row.title}</span>
+ <span className="min-w-0 font-mono text-[13px] text-paper">
+ {/* Title anchor + Source anchor are SIBLINGS (never nested <a>). */}
+ {row.href ? (
+ <Link
+ href={row.href}
+ className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper"
+ >
+ <span className="block break-words">{row.title}</span>
+ </Link>
+ ) : (
+ <span className="block break-words">{row.title}</span>
+ )}
{/* Always-visible source (a11y #5: no hover-only affordance). */}
<SourceLink
url={row.sourceUrl}
label="Source"
- className="mt-0.5 !inline-flex !opacity-100"
+ className="mt-1"
/>
</span>
<span
@@ -192,9 +202,9 @@ function RegisterRowInner({
>
{row.actionType}
</span>
- <span className="mt-1 font-mono text-[12px] tabular-nums text-paper sm:mt-0 sm:text-right">
+ <span className="font-mono text-[12px] tabular-nums text-paper sm:text-right">
{result}
</span>
- </>
+ </div>
);
}
components/site-header.tsx +123 -25
@@ -1,11 +1,27 @@
"use client";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CustodyMark, Wordmark } from "@/components/custody-mark";
import { DisplayToggle } from "@/components/display-toggle";
import { clsx } from "@/lib/cx";
+/**
+ * Site header - two cleanly separated controls (mobile-first overhaul):
+ *
+ * (a) PAGE NAV: Register · Calendar · Schedule · Votes · Members · Bills ·
+ * Spending · Executive · States · Search. On >=md a clean nav bar; on
+ * <md a hamburger that opens a full stacked menu with a clear active-page
+ * indicator and >=44px targets (never the old crammed strip that clipped
+ * "Votes").
+ * (b) DISPLAY MODE (Dark/Readable/Plain): a compact segmented control that
+ * sits on the logo row, visually distinct from the page-nav - never on the
+ * same line as the nav.
+ *
+ * The header is identical on every route; active state is derived from the
+ * pathname. The mobile menu closes on navigation and on Escape.
+ */
const NAV: Array<{ href: string; label: string }> = [
{ href: "/register", label: "Register" },
{ href: "/calendar", label: "Calendar" },
@@ -20,32 +36,76 @@ const NAV: Array<{ href: string; label: string }> = [
{ href: "/search", label: "Search" },
];
+function isActive(pathname: string | null, href: string): boolean {
+ return pathname === href || !!pathname?.startsWith(href + "/");
+}
+
export function SiteHeader() {
const pathname = usePathname();
+ const [open, setOpen] = useState(false);
+
+ // Close the mobile menu whenever the route changes.
+ useEffect(() => {
+ setOpen(false);
+ }, [pathname]);
+
+ // Close on Escape; lock body scroll while the menu is open.
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("keydown", onKey);
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ document.body.style.overflow = prev;
+ };
+ }, [open]);
return (
- <header className="sticky top-0 z-40 border-b border-line bg-ink/85 backdrop-blur-md">
- <div className="mx-auto flex h-auto w-full max-w-register flex-wrap items-center justify-between gap-3 px-4 py-2.5 sm:px-6">
- <Link
- href="/register"
- className="focus-ring flex items-center gap-2.5 rounded-sm"
- aria-label="Docket - home"
- >
- <CustodyMark size={26} showTick={false} />
- <Wordmark className="text-[15px]" />
- </Link>
+ <header className="sticky top-0 z-40 border-b border-line bg-ink/90 backdrop-blur-md">
+ <div className="mx-auto w-full max-w-register px-4 sm:px-6">
+ {/* Row 1: logo (left) · display toggle + hamburger (right).
+ Display mode is its own compact control, separate from page-nav. */}
+ <div className="flex h-14 items-center justify-between gap-2">
+ <Link
+ href="/register"
+ className="focus-ring tap shrink-0 gap-2.5 rounded-sm pr-2"
+ aria-label="Docket - home"
+ >
+ <CustodyMark size={26} showTick={false} />
+ <Wordmark className="text-[15px]" />
+ </Link>
+
+ <div className="flex items-center gap-2">
+ <DisplayToggle />
+ {/* Hamburger - page nav only (<md). */}
+ <button
+ type="button"
+ onClick={() => setOpen((v) => !v)}
+ aria-label={open ? "Close menu" : "Open menu"}
+ aria-expanded={open}
+ aria-controls="primary-mobile-nav"
+ className="focus-ring label inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-[3px] border border-line bg-surface px-3 text-paper md:hidden"
+ >
+ <BurgerIcon open={open} />
+ </button>
+ </div>
+ </div>
+ {/* Row 2 (>=md): clean desktop nav bar. */}
<nav aria-label="Primary" className="hidden md:block">
- <ul className="flex items-center gap-1">
+ <ul className="flex items-center gap-1 pb-2">
{NAV.map((item) => {
- const active =
- pathname === item.href || pathname?.startsWith(item.href + "/");
+ const active = isActive(pathname, item.href);
return (
<li key={item.href}>
<Link
href={item.href}
className={clsx(
- "focus-ring label inline-flex min-h-[44px] items-center rounded-sm px-2.5 transition-colors underline-offset-4",
+ "focus-ring label tap rounded-sm px-2.5 underline-offset-4 transition-colors",
active
? "text-paper underline"
: "text-ash hover:text-paper hover:underline"
@@ -59,24 +119,37 @@ export function SiteHeader() {
})}
</ul>
</nav>
+ </div>
- <div className="flex items-center gap-2">
- <DisplayToggle />
- {/* Mobile nav: condensed row of mono labels */}
- <nav aria-label="Primary mobile" className="md:hidden">
- <ul className="flex items-center gap-x-2 gap-y-1 overflow-x-auto">
- {NAV.slice(0, 4).map((item) => {
- const active = pathname === item.href;
+ {/* MOBILE menu panel (<md): full stacked page-nav with active state. */}
+ {open ? (
+ <div
+ id="primary-mobile-nav"
+ className="border-t border-line bg-ink/98 md:hidden"
+ role="dialog"
+ aria-label="Primary navigation"
+ aria-modal="true"
+ >
+ <nav aria-label="Primary mobile">
+ <ul className="mx-auto flex w-full max-w-register flex-col px-4 py-2">
+ {NAV.map((item) => {
+ const active = isActive(pathname, item.href);
return (
<li key={item.href}>
<Link
href={item.href}
+ aria-current={active ? "page" : undefined}
className={clsx(
- "label inline-flex min-h-[44px] items-center whitespace-nowrap py-1 underline-offset-4",
- active ? "text-paper underline" : "text-ash"
+ "focus-ring label tap w-full justify-between rounded-[3px] border border-transparent px-3 underline-offset-4",
+ active
+ ? "bg-graphite text-paper underline"
+ : "text-ash hover:text-paper hover:underline"
)}
>
- {item.label}
+ <span>{item.label}</span>
+ {active ? (
+ <span aria-hidden="true" className="text-paper">●</span>
+ ) : null}
</Link>
</li>
);
@@ -84,7 +157,32 @@ export function SiteHeader() {
</ul>
</nav>
</div>
- </div>
+ ) : null}
</header>
);
}
+
+function BurgerIcon({ open }: { open: boolean }) {
+ // Two-stroke mark that shifts to an X when open. Pure CSS, no color cue
+ // alone - the button also carries an aria-label.
+ return (
+ <span
+ aria-hidden="true"
+ className="relative inline-block h-3 w-5"
+ style={{ marginTop: open ? 0 : 0 }}
+ >
+ <span
+ className="absolute left-0 block h-0.5 w-5 bg-current transition-transform"
+ style={{ top: open ? "5px" : "0px", transform: open ? "rotate(45deg)" : "none" }}
+ />
+ <span
+ className="absolute left-0 top-[5px] block h-0.5 w-5 bg-current transition-opacity"
+ style={{ opacity: open ? 0 : 1 }}
+ />
+ <span
+ className="absolute left-0 block h-0.5 w-5 bg-current transition-transform"
+ style={{ top: open ? "5px" : "10px", transform: open ? "rotate(-45deg)" : "none" }}
+ />
+ </span>
+ );
+}
components/source-link.tsx +3 -1
@@ -27,7 +27,9 @@ export function SourceLink({
target="_blank"
rel="noopener noreferrer nofollow"
className={clsx(
- "focus-ring label inline-flex items-center gap-1 rounded-sm border border-line px-1.5 py-0.5 transition-colors",
+ // a11y #3/#7: every Source link is a >=40px underlined target, never
+ // color alone. `label` keeps the mono look; min-h keeps it tappable.
+ "focus-ring label inline-flex min-h-[40px] items-center gap-1 rounded-[3px] border border-line px-2 py-1 underline underline-offset-4 transition-colors",
allowed
? "text-ash hover:text-paper hover:border-paper"
: "text-nay",
components/ui.tsx +12 -2
@@ -44,7 +44,12 @@ export function SectionHeader({
<div className={clsx("mb-6", className)}>
{eyebrow ? <p className="label mb-2">{eyebrow}</p> : null}
{title ? (
- <h2 className="display text-2xl text-paper sm:text-3xl">{title}</h2>
+ <h2
+ className="display text-paper"
+ style={{ fontSize: "clamp(1.25rem, 4vw, 1.875rem)" }}
+ >
+ {title}
+ </h2>
) : null}
{description ? (
<p className="prose-lead mt-3 max-w-2xl text-ash">{description}</p>
@@ -66,7 +71,12 @@ export function PageHeader({
return (
<header className="border-b border-line pb-8">
<p className="label mb-3">{eyebrow}</p>
- <h1 className="display text-4xl text-paper sm:text-5xl">{title}</h1>
+ <h1
+ className="display text-paper"
+ style={{ fontSize: "clamp(1.875rem, 7vw, 3rem)", lineHeight: 1 }}
+ >
+ {title}
+ </h1>
{lead ? <p className="prose-lead mt-4 max-w-2xl text-ash">{lead}</p> : null}
</header>
);
lib/feed.ts +57 -1
@@ -372,7 +372,63 @@ export function getFeedItems(): FeedItem[] {
)
);
- return items.sort(rankItems);
+ return diversifyByKind(items.sort(rankItems));
+}
+
+/**
+ * Source-variety pass (feed diversity). The feed is FIRST ranked strictly by
+ * rule #3 (action date → dollar magnitude → affected). Then, WITHIN each
+ * equal-action-date bucket, items are round-robin'd across kinds (award /
+ * vote / executive / …) so the visible top of the Register isn't a wall of
+ * same-source, same-day Federal Register rows. Buckets stay in rank order and
+ * items never leave their date bucket, so the official-action ranking is
+ * preserved - only the interleaving within a day changes.
+ */
+function diversifyByKind(items: FeedItem[]): FeedItem[] {
+ if (items.length <= 1) return items;
+ // Group consecutive equal-dateKey items (the array is already date-ranked).
+ const groups: FeedItem[][] = [];
+ let cur: FeedItem[] = [];
+ let curKey = "";
+ for (const it of items) {
+ const k = it.actionDate?.slice(0, 10) ?? "";
+ if (k !== curKey) {
+ if (cur.length) groups.push(cur);
+ cur = [it];
+ curKey = k;
+ } else {
+ cur.push(it);
+ }
+ }
+ if (cur.length) groups.push(cur);
+
+ const out: FeedItem[] = [];
+ for (const g of groups) {
+ if (g.length <= 2) {
+ out.push(...g);
+ continue;
+ }
+ // Round-robin by kind within the bucket: deal one of each kind in turn,
+ // preserving the within-kind rank order. Keeps the bucket date-locked.
+ const byKind = new Map<string, FeedItem[]>();
+ const order: string[] = [];
+ for (const it of g) {
+ const arr = byKind.get(it.kind);
+ if (arr) arr.push(it);
+ else {
+ byKind.set(it.kind, [it]);
+ order.push(it.kind);
+ }
+ }
+ const maxLen = Math.max(...[...byKind.values()].map((a) => a.length));
+ for (let i = 0; i < maxLen; i++) {
+ for (const k of order) {
+ const arr = byKind.get(k);
+ if (arr && i < arr.length) out.push(arr[i]);
+ }
+ }
+ }
+ return out;
}
/**
lib/format.ts +13 -0
@@ -33,6 +33,19 @@ export function formatDate(iso: string): string {
});
}
+/** Compact "Mon DD" label (UTC, deterministic) for tight UI slots like the
+ * live hero's date column - no year, no time-of-day theater. */
+export function formatDateShort(iso: string): string {
+ if (!iso) return "\u2014";
+ const d = new Date(iso.length === 10 ? iso + "T00:00:00Z" : iso);
+ if (Number.isNaN(d.getTime())) return "\u2014";
+ return d.toLocaleDateString("en-US", {
+ timeZone: "UTC",
+ month: "short",
+ day: "numeric",
+ });
+}
+
/** ET wall-clock label like "14:02 ET" from an ISO string, or "" if there is
* no real time component. Date-only values (e.g. Federal Register documents,
* which are dated but not timed) return "" so callers fall back to the DATE -
next.config.js +2 -1
@@ -11,7 +11,8 @@ const csp = [
"object-src 'none'",
// Next.js injects a small inline hydration bootstrap; 'unsafe-inline' kept
// for scripts/styles (no nonce pipeline). No third-party script origins.
- "script-src 'self' 'unsafe-inline'",
+ // In dev only, React/Next need 'unsafe-eval' for HMR + refresh; never in prod.
+ `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data: https://unitedstates.github.io",