● Zion Boggan zionboggan.com ↗

Redesign R1: light Reference theme default (Atkinson Hyperlegible, high-contrast, brass), header search + relabeled toggle, register declutter, states '50 states' + DC/territories separate

8fe6067   Zion Boggan committed on Jun 27, 2026 (1 month ago)
FRONTEND_NOTES.md +33 -1
@@ -1,10 +1,42 @@
# Docket - Frontend Notes
-Wave F2 + F3 + F4 + F5 build log. All surfaces consume the live read-API
+Wave F2 + F3 + F4 + F5 + R1 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.
+## R1 (this wave) - UX/QoL redesign foundation (Claude)
+
+### Summary
+Wave R1 delivers the four foundation changes from docs/REDESIGN.md. QA CLEAN (17 routes, zero overflow, zero hydration errors).
+
+### 1. Default theme: Reference (light) replaces Dark
+- `app/layout.tsx`: initial `<html>` class changed from `dark` → `light`; inline pre-paint script defaults to `'light'` instead of `'dark'`; added Atkinson Hyperlegible via `next/font/google` as `--font-body` CSS variable.
+- `app/globals.css`: `:root.light` token values updated to spec (docs/REDESIGN.md §"Theme system"):
+ - bg `#F8FAFC`, surface `#FFFFFF`, text `#0F172A`, muted `#475569`, border `#E2E8F0`
+ - body-size bumped to **18px** (was 16px), leading to **1.6** (was 1.5) - ACCESSIBILITY.md Rule #2
+ - Added `:root.light body { font-family: var(--font-body), ... }` - Atkinson Hyperlegible in Reference mode; `.label`/`.pill`/`.font-mono` classes still apply JetBrains Mono explicitly for data tokens.
+ - Muted semantic tokens (`--dk-yea/nay/hold`) adjusted for AA on light bg.
+- `tailwind.config.ts`: added `font-body` alias for `var(--font-body)`.
+- Three-mode toggle still works; persisted in localStorage. Dark mode and Plain mode are unchanged.
+
+### 2. Toggle relabeled: Reference / Dark / Plain
+- `components/display-toggle.tsx`: mode order changed to Reference first, labels updated ("Readable" → "Reference"), default state and localStorage fallback both changed from `"dark"` to `"light"`.
+
+### 3. Header: global search icon added
+- `components/site-header.tsx`: added a search icon `<Link href="/search">` in the header logo row, visible on all viewports. Inline SVG, `min-h-[44px] min-w-[44px]` (a11y Rule #3). The display toggle and hamburger are unchanged.
+
+### 4. Register home decluttered
+- `app/register/page.tsx`: plain-language explainer paragraph added above the feed table (≤8th-grade, active voice, source stated). The four secondary cards (composition by type, awards total, recently-changed, browse by topic) moved into a `<details>` / `<summary>` collapsed block labeled "More: composition, awards & recently changed". The ranked feed is now the single primary focus.
+
+### 5. States page: 50 states vs DC & territories
+- `app/states/page.tsx`: introduced `NON_STATE_CODES = Set(["DC","PR","AS","GU","MP","VI"])`. States split into two separate `DataTable` sections: "50 states" (never produces a "51" count) and "DC & U.S. territories · not states". The DC section copy explicitly states "DC is a federal district" and names each territory. Coverage summary cards label "states" not "jurisdictions". The "51 states" count can never appear.
+
+### Out of scope (R2)
+Per-page search/filter bars and the "Cite this" component are R2. Stubs may be added later.
+
+---
+
## F5 (this wave) - mobile-responsive overhaul (GLM start / Claude finish)
### Zero-overflow guarantee at 390px
_ops/qa-view/members.png +0 -0
Binary file not shown
_ops/qa-view/register.png +0 -0
Binary file not shown
_ops/qa-view/states.png +0 -0
Binary file not shown
app/globals.css +42 -12
@@ -46,21 +46,41 @@
color-scheme: dark;
}
-/* Light mode - parchment, reads like an official document. */
+/*
+ * 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).
+ */
:root.light {
- --dk-ink: #f6f4ef; /* parchment bg */
- --dk-surface: #ffffff;
- --dk-graphite: #ece9e2;
- --dk-paper: #0b0c0e; /* ink text */
- --dk-ash: #565b62;
- --dk-ash-2: #868c94;
- --dk-line: rgba(11, 12, 14, 0.16);
- --dk-signal: #b07f25; /* brass darkens for contrast */
+ --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);
- /* a11y #2: light body >=16px, line-height 1.5 */
- --dk-body-size: 16px;
- --dk-leading: 1.5;
+
+ /* Muted semantic tokens - same hues, readable on light bg */
+ --dk-yea: #3d7a56;
+ --dk-nay: #8b4040;
+ --dk-hold: #8a6520;
+
+ --dk-hero-glow: radial-gradient(
+ 60% 80% at 50% -10%,
+ rgba(176, 127, 37, 0.06) 0%,
+ rgba(176, 127, 37, 0) 70%
+ );
+
+ /* a11y #2: Reference body ≥18px, line-height 1.6 */
+ --dk-body-size: 18px;
+ --dk-leading: 1.6;
color-scheme: light;
}
@@ -106,12 +126,22 @@
body {
background-color: var(--dk-ink);
color: var(--dk-paper);
+ /* Dark + Plain modes: forensic mono body. Reference (light): see override below. */
font-family: var(--font-mono), ui-monospace, monospace;
font-size: var(--dk-body-size);
line-height: var(--dk-leading);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
+
+ /*
+ * 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.
+ */
+ :root.light body {
+ font-family: var(--font-body), "Atkinson Hyperlegible", system-ui, sans-serif;
+ }
::selection {
background: var(--dk-signal-tint);
color: var(--dk-paper);
app/layout.tsx +17 -6
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
-import { Archivo, JetBrains_Mono, Fraunces } from "next/font/google";
+import { Archivo, JetBrains_Mono, Fraunces, Atkinson_Hyperlegible } from "next/font/google";
import "./globals.css";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
@@ -26,6 +26,15 @@ 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",
@@ -41,7 +50,8 @@ export const metadata: Metadata = {
};
export const viewport: Viewport = {
- themeColor: "#0B0C0E",
+ // Light-mode reference bg; the inline script may change this per-user.
+ themeColor: "#F8FAFC",
width: "device-width",
initialScale: 1,
};
@@ -54,19 +64,20 @@ export default function RootLayout({
return (
<html
lang="en"
- className={`${archivo.variable} ${jetbrains.variable} ${fraunces.variable} dark`}
+ className={`${archivo.variable} ${jetbrains.variable} ${fraunces.variable} ${atkinson.variable} light`}
suppressHydrationWarning
>
<head>
{/*
Apply the persisted display mode before paint (a11y §"Three display
- modes"). Runs synchronously in <head> so the first frame is already
- the user's chosen mode - no flash, and suppressHydrationWarning lets
+ 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.
*/}
<script
dangerouslySetInnerHTML={{
- __html: `(function(){try{var m=localStorage.getItem('docket-display')||'dark';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')||'light';var c=document.documentElement.classList;c.remove('dark','light','plain');c.add(m);document.documentElement.dataset.display=m;}catch(e){}})();`,
}}
/>
</head>
app/register/page.tsx +114 -92
@@ -51,7 +51,7 @@ export default async function RegisterPage() {
.filter((c) => c.total > 0)
.sort((a, b) => b.total - a.total)
.slice(0, 8);
- // Compute byKind once - it's used twice in the JSX below.
+ // Compute byKind once - it's used in the details section below.
const kindBreakdown = byKind(items);
return (
@@ -59,10 +59,20 @@ export default async function RegisterPage() {
<RegisterHero seed={shown.slice(0, 12)} />
<div className="mx-auto w-full max-w-register px-4 py-12 sm:px-6">
+ {/*
+ Plain-language explainer - ≤8th-grade reading level (ACCESSIBILITY.md §6).
+ One sentence. Active voice. No adjectives. Sourcing stated.
+ */}
+ <p className="prose-lead mb-8 max-w-2xl text-ash">
+ Every bill, vote, and federal award is listed below, drawn from
+ Congress.gov, the Federal Register, and USAspending.gov. Ranked by
+ date - never by clicks or editorial judgment. Every entry links to
+ its official source.
+ </p>
+
<SectionHeader
eyebrow="Full register · ranked"
title="Every official action, in order"
- description="The complete record below is the same stream as the live ledger, ranked by action date and dollar magnitude - never by coverage or clicks."
/>
{items.length === 0 ? (
@@ -77,102 +87,114 @@ export default async function RegisterPage() {
<RegisterTable items={shown} />
)}
- {/* Composition by kind - proportional to official-action volume (rule #3). */}
- <div className="mt-12 grid gap-6 sm:grid-cols-2">
- <Card className="p-5">
- <p className="label mb-4">Composition by action type</p>
- <ul className="space-y-2.5">
- {kindBreakdown.map((a) => {
- const max = Math.max(1, ...kindBreakdown.map((x) => x.count));
- return (
- <li key={a.kind} className="font-mono text-[12px]">
- <div className="flex items-center justify-between">
- <span className="text-ash">{ACTION_LABEL[a.kind]}</span>
- <span className="tabular-nums text-paper">{a.count}</span>
- </div>
- <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-ink">
- <span
- className="block h-full rounded-full"
- style={{
- width: `${(a.count / max) * 100}%`,
- backgroundColor: "var(--dk-ash)",
- }}
- />
- </div>
- </li>
- );
- })}
- </ul>
- <p className="label mt-4 text-ash-2">
- Proportional to action volume. No editorial weighting.
- </p>
- </Card>
+ {/*
+ Secondary details - collapsed by default so the feed is the primary
+ focus (docs/REDESIGN.md §"Register (home)").
+ Composition chart, awards, recently-changed, and topic chips are
+ available here for users who want them.
+ */}
+ <details className="group mt-12">
+ <summary className="focus-ring label inline-flex cursor-pointer list-none items-center gap-2 rounded-[3px] border border-line bg-surface px-4 py-3 text-ash hover:text-paper">
+ <span aria-hidden="true" className="transition-transform group-open:rotate-90">▶</span>
+ More: composition, awards &amp; recently changed
+ </summary>
- <Card className="p-5">
- <p className="label mb-4">Awards, on record</p>
- <p className="display text-3xl text-paper">
- {formatUsd(awardStats.total, { compact: true })}
- </p>
- <p className="prose-lead mt-2 text-ash">
- {awardStats.count} federal awards from USAspending.gov, summed across
- the record. Figures only - no characterization.
- </p>
- <Link
- href="/spending"
- className="focus-ring tap label mt-5 gap-1 text-ash underline underline-offset-4 hover:text-paper"
- >
- Spending register →
- </Link>
- </Card>
- </div>
+ <div className="mt-6 grid gap-6 sm:grid-cols-2">
+ {/* Composition by kind - proportional to official-action volume. */}
+ <Card className="p-5">
+ <p className="label mb-4">Composition by action type</p>
+ <ul className="space-y-2.5">
+ {kindBreakdown.map((a) => {
+ const max = Math.max(1, ...kindBreakdown.map((x) => x.count));
+ return (
+ <li key={a.kind} className="font-mono text-[12px]">
+ <div className="flex items-center justify-between">
+ <span className="text-ash">{ACTION_LABEL[a.kind]}</span>
+ <span className="tabular-nums text-paper">{a.count}</span>
+ </div>
+ <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-ink">
+ <span
+ className="block h-full rounded-full"
+ style={{
+ width: `${(a.count / max) * 100}%`,
+ backgroundColor: "var(--dk-ash)",
+ }}
+ />
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ <p className="label mt-4 text-ash-2">
+ Proportional to action volume. No editorial weighting.
+ </p>
+ </Card>
- {/* Recently changed + browse by topic */}
- <div className="mt-12 grid gap-6 sm:grid-cols-2">
- <Card className="p-5">
- <div className="mb-3 flex items-baseline justify-between">
- <p className="label">Recently changed</p>
+ <Card className="p-5">
+ <p className="label mb-4">Awards, on record</p>
+ <p className="display text-3xl text-paper">
+ {formatUsd(awardStats.total, { compact: true })}
+ </p>
+ <p className="prose-lead mt-2 text-ash">
+ {awardStats.count} federal awards from USAspending.gov, summed across
+ the record. Figures only - no characterization.
+ </p>
<Link
- href="/calendar"
- className="focus-ring tap label gap-1 text-ash underline underline-offset-4 hover:text-paper"
+ href="/spending"
+ className="focus-ring tap label mt-5 gap-1 text-ash underline underline-offset-4 hover:text-paper"
>
- Calendar →
+ Spending register →
</Link>
- </div>
- <ChangesFeed items={changes} />
- <p className="label mt-4 text-ash-2">
- What the ingest added, updated, or removed. Each entry links to its
- source.
- </p>
- </Card>
+ </Card>
+ </div>
- <Card className="p-5">
- <div className="mb-3 flex items-baseline justify-between">
- <p className="label">Browse by topic</p>
- <Link
- href="/categories"
- className="focus-ring tap label gap-1 text-ash underline underline-offset-4 hover:text-paper"
- >
- All topics →
- </Link>
- </div>
- <ul className="flex flex-wrap gap-1.5">
- {topTopics.map((c) => (
- <li key={c.slug}>
- <Link
- href={`/bills?policyArea=${encodeURIComponent(c.label)}`}
- className="focus-ring label inline-flex min-h-[44px] items-center gap-2 rounded-[3px] border border-line px-2.5 text-ash hover:text-paper"
- >
- <span className="text-paper">{c.label}</span>
- <span className="text-ash-2">{c.total}</span>
- </Link>
- </li>
- ))}
- </ul>
- <p className="label mt-4 text-ash-2">
- Bills and awards grouped by policy area. Counts only - no weighting.
- </p>
- </Card>
- </div>
+ <div className="mt-6 grid gap-6 sm:grid-cols-2">
+ <Card className="p-5">
+ <div className="mb-3 flex items-baseline justify-between">
+ <p className="label">Recently changed</p>
+ <Link
+ href="/calendar"
+ className="focus-ring tap label gap-1 text-ash underline underline-offset-4 hover:text-paper"
+ >
+ Calendar →
+ </Link>
+ </div>
+ <ChangesFeed items={changes} />
+ <p className="label mt-4 text-ash-2">
+ What the ingest added, updated, or removed. Each entry links to its
+ source.
+ </p>
+ </Card>
+
+ <Card className="p-5">
+ <div className="mb-3 flex items-baseline justify-between">
+ <p className="label">Browse by topic</p>
+ <Link
+ href="/categories"
+ className="focus-ring tap label gap-1 text-ash underline underline-offset-4 hover:text-paper"
+ >
+ All topics →
+ </Link>
+ </div>
+ <ul className="flex flex-wrap gap-1.5">
+ {topTopics.map((c) => (
+ <li key={c.slug}>
+ <Link
+ href={`/bills?policyArea=${encodeURIComponent(c.label)}`}
+ className="focus-ring label inline-flex min-h-[44px] items-center gap-2 rounded-[3px] border border-line px-2.5 text-ash hover:text-paper"
+ >
+ <span className="text-paper">{c.label}</span>
+ <span className="text-ash-2">{c.total}</span>
+ </Link>
+ </li>
+ ))}
+ </ul>
+ <p className="label mt-4 text-ash-2">
+ Bills and awards grouped by policy area. Counts only - no weighting.
+ </p>
+ </Card>
+ </div>
+ </details>
</div>
</>
);
app/states/page.tsx +64 -25
@@ -32,23 +32,23 @@ function coverageMeta(c: StateCoverage["confidence"]): {
return { label: "Coverage rolling out", color: "var(--dk-ash)" };
}
-export default function StatesPage() {
- const states = getStates();
- const ok = states.filter((s) => s.confidence === "ok").length;
- const stale = states.filter((s) => s.confidence === "stale").length;
- const rolling = states.filter((s) => s.confidence === "missing").length;
-
- // 50 states + DC + territories (PR, AS, GU, MP, VI). Shown honestly as
- // jurisdictions, never as a bogus "56 states".
- const territories = states.length - 50;
+/**
+ * DC is a federal district; PR/AS/GU/MP/VI are U.S. territories.
+ * None of them are states. Separating them avoids any "51 states" count.
+ */
+const NON_STATE_CODES = new Set(["DC", "PR", "AS", "GU", "MP", "VI"]);
- const columns: Column[] = [
- { key: "state", header: "State", cardTitle: true },
+function buildColumns(): Column[] {
+ return [
+ { key: "state", header: "Name", cardTitle: true },
{ key: "confidence", header: "Coverage" },
{ key: "seen", header: "Last seen", hideOnCard: true },
{ key: "missing", header: "Not yet in record" },
];
- const rows: Row[] = states.map((s) => {
+}
+
+function buildRows(entries: StateCoverage[]): Row[] {
+ return entries.map((s) => {
const m = coverageMeta(s.confidence);
return {
key: s.code,
@@ -85,6 +85,21 @@ export default function StatesPage() {
},
};
});
+}
+
+export default function StatesPage() {
+ const allJurisdictions = getStates();
+
+ // 50 states - never DC or territories (never produces a "51 states" count).
+ const states50 = allJurisdictions.filter((s) => !NON_STATE_CODES.has(s.code));
+ // DC (federal district) + 5 U.S. territories - clearly not states.
+ const dcAndTerritories = allJurisdictions.filter((s) => NON_STATE_CODES.has(s.code));
+
+ const ok = states50.filter((s) => s.confidence === "ok").length;
+ const stale = states50.filter((s) => s.confidence === "stale").length;
+ const rolling = states50.filter((s) => s.confidence === "missing").length;
+
+ const columns = buildColumns();
return (
<div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6">
@@ -94,16 +109,21 @@ export default function StatesPage() {
lead="Federal delegation and sponsored bills are on record for every state, DC, and U.S. territory. State-level legislative coverage is rolling out - gaps are recorded here, not hidden, and never imply the site is broken."
/>
- {/* Coverage summary - muted, calm. "Rolling out" uses an ash tone, not red. */}
+ {/* Coverage summary - 50 states only; DC and territories listed separately below. */}
<div className="mt-8 grid gap-4 sm:grid-cols-3">
<CoverageCard label="Current" count={ok} level="ok" />
<CoverageCard label="Stale" count={stale} level="stale" />
<CoverageCard label="Coverage rolling out" count={rolling} level="missing" />
</div>
+ <p className="label mt-3 text-ash-2">
+ Coverage counts above apply to the 50 states. DC and U.S. territories are listed
+ in their own section below.
+ </p>
+ {/* 50 states table */}
<div className="mt-12">
<div className="mb-6">
- <p className="label mb-2">Per-state · {states.length} jurisdictions</p>
+ <p className="label mb-2">50 states · federal delegation on record</p>
<h2
className="display text-paper"
style={{ fontSize: "clamp(1.25rem, 4vw, 1.875rem)" }}
@@ -111,24 +131,43 @@ export default function StatesPage() {
Coverage by state
</h2>
<p className="prose-lead mt-3 max-w-2xl text-ash">
- Each jurisdiction&apos;s worst-case state-legislative coverage across bills, votes,
- and sponsors. Federal delegation and bill sponsorships (from congress.gov) are
- shown regardless. Open a state for its congressional delegation.
+ Each state&apos;s worst-case state-legislative coverage across bills, votes,
+ and sponsors. Federal delegation and bill sponsorships (from congress.gov)
+ are on record regardless. Open a state for its congressional delegation.
</p>
</div>
<DataTable
- caption="Per-state coverage: state, coverage level, last scrape, data not yet in record."
+ caption="Per-state coverage: name, coverage level, last scrape, data not yet in record."
cardLabel="Per-state coverage"
columns={columns}
- rows={rows}
+ rows={buildRows(states50)}
/>
+ </div>
- <p className="label mt-4 text-ash-2">
- {territories} of these jurisdictions are DC &amp; U.S. territories. State
- legislative coverage requires per-state polling and rolls out over time; the
- federal delegation is on record now.
- </p>
+ {/* DC & territories - separate section, clearly labeled as NOT states */}
+ <div className="mt-14">
+ <div className="mb-6">
+ <p className="label mb-2">DC &amp; U.S. territories · not states</p>
+ <h2
+ className="display text-paper"
+ style={{ fontSize: "clamp(1.25rem, 4vw, 1.875rem)" }}
+ >
+ District of Columbia &amp; territories
+ </h2>
+ <p className="prose-lead mt-3 max-w-2xl text-ash">
+ DC is a federal district. Puerto Rico, American Samoa, Guam, the Northern
+ Mariana Islands, and the U.S. Virgin Islands are U.S. territories. None are
+ states. Congressional delegation and federal data are included in the record.
+ </p>
+ </div>
+
+ <DataTable
+ caption="DC and U.S. territory coverage: name, coverage level, last scrape, data not yet in record."
+ cardLabel="DC and territory coverage"
+ columns={columns}
+ rows={buildRows(dcAndTerritories)}
+ />
</div>
</div>
);
@@ -154,7 +193,7 @@ function CoverageCard({
>
{count}
</span>
- <span className="label text-ash-2">jurisdictions</span>
+ <span className="label text-ash-2">states</span>
</div>
<span
className={clsx("mt-3 inline-block h-0.5 w-12 rounded-full")}
components/display-toggle.tsx +10 -10
@@ -5,27 +5,28 @@ import { clsx } from "@/lib/cx";
/**
* 3-mode display toggle (docs/ACCESSIBILITY.md §"Three display modes").
- * Dark (default forensic brand) / Readable Light / Plain (high-contrast,
- * system sans, >=20px). Persisted in localStorage("docket-display"); the
+ * 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.
*
- * Always-visible, labelled, >=44x44px hit targets (a11y rules #3 & #8).
- * Fully keyboard-operable (native radio group).
+ * Default is now "light" (Reference) per docs/REDESIGN.md §"Theme system".
+ * 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: "dark", label: "Dark", hint: "Default register look" },
- { value: "light", label: "Readable", hint: "Light, for reading" },
- { value: "plain", label: "Plain", hint: "High contrast, large text" },
+ { 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" },
];
export function DisplayToggle() {
- const [mode, setMode] = useState<DisplayMode>("dark");
+ const [mode, setMode] = useState<DisplayMode>("light");
useEffect(() => {
- const current = (localStorage.getItem("docket-display") as DisplayMode) || "dark";
+ const current = (localStorage.getItem("docket-display") as DisplayMode) || "light";
if (current !== mode) setMode(current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -36,7 +37,6 @@ export function DisplayToggle() {
const el = document.documentElement;
el.classList.remove("dark", "light", "plain");
el.classList.add(next);
- // keep the brand's font CSS variables in sync for plain mode
el.dataset.display = next;
}
components/site-header.tsx +28 -0
@@ -80,6 +80,15 @@ export function SiteHeader() {
</Link>
<div className="flex items-center gap-2">
+ {/* Global search shortcut - visible on all viewports. */}
+ <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"
+ >
+ <SearchIcon />
+ </Link>
<DisplayToggle />
{/* Hamburger - page nav only (<md). */}
<button
@@ -162,6 +171,25 @@ export function SiteHeader() {
);
}
+function SearchIcon() {
+ return (
+ <svg
+ aria-hidden="true"
+ width="16"
+ height="16"
+ viewBox="0 0 16 16"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <circle cx="6.5" cy="6.5" r="4.5" />
+ <line x1="10.5" y1="10.5" x2="14" y2="14" />
+ </svg>
+ );
+}
+
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.
tailwind.config.ts +2 -0
@@ -44,6 +44,8 @@ const config: Config = {
display: ["var(--font-display)", "system-ui", "sans-serif"],
mono: ["var(--font-mono)", "ui-monospace", "monospace"],
prose: ["var(--font-prose)", "Georgia", "serif"],
+ // Atkinson Hyperlegible - body font for Reference (light) mode.
+ body: ["var(--font-body)", "system-ui", "sans-serif"],
},
letterSpacing: {
tightest: "-0.04em",