| @@ -1,6 +1,54 @@ | ||
| # Docket - Frontend Notes | ||
| - | Wave F2 + F3 + F4 + F5 + R1 + R2 + R3 + V2 build log. All surfaces consume the live read-API | |
| + | Wave F2 + F3 + F4 + F5 + R1 + R2 + R3 + V2 + V3b build log. | |
| + | ||
| + | ## V3b (2026-06-27) - Two-mode night/day switcher + Plain removed (Claude) | |
| + | ||
| + | ### Summary | |
| + | Simplified the theme system: Plain mode removed, 3-way text toggle replaced by a clean | |
| + | moon/sun icon button. Light mode refreshed to be a true day counterpart of the dark V3 | |
| + | ink aesthetic - same fonts, same composition, warm paper palette. | |
| + | ||
| + | ### Changes | |
| + | ||
| + | **`components/display-toggle.tsx`** - complete rewrite | |
| + | - Removed "Plain" mode entirely; `DisplayMode` type is now `"dark" | "light"` only. | |
| + | - Replaced the text segmented control (Dark / Light / Plain) with a single icon button. | |
| + | - Icon pattern: **moon shown in dark mode** (click = switch to light); **sun shown in light mode** (click = switch to dark). | |
| + | - `aria-label` describes the action: "Switch to light mode" / "Switch to dark mode". | |
| + | - 44×44px touch target, `focus-ring` class, visible focus outline. | |
| + | - Hydration-safe: SSR renders dark default; `useEffect` reads `localStorage`. Button | |
| + | fades in after mount (`opacity: 0 → 1`) to suppress any icon-flicker. | |
| + | - Legacy `"plain"` in `localStorage` is coerced to `"dark"` on first read. | |
| + | ||
| + | **`app/globals.css`** | |
| + | - Removed `:root.plain { ... }` token block entirely. | |
| + | - Removed `:root.plain` font overrides in `@layer base`. | |
| + | - Removed `:root.plain :focus-visible` outline override in `@layer a11y`. | |
| + | - Removed `:root.light body` font override (Archivo body) - light mode now uses the | |
| + | same JetBrains Mono body as dark mode (day version of same theme, same font roles). | |
| + | - Removed `:root.light .pill` Archivo override - pills stay mono in both modes. | |
| + | - Light mode `--dk-body-size` set to `18px` (was 20px "elder-first") - matches dark. | |
| + | - Light mode `--dk-signal-dim` tightened to `#6b4100` (was `#ce5c00`) for stronger | |
| + | contrast on fills. | |
| + | - Light mode `--dk-yea`/`--dk-nay` darkened slightly for better AA on white: | |
| + | `#3d6b00` (yea) / `#8a1c1c` (nay). | |
| + | - Comment block updated: marks Plain as removed, notes contrast values. | |
| + | ||
| + | **Contrast verification (light mode on white `#ffffff`)** | |
| + | - Text `#0B0C0E`: ~18:1 - well above AAA | |
| + | - Secondary `#44423D`: ~10:1 - AAA | |
| + | - Brass accent `#8F5902`: ~5.8:1 - passes AA (>=4.5:1 required) | |
| + | - Borders/inactive ash `#6B6860`: used for non-text decorative only | |
| + | ||
| + | **`app/layout.tsx`** | |
| + | - Pre-paint inline script updated: only handles `"dark"` / `"light"`. | |
| + | Any stored `"plain"` falls back to `"dark"`. Removes `'plain'` from | |
| + | `classList.remove()` call (kept for safety during the storage coercion window). | |
| + | ||
| + | ### What did NOT change | |
| + | All functional work, V3 dark tokens, fonts (Archivo/JetBrains Mono/Fraunces), nav, | |
| + | all page data surfaces, and QA gate. Dark mode is identical to V3. 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. |
| @@ -0,0 +1,49 @@ | ||
| + | // Docket V3b screenshots - dark + light mode, home page, header switcher visible. | |
| + | // Outputs: v3b-home-dark.png and v3b-home-light.png to qa-view/ | |
| + | 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 }); | |
| + | ||
| + | (async () => { | |
| + | const browser = await chromium.launch(); | |
| + | ||
| + | const modes = [ | |
| + | { key: "dark", storage: "dark", colorScheme: "dark" }, | |
| + | { key: "light", storage: "light", colorScheme: "light" }, | |
| + | ]; | |
| + | ||
| + | for (const m of modes) { | |
| + | const ctx = await browser.newContext({ | |
| + | viewport: { width: 1280, height: 900 }, | |
| + | deviceScaleFactor: 1, | |
| + | isMobile: false, | |
| + | colorScheme: m.colorScheme, | |
| + | }); | |
| + | const page = await ctx.newPage(); | |
| + | ||
| + | // Set the persisted mode so the pre-paint script and React both see the right value | |
| + | await page.addInitScript((mode) => { | |
| + | localStorage.setItem("docket-display", mode); | |
| + | }, m.storage); | |
| + | ||
| + | try { | |
| + | await page.goto(BASE + "/", { waitUntil: "networkidle", timeout: 35000 }); | |
| + | // Wait for fonts, transitions, and the icon switcher to mount | |
| + | await page.waitForTimeout(1500); | |
| + | const outPath = path.join(OUT, `v3b-home-${m.key}.png`); | |
| + | await page.screenshot({ path: outPath, fullPage: false }); | |
| + | console.log("OK ", outPath); | |
| + | } catch (e) { | |
| + | console.error("FAIL", m.key + ":", e.message); | |
| + | } | |
| + | ||
| + | await ctx.close(); | |
| + | } | |
| + | ||
| + | await browser.close(); | |
| + | console.log("\nV3b screenshots saved to", OUT); | |
| + | })(); |
| @@ -4,9 +4,9 @@ | ||
| /* | ||
| * 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 (default): Zion's ink treatment, brass accent (docs/DESIGN_V3.md). | |
| + | * Light: warm paper day version - same fonts/composition, light counterpart. | |
| + | * Plain mode removed (V3b, 2026-06-27). | |
| */ | ||
| /* ------------------------------------------------------------------ */ | ||
| @@ -50,29 +50,31 @@ | ||
| } | ||
| /* ------------------------------------------------------------------ */ | ||
| - | /* LIGHT MODE - Zion's warm palette (default display for new visitors) */ | |
| - | /* docs/DESIGN_V2.md §Color + §Type */ | |
| + | /* LIGHT MODE - day counterpart of the V3 dark ink theme */ | |
| + | /* Same fonts, composition, and brass accent - warm paper palette. */ | |
| + | /* docs/DESIGN_V3.md: white page, ink text, darkened brass (#8F5902) */ | |
| + | /* Contrast: text #0B0C0E on white ~18:1; brass #8F5902 on white ~5.8:1 (AA pass) */ | |
| /* ------------------------------------------------------------------ */ | ||
| :root.light { | ||
| --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: #44423d; /* secondary text, >=7:1 on white */ | |
| --dk-ash-2: #6b6860; /* tertiary text */ | ||
| --dk-line: #d8d2c4; /* warm visible border */ | ||
| - | /* accent - his amber/ochre (Tango), ~6:1 on white */ | |
| + | /* accent - brass darkened to #8F5902 for AA on light (5.8:1 on white) */ | |
| --dk-signal: #8f5902; | ||
| - | --dk-signal-dim: #ce5c00; /* accent-strong - large fills */ | |
| + | --dk-signal-dim: #6b4100; /* accent-strong - large fills, extra dark */ | |
| --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 */ | |
| + | /* semantic - muted, always with label (neutrality) */ | |
| + | --dk-yea: #3d6b00; /* pass, always with label */ | |
| + | --dk-nay: #8a1c1c; /* fail, small only */ | |
| --dk-hold: #8f5902; /* stalled/pending */ | ||
| - | /* party tones - desaturated, equal weight */ | |
| + | /* party tones - desaturated, equal visual weight */ | |
| --dk-party-d: #5a7499; | ||
| --dk-party-r: #996355; | ||
| --dk-party-i: #7a7f86; | ||
| @@ -83,41 +85,12 @@ | ||
| rgba(143, 89, 2, 0) 70% | ||
| ); | ||
| - | /* elder-first: 20px body, generous leading */ | |
| - | --dk-body-size: 20px; | |
| + | /* Match dark mode body size - day version, same rhythm */ | |
| + | --dk-body-size: 18px; | |
| --dk-leading: 1.6; | ||
| color-scheme: light; | ||
| } | ||
| - | /* ------------------------------------------------------------------ */ | |
| - | /* PLAIN MODE - max-contrast (ACCESSIBILITY.md §3) */ | |
| - | /* ------------------------------------------------------------------ */ | |
| - | :root.plain { | |
| - | --dk-ink: #ffffcc; | |
| - | --dk-surface: #ffffe0; | |
| - | --dk-graphite: #f2f2b8; | |
| - | --dk-paper: #000000; | |
| - | --dk-ash: #1a1a1a; | |
| - | --dk-ash-2: #333333; | |
| - | --dk-line: rgba(0, 0, 0, 0.45); | |
| - | ||
| - | --dk-signal: #000000; | |
| - | --dk-signal-dim: #000000; | |
| - | --dk-signal-tint: rgba(0, 0, 0, 0.06); | |
| - | ||
| - | --dk-yea: #1a1a1a; | |
| - | --dk-nay: #1a1a1a; | |
| - | --dk-hold: #1a1a1a; | |
| - | --dk-party-d: #1a1a1a; | |
| - | --dk-party-r: #1a1a1a; | |
| - | --dk-party-i: #333333; | |
| - | ||
| - | --dk-hero-glow: none; | |
| - | ||
| - | --dk-body-size: 20px; | |
| - | --dk-leading: 1.6; | |
| - | color-scheme: light; | |
| - | } | |
| @layer base { | ||
| html { | ||
| @@ -135,34 +108,17 @@ | ||
| } | ||
| /* | ||
| - | * 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) | |
| + | * Light mode: same JetBrains Mono body as dark - it's the day version of | |
| + | * the same theme. Font roles are identical: Archivo for display headlines, | |
| + | * JetBrains Mono for labels/data/body, Fraunces for prose leads. | |
| + | * (docs/DESIGN_V3.md §Fonts) | |
| */ | ||
| - | :root.light body { | |
| - | 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: system stack, uniform weight (a11y §3). */ | |
| - | :root.plain body, | |
| - | :root.plain .display, | |
| - | :root.plain .label, | |
| - | :root.plain .prose-lead, | |
| - | :root.plain .font-mono, | |
| - | :root.plain .font-display, | |
| - | :root.plain .font-prose { | |
| - | font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif !important; | |
| - | letter-spacing: normal !important; | |
| - | text-transform: none !important; | |
| - | } | |
| } | ||
| /* ------------------------------------------------------------------ */ | ||
| @@ -175,11 +131,6 @@ | ||
| outline-offset: 2px; | ||
| border-radius: 2px; | ||
| } | ||
| - | :root.plain :focus-visible { | |
| - | outline-color: #000000; | |
| - | outline-width: 3px; | |
| - | } | |
| - | ||
| /* Rule #7: links carry underline + color, never color alone. */ | ||
| a { | ||
| text-decoration: underline; | ||
| @@ -258,7 +209,7 @@ | ||
| /* | ||
| * 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. | |
| + | * Light mode keeps the same mono style - it's the brand voice (day version). | |
| */ | ||
| .label { | ||
| font-family: var(--font-mono), ui-monospace, monospace; | ||
| @@ -303,15 +254,6 @@ | ||
| 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; | |
| - | } | |
| - | ||
| /* Live dot. */ | ||
| .live-dot { | ||
| width: 0.5rem; |
| @@ -66,16 +66,15 @@ export default function RootLayout({ | ||
| > | ||
| <head> | ||
| {/* | ||
| - | Apply the persisted display mode before paint (a11y §"Three display | |
| - | 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. | |
| + | Apply the persisted display mode before paint - dark (default, no | |
| + | class on <html>) or light. Plain mode removed in V3b (2026-06-27). | |
| + | Any legacy "plain" value in storage falls back to "dark". | |
| + | Runs synchronously so the first frame is correct - no flash. | |
| + | suppressHydrationWarning lets the inline script own the class. | |
| */} | ||
| <script | ||
| dangerouslySetInnerHTML={{ | ||
| - | __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){}})();`, | |
| + | __html: `(function(){try{var s=localStorage.getItem('docket-display');var m=(s==='light')?'light':'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> |
| @@ -1,33 +1,31 @@ | ||
| "use client"; | ||
| import { useEffect, useState } from "react"; | ||
| - | import { clsx } from "@/lib/cx"; | |
| /** | ||
| - | * 3-mode display toggle (docs/ACCESSIBILITY.md §"Three display modes"). | |
| - | * 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. | |
| + | * Day/Night icon switcher - two modes only: dark (default ink) and light (warm day). | |
| * | ||
| - | * 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). | |
| + | * Pattern: show the CURRENT mode's icon (moon = night/dark; sun = day/light). | |
| + | * aria-label tells the user what clicking will do ("Switch to light mode" etc.). | |
| + | * | |
| + | * Hydration-safe: SSR renders dark default; useEffect reads localStorage. | |
| + | * Persisted in localStorage("docket-display"); default = dark. | |
| + | * Any legacy "plain" value in storage is coerced to "dark". | |
| + | * | |
| + | * Accessibility: >=44px touch target, visible focus ring, aria-label on every state. | |
| */ | ||
| - | export type DisplayMode = "dark" | "light" | "plain"; | |
| - | ||
| - | const MODES: Array<{ value: DisplayMode; label: string; hint: string }> = [ | |
| - | { 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 type DisplayMode = "dark" | "light"; | |
| export function DisplayToggle() { | ||
| const [mode, setMode] = useState<DisplayMode>("dark"); | ||
| + | const [mounted, setMounted] = useState(false); | |
| useEffect(() => { | ||
| - | const current = (localStorage.getItem("docket-display") as DisplayMode) || "dark"; | |
| - | if (current !== mode) setMode(current); | |
| - | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| + | setMounted(true); | |
| + | const stored = localStorage.getItem("docket-display"); | |
| + | // Coerce any legacy "plain" value to "dark" | |
| + | const valid: DisplayMode = stored === "light" ? "light" : "dark"; | |
| + | setMode(valid); | |
| }, []); | ||
| function apply(next: DisplayMode) { | ||
| @@ -35,43 +33,84 @@ export function DisplayToggle() { | ||
| localStorage.setItem("docket-display", next); | ||
| const el = document.documentElement; | ||
| el.classList.remove("dark", "light", "plain"); | ||
| - | if (next !== "dark") el.classList.add(next); // dark = no class (:root tokens) | |
| + | if (next !== "dark") el.classList.add(next); | |
| el.dataset.display = next; | ||
| } | ||
| + | const isDark = mode === "dark"; | |
| + | const ariaLabel = isDark ? "Switch to light mode" : "Switch to dark mode"; | |
| + | ||
| + | return ( | |
| + | <button | |
| + | type="button" | |
| + | onClick={() => apply(isDark ? "light" : "dark")} | |
| + | aria-label={ariaLabel} | |
| + | title={ariaLabel} | |
| + | className="focus-ring" | |
| + | style={{ | |
| + | display: "inline-flex", | |
| + | alignItems: "center", | |
| + | justifyContent: "center", | |
| + | width: "44px", | |
| + | height: "44px", | |
| + | border: "1px solid var(--dk-line)", | |
| + | borderRadius: "8px", | |
| + | background: "transparent", | |
| + | color: "var(--dk-ash)", | |
| + | cursor: "pointer", | |
| + | transition: "color 0.2s, border-color 0.2s", | |
| + | flexShrink: 0, | |
| + | /* suppress any SSR/hydration icon flicker by hiding until mounted */ | |
| + | opacity: mounted ? 1 : 0, | |
| + | }} | |
| + | > | |
| + | {isDark ? <MoonIcon /> : <SunIcon />} | |
| + | </button> | |
| + | ); | |
| + | } | |
| + | ||
| + | /** Moon - shown when in dark/night mode */ | |
| + | function MoonIcon() { | |
| + | return ( | |
| + | <svg | |
| + | aria-hidden="true" | |
| + | width="18" | |
| + | height="18" | |
| + | viewBox="0 0 24 24" | |
| + | fill="none" | |
| + | stroke="currentColor" | |
| + | strokeWidth="1.75" | |
| + | strokeLinecap="round" | |
| + | strokeLinejoin="round" | |
| + | > | |
| + | <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> | |
| + | </svg> | |
| + | ); | |
| + | } | |
| + | ||
| + | /** Sun - shown when in light/day mode */ | |
| + | function SunIcon() { | |
| return ( | ||
| - | <div | |
| - | role="group" | |
| - | aria-label="Display mode" | |
| - | className="flex items-center gap-1 rounded-[3px] border border-line p-1 bg-surface" | |
| + | <svg | |
| + | aria-hidden="true" | |
| + | width="18" | |
| + | height="18" | |
| + | viewBox="0 0 24 24" | |
| + | fill="none" | |
| + | stroke="currentColor" | |
| + | strokeWidth="1.75" | |
| + | strokeLinecap="round" | |
| + | strokeLinejoin="round" | |
| > | ||
| - | <span className="sr-only">Choose how Docket looks</span> | |
| - | {MODES.map((m) => { | |
| - | const active = m.value === mode; | |
| - | return ( | |
| - | <button | |
| - | key={m.value} | |
| - | type="button" | |
| - | onClick={() => apply(m.value)} | |
| - | aria-pressed={active} | |
| - | aria-label={`${m.label} mode - ${m.hint}`} | |
| - | title={`${m.label} - ${m.hint}`} | |
| - | className={clsx( | |
| - | "focus-ring label inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-[2px] px-2.5 transition-colors", | |
| - | active | |
| - | ? "bg-graphite text-paper" | |
| - | : "text-ash hover:text-paper" | |
| - | )} | |
| - | style={ | |
| - | active | |
| - | ? { boxShadow: "inset 0 -2px 0 var(--dk-signal)" } | |
| - | : undefined | |
| - | } | |
| - | > | |
| - | {m.label} | |
| - | </button> | |
| - | ); | |
| - | })} | |
| - | </div> | |
| + | <circle cx="12" cy="12" r="5" /> | |
| + | <line x1="12" y1="1" x2="12" y2="3" /> | |
| + | <line x1="12" y1="21" x2="12" y2="23" /> | |
| + | <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /> | |
| + | <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /> | |
| + | <line x1="1" y1="12" x2="3" y2="12" /> | |
| + | <line x1="21" y1="12" x2="23" y2="12" /> | |
| + | <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /> | |
| + | <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /> | |
| + | </svg> | |
| ); | ||
| } |