| @@ -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. |
| @@ -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>; | |
| - | } |
| @@ -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>; | |
| - | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> |
| @@ -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); |
| @@ -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'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'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} | |
| + | /> | |
| + | ); | |
| } |
| @@ -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> |
| @@ -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} | |
| + | /> | |
| + | ); | |
| } |
| @@ -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> |
| @@ -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>; | |
| - | } |
| @@ -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"> |
| @@ -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'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 & 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>; | |
| - | } |
| @@ -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} | |
| + | /> | |
| + | ); | |
| } |
| @@ -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} | |
| + | /> | |
| + | ); | |
| } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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} | |
| + | /> | |
| + | ); | |
| } |
| @@ -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> | |
| ); | ||
| } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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", |
| @@ -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> | ||
| ); |
| @@ -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; | |
| } | ||
| /** |
| @@ -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 - |
| @@ -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", |