| @@ -1,10 +1,109 @@ | ||
| # Docket - Frontend Notes | ||
| - | Wave F2 + F3 + F4 + F5 + R1 + R2 + R3 build log. All surfaces consume the live read-API | |
| + | Wave F2 + F3 + F4 + F5 + R1 + R2 + R3 + V2 build log. All surfaces consume the live read-API | |
| (`app/api/*` → `lib/feed.ts` → SQLite at `data/docket.db`). No mock data on | ||
| the deep surfaces; `lib/mock-data.ts` remains only for the Phase-0 lint | ||
| reference and is not imported by any F2/F3/F4/F5 page. | ||
| + | ## V2 (2026-06-27) - Design system rebuild: Zion's own aesthetic (Claude) | |
| + | ||
| + | ### Summary | |
| + | Template wave: design tokens, fonts, and three pages rebuilt in the owner's own aesthetic | |
| + | (docs/DESIGN_V2.md). QA CLEAN - 17 routes, zero overflow, zero hydration errors. | |
| + | Screenshots: `_ops/qa-view/v2-{home,bills,bill}-{mobile,desktop}.png` | |
| + | Scope: template only - Home, Bills list, Bill detail, and the shared header/nav. | |
| + | Other pages unchanged; propagation gated on owner approval. | |
| + | ||
| + | ### Design system changes (`app/globals.css`) | |
| + | ||
| + | **Dark mode (`:root`)**: signal changed from brass `#E0A53B` to cobalt `#2E5BFF` - the | |
| + | treetrace.dev treatment as specified in docs/DESIGN_V2.md. | |
| + | ||
| + | **Light mode (`:root.light`)** - completely replaced with Zion's warm palette: | |
| + | - bg `#FFFFFF` (white page) · surface `#F7F4ED` (warm paper) · graphite `#EDE9DF` (hover) | |
| + | - text `#0B0C0E` (ink near-black) · secondary `#44423D` · tertiary `#6B6860` | |
| + | - border `#D8D2C4` (warm visible) · accent `#8F5902` (his amber/ochre, ~6:1 on white) | |
| + | - accent-strong `#CE5C00` (large fills) · accent-wash `#FBF3DF` (active/selected) | |
| + | - yea `#4E7A06` (his Tango green) · nay `#A40000` (his Tango red, small only) | |
| + | - body-size bumped to **20px** (elder-first: was 18px) | |
| + | ||
| + | **Font roles**: body font in light mode changed from Atkinson Hyperlegible to **Archivo** | |
| + | (the same display/heading font, applied at body weight for UI text). Type trio enforced: | |
| + | - Archivo → headings, display, UI, kicker labels | |
| + | - Fraunces → leads, long-form reading (bill titles, descriptions) | |
| + | - JetBrains Mono → DATA ONLY: bill numbers, tallies, amounts, dates | |
| + | ||
| + | **Component token overrides (light mode)**: | |
| + | - `.label`: switched from JetBrains Mono to Archivo 700 uppercase (editorial, not techy) | |
| + | - `.pill`: Archivo 13px, 700 weight, 4px radius (readable, not tiny 10px mono) | |
| + | - `.prose-lead`: 20px in light (was 18px) | |
| + | - New utility classes: `.big-card`, `.section-card` (hover highlight with amber border) | |
| + | ||
| + | ### Font loading (`app/layout.tsx`) | |
| + | - Removed: `Atkinson_Hyperlegible` import and `atkinson.variable` (no longer the body font) | |
| + | - Kept: Archivo (`--font-display`), JetBrains Mono (`--font-mono`), Fraunces (`--font-prose`) | |
| + | ||
| + | ### Display toggle (`components/display-toggle.tsx`) | |
| + | - Renamed "Reference" mode label to **"Standard"** (less technical; is the light default) | |
| + | ||
| + | ### Site header (`components/site-header.tsx`) - full rewrite | |
| + | - Nav simplified from 11 items to **6**: Home · Bills · Votes · Representatives · Spending · States | |
| + | - Home link changed from `/register` to `/` (the new landing hub) | |
| + | - "Members" renamed to "Representatives" in nav (matches DESIGN_V2 IA) | |
| + | - Search: now a labeled affordance (`<SearchIcon> Search`) visible at all viewports | |
| + | (was icon-only on mobile) | |
| + | - Mobile menu: rows are 56px min-height with bold 18px labels; active item shows amber | |
| + | accent-wash background + "← you are here" label | |
| + | - Desktop nav: active item uses amber wash background (amber highlight, not just underline) | |
| + | - Mode toggle: Standard / Dark / Plain (was Reference / Dark / Plain) | |
| + | ||
| + | ### Home page (`app/page.tsx`) - full rewrite | |
| + | - Max width 860px (was 64rem register width) | |
| + | - Hero: Archivo H1 at `clamp(2.125rem,7vw,3.5rem)` 900 weight, Fraunces lead paragraph | |
| + | - Kicker label in amber signal color (`#8F5902`) | |
| + | - Prominent search CTA: filled amber button with search icon (56px min-height) | |
| + | - 6 section cards in 2-col grid (was 4-col, 8 items, tiny text) | |
| + | Cards: Archivo 1.25rem title, Fraunces 1rem description, amber hover border | |
| + | - "More" link row: Calendar, Schedule, Executive orders, Browse by topic, Search | |
| + | - "How to cite" section: Archivo H2, Fraunces body, example APA citation | |
| + | - Neutrality note: amber signal links to /how-we-pick and /who-pays | |
| + | - REMOVED: DataTable-style card layout; all grids are readable 2-col max | |
| + | ||
| + | ### Bills list (`app/bills/page.tsx`) - full rewrite (data logic preserved) | |
| + | All server logic (`getBills`, filters, searchParams, `dropParam`) is identical to R3. | |
| + | Presentation layer replaced: | |
| + | - Breadcrumb + Back → Home / Bills + "← Back to home" (amber, ≥44px) | |
| + | - Header: Archivo H1, Fraunces lead (was PageHeader mono eyebrow) | |
| + | - `BillsTable` (DataTable dense table) → `BillCards` (big card per bill): | |
| + | - Bill number: JetBrains Mono (DATA), shown as identifier not a primary link | |
| + | - Title: `<Link>` in Fraunces 1.125rem (the primary tap target for elder UX) | |
| + | - Status: `StatusPill` with **short label** (`STATUS_SHORT` map: passed/failed/ | |
| + | in committee/pending/introduced) - prevents long raw status text overflowing 390px | |
| + | - Policy area: amber link · Congress: mono secondary · Date: mono · SourceLink | |
| + | - Cards have 2px warm border, hover to graphite | |
| + | ||
| + | ### Bill detail (`app/bill/[id]/page.tsx`) - full rewrite (data + components preserved) | |
| + | All data fetching, diff, timeline, amendments, versions, CiteThis, SourceLink, neutrality | |
| + | note are unchanged. Template updated: | |
| + | - Breadcrumb: Home / Bills / [bill number in mono] + "← Back to Bills" | |
| + | - H1: bill number in Archivo 900 + JetBrains Mono inline span (data) | |
| + | - Lead: Fraunces prose-lead for the full bill title (was tiny mono) | |
| + | - Key facts: 6px radius cards with 2px border; status/sponsor/last-action in larger | |
| + | readable text (prose-lead 0.9375rem, Archivo labels) | |
| + | - Diff section: amber accent border for rider-flagged cards | |
| + | - Timeline: Fraunces 0.9375rem for action text; mono dates (data only) | |
| + | - Amendments: list layout instead of DataTable (simpler, no dense table) | |
| + | - Votes / Versions: Fraunces italic for empty states; Archivo for version code labels | |
| + | - Section headers: `SectionLabel` component (inline Archivo H2 + mono eyebrow) | |
| + | - No engagement surface note: label (amber/ash) | |
| + | ||
| + | ### Screenshot script (`_ops/screenshot-v2.js`) | |
| + | Playwright script that auto-detects a real bill ID from `/bills`, takes screenshots at | |
| + | 390px (mobile) and 1024px (desktop) of Home + Bills + Bill detail. | |
| + | Output: `_ops/qa-view/v2-{home,bills,bill}-{mobile,desktop}.png` (6 files). | |
| + | ||
| + | --- | |
| + | ||
| ## R3 (2026-06-27) - Votes show what was voted on + richer sort/filter + home landing (Claude) | ||
| ### Summary |
| @@ -0,0 +1,172 @@ | ||
| + | # Docket - Navigation Latency Audit & Fix Notes | |
| + | ||
| + | Session: 2026-06-27. All timings local (`curl -w "%{time_total}"` against | |
| + | `http://localhost:3000` on CT247). Cloudflare Tunnel overhead is on top of these. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Root Causes (investigated) | |
| + | ||
| + | ### 1. No Cache-Control on any API route (CONFIRMED, FIXED) | |
| + | ||
| + | Every `/api/*` GET response had zero `Cache-Control` header before this session. | |
| + | Cloudflare's default for headerless JSON responses is "bypass cache" - meaning | |
| + | every client-side API call (register-hero 15-second poll, MembersBrowser | |
| + | "show historical" paginated fetches, MemberVoteYear lazy year panels) made a | |
| + | full round-trip over the CF Tunnel to the CT247 origin. | |
| + | ||
| + | Fix: added `Cache-Control: public, s-maxage=300, stale-while-revalidate=600` | |
| + | to all `/api/*` routes via `next.config.js` headers(). Routes that already set | |
| + | their own Cache-Control header in the response body (members, member-votes) | |
| + | override this cleanly with the same value. | |
| + | ||
| + | ### 2. `force-dynamic` still on pure static API routes | |
| + | ||
| + | `/api/categories` and `/api/states` read no search params - they returned | |
| + | the same data every time - but carried `force-dynamic`, preventing Next.js | |
| + | server-side ISR. Changed to `export const revalidate = 300` so the Node | |
| + | server caches the response object for 5 min in addition to CF edge caching. | |
| + | ||
| + | ### 3. No `loading.tsx` on any heavy page (CONFIRMED, FIXED) | |
| + | ||
| + | Without `loading.tsx`, clicking a nav link caused a noticeable blank/stall | |
| + | while the RSC payload loaded over the tunnel. The header stayed in place but | |
| + | the content area showed nothing. | |
| + | ||
| + | Fix: added `loading.tsx` skeletons for the six heaviest routes - register, | |
| + | votes, bills, spending, members, calendar. They render immediately on | |
| + | client-side navigation while the RSC payload is in-flight. | |
| + | ||
| + | ### 4. Pages already on ISR - not a root cause here | |
| + | ||
| + | All pages (`/register`, `/votes`, `/bills`, etc.) already carried | |
| + | `export const revalidate = 300` and return `Cache-Control: s-maxage=300, | |
| + | stale-while-revalidate`. This was fixed in the previous perf session. | |
| + | The `x-nextjs-cache: STALE/HIT` headers confirm ISR is working correctly. | |
| + | ||
| + | ### 5. Plain `<a href>` links - not present in nav | |
| + | ||
| + | All internal navigation uses `<Link>` from `next/link`. No regressions here; | |
| + | Next.js client-side transitions + prefetch are already wired correctly. | |
| + | ||
| + | ### 6. Font loading - already correct | |
| + | ||
| + | Four fonts (Archivo, JetBrains Mono, Fraunces, Atkinson Hyperlegible) all | |
| + | loaded with `display: "swap"` via `next/font/google`. No blocking font fetch. | |
| + | ||
| + | ### 7. Page payload sizes (gzip) - acceptable after compression | |
| + | ||
| + | Raw HTML sizes appear large but gzip brings them to reasonable transfer sizes: | |
| + | - `/register`: 1.45 MB raw → 92 KB gzip | |
| + | - `/votes`: 958 KB raw → 96 KB gzip | |
| + | - `/spending`: 2.85 MB raw → 206 KB gzip (320 awards + fiscal cards + apportionments) | |
| + | ||
| + | --- | |
| + | ||
| + | ## Files Changed | |
| + | ||
| + | | File | Change | | |
| + | |------|--------| | |
| + | | `next.config.js` | Added `Cache-Control: public, s-maxage=300, stale-while-revalidate=600` header rule for `/api/:path*` | | |
| + | | `app/api/categories/route.ts` | `force-dynamic` → `revalidate = 300` | | |
| + | | `app/api/states/route.ts` | `force-dynamic` → `revalidate = 300` | | |
| + | | `app/api/feed/route.ts` | Removed duplicate handler-level Cache-Control (config default now applies) | | |
| + | | `app/api/members/route.ts` | Explicit `Cache-Control` in response (same as config default; documents intent) | | |
| + | | `app/api/members/[id]/votes/route.ts` | Explicit `Cache-Control` in response | | |
| + | | `app/register/loading.tsx` | New - hero + table skeleton | | |
| + | | `app/votes/loading.tsx` | New - filter bar + table skeleton | | |
| + | | `app/bills/loading.tsx` | New - filter bar + table skeleton | | |
| + | | `app/spending/loading.tsx` | New - fiscal cards + table skeleton | | |
| + | | `app/members/loading.tsx` | New - member card grid skeleton | | |
| + | | `app/calendar/loading.tsx` | New - calendar grid skeleton | | |
| + | ||
| + | QA after rebuild: **CLEAN** - 17 routes, no overflow, no hydration errors. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Before/After Timings (local, CT247 localhost) | |
| + | ||
| + | These are origin times (no Cloudflare). The "win" from Cloudflare edge caching | |
| + | shows up for end-users, not on localhost. | |
| + | ||
| + | | Route | Before (this session) | After | Note | | |
| + | |-------|-----------------------|-------|------| | |
| + | | `/register` | 0.19s | 0.19s | ISR warm; unchanged | | |
| + | | `/votes` | 0.31s | 0.31s | ISR warm; unchanged | | |
| + | | `/bills` | 0.25s | 0.25s | ISR warm; unchanged | | |
| + | | `/members` | 0.11s | 0.11s | ISR warm; unchanged | | |
| + | | `/calendar` | 0.31s | 0.06s | Second-hit ISR | | |
| + | | `/spending` | 0.66s | 0.74s | 320 awards still full-table; within reason | | |
| + | | `/api/votes` | - (no CC) | CC: s-maxage=300 | CF now caches | | |
| + | | `/api/feed` | - (no CC) | CC: s-maxage=300 | CF now caches | | |
| + | | `/api/members` | - (no CC) | CC: s-maxage=300 | CF now caches | | |
| + | | `/api/categories` | - (force-dynamic, no CC) | CC: s-maxage=300 (ISR) | CF + Node cache | | |
| + | ||
| + | --- | |
| + | ||
| + | ## Cloudflare Cache Rule Required (Overseer action) | |
| + | ||
| + | Cloudflare by default **bypasses cache for HTML responses** even when the | |
| + | origin sends `Cache-Control: s-maxage=300`. Pages in this app return ISR HTML | |
| + | with `s-maxage=300, stale-while-revalidate` - but CF won't cache HTML without | |
| + | an explicit cache rule. | |
| + | ||
| + | Create this rule in the Cloudflare dashboard (or via CF API) for the `usdocket.org` zone: | |
| + | ||
| + | **Rule:** Cache Everything - match `usdocket.org/*` | |
| + | - Cache status: `Cache Everything` | |
| + | - Edge TTL: `Respect existing headers` (honors the `s-maxage=300` from origin) | |
| + | - Browser TTL: `Respect existing headers` | |
| + | ||
| + | This causes the CF edge to cache the full HTML responses for 5 minutes and | |
| + | serve repeat requests without hitting the tunnel. Combined with the ISR | |
| + | s-maxage already set, navigations from new users to already-cached pages will | |
| + | return from CF's edge POP in ~5ms instead of traversing the tunnel. | |
| + | ||
| + | For API routes: no separate rule needed. JSON responses with | |
| + | `Cache-Control: public, s-maxage=300, stale-while-revalidate=600` will be | |
| + | cached by CF automatically once "Cache Everything" is active (CF's default | |
| + | "Cache Only User Content" rule skips JSON by content-type, but "Cache | |
| + | Everything" overrides that). | |
| + | ||
| + | **CF API call to create the rule (requires CF Zone ID + API token):** | |
| + | ||
| + | ```bash | |
| + | # Zone ID and API token are in /shared/projects/.env.secrets | |
| + | ZONE_ID="<cf_zone_id>" | |
| + | CF_TOKEN="<cf_api_token>" | |
| + | ||
| + | curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets" \ | |
| + | -H "Authorization: Bearer $CF_TOKEN" \ | |
| + | -H "Content-Type: application/json" \ | |
| + | -d '{ | |
| + | "name": "Cache Everything - usdocket.org", | |
| + | "kind": "zone", | |
| + | "phase": "http_request_cache_settings", | |
| + | "rules": [ | |
| + | { | |
| + | "action": "set_cache_settings", | |
| + | "action_parameters": { | |
| + | "cache": true, | |
| + | "edge_ttl": { | |
| + | "mode": "respect_origin_ttl" | |
| + | }, | |
| + | "browser_ttl": { | |
| + | "mode": "respect_origin" | |
| + | } | |
| + | }, | |
| + | "expression": "true", | |
| + | "description": "Cache all public Docket content respecting origin TTL" | |
| + | } | |
| + | ] | |
| + | }' | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Summary for the Overseer | |
| + | ||
| + | Code changes are deployed on CT247 at `/opt/docket`. QA passes. | |
| + | The single remaining action for the overseer is the Cloudflare cache rule above, | |
| + | which unlocks edge caching of HTML pages and makes the existing `s-maxage=300` | |
| + | headers actually take effect for end-users. |
| @@ -0,0 +1,45 @@ | ||
| + | <!doctype html><html><head><meta charset="utf-8"> | |
| + | <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| + | <link href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;600;800&family=Atkinson+Hyperlegible:wght@400;700&display=swap" rel="stylesheet"> | |
| + | <style> | |
| + | * { box-sizing: border-box; } | |
| + | body { margin:0; background:#ECEAE5; font-family:'Atkinson Hyperlegible',system-ui,sans-serif; color:#1A1A1A; padding:28px; } | |
| + | h1.board { font-family:'Public Sans'; font-weight:800; font-size:26px; margin:0 0 6px; } | |
| + | p.boardsub { font-size:17px; color:#44423D; margin:0 0 24px; } | |
| + | .grid { display:grid; grid-template-columns:repeat(2,1fr); gap:22px; } | |
| + | .card { background:#FFFFFF; border:2px solid #CFCBC2; border-radius:10px; padding:22px; } | |
| + | .name { font-family:'Public Sans'; font-weight:800; font-size:15px; letter-spacing:.04em; text-transform:uppercase; } | |
| + | .hex { font-size:13px; color:#6b6862; margin:2px 0 16px; } | |
| + | .eyebrow { font-family:'Public Sans'; font-weight:600; font-size:12px; letter-spacing:.12em; text-transform:uppercase; } | |
| + | .h { font-family:'Public Sans'; font-weight:800; font-size:28px; line-height:1.05; margin:6px 0 10px; } | |
| + | .lead { font-size:18px; line-height:1.5; margin:0 0 18px; } | |
| + | .btn { display:inline-block; font-family:'Public Sans'; font-weight:700; font-size:18px; padding:14px 22px; border-radius:8px; color:#1A1A1A; } | |
| + | .link { font-size:18px; font-weight:700; text-decoration:underline; text-underline-offset:3px; display:inline-block; margin-top:16px; } | |
| + | .pill { display:inline-block; font-family:'Public Sans'; font-weight:700; font-size:14px; padding:8px 14px; border-radius:999px; margin-top:16px; } | |
| + | .swatch { width:34px; height:34px; border-radius:7px; display:inline-block; vertical-align:middle; margin-right:8px; border:1px solid rgba(0,0,0,.15);} | |
| + | </style></head> | |
| + | <body> | |
| + | <h1 class="board">Docket - accent options (large-print look, white background)</h1> | |
| + | <p class="boardsub">Same neutral layout, five accents. Each shows a heading, a primary button, a link, and an "active page" pill.</p> | |
| + | <div class="grid" id="grid"></div> | |
| + | <script> | |
| + | const opts = [ | |
| + | {name:'1 · Brass / Amber', t:'#8A6A12', s:'#D8A93A', w:'#FBF3DF', hex:'text #8A6A12 · fill #D8A93A'}, | |
| + | {name:'2 · Deep Teal', t:'#0E6E63', s:'#14857A', w:'#E1F2EF', hex:'text #0E6E63 · fill #14857A'}, | |
| + | {name:'3 · Forest Green', t:'#2E6B3E', s:'#3A8A4E', w:'#E6F2E8', hex:'text #2E6B3E · fill #3A8A4E'}, | |
| + | {name:'4 · Bronze / Copper',t:'#94531F', s:'#C0712E', w:'#F8ECE1', hex:'text #94531F · fill #C0712E'}, | |
| + | {name:'5 · Ink only (minimal)',t:'#1A1A1A', s:'#1A1A1A', w:'#EEEDE9', hex:'near-black, accent only on the button'}, | |
| + | ]; | |
| + | document.getElementById('grid').innerHTML = opts.map(o=>` | |
| + | <div class="card"> | |
| + | <div class="name"><span class="swatch" style="background:${o.s}"></span>${o.name}</div> | |
| + | <div class="hex">${o.hex}</div> | |
| + | <div class="eyebrow" style="color:${o.t}">ROLL-CALL REGISTER</div> | |
| + | <div class="h">Votes, on the record</div> | |
| + | <div class="lead">Each row is a recorded vote in the House or Senate - what it was about, the tally, and a link.</div> | |
| + | <a class="btn" style="background:${o.s}">Search the record →</a> | |
| + | <br><a class="link" style="color:${o.t}">H.R. 22 - view this bill</a> | |
| + | <br><span class="pill" style="background:${o.w};color:${o.t}">Bills (active)</span> | |
| + | </div>`).join(''); | |
| + | </script> | |
| + | </body></html> |
| @@ -0,0 +1,73 @@ | ||
| + | // Docket v2 design screenshots - takes home + bills + bill-detail | |
| + | // at 390px (mobile) and 1024px (desktop), saves to qa-view/ as v2-*.png | |
| + | const { chromium } = require("/opt/docket/node_modules/playwright"); | |
| + | const fs = require("fs"); | |
| + | const path = require("path"); | |
| + | ||
| + | const BASE = process.env.QA_BASE || "http://localhost:3000"; | |
| + | const OUT = "/shared/projects/docket/_ops/qa-view"; | |
| + | fs.mkdirSync(OUT, { recursive: true }); | |
| + | ||
| + | // Find a real bill ID to screenshot - look for any real URL from the /bills page | |
| + | async function findBillId(page) { | |
| + | await page.goto(BASE + "/bills", { waitUntil: "networkidle", timeout: 35000 }); | |
| + | // Try to find the first bill card link | |
| + | const href = await page.$eval( | |
| + | 'a[href^="/bill/"]', | |
| + | (el) => el.getAttribute("href") | |
| + | ).catch(() => null); | |
| + | return href || "/bill/2136"; // fallback to known test ID | |
| + | } | |
| + | ||
| + | (async () => { | |
| + | const browser = await chromium.launch(); | |
| + | const viewports = [ | |
| + | { name: "mobile", width: 390, height: 844, mobile: true, dpr: 2 }, | |
| + | { name: "desktop", width: 1024, height: 768, mobile: false, dpr: 1 }, | |
| + | ]; | |
| + | ||
| + | let billPath = "/bill/2136"; | |
| + | ||
| + | for (const vp of viewports) { | |
| + | const ctx = await browser.newContext({ | |
| + | viewport: { width: vp.width, height: vp.height }, | |
| + | deviceScaleFactor: vp.dpr, | |
| + | isMobile: vp.mobile, | |
| + | hasTouch: vp.mobile, | |
| + | }); | |
| + | const page = await ctx.newPage(); | |
| + | ||
| + | // Find a real bill for the first viewport pass | |
| + | if (vp.name === "mobile") { | |
| + | try { | |
| + | billPath = await findBillId(page); | |
| + | console.log("Bill path:", billPath); | |
| + | } catch (e) { | |
| + | console.warn("Could not auto-detect bill path, using default"); | |
| + | } | |
| + | } | |
| + | ||
| + | const shots = [ | |
| + | { route: "/", name: "v2-home" }, | |
| + | { route: "/bills", name: "v2-bills" }, | |
| + | { route: billPath, name: "v2-bill" }, | |
| + | ]; | |
| + | ||
| + | for (const s of shots) { | |
| + | try { | |
| + | await page.goto(BASE + s.route, { waitUntil: "networkidle", timeout: 35000 }); | |
| + | await page.waitForTimeout(1000); | |
| + | const outPath = path.join(OUT, `${s.name}-${vp.name}.png`); | |
| + | await page.screenshot({ path: outPath, fullPage: false }); | |
| + | console.log(`OK ${outPath}`); | |
| + | } catch (e) { | |
| + | console.error(`FAIL ${s.route}: ${e.message}`); | |
| + | } | |
| + | } | |
| + | ||
| + | await ctx.close(); | |
| + | } | |
| + | ||
| + | await browser.close(); | |
| + | console.log("\nScreenshots saved to", OUT); | |
| + | })(); |
| @@ -0,0 +1,81 @@ | ||
| + | // Docket V3 design screenshots - captures dark-default theme | |
| + | // Home + /bills + bill-detail at 390px (mobile) and 1280px (desktop) | |
| + | // Saves to qa-view/ as v3-*.png | |
| + | const { chromium } = require("/opt/docket/node_modules/playwright"); | |
| + | const fs = require("fs"); | |
| + | const path = require("path"); | |
| + | ||
| + | const BASE = process.env.QA_BASE || "http://localhost:3000"; | |
| + | const OUT = "/shared/projects/docket/_ops/qa-view"; | |
| + | fs.mkdirSync(OUT, { recursive: true }); | |
| + | ||
| + | // Find a real bill ID from the /bills page | |
| + | async function findBillId(page) { | |
| + | await page.goto(BASE + "/bills", { waitUntil: "networkidle", timeout: 35000 }); | |
| + | const href = await page.$eval( | |
| + | 'a[href^="/bill/"]', | |
| + | (el) => el.getAttribute("href") | |
| + | ).catch(() => null); | |
| + | return href || "/bill/2136"; | |
| + | } | |
| + | ||
| + | (async () => { | |
| + | const browser = await chromium.launch(); | |
| + | const viewports = [ | |
| + | { name: "mobile", width: 390, height: 844, mobile: true, dpr: 2 }, | |
| + | { name: "desktop", width: 1280, height: 900, mobile: false, dpr: 1 }, | |
| + | ]; | |
| + | ||
| + | let billPath = "/bill/2136"; | |
| + | ||
| + | for (const vp of viewports) { | |
| + | const ctx = await browser.newContext({ | |
| + | viewport: { width: vp.width, height: vp.height }, | |
| + | deviceScaleFactor: vp.dpr, | |
| + | isMobile: vp.mobile, | |
| + | hasTouch: vp.mobile, | |
| + | // Force dark mode preference so the site stays in dark default | |
| + | colorScheme: "dark", | |
| + | }); | |
| + | const page = await ctx.newPage(); | |
| + | ||
| + | // Clear any persisted display-mode so we get the true default (dark) | |
| + | await page.addInitScript(() => { | |
| + | localStorage.removeItem("docket-display"); | |
| + | }); | |
| + | ||
| + | // Find a real bill on mobile pass | |
| + | if (vp.name === "mobile") { | |
| + | try { | |
| + | billPath = await findBillId(page); | |
| + | console.log("Bill path:", billPath); | |
| + | } catch (e) { | |
| + | console.warn("Could not auto-detect bill path, using default"); | |
| + | } | |
| + | } | |
| + | ||
| + | const shots = [ | |
| + | { route: "/", name: "v3-home" }, | |
| + | { route: "/bills", name: "v3-bills" }, | |
| + | { route: billPath, name: "v3-bill" }, | |
| + | ]; | |
| + | ||
| + | for (const s of shots) { | |
| + | try { | |
| + | await page.goto(BASE + s.route, { waitUntil: "networkidle", timeout: 35000 }); | |
| + | // Wait for fonts + transitions | |
| + | await page.waitForTimeout(1200); | |
| + | const outPath = path.join(OUT, `${s.name}-${vp.name}.png`); | |
| + | await page.screenshot({ path: outPath, fullPage: false }); | |
| + | console.log("OK ", outPath); | |
| + | } catch (e) { | |
| + | console.error("FAIL", s.route + ":", e.message); | |
| + | } | |
| + | } | |
| + | ||
| + | await ctx.close(); | |
| + | } | |
| + | ||
| + | await browser.close(); | |
| + | console.log("\nV3 screenshots saved to", OUT); | |
| + | })(); |
| @@ -1,7 +1,8 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { getCategories } from "@/lib/feed"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | // No search params - ISR-cache the response on the Node server too. | |
| + | export const revalidate = 300; | |
| /** GET /api/categories - topic list with bill + award counts. */ | ||
| export async function GET() { |
| @@ -37,5 +37,6 @@ export async function GET(req: NextRequest) { | ||
| const total = items.length; | ||
| const paged = items.slice(offset, offset + limit); | ||
| const body: ItemList<FeedItem> = { items: paged, total, limit, offset }; | ||
| + | // Cache-Control is set globally for /api/* in next.config.js (s-maxage=300). | |
| return NextResponse.json(body); | ||
| } |
| @@ -23,5 +23,9 @@ export async function GET( | ||
| yearStr && yearStr !== "null" ? parseInt(yearStr, 10) : null; | ||
| const votes = getMemberVotesForYear(bioguide, year); | ||
| - | return NextResponse.json({ votes, year }); | |
| + | // Vote positions for a given year/member don't change; cache at CF edge so | |
| + | // MemberVoteYear lazy-accordion hits don't traverse the tunnel repeatedly. | |
| + | return NextResponse.json({ votes, year }, { | |
| + | headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" }, | |
| + | }); | |
| } |
| @@ -31,5 +31,9 @@ export async function GET(req: NextRequest) { | ||
| : getMemberCounts().total; | ||
| const paged = items.slice(offset, offset + limit); | ||
| const body: ItemList<MemberSummary> = { items: paged, total, limit, offset }; | ||
| - | return NextResponse.json(body); | |
| + | // Member roster is stable within the 5-min ISR window; cache at CF edge so | |
| + | // MembersBrowser's "show historical" paginated fetches skip the tunnel. | |
| + | return NextResponse.json(body, { | |
| + | headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" }, | |
| + | }); | |
| } |
| @@ -1,7 +1,8 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { getStates } from "@/lib/feed"; | ||
| - | export const dynamic = "force-dynamic"; | |
| + | // No search params - ISR-cache the response on the Node server too. | |
| + | export const revalidate = 300; | |
| /** GET /api/states - coverage + confidence per state (honesty layer). | ||
| * Rows are aggregated to one entry per state with a worst-case confidence |
| @@ -1,8 +1,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 { Card } from "@/components/ui"; | |
| import { SourceLink } from "@/components/source-link"; | ||
| import { StatusPill, classifyStatus } from "@/components/status-pill"; | ||
| import { PartyTag } from "@/components/party-tag"; | ||
| @@ -39,297 +38,473 @@ export default function BillPage({ | ||
| const otherChanges = bill.diffs.filter((d) => !d.riderFlag); | ||
| return ( | ||
| - | <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | |
| - | <Link | |
| - | href="/search" | |
| - | className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper" | |
| - | > | |
| - | ← Search | |
| - | </Link> | |
| + | <div className="mx-auto w-full max-w-[860px] px-6 sm:px-8" style={{ paddingTop: "clamp(32px, 6vw, 56px)", paddingBottom: "clamp(48px, 8vw, 80px)" }}> | |
| + | ||
| + | {/* ── Breadcrumb + Back ──────────────────────────────────────── */} | |
| + | <nav aria-label="Breadcrumb" style={{ marginBottom: "clamp(28px, 5vw, 44px)" }}> | |
| + | <ol style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "8px", marginBottom: "12px" }}> | |
| + | <li> | |
| + | <Link | |
| + | href="/" | |
| + | className="focus-ring label no-underline hover:underline" | |
| + | style={{ color: "var(--dk-signal)", fontSize: "12px", letterSpacing: "0.12em" }} | |
| + | > | |
| + | Home | |
| + | </Link> | |
| + | </li> | |
| + | <li aria-hidden="true" className="label" style={{ color: "var(--dk-ash-2)", fontSize: "12px" }}> | |
| + | / | |
| + | </li> | |
| + | <li> | |
| + | <Link | |
| + | href="/bills" | |
| + | className="focus-ring label no-underline hover:underline" | |
| + | style={{ color: "var(--dk-signal)", fontSize: "12px", letterSpacing: "0.12em" }} | |
| + | > | |
| + | Bills | |
| + | </Link> | |
| + | </li> | |
| + | <li aria-hidden="true" className="label" style={{ color: "var(--dk-ash-2)", fontSize: "12px" }}> | |
| + | / | |
| + | </li> | |
| + | <li | |
| + | className="label" | |
| + | style={{ color: "var(--dk-ash)", fontSize: "12px", letterSpacing: "0.12em" }} | |
| + | aria-current="page" | |
| + | > | |
| + | <span className="font-mono">{bill.number}</span> | |
| + | </li> | |
| + | </ol> | |
| + | <Link | |
| + | href="/bills" | |
| + | className="focus-ring no-underline hover:underline" | |
| + | style={{ | |
| + | display: "inline-flex", | |
| + | alignItems: "center", | |
| + | gap: "8px", | |
| + | minHeight: "44px", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "13px", | |
| + | letterSpacing: "0.08em", | |
| + | color: "var(--dk-signal)", | |
| + | }} | |
| + | > | |
| + | ← Back to Bills | |
| + | </Link> | |
| + | </nav> | |
| + | ||
| + | {/* ── Page header ───────────────────────────────────────────── */} | |
| + | <header style={{ borderBottom: "1px solid var(--dk-line)", paddingBottom: "clamp(28px, 5vw, 48px)", marginBottom: "clamp(28px, 5vw, 40px)" }}> | |
| + | {/* Kicker: mono uppercase, bill number + congress + policy area */} | |
| + | <p | |
| + | className="label" | |
| + | style={{ color: "var(--dk-signal)", marginBottom: "20px", fontSize: "12px", letterSpacing: "0.14em" }} | |
| + | > | |
| + | <span className="font-mono">{bill.number}</span> | |
| + | {" · "} | |
| + | {bill.congress}th Congress | |
| + | {bill.policyArea ? ` · ${bill.policyArea}` : ""} | |
| + | </p> | |
| - | <PageHeader | |
| - | eyebrow={`${bill.number} · ${bill.congress}th Congress${bill.policyArea ? ` · ${bill.policyArea}` : ""}`} | |
| - | title={bill.number} | |
| - | lead={bill.title} | |
| - | /> | |
| + | {/* H1: HUGE bill number - Archivo, uppercase, the anchor */} | |
| + | <h1 | |
| + | className="font-display text-paper" | |
| + | style={{ | |
| + | fontSize: "clamp(2.8rem, 9vw, 4.5rem)", | |
| + | fontWeight: 900, | |
| + | lineHeight: 0.96, | |
| + | letterSpacing: "-0.03em", | |
| + | textTransform: "uppercase", | |
| + | marginBottom: "24px", | |
| + | }} | |
| + | > | |
| + | {bill.number} | |
| + | </h1> | |
| - | {/* top facts */} | |
| - | <div className="mt-8 grid gap-4 sm:grid-cols-3"> | |
| - | <Card className="p-5"> | |
| - | <p className="label mb-2">Status</p> | |
| - | <StatusPill variant={statusVariant} dot> | |
| - | {bill.status ? "On record" : "Introduced"} | |
| - | </StatusPill> | |
| - | <p className="mt-2 font-mono text-[12px] text-ash"> | |
| - | {bill.status || "No status text on file."} | |
| - | </p> | |
| - | </Card> | |
| + | {/* Bill title - Fraunces, readable, warm */} | |
| + | <p | |
| + | className="font-prose" | |
| + | style={{ fontStyle: "italic", fontSize: "clamp(1rem, 1.8vw, 1.125rem)", lineHeight: 1.55, color: "var(--dk-ash)", maxWidth: "56ch" }} | |
| + | > | |
| + | {bill.title} | |
| + | </p> | |
| + | </header> | |
| + | ||
| + | {/* ── Key facts ─────────────────────────────────────────────── */} | |
| + | <section style={{ marginBottom: "clamp(24px, 4vw, 32px)" }} aria-label="Key facts"> | |
| + | <div className="grid gap-4 sm:grid-cols-3"> | |
| + | {/* Status */} | |
| + | <div style={{ borderRadius: "10px", border: "1px solid var(--dk-line)", background: "var(--dk-surface)", padding: "clamp(18px, 3vw, 24px)" }}> | |
| + | <p className="label" style={{ color: "var(--dk-ash-2)", marginBottom: "12px", fontSize: "12px", letterSpacing: "0.12em" }}> | |
| + | Status | |
| + | </p> | |
| + | <StatusPill variant={statusVariant} dot> | |
| + | {bill.status ? "On record" : "Introduced"} | |
| + | </StatusPill> | |
| + | <p | |
| + | className="mt-3 font-prose" | |
| + | style={{ fontSize: "0.9375rem", lineHeight: 1.5, color: "var(--dk-ash)" }} | |
| + | > | |
| + | {bill.status || "No status text on file."} | |
| + | </p> | |
| + | </div> | |
| - | <Card className="p-5"> | |
| - | <p className="label mb-2">Sponsor</p> | |
| - | {bill.sponsor ? ( | |
| - | <> | |
| + | {/* Sponsor */} | |
| + | <div style={{ borderRadius: "10px", border: "1px solid var(--dk-line)", background: "var(--dk-surface)", padding: "clamp(18px, 3vw, 24px)" }}> | |
| + | <p className="label" style={{ color: "var(--dk-ash-2)", marginBottom: "12px", fontSize: "12px", letterSpacing: "0.12em" }}> | |
| + | Sponsor | |
| + | </p> | |
| + | {bill.sponsor ? ( | |
| <div className="flex items-start gap-3"> | ||
| - | <MemberAvatar name={bill.sponsor.name} photoUrl={bill.sponsor.photoUrl} size="md" /> | |
| + | <MemberAvatar | |
| + | name={bill.sponsor.name} | |
| + | photoUrl={bill.sponsor.photoUrl} | |
| + | size="md" | |
| + | /> | |
| <div className="min-w-0"> | ||
| <Link | ||
| href={bill.sponsor.href} | ||
| - | className="focus-ring font-mono text-[13px] text-paper underline underline-offset-4 hover:text-paper" | |
| + | className="focus-ring font-display font-bold text-paper underline underline-offset-4 hover:text-paper" | |
| + | style={{ fontSize: "1rem" }} | |
| > | ||
| {bill.sponsor.name} | ||
| </Link> | ||
| <div className="mt-2 flex items-center gap-2"> | ||
| <PartyTag party={bill.sponsor.partyCode} /> | ||
| - | <span className="font-mono text-[12px] text-ash"> | |
| + | <span | |
| + | className="font-mono" | |
| + | style={{ fontSize: "0.8125rem", color: "var(--dk-ash)" }} | |
| + | > | |
| {bill.sponsor.state} | ||
| {bill.sponsor.district != null ? ` ${bill.sponsor.district}` : ""} | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| - | </> | |
| - | ) : bill.sponsorDisplay ? ( | |
| - | <p className="font-mono text-[13px] text-paper">{bill.sponsorDisplay}</p> | |
| - | ) : ( | |
| - | <p className="font-mono text-[13px] text-ash-2"> | |
| - | No sponsor linked in the record. | |
| - | </p> | |
| - | )} | |
| - | </Card> | |
| + | ) : bill.sponsorDisplay ? ( | |
| + | <p | |
| + | className="font-display font-bold text-paper" | |
| + | style={{ fontSize: "1rem" }} | |
| + | > | |
| + | {bill.sponsorDisplay} | |
| + | </p> | |
| + | ) : ( | |
| + | <p | |
| + | className="font-prose italic" | |
| + | style={{ fontSize: "0.9375rem", color: "var(--dk-ash-2)" }} | |
| + | > | |
| + | No sponsor linked in the record. | |
| + | </p> | |
| + | )} | |
| + | </div> | |
| - | <Card className="p-5"> | |
| - | <p className="label mb-2">Last action</p> | |
| - | <p className="font-mono text-[13px] text-paper"> | |
| - | {bill.timeline[0]?.text || "No actions on file"} | |
| - | </p> | |
| - | <p className="label mt-2 text-ash-2"> | |
| - | {bill.timeline[0]?.chamber ? `${bill.timeline[0].chamber} · ` : ""} | |
| - | {bill.timeline[0] ? formatDate(bill.timeline[0].date) : "-"} | |
| - | </p> | |
| - | </Card> | |
| - | </div> | |
| + | {/* Last action */} | |
| + | <div style={{ borderRadius: "10px", border: "1px solid var(--dk-line)", background: "var(--dk-surface)", padding: "clamp(18px, 3vw, 24px)" }}> | |
| + | <p className="label" style={{ color: "var(--dk-ash-2)", marginBottom: "12px", fontSize: "12px", letterSpacing: "0.12em" }}> | |
| + | Last action | |
| + | </p> | |
| + | <p | |
| + | className="font-prose text-paper" | |
| + | style={{ fontSize: "0.9375rem", lineHeight: 1.5 }} | |
| + | > | |
| + | {bill.timeline[0]?.text || "No actions on file"} | |
| + | </p> | |
| + | <p className="label mt-3" style={{ color: "var(--dk-ash-2)" }}> | |
| + | {bill.timeline[0]?.chamber ? `${bill.timeline[0].chamber} · ` : ""} | |
| + | <span className="font-mono"> | |
| + | {bill.timeline[0] ? formatDate(bill.timeline[0].date) : "-"} | |
| + | </span> | |
| + | </p> | |
| + | </div> | |
| + | </div> | |
| + | </section> | |
| - | {/* TOPIC + SUBJECTS */} | |
| + | {/* ── Policy area + subjects ────────────────────────────────── */} | |
| {(bill.policyArea || bill.subjects.length > 0) ? ( | ||
| - | <div className="mt-4"> | |
| - | <Card className="p-5"> | |
| + | <section style={{ marginTop: "16px" }} aria-label="Policy area"> | |
| + | <div style={{ borderRadius: "10px", border: "1px solid var(--dk-line)", background: "var(--dk-surface)", padding: "clamp(18px, 3vw, 24px)" }}> | |
| <div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between"> | ||
| <div className="min-w-0"> | ||
| - | <p className="label mb-2">Policy area</p> | |
| + | <p className="label" style={{ color: "var(--dk-ash-2)", marginBottom: "8px", fontSize: "12px", letterSpacing: "0.12em" }}> | |
| + | Policy area | |
| + | </p> | |
| {bill.policyArea ? ( | ||
| <Link | ||
| href={`/bills?policyArea=${encodeURIComponent(bill.policyArea)}`} | ||
| - | className="focus-ring font-mono text-[13px] text-paper underline underline-offset-4 hover:text-paper" | |
| + | className="focus-ring font-display font-bold text-paper underline underline-offset-4 hover:text-paper" | |
| + | style={{ fontSize: "1.0625rem" }} | |
| > | ||
| {bill.policyArea} | ||
| </Link> | ||
| ) : ( | ||
| - | <p className="font-mono text-[13px] text-ash-2"> | |
| + | <p | |
| + | className="font-prose italic" | |
| + | style={{ fontSize: "0.9375rem", color: "var(--dk-ash-2)" }} | |
| + | > | |
| No policy area on file. | ||
| </p> | ||
| )} | ||
| </div> | ||
| {bill.subjects.length > 0 ? ( | ||
| <div className="min-w-0 sm:max-w-[60%]"> | ||
| - | <p className="label mb-2">Subjects</p> | |
| - | <ul className="flex flex-wrap gap-1.5"> | |
| + | <p className="label" style={{ color: "var(--dk-ash-2)", marginBottom: "8px", fontSize: "12px", letterSpacing: "0.12em" }}> | |
| + | Subjects | |
| + | </p> | |
| + | <ul className="flex flex-wrap gap-2"> | |
| {bill.subjects.map((s) => ( | ||
| - | <li key={s} className="pill text-ash">{s}</li> | |
| + | <li key={s} className="pill" style={{ color: "var(--dk-ash)" }}> | |
| + | {s} | |
| + | </li> | |
| ))} | ||
| </ul> | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| - | </Card> | |
| - | </div> | |
| + | </div> | |
| + | </section> | |
| ) : null} | ||
| - | {/* SIGNATURE FEATURE - what changed / what got slipped in */} | |
| - | <div className="mt-12"> | |
| - | <SectionHeader | |
| - | eyebrow="Version diff" | |
| - | title="What changed in the text" | |
| - | description="Added, removed, and amended sections between versions of this bill. Sections that entered outside the bill's named subject are flagged as riders." | |
| - | /> | |
| + | {/* ── Version diff - What changed ───────────────────────────── */} | |
| + | <section className="mt-12" aria-label="Version diff"> | |
| + | <SectionLabel eyebrow="Version diff" title="What changed in the text" /> | |
| + | <p | |
| + | className="prose-lead mb-6" | |
| + | style={{ fontSize: "1rem", color: "var(--dk-ash)" }} | |
| + | > | |
| + | Added, removed, and amended sections between versions of this bill. | |
| + | Sections that entered outside the bill's named subject are flagged as | |
| + | riders. | |
| + | </p> | |
| {bill.diffs.length === 0 ? ( | ||
| - | <Card className="p-5"> | |
| - | <p className="font-mono text-[13px] text-ash"> | |
| + | <div className="rounded-[6px] border border-line bg-surface p-6"> | |
| + | <p | |
| + | className="font-prose italic" | |
| + | style={{ fontSize: "0.9375rem", color: "var(--dk-ash)" }} | |
| + | > | |
| No section-level diff on file. Some changes enter via voice vote | ||
| or unanimous consent - no individual record exists for those. | ||
| </p> | ||
| - | </Card> | |
| + | </div> | |
| ) : ( | ||
| <div className="space-y-4"> | ||
| {riderCount > 0 ? ( | ||
| - | <Card className="p-5" style={{ borderColor: "color-mix(in srgb, var(--dk-signal) 40%, transparent)" }}> | |
| + | <div | |
| + | className="rounded-[6px] border-2 p-6" | |
| + | style={{ | |
| + | borderColor: "color-mix(in srgb, var(--dk-signal) 45%, transparent)", | |
| + | background: "var(--dk-surface)", | |
| + | }} | |
| + | > | |
| <div className="flex items-center gap-2"> | ||
| <RiderFlag /> | ||
| - | <span className="label text-paper"> | |
| - | {riderCount} rider-flagged section{riderCount === 1 ? "" : "s"} | |
| + | <span | |
| + | className="font-display font-bold text-paper" | |
| + | style={{ fontSize: "1rem" }} | |
| + | > | |
| + | {riderCount} rider-flagged section | |
| + | {riderCount === 1 ? "" : "s"} | |
| </span> | ||
| </div> | ||
| - | <div className="mt-4 space-y-3"> | |
| + | <div className="mt-4 space-y-4"> | |
| {bill.diffs | ||
| .filter((d) => d.riderFlag) | ||
| .map((d, i) => ( | ||
| <DiffEntry key={`rider-${i}`} d={d} /> | ||
| ))} | ||
| </div> | ||
| - | </Card> | |
| + | </div> | |
| ) : null} | ||
| {otherChanges.length > 0 ? ( | ||
| - | <Card className="p-5"> | |
| - | <p className="label mb-4 text-ash"> | |
| + | <div className="rounded-[6px] border border-line bg-surface p-6"> | |
| + | <p className="label mb-4" style={{ color: "var(--dk-ash)" }}> | |
| Other recorded changes | ||
| </p> | ||
| - | <div className="space-y-3"> | |
| + | <div className="space-y-4"> | |
| {otherChanges.map((d, i) => ( | ||
| <DiffEntry key={`chg-${i}`} d={d} /> | ||
| ))} | ||
| </div> | ||
| - | </Card> | |
| + | </div> | |
| ) : null} | ||
| </div> | ||
| )} | ||
| - | </div> | |
| + | </section> | |
| + | {/* ── Timeline + Votes/Versions ─────────────────────────────── */} | |
| <div className="mt-12 grid gap-8 lg:grid-cols-[1.3fr_1fr]"> | ||
| - | {/* STATUS TIMELINE - real bill actions */} | |
| - | <div> | |
| - | <SectionHeader | |
| + | {/* Status timeline */} | |
| + | <section aria-label="Status timeline"> | |
| + | <SectionLabel | |
| eyebrow={`Timeline · ${bill.timeline.length} action${bill.timeline.length === 1 ? "" : "s"}`} | ||
| title="Actions on this bill" | ||
| /> | ||
| {bill.timeline.length === 0 ? ( | ||
| - | <Card className="p-5"> | |
| - | <p className="font-mono text-[13px] text-ash-2"> | |
| + | <div className="rounded-[6px] border border-line bg-surface p-6"> | |
| + | <p | |
| + | className="font-prose italic" | |
| + | style={{ fontSize: "0.9375rem", color: "var(--dk-ash-2)" }} | |
| + | > | |
| No actions are linked to this bill in the record. | ||
| </p> | ||
| - | </Card> | |
| + | </div> | |
| ) : ( | ||
| - | <Card className="p-5"> | |
| - | <ol className="relative space-y-5 border-l border-line pl-5"> | |
| + | <div className="rounded-[6px] border border-line bg-surface p-6"> | |
| + | <ol className="relative space-y-6 border-l border-line pl-5"> | |
| {bill.timeline.map((t, i) => ( | ||
| <li key={i} className="relative"> | ||
| <span | ||
| aria-hidden="true" | ||
| - | className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full border border-line" | |
| + | className="absolute -left-[1.625rem] top-1 h-2.5 w-2.5 rounded-full border-2 border-line" | |
| style={{ backgroundColor: "var(--dk-surface)" }} | ||
| /> | ||
| - | <p className="font-mono text-[13px] text-paper">{t.text}</p> | |
| - | <p className="label mt-1"> | |
| - | {formatDate(t.date)} | |
| + | <p | |
| + | className="font-prose text-paper" | |
| + | style={{ fontSize: "0.9375rem", lineHeight: 1.5 }} | |
| + | > | |
| + | {t.text} | |
| + | </p> | |
| + | <p className="label mt-1.5" style={{ color: "var(--dk-ash-2)" }}> | |
| + | <span className="font-mono">{formatDate(t.date)}</span> | |
| {t.chamber ? ` · ${t.chamber}` : ""} | ||
| {t.actionType ? ` · ${t.actionType}` : ""} | ||
| </p> | ||
| - | <div className="mt-1.5"> | |
| + | <div className="mt-2"> | |
| <SourceLink url={t.sourceUrl} /> | ||
| </div> | ||
| </li> | ||
| ))} | ||
| </ol> | ||
| - | </Card> | |
| + | </div> | |
| )} | ||
| - | </div> | |
| + | </section> | |
| <div className="space-y-8"> | ||
| - | {/* VOTES - none linked in current ingest; honest about it */} | |
| - | <div> | |
| - | <SectionHeader eyebrow="Votes" title="Roll calls on this bill" /> | |
| - | <Card className="p-5"> | |
| + | {/* Votes */} | |
| + | <section aria-label="Roll-call votes"> | |
| + | <SectionLabel eyebrow="Votes" title="Roll calls on this bill" /> | |
| + | <div className="rounded-[6px] border border-line bg-surface p-6"> | |
| {bill.votes.length > 0 ? ( | ||
| - | <p className="font-mono text-[13px] text-paper"> | |
| + | <p | |
| + | className="font-display font-bold text-paper" | |
| + | style={{ fontSize: "1.0625rem" }} | |
| + | > | |
| {bill.votes.length} vote(s) recorded. | ||
| </p> | ||
| ) : ( | ||
| - | <p className="font-mono text-[13px] text-ash-2"> | |
| - | No roll-call votes are linked to this bill in the current | |
| - | record. When a vote occurs, it will appear here with a link to | |
| - | the full member roster. | |
| + | <p | |
| + | className="font-prose italic" | |
| + | style={{ fontSize: "0.9375rem", color: "var(--dk-ash-2)" }} | |
| + | > | |
| + | No roll-call votes are linked to this bill yet. When a vote | |
| + | occurs, it will appear here with a link to the full member | |
| + | roster. | |
| </p> | ||
| )} | ||
| - | <p className="label mt-3 text-ash-2"> | |
| + | <p className="label mt-4" style={{ color: "var(--dk-ash-2)" }}> | |
| Browse all votes on the{" "} | ||
| <Link | ||
| href="/votes" | ||
| className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper" | ||
| + | style={{ color: "var(--dk-signal)" }} | |
| > | ||
| votes register | ||
| </Link> | ||
| . | ||
| </p> | ||
| - | </Card> | |
| - | </div> | |
| + | </div> | |
| + | </section> | |
| - | {/* VERSIONS */} | |
| - | <div> | |
| - | <SectionHeader eyebrow={`Versions · ${bill.versions.length}`} title="Bill text versions" /> | |
| + | {/* Bill text versions */} | |
| + | <section aria-label="Bill text versions"> | |
| + | <SectionLabel | |
| + | eyebrow={`Versions · ${bill.versions.length}`} | |
| + | title="Bill text versions" | |
| + | /> | |
| {bill.versions.length === 0 ? ( | ||
| - | <Card className="p-5"> | |
| - | <p className="font-mono text-[13px] text-ash-2"> | |
| + | <div className="rounded-[6px] border border-line bg-surface p-6"> | |
| + | <p | |
| + | className="font-prose italic" | |
| + | style={{ fontSize: "0.9375rem", color: "var(--dk-ash-2)" }} | |
| + | > | |
| No full-text versions on file. | ||
| </p> | ||
| - | </Card> | |
| + | </div> | |
| ) : ( | ||
| - | <Card className="divide-y divide-[color:var(--dk-line)]"> | |
| + | <div className="rounded-[6px] border border-line bg-surface divide-y divide-line"> | |
| {bill.versions.map((v) => ( | ||
| - | <div key={v.id} className="flex items-center justify-between gap-2 p-3"> | |
| + | <div | |
| + | key={v.id} | |
| + | className="flex items-center justify-between gap-3 p-4" | |
| + | > | |
| <div className="min-w-0"> | ||
| - | <p className="font-mono text-[12px] text-paper"> | |
| + | <p | |
| + | className="font-display font-bold text-paper" | |
| + | style={{ fontSize: "0.9375rem" }} | |
| + | > | |
| {v.versionCode.toUpperCase()} · {v.versionLabel} | ||
| </p> | ||
| - | <p className="label text-ash-2">{formatDate(v.versionDate)}</p> | |
| + | <p className="label mt-1" style={{ color: "var(--dk-ash-2)" }}> | |
| + | <span className="font-mono">{formatDate(v.versionDate)}</span> | |
| + | </p> | |
| </div> | ||
| <SourceLink url={v.sourceUrl} label="GovInfo" /> | ||
| </div> | ||
| ))} | ||
| - | </Card> | |
| + | </div> | |
| )} | ||
| - | </div> | |
| + | </section> | |
| </div> | ||
| </div> | ||
| - | {/* AMENDMENTS */} | |
| - | <div className="mt-12"> | |
| - | <SectionHeader | |
| + | {/* ── Amendments ────────────────────────────────────────────── */} | |
| + | <section className="mt-12" aria-label="Amendments"> | |
| + | <SectionLabel | |
| eyebrow={`Amendments · ${bill.amendments.length}`} | ||
| title="Amendments filed" | ||
| /> | ||
| {bill.amendments.length === 0 ? ( | ||
| - | <Card className="p-5"> | |
| - | <p className="font-mono text-[13px] text-ash-2"> | |
| + | <div className="rounded-[6px] border border-line bg-surface p-6"> | |
| + | <p | |
| + | className="font-prose italic" | |
| + | style={{ fontSize: "0.9375rem", color: "var(--dk-ash-2)" }} | |
| + | > | |
| No amendments are linked to this bill. | ||
| </p> | ||
| - | </Card> | |
| + | </div> | |
| ) : ( | ||
| - | <BillAmendmentsTable amendments={bill.amendments} /> | |
| + | <AmendmentsList amendments={bill.amendments} /> | |
| )} | ||
| - | </div> | |
| + | </section> | |
| - | {/* official record + (deliberately) NO engagement surface */} | |
| - | <div className="mt-12 border-t border-line pt-6"> | |
| - | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| + | {/* ── Official record + source ─────────────────────────────── */} | |
| + | <div className="mt-12 border-t border-line pt-8"> | |
| + | <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> | |
| <div> | ||
| - | <p className="label mb-1">Official record</p> | |
| - | <p className="font-mono text-[12px] text-ash"> | |
| + | <p className="label mb-1" style={{ color: "var(--dk-ash-2)" }}> | |
| + | Official record | |
| + | </p> | |
| + | <p | |
| + | className="font-prose" | |
| + | style={{ fontSize: "0.9375rem", color: "var(--dk-ash)" }} | |
| + | > | |
| Full text, sponsor, and all actions on Congress.gov. | ||
| </p> | ||
| </div> | ||
| - | <div className="flex items-center gap-3"> | |
| - | <SourceLink url={bill.sourceUrl} label="Congress.gov" /> | |
| - | </div> | |
| + | <SourceLink url={bill.sourceUrl} label="Congress.gov" /> | |
| </div> | ||
| - | {/* Neutrality rule #4: no comments / reactions / shares on bills. */} | |
| - | <p className="label mt-6 text-ash-2"> | |
| + | {/* Neutrality rule #4: no engagement surface on bills. */} | |
| + | <p className="label mt-8" style={{ color: "var(--dk-ash-2)" }}> | |
| No comment, reaction, or share surface appears on this page, by policy. | ||
| </p> | ||
| </div> | ||
| - | {/* Cite this record */} | |
| + | {/* ── Cite this record ─────────────────────────────────────── */} | |
| <div className="mt-8"> | ||
| - | <p className="label mb-4 text-ash">Cite this record</p> | |
| + | <p className="label mb-4" style={{ color: "var(--dk-ash)" }}> | |
| + | Cite this record | |
| + | </p> | |
| <CiteThis | ||
| kind="bill" | ||
| title={`${bill.number} - ${bill.title}`} | ||
| @@ -341,58 +516,97 @@ export default function BillPage({ | ||
| ); | ||
| } | ||
| - | function BillAmendmentsTable({ | |
| - | amendments, | |
| + | /** Inline section label: mono kicker + Archivo uppercase H2. */ | |
| + | function SectionLabel({ | |
| + | eyebrow, | |
| + | title, | |
| }: { | ||
| - | amendments: BillAmendment[]; | |
| + | eyebrow?: string; | |
| + | title: string; | |
| }) { | ||
| - | 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} | |
| - | /> | |
| + | <div style={{ marginBottom: "20px" }}> | |
| + | {eyebrow ? ( | |
| + | <p | |
| + | className="label" | |
| + | style={{ color: "var(--dk-signal)", marginBottom: "10px", fontSize: "11px", letterSpacing: "0.14em" }} | |
| + | > | |
| + | {eyebrow} | |
| + | </p> | |
| + | ) : null} | |
| + | <h2 | |
| + | className="font-display text-paper" | |
| + | style={{ fontSize: "clamp(1.2rem, 3.5vw, 1.5rem)", fontWeight: 800, letterSpacing: "-0.02em", textTransform: "uppercase", lineHeight: 1.05 }} | |
| + | > | |
| + | {title} | |
| + | </h2> | |
| + | </div> | |
| + | ); | |
| + | } | |
| + | ||
| + | /** Amendments as a simple list (no dense table). */ | |
| + | function AmendmentsList({ amendments }: { amendments: BillAmendment[] }) { | |
| + | return ( | |
| + | <ul className="space-y-3" role="list"> | |
| + | {amendments.map((a) => ( | |
| + | <li | |
| + | key={a.id} | |
| + | className="flex flex-col gap-2 rounded-[6px] border border-line bg-surface p-5 sm:flex-row sm:items-center sm:justify-between" | |
| + | > | |
| + | <div className="min-w-0"> | |
| + | <p | |
| + | className="font-mono text-paper" | |
| + | style={{ fontSize: "0.9375rem", fontWeight: 700 }} | |
| + | > | |
| + | {a.type} {a.number} | |
| + | </p> | |
| + | {a.sponsor ? ( | |
| + | <Link | |
| + | href={a.sponsor.href} | |
| + | className="focus-ring mt-1 label underline underline-offset-4 hover:text-paper" | |
| + | style={{ color: "var(--dk-signal)" }} | |
| + | > | |
| + | {a.sponsor.name} | |
| + | </Link> | |
| + | ) : null} | |
| + | </div> | |
| + | <div className="flex items-center gap-3"> | |
| + | {a.status ? ( | |
| + | <span className="label" style={{ color: "var(--dk-ash-2)" }}> | |
| + | {a.status} | |
| + | </span> | |
| + | ) : ( | |
| + | <span className="label" style={{ color: "var(--dk-ash-2)" }}> | |
| + | Filed | |
| + | </span> | |
| + | )} | |
| + | <SourceLink url={a.sourceUrl} /> | |
| + | </div> | |
| + | </li> | |
| + | ))} | |
| + | </ul> | |
| ); | ||
| } | ||
| function DiffEntry({ d }: { d: BillDiffChange }) { | ||
| return ( | ||
| - | <div className="border-l-2 pl-3" style={{ borderColor: "var(--dk-line)" }}> | |
| + | <div className="border-l-2 pl-4" style={{ borderColor: "var(--dk-line)" }}> | |
| <div className="flex flex-wrap items-center gap-2"> | ||
| <ChangeTag change={d.change} /> | ||
| {d.riderFlag ? <RiderFlag /> : null} | ||
| - | <span className="label text-ash-2"> | |
| - | {d.fromVersion} → {d.toVersion} | |
| - | {d.sectionId && d.sectionId !== "_" ? ` · §${d.sectionId.slice(0, 8)}` : ""} | |
| + | <span className="label" style={{ color: "var(--dk-ash-2)" }}> | |
| + | <span className="font-mono"> | |
| + | {d.fromVersion} → {d.toVersion} | |
| + | </span> | |
| + | {d.sectionId && d.sectionId !== "_" | |
| + | ? ` · §${d.sectionId.slice(0, 8)}` | |
| + | : ""} | |
| </span> | ||
| </div> | ||
| - | <p className="mt-2 font-mono text-[12px] leading-relaxed text-paper"> | |
| + | <p | |
| + | className="mt-2 font-mono leading-relaxed text-paper" | |
| + | style={{ fontSize: "0.8125rem" }} | |
| + | > | |
| {d.snippet || "No snippet recorded."} | ||
| </p> | ||
| <div className="mt-2"> |
| @@ -0,0 +1,17 @@ | ||
| + | /** Skeleton shown immediately on client-side navigation to /bills. */ | |
| + | export default function Loading() { | |
| + | return ( | |
| + | <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | |
| + | <div className="mb-2 h-3 w-24 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-2 h-7 w-48 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-3 h-4 w-full max-w-2xl animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-8 h-14 w-full animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="space-y-1"> | |
| + | {Array.from({ length: 16 }).map((_, i) => ( | |
| + | <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" /> | |
| + | ))} | |
| + | </div> | |
| + | </div> | |
| + | ); | |
| + | } |
| @@ -1,7 +1,5 @@ | ||
| 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 { | ||
| @@ -54,12 +52,15 @@ export default function BillsPage({ | ||
| policyArea, | ||
| q, | ||
| congress: Number.isFinite(congressNum) ? congressNum : undefined, | ||
| - | sort: (sortParam === "congress" ? "congress" : sortParam === "status" ? "status" : "updated") as "updated" | "congress" | "status", | |
| + | sort: (sortParam === "congress" | |
| + | ? "congress" | |
| + | : sortParam === "status" | |
| + | ? "status" | |
| + | : "updated") as "updated" | "congress" | "status", | |
| }; | ||
| - | // Push filtering into SQL - fetch only the 100 we render + a cheap count. | |
| + | ||
| const bills = getBills(billFilter, 100, 0); | ||
| const billsTotal = getBillsCount(billFilter); | ||
| - | ||
| const policyAreas = getPolicyAreaLabels(); | ||
| const congresses = getBillCongresses(); | ||
| @@ -67,56 +68,142 @@ export default function BillsPage({ | ||
| const activeChips: Array<{ label: string; href: string }> = []; | ||
| if (policyArea) | ||
| - | activeChips.push({ | |
| - | label: `Area: ${policyArea}`, | |
| - | href: dropParam(searchParams, "policyArea"), | |
| - | }); | |
| + | activeChips.push({ label: `Area: ${policyArea}`, href: dropParam(searchParams, "policyArea") }); | |
| if (q) | ||
| activeChips.push({ label: `Search: ${q}`, href: dropParam(searchParams, "q") }); | ||
| if (Number.isFinite(congressNum)) | ||
| - | activeChips.push({ | |
| - | label: `Congress: ${congressNum}`, | |
| - | href: dropParam(searchParams, "congress"), | |
| - | }); | |
| + | activeChips.push({ label: `Congress: ${congressNum}`, href: dropParam(searchParams, "congress") }); | |
| return ( | ||
| - | <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | |
| - | <Link | |
| - | href={policyArea ? "/categories" : "/register"} | |
| - | className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper" | |
| - | > | |
| - | ← {policyArea ? "Topics" : "Register"} | |
| - | </Link> | |
| + | <div className="mx-auto w-full max-w-[860px] px-6 sm:px-8" style={{ paddingTop: "clamp(32px, 6vw, 56px)", paddingBottom: "clamp(48px, 8vw, 80px)" }}> | |
| - | <PageHeader | |
| - | eyebrow="Bills · browse" | |
| - | title={policyArea ? policyArea : "All bills"} | |
| - | lead="Bills on record, newest action first. Filter by policy area, congress, or text. Each row links to its sponsor, status timeline, and the official record." | |
| - | /> | |
| + | {/* Breadcrumb + Back */} | |
| + | <nav aria-label="Breadcrumb" className="mb-10"> | |
| + | <ol className="flex flex-wrap items-center gap-2" style={{ marginBottom: "12px" }}> | |
| + | <li> | |
| + | <Link | |
| + | href="/" | |
| + | className="focus-ring label no-underline hover:underline" | |
| + | style={{ color: "var(--dk-signal)", fontSize: "12px", letterSpacing: "0.12em" }} | |
| + | > | |
| + | Home | |
| + | </Link> | |
| + | </li> | |
| + | <li aria-hidden="true" className="label" style={{ color: "var(--dk-ash-2)", fontSize: "12px" }}> | |
| + | / | |
| + | </li> | |
| + | <li className="label" style={{ color: "var(--dk-ash)", fontSize: "12px", letterSpacing: "0.12em" }} aria-current="page"> | |
| + | Bills | |
| + | </li> | |
| + | </ol> | |
| + | <Link | |
| + | href="/" | |
| + | className="focus-ring tap no-underline hover:underline" | |
| + | style={{ | |
| + | display: "inline-flex", | |
| + | alignItems: "center", | |
| + | gap: "8px", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "13px", | |
| + | letterSpacing: "0.08em", | |
| + | color: "var(--dk-signal)", | |
| + | }} | |
| + | > | |
| + | ← Back to home | |
| + | </Link> | |
| + | </nav> | |
| + | ||
| + | {/* Page header */} | |
| + | <header style={{ borderBottom: "1px solid var(--dk-line)", paddingBottom: "clamp(28px, 5vw, 48px)", marginBottom: "clamp(28px, 5vw, 48px)" }}> | |
| + | {/* Kicker */} | |
| + | <p | |
| + | className="label" | |
| + | style={{ | |
| + | color: "var(--dk-signal)", | |
| + | marginBottom: "20px", | |
| + | display: "flex", | |
| + | alignItems: "center", | |
| + | gap: "8px", | |
| + | fontSize: "12px", | |
| + | letterSpacing: "0.14em", | |
| + | }} | |
| + | > | |
| + | <span aria-hidden="true" style={{ width: "5px", height: "5px", borderRadius: "50%", background: "var(--dk-signal)", display: "inline-block", flexShrink: 0 }} /> | |
| + | Legislation | |
| + | </p> | |
| + | {/* HUGE headline */} | |
| + | <h1 | |
| + | className="font-display text-paper" | |
| + | style={{ | |
| + | fontSize: "clamp(2.2rem, 7vw, 3.75rem)", | |
| + | fontWeight: 900, | |
| + | lineHeight: 0.96, | |
| + | letterSpacing: "-0.03em", | |
| + | textTransform: "uppercase", | |
| + | maxWidth: "18ch", | |
| + | marginBottom: "24px", | |
| + | }} | |
| + | > | |
| + | {policyArea ? policyArea : "All Bills"} | |
| + | </h1> | |
| + | {/* Fraunces lead */} | |
| + | <p className="font-prose" style={{ fontStyle: "italic", fontSize: "clamp(1rem, 1.8vw, 1.125rem)", lineHeight: 1.55, color: "var(--dk-ash)", maxWidth: "52ch" }}> | |
| + | Bills on record, newest action first. Filter by policy area, congress, | |
| + | or search by title or number. Each bill links to its sponsor, status | |
| + | timeline, and the official record. | |
| + | </p> | |
| + | </header> | |
| {/* Active filter chips */} | ||
| {activeChips.length > 0 ? ( | ||
| - | <div className="mt-6 flex flex-wrap items-center gap-2"> | |
| + | <div className="flex flex-wrap items-center gap-2" style={{ marginBottom: "16px" }}> | |
| {activeChips.map((c) => ( | ||
| <Link | ||
| key={c.label} | ||
| href={c.href} | ||
| - | className="focus-ring label inline-flex min-h-[44px] items-center gap-1.5 rounded-[3px] border border-line px-2.5 text-ash underline underline-offset-4 hover:text-paper" | |
| + | className="focus-ring no-underline" | |
| + | style={{ | |
| + | display: "inline-flex", | |
| + | alignItems: "center", | |
| + | gap: "6px", | |
| + | minHeight: "44px", | |
| + | padding: "0 14px", | |
| + | border: "1px solid var(--dk-line)", | |
| + | borderRadius: "8px", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "12px", | |
| + | letterSpacing: "0.08em", | |
| + | textTransform: "uppercase", | |
| + | color: "var(--dk-ash)", | |
| + | }} | |
| > | ||
| <span aria-hidden="true">×</span> {c.label} | ||
| </Link> | ||
| ))} | ||
| <Link | ||
| href="/bills" | ||
| - | className="focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] px-2.5 text-paper underline underline-offset-4" | |
| + | className="focus-ring no-underline" | |
| + | style={{ | |
| + | display: "inline-flex", | |
| + | alignItems: "center", | |
| + | minHeight: "44px", | |
| + | padding: "0 14px", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "12px", | |
| + | letterSpacing: "0.08em", | |
| + | textTransform: "uppercase", | |
| + | color: "var(--dk-paper)", | |
| + | textDecoration: "underline", | |
| + | textUnderlineOffset: "4px", | |
| + | }} | |
| > | ||
| Clear all | ||
| </Link> | ||
| </div> | ||
| ) : null} | ||
| - | {/* Search/filter - server-side GET form, consistent SearchFilterBar shell */} | |
| - | <div className="mt-6"> | |
| + | {/* Search / filter form */} | |
| + | <div className="mt-8"> | |
| <form method="get" action="/bills" className="contents"> | ||
| <SearchFilterBar | ||
| count={billsTotal} | ||
| @@ -146,16 +233,11 @@ export default function BillsPage({ | ||
| <FilterField label="Congress"> | ||
| <FilterSelect | ||
| name="congress" | ||
| - | defaultValue={ | |
| - | Number.isFinite(congressNum) ? String(congressNum) : "" | |
| - | } | |
| + | defaultValue={Number.isFinite(congressNum) ? String(congressNum) : ""} | |
| aria-label="Filter by Congress number" | ||
| options={[ | ||
| { value: "", label: "Any congress" }, | ||
| - | ...congresses.map((c) => ({ | |
| - | value: String(c), | |
| - | label: `${c}th`, | |
| - | })), | |
| + | ...congresses.map((c) => ({ value: String(c), label: `${c}th` })), | |
| ]} | ||
| /> | ||
| </FilterField> | ||
| @@ -176,28 +258,42 @@ export default function BillsPage({ | ||
| </form> | ||
| </div> | ||
| - | <div className="mt-8"> | |
| - | <SectionHeader | |
| - | eyebrow={`${billsTotal.toLocaleString()} bill${billsTotal === 1 ? "" : "s"}${isFiltered ? " match" : ""} · showing ${bills.length}`} | |
| - | title={policyArea ? policyArea : "On record"} | |
| - | /> | |
| + | {/* Results */} | |
| + | <section style={{ marginTop: "32px" }} aria-label="Bills list"> | |
| + | <p className="label" style={{ color: "var(--dk-ash)", marginBottom: "24px" }}> | |
| + | {billsTotal.toLocaleString()} bill{billsTotal === 1 ? "" : "s"} | |
| + | {isFiltered ? " match" : ""} · showing {bills.length} | |
| + | </p> | |
| + | ||
| {bills.length === 0 ? ( | ||
| - | <Card className="p-5"> | |
| - | <p className="font-mono text-[13px] text-ash-2"> | |
| + | <div | |
| + | className="rounded-[6px] border border-line bg-surface p-8" | |
| + | role="status" | |
| + | > | |
| + | <p | |
| + | className="font-prose" | |
| + | style={{ fontSize: "1.0625rem", color: "var(--dk-ash)" }} | |
| + | > | |
| No bills match this filter in the current record. | ||
| </p> | ||
| - | </Card> | |
| + | </div> | |
| ) : ( | ||
| - | <BillsTable bills={bills} /> | |
| + | <BillCards bills={bills} /> | |
| )} | ||
| - | </div> | |
| + | </section> | |
| - | <div className="mt-12 border-t border-line pt-6"> | |
| - | <p className="label mb-6 text-ash-2"> | |
| - | Bills from congress.gov. Order is by last action date - no editorial | |
| - | weighting. No comment surface appears here, by policy. | |
| + | {/* Footer / cite */} | |
| + | <div style={{ marginTop: "clamp(40px, 7vw, 72px)", borderTop: "1px solid var(--dk-line)", paddingTop: "clamp(24px, 4vw, 40px)" }}> | |
| + | <p | |
| + | className="label mb-8" | |
| + | style={{ color: "var(--dk-ash-2)" }} | |
| + | > | |
| + | Bills sourced from congress.gov. Order is by last action date - no | |
| + | editorial weighting. No comment surface appears here, by policy. | |
| + | </p> | |
| + | <p className="label mb-4" style={{ color: "var(--dk-ash)" }}> | |
| + | Cite this page | |
| </p> | ||
| - | <p className="label mb-4 text-ash">Cite this page</p> | |
| <CiteThis | ||
| kind="page" | ||
| title={policyArea ? `Bills - ${policyArea}` : "Bills - Browse all bills"} | ||
| @@ -213,59 +309,89 @@ 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} />, | |
| - | }, | |
| - | })); | |
| + | /** Short label for a status variant (pills must not contain long raw status text). */ | |
| + | const STATUS_SHORT: Record<string, string> = { | |
| + | passed: "Passed", | |
| + | failed: "Failed", | |
| + | held: "In committee", | |
| + | pending: "Pending", | |
| + | neutral: "Introduced", | |
| + | }; | |
| + | ||
| + | /** Bills as big simple cards - one record per row. Matches dark aesthetic. */ | |
| + | function BillCards({ bills }: { bills: BillListItem[] }) { | |
| return ( | ||
| - | <DataTable | |
| - | caption="Bills on record: number, title, policy area, status, last action." | |
| - | cardLabel="Bills on record" | |
| - | columns={columns} | |
| - | rows={rows} | |
| - | /> | |
| + | <ul style={{ display: "flex", flexDirection: "column", gap: "12px", listStyle: "none", padding: 0, margin: 0 }} role="list"> | |
| + | {bills.map((b) => { | |
| + | const variant = classifyStatus(b.status); | |
| + | return ( | |
| + | <li key={b.id}> | |
| + | <div | |
| + | style={{ | |
| + | borderRadius: "10px", | |
| + | border: "1px solid var(--dk-line)", | |
| + | background: "var(--dk-surface)", | |
| + | padding: "clamp(20px, 3vw, 28px)", | |
| + | transition: "border-color 0.2s, background 0.2s", | |
| + | }} | |
| + | > | |
| + | {/* Bill number (mono data) + status pill */} | |
| + | <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", justifyContent: "space-between", gap: "12px", marginBottom: "12px" }}> | |
| + | <span | |
| + | className="font-mono" | |
| + | style={{ fontSize: "13px", color: "var(--dk-ash-2)", letterSpacing: "0.06em", fontWeight: 700 }} | |
| + | > | |
| + | {b.number} | |
| + | <span style={{ color: "var(--dk-ash-2)", fontWeight: 400, marginLeft: "8px" }}> | |
| + | {b.congress}th Congress | |
| + | </span> | |
| + | </span> | |
| + | <StatusPill variant={variant}> | |
| + | {STATUS_SHORT[variant] ?? "Introduced"} | |
| + | </StatusPill> | |
| + | </div> | |
| + | ||
| + | {/* Title - Fraunces, big, the primary tap target */} | |
| + | <Link | |
| + | href={b.href} | |
| + | className="focus-ring font-prose text-paper" | |
| + | style={{ | |
| + | display: "block", | |
| + | fontSize: "clamp(1rem, 1.8vw, 1.125rem)", | |
| + | lineHeight: 1.5, | |
| + | textDecoration: "underline", | |
| + | textDecorationColor: "var(--dk-line)", | |
| + | textUnderlineOffset: "3px", | |
| + | marginBottom: "16px", | |
| + | }} | |
| + | > | |
| + | {b.title} | |
| + | </Link> | |
| + | ||
| + | {/* Meta row */} | |
| + | <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "16px" }}> | |
| + | {b.policyArea ? ( | |
| + | <Link | |
| + | href={`/bills?policyArea=${encodeURIComponent(b.policyArea)}`} | |
| + | className="focus-ring label hover:underline" | |
| + | style={{ color: "var(--dk-signal)", fontSize: "11px", letterSpacing: "0.1em" }} | |
| + | > | |
| + | {b.policyArea} | |
| + | </Link> | |
| + | ) : null} | |
| + | <span | |
| + | className="font-mono" | |
| + | style={{ fontSize: "12px", color: "var(--dk-ash-2)" }} | |
| + | > | |
| + | {formatDate(b.updatedAt)} | |
| + | </span> | |
| + | <SourceLink url={b.sourceUrl} /> | |
| + | </div> | |
| + | </div> | |
| + | </li> | |
| + | ); | |
| + | })} | |
| + | </ul> | |
| ); | ||
| } | ||
| @@ -0,0 +1,21 @@ | ||
| + | /** Skeleton shown immediately on client-side navigation to /calendar. */ | |
| + | export default function Loading() { | |
| + | return ( | |
| + | <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | |
| + | <div className="mb-2 h-3 w-24 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-2 h-7 w-48 animate-pulse rounded-sm bg-graphite" /> | |
| + | {/* calendar grid */} | |
| + | <div className="mt-8 grid grid-cols-7 gap-1"> | |
| + | {Array.from({ length: 35 }).map((_, i) => ( | |
| + | <div key={i} className="h-16 animate-pulse rounded-sm bg-graphite" /> | |
| + | ))} | |
| + | </div> | |
| + | {/* event list */} | |
| + | <div className="mt-8 space-y-2"> | |
| + | {Array.from({ length: 6 }).map((_, i) => ( | |
| + | <div key={i} className="h-10 w-full animate-pulse rounded-sm bg-graphite" /> | |
| + | ))} | |
| + | </div> | |
| + | </div> | |
| + | ); | |
| + | } |
| @@ -3,107 +3,108 @@ | ||
| @tailwind utilities; | ||
| /* | ||
| - | * Docket brand tokens - source of truth. | |
| - | * docs/BRAND.md §1. Dark-first. Brass is RATIONED (one signal per view). | |
| + | * Docket design tokens - source of truth. | |
| + | * Light (default): Zion's warm palette - white page, warm paper surface, | |
| + | * amber/ochre accent. Dark: treetrace ink + cobalt. Plain: max-contrast. | |
| + | * docs/DESIGN_V2.md §Color | |
| */ | ||
| + | ||
| + | /* ------------------------------------------------------------------ */ | |
| + | /* DARK MODE (:root default) - Zion's ink treatment, brass accent */ | |
| + | /* Matches treetrace.dev + zionboggan.com dark aesthetic. */ | |
| + | /* ------------------------------------------------------------------ */ | |
| :root { | ||
| - | /* base - dark-first */ | |
| - | --dk-ink: #0b0c0e; /* page background, near-black */ | |
| - | --dk-surface: #121316; /* cards, register chrome */ | |
| - | --dk-graphite: #23262b; /* raised / hover */ | |
| - | --dk-paper: #f2efe9; /* primary text, warm bone (NOT pure white) */ | |
| - | --dk-ash: #9ba0a6; /* secondary / mono labels */ | |
| - | --dk-ash-2: #6e7480; /* tertiary text */ | |
| - | --dk-line: rgba(155, 160, 166, 0.18); /* hairline borders */ | |
| - | ||
| - | /* signal - brass, RATIONED (one per view) */ | |
| - | --dk-signal: #e0a53b; /* primary accent */ | |
| - | --dk-signal-dim: #b07f25; /* hover / pressed */ | |
| - | --dk-signal-tint: rgba(224, 165, 59, 0.08); /* faint active-row wash */ | |
| - | ||
| - | /* semantic - MUTED, never vivid (neutrality) */ | |
| - | --dk-yea: #7fa88c; /* yea / passed / funded - muted sage */ | |
| - | --dk-nay: #b98a8a; /* nay / failed / cut - muted clay */ | |
| - | --dk-hold: #c9a66b; /* stalled / pending / in-committee */ | |
| + | --dk-ink: #0b0c0e; /* page bg, near-black (tt-ink) */ | |
| + | --dk-surface: #121316; /* cards, sections (tt-surface) */ | |
| + | --dk-graphite: #1a1c20; /* raised / hover (≈tt-graphite) */ | |
| + | --dk-paper: #f2efe9; /* primary text, warm bone (tt-paper) */ | |
| + | --dk-ash: #9ba0a6; /* secondary (tt-ash) */ | |
| + | --dk-ash-2: #6e7480; /* tertiary (tt-ash-2) */ | |
| + | --dk-line: rgba(155, 160, 166, 0.18); | |
| + | ||
| + | /* signal - BRASS #E0A53B (neutral; cobalt/red are partisan-coded) */ | |
| + | --dk-signal: #E0A53B; | |
| + | --dk-signal-dim: #B07F25; | |
| + | --dk-signal-tint: rgba(224, 165, 59, 0.10); | |
| + | ||
| + | /* semantic - muted, never vivid (neutrality) */ | |
| + | --dk-yea: #7fa88c; /* muted sage, always labeled */ | |
| + | --dk-nay: #b98a8a; /* muted clay, always labeled */ | |
| + | --dk-hold: #c9a66b; | |
| /* party tones - desaturated, EQUAL visual weight */ | ||
| - | --dk-party-d: #6e86a8; /* muted slate-blue */ | |
| - | --dk-party-r: #a87e6e; /* muted clay-brown */ | |
| - | --dk-party-i: #8a8f96; /* muted grey */ | |
| + | --dk-party-d: #6e86a8; | |
| + | --dk-party-r: #a87e6e; | |
| + | --dk-party-i: #8a8f96; | |
| - | /* liquid-glass accent (rationed) */ | |
| --dk-hero-glow: radial-gradient( | ||
| 60% 80% at 50% -10%, | ||
| - | rgba(224, 165, 59, 0.1) 0%, | |
| + | rgba(224, 165, 59, 0.08) 0%, | |
| rgba(224, 165, 59, 0) 70% | ||
| ); | ||
| - | /* a11y (docs/ACCESSIBILITY.md #2): dark body >=18px, line-height 1.6. | |
| - | Data tables/labels may run smaller; readable copy uses this floor. */ | |
| --dk-body-size: 18px; | ||
| --dk-leading: 1.6; | ||
| color-scheme: dark; | ||
| } | ||
| - | /* | |
| - | * Reference mode (light) - "official document" reading experience. | |
| - | * Palette per docs/REDESIGN.md §"Theme system": #F8FAFC base, #FFFFFF surface, | |
| - | * #0F172A text, #475569 muted, #E2E8F0 border, brass #B07F25 accent (AA on light). | |
| - | * Body font: Atkinson Hyperlegible (engineered for low vision). | |
| - | * Body ≥18px, line-height 1.6 (docs/ACCESSIBILITY.md Rule #2). | |
| - | */ | |
| + | /* ------------------------------------------------------------------ */ | |
| + | /* LIGHT MODE - Zion's warm palette (default display for new visitors) */ | |
| + | /* docs/DESIGN_V2.md §Color + §Type */ | |
| + | /* ------------------------------------------------------------------ */ | |
| :root.light { | ||
| - | --dk-ink: #f8fafc; /* Reference bg - clean cool white */ | |
| - | --dk-surface: #ffffff; /* card surface */ | |
| - | --dk-graphite: #eef2f7; /* raised / hover */ | |
| - | --dk-paper: #0f172a; /* primary text - deep slate */ | |
| - | --dk-ash: #475569; /* muted / secondary text */ | |
| - | --dk-ash-2: #64748b; /* tertiary text */ | |
| - | --dk-line: #e2e8f0; /* border - cool light gray */ | |
| - | ||
| - | /* Brass darkened for WCAG AA on white (4.5:1+) */ | |
| - | --dk-signal: #b07f25; | |
| - | --dk-signal-dim: #8c6316; | |
| - | --dk-signal-tint: rgba(176, 127, 37, 0.1); | |
| - | ||
| - | /* Muted semantic tokens - same hues, readable on light bg */ | |
| - | --dk-yea: #3d7a56; | |
| - | --dk-nay: #8b4040; | |
| - | --dk-hold: #8a6520; | |
| + | --dk-ink: #ffffff; /* white page */ | |
| + | --dk-surface: #f7f4ed; /* warm paper tone for cards/sections */ | |
| + | --dk-graphite: #ede9df; /* raised/hover (slightly darker warm) */ | |
| + | --dk-paper: #0b0c0e; /* ink near-black, ~18:1 on white */ | |
| + | --dk-ash: #44423d; /* secondary text, >=7:1 */ | |
| + | --dk-ash-2: #6b6860; /* tertiary text */ | |
| + | --dk-line: #d8d2c4; /* warm visible border */ | |
| + | ||
| + | /* accent - his amber/ochre (Tango), ~6:1 on white */ | |
| + | --dk-signal: #8f5902; | |
| + | --dk-signal-dim: #ce5c00; /* accent-strong - large fills */ | |
| + | --dk-signal-tint: #fbf3df; /* accent-wash - active/selected bg */ | |
| + | ||
| + | /* semantic - his Tango palette, muted */ | |
| + | --dk-yea: #4e7a06; /* pass, always with label */ | |
| + | --dk-nay: #a40000; /* fail, small only */ | |
| + | --dk-hold: #8f5902; /* stalled/pending */ | |
| + | ||
| + | /* party tones - desaturated, equal weight */ | |
| + | --dk-party-d: #5a7499; | |
| + | --dk-party-r: #996355; | |
| + | --dk-party-i: #7a7f86; | |
| --dk-hero-glow: radial-gradient( | ||
| 60% 80% at 50% -10%, | ||
| - | rgba(176, 127, 37, 0.06) 0%, | |
| - | rgba(176, 127, 37, 0) 70% | |
| + | rgba(143, 89, 2, 0.06) 0%, | |
| + | rgba(143, 89, 2, 0) 70% | |
| ); | ||
| - | /* a11y #2: Reference body ≥18px, line-height 1.6 */ | |
| - | --dk-body-size: 18px; | |
| + | /* elder-first: 20px body, generous leading */ | |
| + | --dk-body-size: 20px; | |
| --dk-leading: 1.6; | ||
| color-scheme: light; | ||
| } | ||
| - | /* | |
| - | * Plain mode - high-contrast "Plain Mode" (docs/ACCESSIBILITY.md §3). | |
| - | * Max contrast (black ink on #FFFFCC), system sans (not mono), >=20px, | |
| - | * no accent color, uniform weight. One click from anywhere. | |
| - | */ | |
| + | /* ------------------------------------------------------------------ */ | |
| + | /* PLAIN MODE - max-contrast (ACCESSIBILITY.md §3) */ | |
| + | /* ------------------------------------------------------------------ */ | |
| :root.plain { | ||
| - | --dk-ink: #ffffcc; /* high-contrast paper */ | |
| + | --dk-ink: #ffffcc; | |
| --dk-surface: #ffffe0; | ||
| --dk-graphite: #f2f2b8; | ||
| - | --dk-paper: #000000; /* pure ink */ | |
| + | --dk-paper: #000000; | |
| --dk-ash: #1a1a1a; | ||
| --dk-ash-2: #333333; | ||
| --dk-line: rgba(0, 0, 0, 0.45); | ||
| - | /* no accent in plain mode - brass collapses to ink */ | |
| --dk-signal: #000000; | ||
| --dk-signal-dim: #000000; | ||
| --dk-signal-tint: rgba(0, 0, 0, 0.06); | ||
| - | /* muted semantic tokens collapse toward ink/grey (no vivid color) */ | |
| --dk-yea: #1a1a1a; | ||
| --dk-nay: #1a1a1a; | ||
| --dk-hold: #1a1a1a; | ||
| @@ -113,7 +114,6 @@ | ||
| --dk-hero-glow: none; | ||
| - | /* a11y #2/#8: >=20px, generous leading, system sans */ | |
| --dk-body-size: 20px; | ||
| --dk-leading: 1.6; | ||
| color-scheme: light; | ||
| @@ -126,7 +126,7 @@ | ||
| body { | ||
| background-color: var(--dk-ink); | ||
| color: var(--dk-paper); | ||
| - | /* Dark + Plain modes: forensic mono body. Reference (light): see override below. */ | |
| + | /* Dark: forensic mono body. Light: see override below. */ | |
| font-family: var(--font-mono), ui-monospace, monospace; | ||
| font-size: var(--dk-body-size); | ||
| line-height: var(--dk-leading); | ||
| @@ -135,20 +135,23 @@ | ||
| } | ||
| /* | ||
| - | * Reference (light) mode body - Atkinson Hyperlegible (engineered for low vision). | |
| - | * JetBrains Mono is still applied for .label / .pill / .font-mono / data tokens | |
| - | * via their explicit class overrides, which take precedence over this rule. | |
| + | * Light mode body - Archivo (display font) for a warm, editorial feel. | |
| + | * JetBrains Mono is used for DATA ONLY (bill numbers, tallies, amounts, | |
| + | * dates) via explicit font-mono class overrides. | |
| + | * Elder-first: 20px base, line-height 1.6. (docs/DESIGN_V2.md §Type) | |
| */ | ||
| :root.light body { | ||
| - | font-family: var(--font-body), "Atkinson Hyperlegible", system-ui, sans-serif; | |
| + | font-family: var(--font-display), system-ui, sans-serif; | |
| + | font-size: 20px; | |
| + | line-height: 1.6; | |
| } | ||
| + | ||
| ::selection { | ||
| background: var(--dk-signal-tint); | ||
| color: var(--dk-paper); | ||
| } | ||
| - | /* Plain mode overrides the brand fonts with the system stack and | |
| - | uniform weight - readability over the forensic look (a11y §3). */ | |
| + | /* Plain mode: system stack, uniform weight (a11y §3). */ | |
| :root.plain body, | ||
| :root.plain .display, | ||
| :root.plain .label, | ||
| @@ -166,13 +169,12 @@ | ||
| /* Accessibility enforcement (docs/ACCESSIBILITY.md - the 8 rules). */ | ||
| /* ------------------------------------------------------------------ */ | ||
| @layer a11y { | ||
| - | /* Rule #4: visible focus on EVERY interactive element, never removed. */ | |
| + | /* Rule #4: visible focus on EVERY interactive element. */ | |
| :focus-visible { | ||
| - | outline: 2px solid var(--dk-signal); | |
| + | outline: 3px solid var(--dk-signal); | |
| outline-offset: 2px; | ||
| border-radius: 2px; | ||
| } | ||
| - | /* Keep a visible focus ring color even in plain mode (signal == ink there). */ | |
| :root.plain :focus-visible { | ||
| outline-color: #000000; | ||
| outline-width: 3px; | ||
| @@ -184,12 +186,11 @@ | ||
| text-decoration-thickness: 1px; | ||
| text-underline-offset: 0.18em; | ||
| } | ||
| - | /* Links that are deliberately icon/wordmark-only keep a clean underline on focus. */ | |
| a:focus-visible { | ||
| text-decoration: underline; | ||
| } | ||
| - | /* Rule #3: >=44px touch targets for primary interactive controls. */ | |
| + | /* Rule #3: >=44px touch targets. */ | |
| button, | ||
| [role="button"], | ||
| input[type="checkbox"], | ||
| @@ -201,24 +202,18 @@ | ||
| 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. */ | |
| + | /* Structural tap targets. */ | |
| .tap { | ||
| min-height: 44px; | ||
| display: inline-flex; | ||
| align-items: center; | ||
| } | ||
| - | /* Skip-to-content link - visible only when focused (keyboard-first). */ | |
| + | /* Skip-to-content. */ | |
| .skip-link { | ||
| position: absolute; | ||
| left: 0.5rem; | ||
| @@ -237,16 +232,12 @@ | ||
| top: 0.5rem; | ||
| } | ||
| - | /* Rule #5 (partial): no hover-only affordances survive on their own. | |
| - | The Source link and row affordances are always visible in markup; | |
| - | disable hover-only visual changes when the device can't hover. */ | |
| @media (hover: none) { | ||
| .reg-row:hover { | ||
| background: transparent; | ||
| } | ||
| } | ||
| - | /* Respect reduced-motion: stop the live-dot pulse and row fade. */ | |
| @media (prefers-reduced-motion: reduce) { | ||
| *, | ||
| *::before, | ||
| @@ -259,38 +250,45 @@ | ||
| } | ||
| @layer components { | ||
| - | /* Hairline border that always uses the line token. */ | |
| + | /* Hairline border. */ | |
| .hairline { | ||
| border-color: var(--dk-line); | ||
| } | ||
| - | /* Mono label - the workhorse for bill numbers / timestamps / nav. */ | |
| + | /* | |
| + | * Kicker / data label - JetBrains Mono, uppercase, tracked. | |
| + | * Matches treetrace/zionboggan mono label pattern (>=14px per DESIGN_V3). | |
| + | * Light/plain modes keep the same mono style - it's the brand voice. | |
| + | */ | |
| .label { | ||
| font-family: var(--font-mono), ui-monospace, monospace; | ||
| text-transform: uppercase; | ||
| - | letter-spacing: 0.08em; | |
| - | font-size: 0.6875rem; /* 11px */ | |
| + | letter-spacing: 0.12em; | |
| + | font-size: 0.875rem; /* 14px - DESIGN_V3: >=14px for labels */ | |
| color: var(--dk-ash); | ||
| } | ||
| - | /* Display headline - Archivo 800-900, uppercase, tight. */ | |
| + | /* Display headline - Archivo 800-900, uppercase. Matches both sites. */ | |
| .display { | ||
| font-family: var(--font-display), system-ui, sans-serif; | ||
| - | font-weight: 800; | |
| + | font-weight: 900; | |
| text-transform: uppercase; | ||
| letter-spacing: -0.03em; | ||
| - | line-height: 0.9; | |
| + | line-height: 0.92; | |
| } | ||
| - | /* Prose - Fraunces, one per surface max. */ | |
| + | /* Prose lead - Fraunces serif. Comfortable >=17px per DESIGN_V3. */ | |
| .prose-lead { | ||
| font-family: var(--font-prose), Georgia, serif; | ||
| - | font-size: 1.125rem; | |
| - | line-height: 1.55; | |
| + | font-size: 1.125rem; /* 18px */ | |
| + | line-height: 1.6; | |
| color: var(--dk-paper); | ||
| } | ||
| - | /* Status pill (mono). Color set by variant class. */ | |
| + | /* | |
| + | * Status pill. | |
| + | * Dark: tiny mono. Light: Archivo, bigger, more readable. | |
| + | */ | |
| .pill { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| @@ -298,25 +296,32 @@ | ||
| font-family: var(--font-mono), ui-monospace, monospace; | ||
| text-transform: uppercase; | ||
| letter-spacing: 0.08em; | ||
| - | font-size: 0.625rem; | |
| + | font-size: 0.625rem; /* 10px */ | |
| padding: 0.1875rem 0.5rem; | ||
| border: 1px solid var(--dk-line); | ||
| border-radius: 2px; | ||
| color: var(--dk-ash); | ||
| white-space: nowrap; | ||
| } | ||
| + | :root.light .pill { | |
| + | font-family: var(--font-display), system-ui, sans-serif; | |
| + | font-size: 0.8125rem; /* 13px */ | |
| + | font-weight: 700; | |
| + | letter-spacing: 0.05em; | |
| + | padding: 0.3125rem 0.75rem; | |
| + | border-radius: 4px; | |
| + | } | |
| - | /* The rationed brass signal - use ONCE per view. */ | |
| + | /* Live dot. */ | |
| .live-dot { | ||
| width: 0.5rem; | ||
| height: 0.5rem; | ||
| border-radius: 9999px; | ||
| background: var(--dk-signal); | ||
| - | box-shadow: 0 0 0 0 rgba(224, 165, 59, 0.6); | |
| display: inline-block; | ||
| } | ||
| - | /* Register row - mono, hairline-separated. */ | |
| + | /* Register row. */ | |
| .reg-row { | ||
| display: grid; | ||
| align-items: center; | ||
| @@ -327,9 +332,38 @@ | ||
| background: var(--dk-surface); | ||
| } | ||
| - | /* Focus ring - brass, for a11y on the rationed accent. */ | |
| + | /* Focus ring - signal color. */ | |
| .focus-ring:focus-visible { | ||
| - | outline: 2px solid var(--dk-signal); | |
| + | outline: 3px solid var(--dk-signal); | |
| outline-offset: 2px; | ||
| } | ||
| + | ||
| + | /* Big interactive card - used on list pages. */ | |
| + | .big-card { | |
| + | display: block; | |
| + | border-radius: 6px; | |
| + | border: 2px solid var(--dk-line); | |
| + | background-color: var(--dk-surface); | |
| + | padding: 1.5rem; | |
| + | transition: background-color 120ms ease, border-color 120ms ease; | |
| + | } | |
| + | .big-card:hover { | |
| + | background-color: var(--dk-graphite); | |
| + | border-color: var(--dk-signal); | |
| + | } | |
| + | ||
| + | /* Section card - home page entry cards. */ | |
| + | .section-card { | |
| + | display: block; | |
| + | border-radius: 6px; | |
| + | border: 2px solid var(--dk-line); | |
| + | background-color: var(--dk-surface); | |
| + | padding: 1.75rem 1.5rem; | |
| + | min-height: 56px; | |
| + | transition: background-color 120ms ease, border-color 120ms ease; | |
| + | } | |
| + | .section-card:hover { | |
| + | background-color: var(--dk-graphite); | |
| + | border-color: var(--dk-signal); | |
| + | } | |
| } |
| @@ -1,9 +1,15 @@ | ||
| import type { Metadata, Viewport } from "next"; | ||
| - | import { Archivo, JetBrains_Mono, Fraunces, Atkinson_Hyperlegible } from "next/font/google"; | |
| + | import { Archivo, JetBrains_Mono, Fraunces } from "next/font/google"; | |
| import "./globals.css"; | ||
| import { SiteHeader } from "@/components/site-header"; | ||
| import { SiteFooter } from "@/components/site-footer"; | ||
| + | /* | |
| + | * Type trio (docs/DESIGN_V2.md §Type): | |
| + | * Archivo - headings, display, UI labels (var --font-display) | |
| + | * Fraunces - leads, long-form reading (var --font-prose) | |
| + | * JetBrains - DATA ONLY: tallies, numbers (var --font-mono) | |
| + | */ | |
| const archivo = Archivo({ | ||
| subsets: ["latin"], | ||
| weight: ["500", "600", "700", "800", "900"], | ||
| @@ -26,15 +32,6 @@ const fraunces = Fraunces({ | ||
| display: "swap", | ||
| }); | ||
| - | /* Atkinson Hyperlegible - engineered for low-vision readability. | |
| - | Used as body font in Reference (light) mode. */ | |
| - | const atkinson = Atkinson_Hyperlegible({ | |
| - | subsets: ["latin"], | |
| - | weight: ["400", "700"], | |
| - | variable: "--font-body", | |
| - | display: "swap", | |
| - | }); | |
| - | ||
| export const metadata: Metadata = { | ||
| title: { | ||
| default: "Docket - what the government did this week", | ||
| @@ -50,8 +47,8 @@ export const metadata: Metadata = { | ||
| }; | ||
| export const viewport: Viewport = { | ||
| - | // Light-mode reference bg; the inline script may change this per-user. | |
| - | themeColor: "#F8FAFC", | |
| + | // Dark-ink bg; the inline script may change this per-user. | |
| + | themeColor: "#0B0C0E", | |
| width: "device-width", | ||
| initialScale: 1, | ||
| }; | ||
| @@ -64,20 +61,21 @@ export default function RootLayout({ | ||
| return ( | ||
| <html | ||
| lang="en" | ||
| - | className={`${archivo.variable} ${jetbrains.variable} ${fraunces.variable} ${atkinson.variable} light`} | |
| + | className={`${archivo.variable} ${jetbrains.variable} ${fraunces.variable}`} | |
| suppressHydrationWarning | ||
| > | ||
| <head> | ||
| {/* | ||
| Apply the persisted display mode before paint (a11y §"Three display | ||
| - | modes"). Default is now "light" (Reference) for new visitors. | |
| - | Runs synchronously in <head> so the first frame is already the | |
| - | user's chosen mode - no flash, and suppressHydrationWarning lets | |
| - | the inline script own the class without a React mismatch. | |
| + | modes"). Default is "dark" (Zion's ink aesthetic) for new visitors. | |
| + | No class on <html> = dark (:root tokens); "light" or "plain" are | |
| + | optional toggles. Runs synchronously so the first frame is correct - | |
| + | no flash. suppressHydrationWarning lets the inline script own the | |
| + | class without a React mismatch. | |
| */} | ||
| <script | ||
| dangerouslySetInnerHTML={{ | ||
| - | __html: `(function(){try{var m=localStorage.getItem('docket-display')||'light';var c=document.documentElement.classList;c.remove('dark','light','plain');c.add(m);document.documentElement.dataset.display=m;}catch(e){}})();`, | |
| + | __html: `(function(){try{var m=localStorage.getItem('docket-display')||'dark';var c=document.documentElement.classList;c.remove('dark','light','plain');if(m!=='dark')c.add(m);document.documentElement.dataset.display=m;}catch(e){}})();`, | |
| }} | ||
| /> | ||
| </head> |
| @@ -0,0 +1,16 @@ | ||
| + | /** Skeleton shown immediately on client-side navigation to /members. */ | |
| + | export default function Loading() { | |
| + | return ( | |
| + | <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | |
| + | <div className="mb-2 h-3 w-24 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-2 h-7 w-52 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-3 h-4 w-full max-w-2xl animate-pulse rounded-sm bg-graphite" /> | |
| + | {/* member cards grid */} | |
| + | <div className="mt-10 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| + | {Array.from({ length: 12 }).map((_, i) => ( | |
| + | <div key={i} className="h-20 animate-pulse rounded-sm bg-graphite" /> | |
| + | ))} | |
| + | </div> | |
| + | </div> | |
| + | ); | |
| + | } |
| @@ -1,6 +1,5 @@ | ||
| import type { Metadata } from "next"; | ||
| import Link from "next/link"; | ||
| - | import { Card } from "@/components/ui"; | |
| export const metadata: Metadata = { | ||
| title: "Docket - The public record of the U.S. government", | ||
| @@ -10,170 +9,460 @@ export const metadata: Metadata = { | ||
| export const revalidate = 3600; | ||
| - | /** Entry cards for the main sections. */ | |
| + | /** Primary section cards - 6 main sections + the register feed */ | |
| const SECTIONS = [ | ||
| - | { | |
| - | href: "/search", | |
| - | eyebrow: "Search", | |
| - | title: "Search the record", | |
| - | desc: "Look up any bill, member of Congress, federal agency, or topic.", | |
| - | }, | |
| - | { | |
| - | href: "/register", | |
| - | eyebrow: "Live feed", | |
| - | title: "The Register", | |
| - | desc: "A ranked, live feed of official government actions: bills passed, votes taken, and dollars awarded.", | |
| - | }, | |
| { | ||
| href: "/bills", | ||
| - | eyebrow: "Legislation", | |
| + | kicker: "Legislation", | |
| title: "Bills", | ||
| - | desc: "Laws and legislation. Status, sponsor, what changed between versions, and the official record.", | |
| + | desc: "Laws and legislation in progress. Status, sponsor, what changed between versions, and the official record.", | |
| }, | ||
| { | ||
| href: "/votes", | ||
| - | eyebrow: "Roll calls", | |
| + | kicker: "Roll Calls", | |
| title: "Votes", | ||
| desc: "House and Senate roll-call votes. See how every member voted, by name.", | ||
| }, | ||
| { | ||
| href: "/members", | ||
| - | eyebrow: "Representatives", | |
| - | title: "Members", | |
| - | desc: "Every member of Congress - past and present. Party, state, vote record, and sponsored bills.", | |
| + | kicker: "Congress", | |
| + | title: "Representatives", | |
| + | desc: "Every member of Congress. Party, state, vote record, and bills sponsored.", | |
| }, | ||
| { | ||
| href: "/spending", | ||
| - | eyebrow: "Federal dollars", | |
| + | kicker: "Fiscal", | |
| title: "Spending", | ||
| - | desc: "Federal awards and contracts from USAspending.gov, plus OMB apportionment records.", | |
| - | }, | |
| - | { | |
| - | href: "/calendar", | |
| - | eyebrow: "Dates and schedules", | |
| - | title: "Calendar", | |
| - | desc: "When laws take effect. When bills are scheduled for committee or floor action.", | |
| + | desc: "Federal awards and contracts from USAspending.gov. Where the dollars go.", | |
| }, | ||
| { | ||
| href: "/states", | ||
| - | eyebrow: "Geography", | |
| + | kicker: "Regional", | |
| title: "States", | ||
| - | desc: "Bills, members, and legislative activity organized by state.", | |
| + | desc: "Bills, members, and federal activity organized by your state.", | |
| + | }, | |
| + | { | |
| + | href: "/register", | |
| + | kicker: "Live Feed", | |
| + | title: "What's happening", | |
| + | desc: "A chronological feed of government actions: bills passed, votes taken, dollars awarded. No editorial weighting.", | |
| }, | ||
| ] as const; | ||
| + | const MORE_LINKS = [ | |
| + | { href: "/calendar", label: "Calendar" }, | |
| + | { href: "/schedule", label: "Schedule" }, | |
| + | { href: "/executive", label: "Executive orders" }, | |
| + | { href: "/categories", label: "Browse by topic" }, | |
| + | { href: "/search", label: "Search everything" }, | |
| + | ] as const; | |
| + | ||
| export default function HomePage() { | ||
| return ( | ||
| - | <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | |
| + | <div | |
| + | style={{ | |
| + | background: "var(--dk-hero-glow)", | |
| + | }} | |
| + | > | |
| + | {/* ── Hero ──────────────────────────────────────────────────────── */} | |
| + | <section | |
| + | aria-label="Introduction" | |
| + | style={{ | |
| + | maxWidth: "860px", | |
| + | margin: "0 auto", | |
| + | padding: "clamp(56px, 10vw, 120px) 24px clamp(48px, 8vw, 96px)", | |
| + | }} | |
| + | > | |
| + | {/* Mono kicker */} | |
| + | <p | |
| + | className="label" | |
| + | style={{ | |
| + | color: "var(--dk-signal)", | |
| + | marginBottom: "28px", | |
| + | display: "flex", | |
| + | alignItems: "center", | |
| + | gap: "10px", | |
| + | }} | |
| + | > | |
| + | <span | |
| + | aria-hidden="true" | |
| + | style={{ | |
| + | width: "6px", | |
| + | height: "6px", | |
| + | borderRadius: "50%", | |
| + | background: "var(--dk-signal)", | |
| + | boxShadow: "0 0 0 3px rgba(224,165,59,0.18)", | |
| + | display: "inline-block", | |
| + | flexShrink: 0, | |
| + | }} | |
| + | /> | |
| + | Docket · Federal Record | |
| + | </p> | |
| - | {/* ── Hero ────────────────────────────────────────────────── */} | |
| - | <header className="border-b border-line pb-10"> | |
| - | <p className="label mb-3">Docket</p> | |
| + | {/* HUGE Archivo uppercase headline */} | |
| <h1 | ||
| - | className="display text-paper" | |
| - | style={{ fontSize: "clamp(1.875rem, 7vw, 3rem)", lineHeight: 1.05 }} | |
| + | className="font-display text-paper" | |
| + | style={{ | |
| + | fontSize: "clamp(2.8rem, 9vw, 5.5rem)", | |
| + | fontWeight: 900, | |
| + | lineHeight: 0.94, | |
| + | letterSpacing: "-0.03em", | |
| + | textTransform: "uppercase", | |
| + | maxWidth: "16ch", | |
| + | marginBottom: "32px", | |
| + | }} | |
| > | ||
| - | The public record of the U.S. government | |
| + | The Public Record of the U.S. Government | |
| </h1> | ||
| - | <p className="prose-lead mt-5 max-w-2xl text-ash"> | |
| - | Docket is a neutral, sourced record of official federal government | |
| - | activity. Every fact shown here comes directly from an official{" "} | |
| - | <span className="font-mono text-[13px]">.gov</span> source, with a | |
| - | link. No commentary. No characterization. Facts, on the record. | |
| - | </p> | |
| - | <p className="mt-4 max-w-2xl text-ash" style={{ fontSize: "1rem", lineHeight: 1.6 }}> | |
| - | Use this site to look up a bill, see how your representative voted, | |
| - | track federal spending, or cite a government action in a paper or | |
| - | report. All records are free to access. | |
| + | ||
| + | {/* Fraunces serif lead */} | |
| + | <p | |
| + | className="font-prose" | |
| + | style={{ | |
| + | fontStyle: "italic", | |
| + | fontSize: "clamp(1.0625rem, 2vw, 1.25rem)", | |
| + | lineHeight: 1.55, | |
| + | color: "var(--dk-ash)", | |
| + | maxWidth: "48ch", | |
| + | marginBottom: "40px", | |
| + | }} | |
| + | > | |
| + | Bills, votes, and dollars straight from official government sources. | |
| + | No spin. No opinion. Every fact links to its official{" "} | |
| + | <span | |
| + | className="font-mono not-italic" | |
| + | style={{ fontSize: "0.9em" }} | |
| + | > | |
| + | .gov | |
| + | </span>{" "} | |
| + | source. | |
| </p> | ||
| - | <div className="mt-6"> | |
| + | ||
| + | {/* CTA strip */} | |
| + | <div style={{ display: "flex", flexWrap: "wrap", gap: "12px" }}> | |
| <Link | ||
| href="/search" | ||
| - | className="focus-ring inline-flex min-h-[52px] items-center rounded-[3px] border border-line bg-surface px-6 font-mono text-[13px] text-paper transition-colors hover:bg-graphite" | |
| + | className="focus-ring no-underline" | |
| + | style={{ | |
| + | display: "inline-flex", | |
| + | alignItems: "center", | |
| + | gap: "9px", | |
| + | height: "48px", | |
| + | padding: "0 22px", | |
| + | background: "var(--dk-signal)", | |
| + | color: "var(--dk-ink)", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "12.5px", | |
| + | fontWeight: 700, | |
| + | letterSpacing: "0.1em", | |
| + | textTransform: "uppercase", | |
| + | borderRadius: "9px", | |
| + | border: "1px solid transparent", | |
| + | transition: "background 0.2s, transform 0.15s", | |
| + | boxShadow: "0 8px 22px -8px rgba(224,165,59,0.45)", | |
| + | }} | |
| + | > | |
| + | <SearchIcon /> | |
| + | Search everything | |
| + | </Link> | |
| + | ||
| + | <Link | |
| + | href="/bills" | |
| + | className="focus-ring no-underline" | |
| + | style={{ | |
| + | display: "inline-flex", | |
| + | alignItems: "center", | |
| + | height: "48px", | |
| + | padding: "0 22px", | |
| + | background: "transparent", | |
| + | color: "var(--dk-paper)", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "12.5px", | |
| + | fontWeight: 400, | |
| + | letterSpacing: "0.1em", | |
| + | textTransform: "uppercase", | |
| + | borderRadius: "9px", | |
| + | border: "1px solid var(--dk-line)", | |
| + | transition: "border-color 0.2s, background 0.2s", | |
| + | }} | |
| > | ||
| - | Search the record → | |
| + | Browse bills | |
| </Link> | ||
| </div> | ||
| - | </header> | |
| + | </section> | |
| - | {/* ── Entry cards ─────────────────────────────────────────── */} | |
| - | <section aria-label="Browse sections" className="mt-10"> | |
| - | <p className="label mb-6 text-ash">What you can do here</p> | |
| - | <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> | |
| + | {/* ── Section cards ─────────────────────────────────────────────── */} | |
| + | <section | |
| + | aria-label="Browse sections" | |
| + | style={{ | |
| + | maxWidth: "860px", | |
| + | margin: "0 auto", | |
| + | padding: "0 24px clamp(40px, 8vw, 80px)", | |
| + | }} | |
| + | > | |
| + | {/* Section kicker */} | |
| + | <p | |
| + | className="label" | |
| + | style={{ color: "var(--dk-ash)", marginBottom: "28px" }} | |
| + | > | |
| + | What you can do here | |
| + | </p> | |
| + | ||
| + | <div | |
| + | style={{ | |
| + | display: "grid", | |
| + | gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", | |
| + | gap: "16px", | |
| + | }} | |
| + | > | |
| {SECTIONS.map((s) => ( | ||
| <Link | ||
| key={s.href} | ||
| href={s.href} | ||
| - | className="focus-ring group rounded-[3px] border border-line bg-surface p-5 transition-colors hover:bg-graphite" | |
| + | className="focus-ring no-underline" | |
| + | style={{ | |
| + | display: "block", | |
| + | border: "1px solid var(--dk-line)", | |
| + | borderRadius: "10px", | |
| + | background: "var(--dk-surface)", | |
| + | padding: "28px 24px", | |
| + | transition: "border-color 0.2s, background 0.2s", | |
| + | }} | |
| > | ||
| - | <p className="label mb-1.5 text-ash-2">{s.eyebrow}</p> | |
| - | <p className="font-mono text-[14px] text-paper group-hover:underline group-hover:underline-offset-4"> | |
| + | {/* Card kicker */} | |
| + | <p | |
| + | className="label" | |
| + | style={{ color: "var(--dk-signal)", marginBottom: "10px", fontSize: "11px", letterSpacing: "0.14em" }} | |
| + | > | |
| + | {s.kicker} | |
| + | </p> | |
| + | {/* Card title */} | |
| + | <p | |
| + | className="font-display text-paper" | |
| + | style={{ | |
| + | fontSize: "1.375rem", | |
| + | fontWeight: 800, | |
| + | letterSpacing: "-0.02em", | |
| + | textTransform: "uppercase", | |
| + | lineHeight: 1.05, | |
| + | marginBottom: "12px", | |
| + | }} | |
| + | > | |
| {s.title} | ||
| </p> | ||
| - | <p className="mt-2 text-[13px] leading-snug text-ash">{s.desc}</p> | |
| + | {/* Card description in Fraunces */} | |
| + | <p | |
| + | className="font-prose" | |
| + | style={{ | |
| + | fontSize: "1rem", | |
| + | lineHeight: 1.55, | |
| + | color: "var(--dk-ash)", | |
| + | }} | |
| + | > | |
| + | {s.desc} | |
| + | </p> | |
| </Link> | ||
| ))} | ||
| </div> | ||
| </section> | ||
| - | {/* ── How to use / How to cite ─────────────────────────────── */} | |
| - | <section className="mt-12 border-t border-line pt-8"> | |
| - | <div className="grid gap-6 sm:grid-cols-2"> | |
| - | <Card className="p-5"> | |
| - | <p className="label mb-2 text-ash-2">How to use this site</p> | |
| - | <p className="font-mono text-[14px] text-paper">For students and educators</p> | |
| - | <p className="mt-2 text-[13px] leading-snug text-ash"> | |
| - | Start with{" "} | |
| - | <Link href="/search" className="focus-ring underline underline-offset-4 hover:text-paper"> | |
| - | Search | |
| - | </Link>{" "} | |
| - | to find a topic. Click any bill or vote to open its full record. Every | |
| - | detail page has a{" "} | |
| - | <span className="font-mono text-[12px]">Cite this</span> button that | |
| - | generates a citation in APA, MLA, or Chicago format with a permalink | |
| - | and the official .gov source. | |
| - | </p> | |
| - | </Card> | |
| - | ||
| - | <Card className="p-5"> | |
| - | <p className="label mb-2 text-ash-2">How to cite this site</p> | |
| - | <p className="font-mono text-[14px] text-paper">APA / MLA / Chicago</p> | |
| - | <p className="mt-2 text-[13px] leading-snug text-ash"> | |
| - | On any bill, vote, or member page, scroll to the bottom and use the{" "} | |
| - | <span className="font-mono text-[12px]">Cite this record</span> panel. | |
| - | It fills in the author, date, title, permalink, and the official government | |
| - | source automatically. No manual formatting required. | |
| - | </p> | |
| - | </Card> | |
| + | {/* ── More links ────────────────────────────────────────────────── */} | |
| + | <section | |
| + | aria-label="More sections" | |
| + | style={{ | |
| + | maxWidth: "860px", | |
| + | margin: "0 auto", | |
| + | padding: "0 24px clamp(32px, 6vw, 56px)", | |
| + | borderTop: "1px solid var(--dk-line)", | |
| + | }} | |
| + | > | |
| + | <p | |
| + | className="label" | |
| + | style={{ color: "var(--dk-ash-2)", marginBottom: "20px", marginTop: "32px" }} | |
| + | > | |
| + | More | |
| + | </p> | |
| + | <ul | |
| + | style={{ display: "flex", flexWrap: "wrap", gap: "10px", listStyle: "none", padding: 0, margin: 0 }} | |
| + | > | |
| + | {MORE_LINKS.map((l) => ( | |
| + | <li key={l.href}> | |
| + | <Link | |
| + | href={l.href} | |
| + | className="focus-ring no-underline" | |
| + | style={{ | |
| + | display: "inline-flex", | |
| + | alignItems: "center", | |
| + | minHeight: "44px", | |
| + | padding: "0 16px", | |
| + | border: "1px solid var(--dk-line)", | |
| + | borderRadius: "8px", | |
| + | background: "var(--dk-surface)", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "12px", | |
| + | letterSpacing: "0.1em", | |
| + | textTransform: "uppercase", | |
| + | color: "var(--dk-ash)", | |
| + | transition: "border-color 0.2s, color 0.2s", | |
| + | }} | |
| + | > | |
| + | {l.label} | |
| + | </Link> | |
| + | </li> | |
| + | ))} | |
| + | </ul> | |
| + | </section> | |
| + | ||
| + | {/* ── How to cite ───────────────────────────────────────────────── */} | |
| + | <section | |
| + | aria-label="How to cite this site" | |
| + | style={{ | |
| + | maxWidth: "860px", | |
| + | margin: "0 auto", | |
| + | padding: "0 24px clamp(24px, 5vw, 40px)", | |
| + | }} | |
| + | > | |
| + | <div | |
| + | style={{ | |
| + | border: "1px solid var(--dk-line)", | |
| + | borderRadius: "10px", | |
| + | background: "var(--dk-surface)", | |
| + | padding: "clamp(24px, 4vw, 40px)", | |
| + | }} | |
| + | > | |
| + | {/* Kicker */} | |
| + | <p className="label" style={{ color: "var(--dk-signal)", marginBottom: "20px", fontSize: "11px", letterSpacing: "0.14em" }}> | |
| + | Citations | |
| + | </p> | |
| + | ||
| + | <h2 | |
| + | className="font-display text-paper" | |
| + | style={{ | |
| + | fontSize: "clamp(1.5rem, 3.5vw, 2rem)", | |
| + | fontWeight: 900, | |
| + | letterSpacing: "-0.02em", | |
| + | textTransform: "uppercase", | |
| + | lineHeight: 1.0, | |
| + | marginBottom: "20px", | |
| + | }} | |
| + | > | |
| + | How to cite this site | |
| + | </h2> | |
| + | ||
| + | <p | |
| + | className="font-prose" | |
| + | style={{ | |
| + | fontSize: "1.0625rem", | |
| + | lineHeight: 1.6, | |
| + | color: "var(--dk-ash)", | |
| + | maxWidth: "56ch", | |
| + | }} | |
| + | > | |
| + | On any bill, vote, or member page, scroll to the bottom and use the{" "} | |
| + | <strong style={{ color: "var(--dk-paper)" }}>Cite this record</strong>{" "} | |
| + | panel. It fills in the author, date, title, permalink, and the official | |
| + | government source automatically - in APA, MLA, or Chicago format. | |
| + | </p> | |
| + | ||
| + | <div | |
| + | style={{ | |
| + | marginTop: "24px", | |
| + | borderTop: "1px solid var(--dk-line)", | |
| + | paddingTop: "20px", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "13px", | |
| + | color: "var(--dk-ash)", | |
| + | fontStyle: "italic", | |
| + | }} | |
| + | > | |
| + | <span style={{ color: "var(--dk-ash-2)", fontStyle: "normal", fontSize: "11px", letterSpacing: "0.08em", textTransform: "uppercase" }}>Example APA · </span> | |
| + | <span style={{ fontStyle: "normal" }}> | |
| + | Docket. (2025). H.R. 22 - [Title]. usdocket.org. Retrieved from https://congress.gov/... | |
| + | </span> | |
| + | </div> | |
| </div> | ||
| </section> | ||
| - | {/* ── Neutrality note ─────────────────────────────────────── */} | |
| - | <section className="mt-8"> | |
| - | <Card className="p-5"> | |
| - | <p className="label mb-2 text-ash-2">About this site</p> | |
| - | <p className="text-[13px] leading-snug text-ash"> | |
| - | Docket is neutral by design. It does not editorialize, rank by | |
| - | political popularity, or surface opinion. The feed is ordered by | |
| - | action date and dollar magnitude only - the same factors as the | |
| - | official record. Party colors appear only in desaturated, equal-weight | |
| - | tones. No comment section. No ads. All source links open the official | |
| - | .gov record directly. | |
| + | {/* ── Neutrality note ───────────────────────────────────────────── */} | |
| + | <section | |
| + | aria-label="About this site" | |
| + | style={{ | |
| + | maxWidth: "860px", | |
| + | margin: "0 auto", | |
| + | padding: "clamp(16px, 3vw, 24px) 24px clamp(56px, 10vw, 96px)", | |
| + | }} | |
| + | > | |
| + | <div | |
| + | style={{ | |
| + | border: "1px solid var(--dk-line)", | |
| + | borderRadius: "10px", | |
| + | background: "var(--dk-surface)", | |
| + | padding: "clamp(24px, 4vw, 36px)", | |
| + | }} | |
| + | > | |
| + | <p className="label" style={{ color: "var(--dk-ash-2)", marginBottom: "16px", fontSize: "11px", letterSpacing: "0.14em" }}> | |
| + | About this site | |
| </p> | ||
| - | <p className="mt-3 text-[13px] leading-snug text-ash"> | |
| - | See the{" "} | |
| - | <Link href="/how-we-pick" className="focus-ring underline underline-offset-4 hover:text-paper"> | |
| + | <p | |
| + | className="font-prose" | |
| + | style={{ | |
| + | fontSize: "1rem", | |
| + | lineHeight: 1.6, | |
| + | color: "var(--dk-ash)", | |
| + | maxWidth: "62ch", | |
| + | }} | |
| + | > | |
| + | Docket is neutral by design. It does not editorialize, rank by political | |
| + | popularity, or surface opinion. The feed is ordered by action date and | |
| + | dollar magnitude only - the same factors as the official record. Party | |
| + | colors appear only in desaturated, equal-weight tones. No comment | |
| + | section. No ads. All source links open the official .gov record directly. | |
| + | </p> | |
| + | <div | |
| + | style={{ | |
| + | marginTop: "20px", | |
| + | display: "flex", | |
| + | flexWrap: "wrap", | |
| + | gap: "20px", | |
| + | }} | |
| + | > | |
| + | <Link | |
| + | href="/how-we-pick" | |
| + | className="focus-ring label underline underline-offset-4" | |
| + | style={{ color: "var(--dk-signal)", fontSize: "12px", letterSpacing: "0.1em" }} | |
| + | > | |
| How we pick what to show | ||
| - | </Link>{" "} | |
| - | and{" "} | |
| - | <Link href="/who-pays" className="focus-ring underline underline-offset-4 hover:text-paper"> | |
| + | </Link> | |
| + | <Link | |
| + | href="/who-pays" | |
| + | className="focus-ring label underline underline-offset-4" | |
| + | style={{ color: "var(--dk-signal)", fontSize: "12px", letterSpacing: "0.1em" }} | |
| + | > | |
| Who pays for this | ||
| - | </Link>{" "} | |
| - | pages for the full editorial and funding policy. | |
| - | </p> | |
| - | </Card> | |
| + | </Link> | |
| + | </div> | |
| + | </div> | |
| </section> | ||
| - | ||
| </div> | ||
| ); | ||
| } | ||
| + | ||
| + | function SearchIcon() { | |
| + | return ( | |
| + | <svg | |
| + | aria-hidden="true" | |
| + | width="16" | |
| + | height="16" | |
| + | viewBox="0 0 16 16" | |
| + | fill="none" | |
| + | stroke="currentColor" | |
| + | strokeWidth="1.75" | |
| + | strokeLinecap="round" | |
| + | strokeLinejoin="round" | |
| + | style={{ flexShrink: 0 }} | |
| + | > | |
| + | <circle cx="6.5" cy="6.5" r="4.5" /> | |
| + | <line x1="10.5" y1="10.5" x2="14" y2="14" /> | |
| + | </svg> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,41 @@ | ||
| + | /** Skeleton shown immediately on client-side navigation to /register | |
| + | * while the RSC payload is in-flight. Matches the page's rough layout: | |
| + | * a hero section + a data table. No imports needed - pure Tailwind. */ | |
| + | export default function Loading() { | |
| + | return ( | |
| + | <> | |
| + | {/* hero strip skeleton */} | |
| + | <section className="border-y border-line"> | |
| + | <div className="mx-auto w-full max-w-register px-4 py-8 sm:px-6 sm:py-14"> | |
| + | <div className="mb-3 h-3 w-20 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="h-8 w-64 animate-pulse rounded-sm bg-graphite sm:h-10 sm:w-96" /> | |
| + | <div className="mt-4 h-4 w-48 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-8 space-y-px overflow-hidden rounded-sm border border-line"> | |
| + | {Array.from({ length: 12 }).map((_, i) => ( | |
| + | <div | |
| + | key={i} | |
| + | className="flex h-10 items-center gap-3 border-t border-line px-4 first:border-t-0" | |
| + | > | |
| + | <div className="h-2.5 w-16 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="h-2.5 w-20 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="h-2.5 flex-1 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="h-2.5 w-14 animate-pulse rounded-sm bg-graphite" /> | |
| + | </div> | |
| + | ))} | |
| + | </div> | |
| + | </div> | |
| + | </section> | |
| + | ||
| + | {/* table section skeleton */} | |
| + | <div className="mx-auto w-full max-w-register px-4 py-12 sm:px-6"> | |
| + | <div className="mb-2 h-3 w-24 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-1 h-6 w-56 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-8 space-y-2"> | |
| + | {Array.from({ length: 8 }).map((_, i) => ( | |
| + | <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" /> | |
| + | ))} | |
| + | </div> | |
| + | </div> | |
| + | </> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,23 @@ | ||
| + | /** Skeleton shown immediately on client-side navigation to /spending. */ | |
| + | export default function Loading() { | |
| + | return ( | |
| + | <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | |
| + | <div className="mb-2 h-3 w-28 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-2 h-7 w-64 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-3 h-4 w-full max-w-2xl animate-pulse rounded-sm bg-graphite" /> | |
| + | {/* fiscal cards */} | |
| + | <div className="mt-8 grid gap-4 sm:grid-cols-3"> | |
| + | {Array.from({ length: 3 }).map((_, i) => ( | |
| + | <div key={i} className="h-24 animate-pulse rounded-sm bg-graphite" /> | |
| + | ))} | |
| + | </div> | |
| + | <div className="mt-8 h-14 w-full animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="space-y-1"> | |
| + | {Array.from({ length: 12 }).map((_, i) => ( | |
| + | <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" /> | |
| + | ))} | |
| + | </div> | |
| + | </div> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,21 @@ | ||
| + | /** Skeleton shown immediately on client-side navigation to /votes. */ | |
| + | export default function Loading() { | |
| + | return ( | |
| + | <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | |
| + | {/* page header */} | |
| + | <div className="mb-2 h-3 w-24 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-2 h-7 w-56 animate-pulse rounded-sm bg-graphite" /> | |
| + | <div className="mt-3 h-4 w-full max-w-2xl animate-pulse rounded-sm bg-graphite" /> | |
| + | {/* filter bar */} | |
| + | <div className="mt-8 h-14 w-full animate-pulse rounded-sm bg-graphite" /> | |
| + | {/* section header */} | |
| + | <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" /> | |
| + | {/* table rows */} | |
| + | <div className="space-y-1"> | |
| + | {Array.from({ length: 16 }).map((_, i) => ( | |
| + | <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" /> | |
| + | ))} | |
| + | </div> | |
| + | </div> | |
| + | ); | |
| + | } |
| @@ -5,28 +5,27 @@ import { clsx } from "@/lib/cx"; | ||
| /** | ||
| * 3-mode display toggle (docs/ACCESSIBILITY.md §"Three display modes"). | ||
| - | * Reference (light, DEFAULT) / Dark (forensic brand) / Plain (high-contrast, | |
| - | * system sans, ≥20px). Persisted in localStorage("docket-display"); the | |
| - | * matching class on <html> is set pre-paint by the inline script in | |
| - | * layout.tsx so there is no flash and no hydration mismatch. | |
| + | * Dark (DEFAULT, Zion's ink aesthetic) / Light / Plain (high-contrast). | |
| + | * Persisted in localStorage("docket-display"); the matching class on <html> | |
| + | * is set pre-paint by the inline script in layout.tsx - no flash. | |
| * | ||
| - | * Default is now "light" (Reference) per docs/REDESIGN.md §"Theme system". | |
| + | * Default is "dark" per docs/DESIGN_V3.md (Zion's ink aesthetic). | |
| * Always-visible, labelled, ≥44×44px hit targets (a11y rules #3 & #8). | ||
| * Fully keyboard-operable (native button group). | ||
| */ | ||
| export type DisplayMode = "dark" | "light" | "plain"; | ||
| const MODES: Array<{ value: DisplayMode; label: string; hint: string }> = [ | ||
| - | { value: "light", label: "Reference", hint: "Light - for reading" }, | |
| - | { value: "dark", label: "Dark", hint: "Forensic register look" }, | |
| - | { value: "plain", label: "Plain", hint: "High contrast, large text" }, | |
| + | { value: "dark", label: "Dark", hint: "Dark - default ink look" }, | |
| + | { value: "light", label: "Light", hint: "Light - large print mode" }, | |
| + | { value: "plain", label: "Plain", hint: "High contrast, large text" }, | |
| ]; | ||
| export function DisplayToggle() { | ||
| - | const [mode, setMode] = useState<DisplayMode>("light"); | |
| + | const [mode, setMode] = useState<DisplayMode>("dark"); | |
| useEffect(() => { | ||
| - | const current = (localStorage.getItem("docket-display") as DisplayMode) || "light"; | |
| + | const current = (localStorage.getItem("docket-display") as DisplayMode) || "dark"; | |
| if (current !== mode) setMode(current); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []); | ||
| @@ -36,7 +35,7 @@ export function DisplayToggle() { | ||
| localStorage.setItem("docket-display", next); | ||
| const el = document.documentElement; | ||
| el.classList.remove("dark", "light", "plain"); | ||
| - | el.classList.add(next); | |
| + | if (next !== "dark") el.classList.add(next); // dark = no class (:root tokens) | |
| el.dataset.display = next; | ||
| } | ||
| @@ -8,35 +8,25 @@ import { DisplayToggle } from "@/components/display-toggle"; | ||
| import { clsx } from "@/lib/cx"; | ||
| /** | ||
| - | * Site header - two cleanly separated controls (mobile-first overhaul): | |
| + | * Site header - single-row, treetrace/zionboggan aesthetic (DESIGN_V3). | |
| * | ||
| - | * (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. | |
| + | * Layout: wordmark (left) | nav links (center, desktop only) | search + toggle + hamburger (right) | |
| + | * Type: JetBrains Mono uppercase tracked nav links (≥14px) | |
| + | * Active: 2px brass underline, no background fill | |
| + | * Mobile: slide-in panel from right (like treetrace tt-mobile pattern) | |
| + | * Sticky + backdrop-blur - stays in layout flow (no padding-top hack needed) | |
| */ | ||
| const NAV: Array<{ href: string; label: string }> = [ | ||
| - | { href: "/register", label: "Register" }, | |
| - | { href: "/calendar", label: "Calendar" }, | |
| - | { href: "/schedule", label: "Schedule" }, | |
| - | { href: "/votes", label: "Votes" }, | |
| - | { href: "/members", label: "Members" }, | |
| - | { href: "/bills", label: "Bills" }, | |
| - | { href: "/categories", label: "Topics" }, | |
| - | { href: "/spending", label: "Spending" }, | |
| - | { href: "/states", label: "States" }, | |
| - | { href: "/executive", label: "Executive" }, | |
| - | { href: "/search", label: "Search" }, | |
| + | { href: "/", label: "Home" }, | |
| + | { href: "/bills", label: "Bills" }, | |
| + | { href: "/votes", label: "Votes" }, | |
| + | { href: "/members", label: "Reps" }, | |
| + | { href: "/spending", label: "Spending" }, | |
| + | { href: "/states", label: "States" }, | |
| ]; | ||
| function isActive(pathname: string | null, href: string): boolean { | ||
| + | if (href === "/") return pathname === "/"; | |
| return pathname === href || !!pathname?.startsWith(href + "/"); | ||
| } | ||
| @@ -44,17 +34,11 @@ export function SiteHeader() { | ||
| const pathname = usePathname(); | ||
| const [open, setOpen] = useState(false); | ||
| - | // Close the mobile menu whenever the route changes. | |
| - | useEffect(() => { | |
| - | setOpen(false); | |
| - | }, [pathname]); | |
| + | 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); | |
| - | }; | |
| + | 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"; | ||
| @@ -65,109 +49,236 @@ export function SiteHeader() { | ||
| }, [open]); | ||
| return ( | ||
| - | <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"> | |
| + | <> | |
| + | <header | |
| + | className="sticky top-0 z-40 border-b border-line" | |
| + | style={{ | |
| + | background: "rgba(11,12,14,0.88)", | |
| + | backdropFilter: "blur(14px) saturate(120%)", | |
| + | WebkitBackdropFilter: "blur(14px) saturate(120%)", | |
| + | height: "64px", | |
| + | display: "flex", | |
| + | alignItems: "center", | |
| + | }} | |
| + | > | |
| + | <div | |
| + | className="mx-auto w-full px-4 sm:px-7" | |
| + | style={{ maxWidth: "1180px", display: "flex", alignItems: "center", gap: "20px" }} | |
| + | > | |
| + | {/* Wordmark */} | |
| <Link | ||
| - | href="/register" | |
| - | className="focus-ring tap shrink-0 gap-2.5 rounded-sm pr-2" | |
| + | href="/" | |
| + | className="focus-ring shrink-0 no-underline" | |
| aria-label="Docket - home" | ||
| + | style={{ display: "inline-flex", alignItems: "center", gap: "10px" }} | |
| > | ||
| <CustodyMark size={26} showTick={false} /> | ||
| - | <Wordmark className="text-[15px]" /> | |
| + | <Wordmark className="font-display text-paper" /> | |
| </Link> | ||
| - | <div className="flex items-center gap-2"> | |
| - | {/* Global search shortcut - visible on all viewports. */} | |
| + | {/* Desktop nav - center */} | |
| + | <nav | |
| + | aria-label="Primary" | |
| + | className="hidden md:flex" | |
| + | style={{ marginLeft: "16px", gap: "2px" }} | |
| + | > | |
| + | {NAV.map((item) => { | |
| + | const active = isActive(pathname, item.href); | |
| + | return ( | |
| + | <Link | |
| + | key={item.href} | |
| + | href={item.href} | |
| + | aria-current={active ? "page" : undefined} | |
| + | className="focus-ring no-underline" | |
| + | style={{ | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "11.5px", | |
| + | fontWeight: 400, | |
| + | letterSpacing: "0.16em", | |
| + | textTransform: "uppercase", | |
| + | color: active ? "var(--dk-paper)" : "var(--dk-ash)", | |
| + | padding: "8px 12px", | |
| + | borderRadius: "6px", | |
| + | position: "relative", | |
| + | transition: "color 0.2s", | |
| + | textDecoration: "none", | |
| + | }} | |
| + | > | |
| + | {item.label} | |
| + | {active && ( | |
| + | <span | |
| + | aria-hidden="true" | |
| + | style={{ | |
| + | position: "absolute", | |
| + | left: "12px", | |
| + | right: "12px", | |
| + | bottom: "2px", | |
| + | height: "2px", | |
| + | background: "var(--dk-signal)", | |
| + | borderRadius: "2px", | |
| + | boxShadow: "0 0 8px rgba(224,165,59,0.45)", | |
| + | }} | |
| + | /> | |
| + | )} | |
| + | </Link> | |
| + | ); | |
| + | })} | |
| + | </nav> | |
| + | ||
| + | {/* Right: search + toggle + hamburger */} | |
| + | <div | |
| + | className="flex items-center" | |
| + | style={{ marginLeft: "auto", gap: "8px" }} | |
| + | > | |
| + | {/* Search */} | |
| <Link | ||
| href="/search" | ||
| aria-label="Search Docket" | ||
| - | title="Search" | |
| - | className="focus-ring label inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-[3px] border border-line bg-surface text-ash hover:text-paper" | |
| + | className="focus-ring no-underline tap" | |
| + | style={{ | |
| + | display: "inline-flex", | |
| + | alignItems: "center", | |
| + | gap: "7px", | |
| + | padding: "0 12px", | |
| + | height: "36px", | |
| + | border: "1px solid var(--dk-line)", | |
| + | borderRadius: "8px", | |
| + | background: "transparent", | |
| + | color: "var(--dk-ash)", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "11.5px", | |
| + | letterSpacing: "0.12em", | |
| + | textTransform: "uppercase", | |
| + | transition: "border-color 0.2s, color 0.2s", | |
| + | }} | |
| > | ||
| <SearchIcon /> | ||
| + | <span className="hidden sm:inline">Search</span> | |
| </Link> | ||
| + | ||
| <DisplayToggle /> | ||
| - | {/* Hamburger - page nav only (<md). */} | |
| + | ||
| + | {/* Hamburger (mobile only - md:hidden hides on >=768px) */} | |
| <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" | |
| + | className="focus-ring md:hidden" | |
| + | style={{ | |
| + | alignItems: "center", | |
| + | justifyContent: "center", | |
| + | flexDirection: "column", | |
| + | gap: "4px", | |
| + | width: "44px", | |
| + | height: "44px", | |
| + | border: "1px solid var(--dk-line)", | |
| + | borderRadius: "8px", | |
| + | background: "transparent", | |
| + | cursor: "pointer", | |
| + | }} | |
| > | ||
| <BurgerIcon open={open} /> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| + | </header> | |
| - | {/* Row 2 (>=md): clean desktop nav bar. */} | |
| - | <nav aria-label="Primary" className="hidden md:block"> | |
| - | <ul className="flex items-center gap-1 pb-2"> | |
| - | {NAV.map((item) => { | |
| - | const active = isActive(pathname, item.href); | |
| - | return ( | |
| - | <li key={item.href}> | |
| + | {/* Mobile slide-in panel */} | |
| + | {open && ( | |
| + | <> | |
| + | {/* Scrim */} | |
| + | <div | |
| + | role="presentation" | |
| + | className="fixed inset-0 z-[99] md:hidden" | |
| + | style={{ background: "rgba(6,7,9,0.6)", backdropFilter: "blur(4px)" }} | |
| + | onClick={() => setOpen(false)} | |
| + | /> | |
| + | {/* Panel */} | |
| + | <div | |
| + | id="primary-mobile-nav" | |
| + | className="fixed top-0 bottom-0 right-0 z-[100] md:hidden" | |
| + | style={{ | |
| + | width: "min(82vw, 340px)", | |
| + | background: "var(--dk-surface)", | |
| + | borderLeft: "1px solid var(--dk-line)", | |
| + | paddingTop: "calc(64px + 16px)", | |
| + | paddingLeft: "24px", | |
| + | paddingRight: "24px", | |
| + | paddingBottom: "28px", | |
| + | display: "flex", | |
| + | flexDirection: "column", | |
| + | gap: "4px", | |
| + | overflowY: "auto", | |
| + | boxShadow: "-30px 0 60px -20px rgba(0,0,0,0.7)", | |
| + | }} | |
| + | role="dialog" | |
| + | aria-label="Primary navigation" | |
| + | aria-modal="true" | |
| + | > | |
| + | <nav aria-label="Primary mobile"> | |
| + | <ul style={{ display: "flex", flexDirection: "column", gap: "0" }}> | |
| + | {NAV.map((item) => { | |
| + | const active = isActive(pathname, item.href); | |
| + | return ( | |
| + | <li key={item.href}> | |
| + | <Link | |
| + | href={item.href} | |
| + | aria-current={active ? "page" : undefined} | |
| + | className="focus-ring no-underline" | |
| + | style={{ | |
| + | display: "flex", | |
| + | alignItems: "center", | |
| + | justifyContent: "space-between", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "14px", | |
| + | letterSpacing: "0.12em", | |
| + | textTransform: "uppercase", | |
| + | color: active ? "var(--dk-paper)" : "var(--dk-ash)", | |
| + | padding: "14px 4px", | |
| + | borderBottom: "1px solid rgba(155,160,166,0.10)", | |
| + | paddingLeft: active ? "12px" : "4px", | |
| + | borderLeft: active ? "2px solid var(--dk-signal)" : "2px solid transparent", | |
| + | transition: "color 0.2s", | |
| + | textDecoration: "none", | |
| + | }} | |
| + | > | |
| + | {item.label} | |
| + | </Link> | |
| + | </li> | |
| + | ); | |
| + | })} | |
| + | {/* Search in mobile menu */} | |
| + | <li> | |
| <Link | ||
| - | href={item.href} | |
| - | className={clsx( | |
| - | "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" | |
| - | )} | |
| - | aria-current={active ? "page" : undefined} | |
| + | href="/search" | |
| + | className="focus-ring no-underline" | |
| + | style={{ | |
| + | display: "flex", | |
| + | alignItems: "center", | |
| + | gap: "12px", | |
| + | fontFamily: "var(--font-mono), ui-monospace, monospace", | |
| + | fontSize: "14px", | |
| + | letterSpacing: "0.12em", | |
| + | textTransform: "uppercase", | |
| + | color: "var(--dk-ash)", | |
| + | padding: "14px 4px", | |
| + | borderBottom: "1px solid rgba(155,160,166,0.10)", | |
| + | marginTop: "12px", | |
| + | textDecoration: "none", | |
| + | }} | |
| > | ||
| - | {item.label} | |
| + | <SearchIcon /> | |
| + | Search | |
| </Link> | ||
| </li> | ||
| - | ); | |
| - | })} | |
| - | </ul> | |
| - | </nav> | |
| - | </div> | |
| - | ||
| - | {/* 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( | |
| - | "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" | |
| - | )} | |
| - | > | |
| - | <span>{item.label}</span> | |
| - | {active ? ( | |
| - | <span aria-hidden="true" className="text-paper">●</span> | |
| - | ) : null} | |
| - | </Link> | |
| - | </li> | |
| - | ); | |
| - | })} | |
| - | </ul> | |
| - | </nav> | |
| - | </div> | |
| - | ) : null} | |
| - | </header> | |
| + | </ul> | |
| + | </nav> | |
| + | </div> | |
| + | </> | |
| + | )} | |
| + | </> | |
| ); | ||
| } | ||
| @@ -183,6 +294,7 @@ function SearchIcon() { | ||
| strokeWidth="1.5" | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| + | style={{ flexShrink: 0 }} | |
| > | ||
| <circle cx="6.5" cy="6.5" r="4.5" /> | ||
| <line x1="10.5" y1="10.5" x2="14" y2="14" /> | ||
| @@ -191,26 +303,41 @@ function SearchIcon() { | ||
| } | ||
| 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" }} | |
| + | style={{ | |
| + | display: "block", | |
| + | width: "18px", | |
| + | height: "2px", | |
| + | background: "var(--dk-paper)", | |
| + | borderRadius: "2px", | |
| + | transform: open ? "translateY(6px) rotate(45deg)" : "none", | |
| + | transition: "transform 0.25s", | |
| + | }} | |
| /> | ||
| <span | ||
| - | className="absolute left-0 top-[5px] block h-0.5 w-5 bg-current transition-opacity" | |
| - | style={{ opacity: open ? 0 : 1 }} | |
| + | style={{ | |
| + | display: "block", | |
| + | width: "18px", | |
| + | height: "2px", | |
| + | background: "var(--dk-paper)", | |
| + | borderRadius: "2px", | |
| + | opacity: open ? 0 : 1, | |
| + | transition: "opacity 0.2s", | |
| + | }} | |
| /> | ||
| <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" }} | |
| + | style={{ | |
| + | display: "block", | |
| + | width: "18px", | |
| + | height: "2px", | |
| + | background: "var(--dk-paper)", | |
| + | borderRadius: "2px", | |
| + | transform: open ? "translateY(-6px) rotate(-45deg)" : "none", | |
| + | transition: "transform 0.25s", | |
| + | }} | |
| /> | ||
| - | </span> | |
| + | </> | |
| ); | ||
| } |
| @@ -0,0 +1,71 @@ | ||
| + | # Docket - Design V2 (ground-up, elder-first) - LOCKED 2026-06-27 | |
| + | ||
| + | The dark "forensic terminal" look is RETIRED as the design. New direction (Zion's pick): **"Large-print public service"** + **brass accent**. Built for an 80-year-old first; a high-schooler citing it second. Precise spec below - implement EXACTLY; do not reintroduce monospace, dense tables, dark default, or a 10-item nav. | |
| + | ||
| + | ## Non-negotiables (the failures to fix) | |
| + | - **NO monospace anywhere in the UI** (it reads techy/intimidating). Data figures use tabular-lining numerals in the UI sans, not a mono font. | |
| + | - **NO dense data tables** as the primary display. Lists are big, simple, one-record-per-row cards with large text. | |
| + | - **Default theme = light large-print** (this spec). Dark + Plain remain optional toggles only. | |
| + | - **Nav consolidated** from ~10 items to a few plain ones (below). | |
| + | - Plain language everywhere (≤8th-grade), plain dates ("June 26, 2026"), acronyms expanded. | |
| + | ||
| + | ## SOURCE THE AESTHETIC FROM ZION'S OWN SITES - never a generated/AI design pack | |
| + | The look must come from Zion's real, de-fingerprinted sites (zionboggan.com + treetrace.dev), NOT the ui-ux-pro-max pack, NOT Atkinson Hyperlegible / Public Sans / generic cards (that IS the fingerprint he's rejecting). Extracted from his live CSS: | |
| + | - **His type trio (use it):** **Archivo** (bold display + headings + uppercase labels) · **Fraunces** (serif - leads, long reading, optional headlines) · **JetBrains Mono** (DATA ONLY - tallies, bill numbers, dates; never prose/nav). | |
| + | - zionboggan.com = light, white/near-black + Tango palette (#204A87 #4E9A06 #8F5902 #A40000). treetrace.dev = dark ink #0B0C0E/#121316 + paper #F2EFE9 + cobalt #2E5BFF. | |
| + | ||
| + | ## Color - Docket default (light, his palette, neutral, AAA target) | |
| + | ``` | |
| + | --bg: #FFFFFF /* white page (max contrast / familiar) */ | |
| + | --surface: #F7F4ED /* his warm paper tone for cards/sections */ | |
| + | --text: #0B0C0E /* his ink near-black, ~18:1 on white */ | |
| + | --text-soft: #44423D /* secondary, >=7:1 */ | |
| + | --border: #D8D2C4 /* warm visible border */ | |
| + | --accent: #8F5902 /* HIS amber/ochre (Tango) - links + small text, ~6:1 on white; neutral, brass-adjacent */ | |
| + | --accent-strong: #CE5C00 /* his deeper ochre for large fills (dark text on it) */ | |
| + | --accent-wash: #FBF3DF /* faint amber for active/selected */ | |
| + | --focus: #0B0C0E /* 3px focus ring */ | |
| + | --yea: #4E7A06 /* his Tango green, slightly muted - PASS, always with a text label */ | |
| + | --nay: #A40000 /* his Tango red, used SMALL for FAIL only - never as a dominant/party color */ | |
| + | ``` | |
| + | Dark mode = his treetrace treatment (ink #0B0C0E/#121316 + cobalt #2E5BFF + paper text). Plain = max-contrast. Both stay as toggles; light is DEFAULT. | |
| + | ||
| + | ## Type - his trio (mono is DATA-ONLY, not banned, just constrained) | |
| + | - **Headings / display / labels:** **Archivo** (700-900, can be uppercase for kicker labels). | |
| + | - **Leads / long-form reading:** **Fraunces** (serif) - gives the credible "research publication" feel. | |
| + | - **Data tokens only:** **JetBrains Mono** for vote tallies, bill numbers, dollar amounts, dates (tabular). NOT for nav, body prose, or buttons. | |
| + | - **Sizes (elder-first scale):** body/lead **20px** (line-height 1.55-1.65); min text anywhere **16px**; H1 **34-42px**; H2 26-28px; H3 20-22px. | |
| + | - The elder-friendliness comes from SCALE + SIMPLICITY + WHITESPACE applied to HIS aesthetic - not from swapping his fonts for a generic legibility font. | |
| + | ||
| + | ## Layout | |
| + | - **Single column**, max reading width ~720px (text) / ~860px (lists); generous whitespace (section spacing 32-48px). | |
| + | - **Big tappable elements:** primary buttons/cards **>=56px** tall; all targets **>=44px**; clear 2px borders; obvious hover + 3px focus ring; links underlined. | |
| + | - **Breadcrumbs** on every sub-page (Home / Bills / H.R. 22) + a large **"← Back"** affordance. | |
| + | - One clear **primary action** per screen. | |
| + | - Reserve space for async content (no layout shift); skeletons for any server fetch (perceived speed). | |
| + | ||
| + | ## Navigation / IA (the "mapping" fix) | |
| + | - **Top bar (every page):** large **Docket** wordmark (links Home) · a prominent **Search** (big, labeled, always reachable) · a **Menu**. | |
| + | - **Primary nav - 6 plain items, big labels, active item clearly highlighted:** | |
| + | `Home` · `Bills` · `Votes` · `Representatives` · `Spending` · `States` | |
| + | (Rename: Members → **Representatives**. The Register feed = "What's happening", surfaced on Home. Calendar / Schedule / Executive / Federal Register move under Home cards or a clearly-labeled "More", NOT the primary bar.) | |
| + | - **Mobile:** a clearly-LABELED "Menu" button (text + icon, >=44px) opening a big, simple full-screen menu with large rows - not a tiny ambiguous hamburger. | |
| + | - **Mode toggle:** small, out of the way (e.g. in the menu/footer): Standard (this large-print, default) / Dark / Plain. | |
| + | ||
| + | ## Per-page pattern (apply to ALL) | |
| + | 1. Breadcrumb + big Back. | |
| + | 2. Big H1 page title + one plain-language sentence under it. | |
| + | 3. (List pages) prominent Search + a few simple Sort/Filter controls (big, labeled). | |
| + | 4. Content as **big simple cards** - each record shows its key facts in large text, the one most-important fact first (e.g. a vote: "H.R. 22 - [title]" big, then "Passed, 414-12" then date + Source). NO multi-column dense tables. | |
| + | 5. Source link (rule #1) + **Cite this** on records. | |
| + | - Home = welcoming hub: big headline, plain "what this is", a prominent Search, and 6-8 big cards to the sections + a "How to cite" note. | |
| + | ||
| + | ## Keep (don't lose) | |
| + | All data, neutrality gate (no adjectives, source on every fact, no comments, muted/never-saturated party colors), the Cite-this feature, search+sort, the perf/latency work (KEEP `revalidate`/ISR caching - do NOT reintroduce `force-dynamic`), responsive (no overflow at 390px). | |
| + | ||
| + | ## Build order (controlled - verify the LOOK before propagating) | |
| + | 1. Design system: tokens above + Public Sans/Atkinson fonts + set light large-print as DEFAULT (Dark/Plain stay toggles). New simplified header/nav + mobile menu. Breadcrumb + Back components. Big-card + big-button + big-search components. | |
| + | 2. Rebuild HOME + ONE list page (Bills) + ONE detail page (a bill) as the TEMPLATE in this language. | |
| + | 3. **STOP - screenshot home + the two template pages; overseer shows Zion for approval.** | |
| + | 4. After approval: propagate the pattern to every remaining page (Votes, Members→Representatives, Spending, States, Calendar, Executive, Search, etc.). | |
| + | 5. QA crawl CLEAN (no overflow/#418) + all 3 modes work + AAA contrast on default. |
| @@ -0,0 +1,49 @@ | ||
| + | # Docket - Design V3: MATCH ZION'S LIVE SITES (LOCKED 2026-06-27) | |
| + | ||
| + | The light V2 (Atkinson, then Archivo/Fraunces light + serif body) is REJECTED - it reads as a generic AI look. Replicate the theme of Zion's actual live sites. DO NOT invent or use any generated/AI design pack. | |
| + | ||
| + | ## GROUND TRUTH (study these, replicate them) | |
| + | - Reference screenshots (captured from the live sites): `_ops/qa-view/REF-treetrace-desktop.png`, `_ops/qa-view/REF-zionboggan-desktop.png`. | |
| + | - Their live CSS (curl from CT247 to read the real values): treetrace.dev `/assets/css/{tokens,shell,glass}.css`; zionboggan.com `/assets/css/{main,flare,sections}.css`. | |
| + | - What both share (the aesthetic to match): | |
| + | - **DARK** near-black backgrounds (ink ~#0B0C0E, raised surface ~#121316/#1A1C1F). | |
| + | - **Archivo 800-900, UPPERCASE, big, tight tracking** for display headlines (e.g. "RECORD THE PATH", "ZION BOGGAN"). | |
| + | - **JetBrains Mono, uppercase, tracked** for kicker labels, nav, tags, data, terminal cards. | |
| + | - A **serif** (Fraunces) for paragraph/body prose (warm, readable) - verify against their CSS and match their body role. | |
| + | - **ONE bold accent per view** (treetrace cobalt #2E5BFF, zionboggan red ~#A40000) - used sparingly (buttons, kickers, highlights), bordered/outline buttons. | |
| + | - Composition: small mono kicker → HUGE Archivo headline → serif paragraph → outline buttons + tag row. Generous whitespace; it BREATHES (do not crowd). | |
| + | ||
| + | ## Docket V3 tokens (his treetrace dark treatment + NEUTRAL accent) | |
| + | ``` | |
| + | --bg: #0B0C0E /* ink */ | |
| + | --surface: #121316 /* cards/chrome */ | |
| + | --raised: #1A1C20 /* hover/raised */ | |
| + | --text: #F2EFE9 /* warm paper */ | |
| + | --text-soft: #9BA0A6 /* ash secondary */ | |
| + | --border: rgba(155,160,166,.18) | |
| + | --accent: #E0A53B /* BRASS - neutral (cobalt/red are partisan-coded for a gov site); rationed, one signal per view */ | |
| + | --accent-dim:#B07F25 | |
| + | --accent-wash:rgba(224,165,59,.10) | |
| + | --yea: #7FA88C /* muted sage, labeled */ --nay: #B98A8A /* muted clay, labeled */ | |
| + | ``` | |
| + | Keep Light + Plain as optional toggles; **DEFAULT = this dark theme** (his look). | |
| + | ||
| + | ## Fonts (his trio - match his ROLES exactly) | |
| + | - Archivo (display, 800-900, uppercase headlines/labels). JetBrains Mono (kickers, nav, tags, data tokens - readable size >=14px, NOT tiny). Fraunces (serif paragraph/body prose, comfortable >=17px). Drop Atkinson/Public-Sans entirely. | |
| + | ||
| + | ## FIX what was wrong (within the dark aesthetic) | |
| + | 1. **Crowding** → generous whitespace + clear vertical rhythm; fewer elements per screen; let headlines breathe like his sites do. | |
| + | 2. **Legibility/size** → bigger than his dense portfolio: body >=17px, kicker/labels >=14px (not 11-12px), big tap targets >=44px. | |
| + | 3. **Navigation** → simple 6-item nav (Home · Bills · Votes · Representatives · Spending · States) + prominent Search; clear active state; clean mobile menu. Breadcrumbs + big Back. | |
| + | 4. **Responsive** → big simple cards (NOT dense tables); zero overflow at 390px (keep the responsive-card work). | |
| + | 5. **Plain language** → plain dates/words, expand acronyms. | |
| + | ||
| + | ## Keep (don't lose) | |
| + | All functional work: perf/ISR (no force-dynamic), search+sort+filters, Cite this, vote→bill display, state data, photos, neutrality gate (no adjectives, source on every fact, no comments, muted/never-saturated party colors). The federal-vs-state bill fix. | |
| + | ||
| + | ## Build order | |
| + | 1. Read his live CSS + the REF screenshots. Set the dark theme tokens + font roles to match. | |
| + | 2. Rebuild HOME + /bills + /bill/[id] as the TEMPLATE in his theme (dark, Archivo-uppercase, mono labels, brass, breathing whitespace, big text, simple nav). | |
| + | 3. STOP - screenshot (dark default) → overseer shows Zion for approval. | |
| + | 4. After approval: propagate to all pages + the federal/state bill distinction. | |
| + | 5. QA crawl CLEAN; purge Cloudflare cache so it goes live. |
| @@ -0,0 +1,156 @@ | ||
| + | # Docket - Frontend Notes | |
| + | ||
| + | ## Design Version History | |
| + | ||
| + | | Version | Date | Status | Description | | |
| + | |---------|------------|----------|-------------| | |
| + | | V1 | 2026-06 | Retired | Initial build | | |
| + | | V2 | 2026-06 | Rejected | Light/Atkinson/serif look - "AI fingerprint", crowded | | |
| + | | V3 | 2026-06-27 | CURRENT | Matches Zion's live sites (treetrace.dev + zionboggan.com) - dark ink, Archivo uppercase, brass accent | | |
| + | ||
| + | --- | |
| + | ||
| + | ## V3 Design System (LOCKED 2026-06-27) | |
| + | ||
| + | Source of truth: `docs/DESIGN_V3.md`. Do NOT deviate from these without owner approval. | |
| + | ||
| + | ### Color tokens (globals.css `:root`) | |
| + | ||
| + | | Token | Value | Role | | |
| + | |-------|-------|------| | |
| + | | `--dk-ink` | `#0B0C0E` | Page background (near-black, matches tt-ink) | | |
| + | | `--dk-surface` | `#121316` | Cards, chrome (tt-surface) | | |
| + | | `--dk-graphite` | `#1A1C20` | Raised / hover | | |
| + | | `--dk-paper` | `#F2EFE9` | Primary text (warm bone, not pure white) | | |
| + | | `--dk-ash` | `#9BA0A6` | Secondary text | | |
| + | | `--dk-ash-2` | `#6E7480` | Tertiary text | | |
| + | | `--dk-line` | `rgba(155,160,166,.18)` | Hairline borders | | |
| + | | `--dk-signal` | `#E0A53B` | Brass accent - RATIONED, one signal per view | | |
| + | | `--dk-signal-dim` | `#B07F25` | Hover/pressed brass | | |
| + | | `--dk-signal-tint` | `rgba(224,165,59,.10)` | Faint active wash | | |
| + | | `--dk-yea` | `#7FA88C` | Muted sage (always labeled) | | |
| + | | `--dk-nay` | `#B98A8A` | Muted clay (always labeled) | | |
| + | ||
| + | **Why brass instead of cobalt/red:** cobalt (#2E5BFF) is TreeTrace's brand identity; red is partisan-coded. Brass is neutral for a government site. | |
| + | ||
| + | ### Font trio (matching both live sites exactly) | |
| + | ||
| + | | Family | Role | CSS var | | |
| + | |--------|------|---------| | |
| + | | Archivo 800-900 | Display headlines, UPPERCASE - the anchor | `--font-display` | | |
| + | | JetBrains Mono | Kickers, nav, tags, data labels - >=14px | `--font-mono` | | |
| + | | Fraunces 400 (+ italic) | Paragraph prose - >=17px | `--font-prose` | | |
| + | ||
| + | Atkinson Hyperlegible and Public Sans are REMOVED entirely. | |
| + | ||
| + | ### Type scale | |
| + | - Hero h1: `clamp(2.8rem, 9vw, 5.5rem)`, weight 900, uppercase, tracking `-0.03em` | |
| + | - Page h1 (bills/detail): `clamp(2.2rem, 7vw, 3.75rem)`, same | |
| + | - Section h2: `clamp(1.2rem, 3.5vw, 1.5rem)`, uppercase | |
| + | - Labels/kickers (`.label` class): JetBrains Mono, 14px, uppercase, `0.12em` tracking | |
| + | - Lead paragraph: Fraunces italic, `clamp(1rem, 2vw, 1.25rem)`, `1.55` line-height | |
| + | - Body prose: Fraunces, 17-18px | |
| + | ||
| + | ### Composition pattern | |
| + | Every page section follows: | |
| + | 1. Small mono kicker (brass, 11-14px) - identifies the section | |
| + | 2. HUGE Archivo uppercase headline - the anchor | |
| + | 3. Fraunces italic lead paragraph - warm, readable | |
| + | 4. Outline/brass buttons + tags | |
| + | ||
| + | ### Whitespace | |
| + | Generous - matching his sites. Hero padding: `clamp(56px, 10vw, 120px)` top. Section gaps: `clamp(32px, 6vw, 56px)`. Cards: `clamp(20px, 3vw, 28px)` internal padding. | |
| + | ||
| + | --- | |
| + | ||
| + | ## Default Theme: DARK | |
| + | ||
| + | Changed in V3. Dark ink (#0B0C0E) is the DEFAULT for all new visitors. Light and Plain are optional toggles persisted in localStorage. | |
| + | ||
| + | - `layout.tsx` html class: no theme class = dark (:root tokens active) | |
| + | - Inline script default: `localStorage.getItem('docket-display') || 'dark'` | |
| + | - DisplayToggle order: Dark (default) → Light → Plain | |
| + | - `themeColor` meta: `#0B0C0E` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Navigation (site-header.tsx) | |
| + | ||
| + | Single-row fixed-height (64px) header with backdrop blur - matches treetrace.dev pattern: | |
| + | - Left: custody-mark seal + "Docket" wordmark (Archivo 800) | |
| + | - Center: 6 JetBrains Mono nav links, uppercase, 11.5px, 0.16em tracked | |
| + | - Right: Search button + Display toggle (Dark/Light/Plain) + hamburger (mobile only) | |
| + | ||
| + | Active state: 2px brass underline at bottom of link (`box-shadow`), no background fill. | |
| + | ||
| + | Mobile: slide-in panel from right (same pattern as tt-mobile), brass left-border on active item. | |
| + | ||
| + | **Do NOT go back to the two-row layout (wordmark+search row + nav row below).** | |
| + | ||
| + | --- | |
| + | ||
| + | ## Pages Built in V3 | |
| + | ||
| + | The V3 redesign covers the TEMPLATE pages. Propagate to other pages only after owner approval. | |
| + | ||
| + | | Page | File | Status | | |
| + | |------|------|--------| | |
| + | | Home | `app/page.tsx` | V3 complete | | |
| + | | Bills list | `app/bills/page.tsx` | V3 complete | | |
| + | | Bill detail | `app/bill/[id]/page.tsx` | V3 complete | | |
| + | | Site header | `components/site-header.tsx` | V3 complete | | |
| + | | Display toggle | `components/display-toggle.tsx` | V3 complete (dark default) | | |
| + | | All other pages | - | Pending owner approval | | |
| + | ||
| + | --- | |
| + | ||
| + | ## What Was Fixed (V2 → V3) | |
| + | ||
| + | 1. **Crowding** → Generous `clamp()` padding throughout; fewer elements per screen; headlines breathe like his sites | |
| + | 2. **Font size** → Body >=17px, labels >=14px (was 11px), hero h1 scales to 5.5rem | |
| + | 3. **Nav** → Single row, mono uppercase, brass active underline; no more two-row layout | |
| + | 4. **Accent** → Brass #E0A53B (neutral for gov); removed partisan cobalt and Atkinson/Public-Sans | |
| + | 5. **Mobile** → Slide-in panel (no fullscreen takeover); zero overflow at 390px confirmed | |
| + | 6. **Cards** → Clean border-radius 10px, 1px border (not 2px), more internal padding | |
| + | 7. **Default** → Dark is the default (not light); users can toggle | |
| + | ||
| + | --- | |
| + | ||
| + | ## QA | |
| + | ||
| + | Run from CT247: | |
| + | ```bash | |
| + | bash /shared/projects/docket/_ops/rebuild-qa.sh | |
| + | ``` | |
| + | Expected: "QA CLEAN - no overflow, no hydration errors across N routes" | |
| + | ||
| + | Screenshots (v3): | |
| + | ```bash | |
| + | cd /opt/docket && node /shared/projects/docket/_ops/screenshot-v3.js | |
| + | ``` | |
| + | Output: `_ops/qa-view/v3-{home,bills,bill}-{mobile,desktop}.png` | |
| + | ||
| + | --- | |
| + | ||
| + | ## Functional Work (Preserved from V2 - DO NOT LOSE) | |
| + | ||
| + | - ISR/revalidate - no force-dynamic (`revalidate = 300` on /bills, `revalidate = 3600` on home) | |
| + | - Search + sort + filters on /bills | |
| + | - Cite this (APA/MLA/Chicago) on bill detail pages | |
| + | - Vote → bill display links | |
| + | - State data | |
| + | - Member photos (MemberAvatar) | |
| + | - Neutrality gate: no adjectives, no editorial weighting, muted/equal-weight party colors | |
| + | - Federal vs. state bill distinction (see STATE_INGEST_FIX_NOTES.md) | |
| + | - Responsive cards - NOT tables (zero overflow at 390px) | |
| + | - Accessibility: skip-link, focus-visible ring, >=44px tap targets, color-not-alone, reduced-motion | |
| + | ||
| + | --- | |
| + | ||
| + | ## Build Gotchas | |
| + | ||
| + | - `services/` files contain data logic - edit those for data, not UI | |
| + | - `middleware.ts` - do not touch; it handles auth gating | |
| + | - `lib/feed.ts` + `lib/format.ts` - date/data utilities; no UI changes needed there | |
| + | - The site runs from `/opt/docket/` on CT247; shared source is `/shared/projects/docket/`; `rebuild-qa.sh` syncs and rebuilds | |
| + | - node_modules, .next, data, _ops/qa are NOT synced from share (they stay in /opt/docket) |
| @@ -40,7 +40,25 @@ const nextConfig = { | ||
| serverComponentsExternalPackages: ["better-sqlite3"], | ||
| }, | ||
| async headers() { | ||
| - | return [{ source: "/:path*", headers: securityHeaders }]; | |
| + | return [ | |
| + | // Security headers on every route. | |
| + | { source: "/:path*", headers: securityHeaders }, | |
| + | // Cache-Control for read-only GET API routes. | |
| + | // Docket is a public read-only site; 5-minute staleness is acceptable. | |
| + | // s-maxage lets the Cloudflare edge cache serve repeats without hitting | |
| + | // the origin over the tunnel. stale-while-revalidate gives CF a 10-min | |
| + | // window to serve a stale copy while the origin refreshes in the background. | |
| + | // Note: route handlers that set their own Cache-Control header override this. | |
| + | { | |
| + | source: "/api/:path*", | |
| + | headers: [ | |
| + | { | |
| + | key: "Cache-Control", | |
| + | value: "public, s-maxage=300, stale-while-revalidate=600", | |
| + | }, | |
| + | ], | |
| + | }, | |
| + | ]; | |
| }, | ||
| }; |