● Zion Boggan zionboggan.com ↗

V3b: remove Plain mode (Dark+Light only), clean Light into day-counterpart (AA brass), moon/sun icon theme switcher

3e6e500   Zion Boggan committed on Jun 27, 2026 (1 month ago)
FRONTEND_NOTES.md +49 -1
@@ -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.
_ops/qa-view/v3b-home-dark.png +0 -0
Binary file not shown
_ops/qa-view/v3b-home-light.png +0 -0
Binary file not shown
_ops/screenshot-v3b.js +49 -0
@@ -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);
+})();
app/globals.css +21 -79
@@ -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;
app/layout.tsx +6 -7
@@ -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>
components/display-toggle.tsx +90 -51
@@ -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>
);
}