From 3b1193c4be69d0c4ba8062d786c4f5483bf2ac9e Mon Sep 17 00:00:00 2001 From: Bolin Huang Date: Thu, 25 Jun 2026 13:43:18 -0700 Subject: [PATCH 01/18] polish(ui): introduce archival technical style --- web/DESIGN_SPEC.md | 29 ++++++---- web/src/app/globals.css | 61 +++++++++++++++----- web/src/app/layout.tsx | 12 +++- web/src/app/page.tsx | 9 ++- web/src/components/DashboardLayoutEditor.tsx | 55 ++++++++++++------ web/src/components/Header.tsx | 8 +-- web/src/components/Tree/TreeModule.tsx | 47 ++++++++------- web/src/components/Tree/TreeServer.tsx | 12 ++-- web/tailwind.config.js | 1 + 9 files changed, 155 insertions(+), 79 deletions(-) diff --git a/web/DESIGN_SPEC.md b/web/DESIGN_SPEC.md index 788be14..d975e2d 100644 --- a/web/DESIGN_SPEC.md +++ b/web/DESIGN_SPEC.md @@ -1,20 +1,23 @@ -# OPAQUE Design Spec — Quiet Instrumentality +# OPAQUE Design Spec — Archival Technical Minimalism -> 安静的工具主义。这份文档是所有 UI 决策的最终依据;新代码与这里冲突时,改代码。 +> 档案式工具面板美学。这份文档是所有 UI 决策的最终依据;新代码与这里冲突时,改代码。 ## The idea -OPAQUE reads like a well-set technical almanac: high information density delivered in a low voice. -Editorial minimalism meets wabi-sabi — serif carries the human voice, monospace carries the honest -numbers, whitespace carries the breathing room. **Restraint itself is the identity: others add -effects; we don't.** +OPAQUE reads like a personal dashboard printed as a technical archive: high information density, +quiet hierarchy, paper texture, instrument-panel precision. Editorial minimalism meets archival +spec sheets — serif carries the human voice, condensed labels carry the catalog system, monospace +carries the honest numbers, whitespace carries the breathing room. **Restraint itself is the +identity: others add effects; we don't.** -Three lineages, one temperament: +Four lineages, one temperament: - **Wabi-sabi** — paper-like surfaces, hairline rules, asymmetry tolerated, time made visible ("2 days ago" matters more than a spinner). - **Editorial minimalism** — Goudy serif titles set like book headings; content reads in columns; typography is the layout. +- **Technical archives** — spec labels, section codes, thin rules, small tables, diagrams, and + instrument-like status marks. Every artifact-looking element must encode information. - **Quiet brutalism** — sharp 2px corners, honest borders, no decoration that isn't information. ## Hard rules @@ -25,10 +28,13 @@ These are enforceable review criteria, not vibes. - `font-serif` (Sorts Mill Goudy, 400) — **titles only**: welcome line, section titles, panel/module titles, group headers. Never for body, buttons, labels, or data. +- `font-condensed` (IBM Plex Sans Condensed) — catalog labels: section codes, buttons, settings + labels, spec chips, small uppercase headers. It gives the interface its archival / instrument + tone without turning body copy into a poster. - `font-body` (system sans) — everything that is prose or UI copy. - `font-mono` — all data: numbers, timestamps, urls, types, meta lines, status words. - Numerals never jitter: `font-variant-numeric: tabular-nums` is set globally on `body`. -- Labels above data use `text-[10px] uppercase tracking-wider text-text-tertiary`. +- Labels above data use `.opaque-spec-label`; cross-section kickers use `.opaque-kicker`. ### Color @@ -50,6 +56,8 @@ These are enforceable review criteria, not vibes. - `accent-blue` is legacy; do not introduce new uses on the dashboard. - Alpha-on-token utilities (e.g. `bg-surface-sunken/50`) are fine — the tokens resolve to full colors, so Tailwind composites the alpha normally in both themes. +- The page canvas uses a subtle CSS-only paper grain and faint horizontal rule. It must remain + barely perceptible: if it competes with text, it is too strong. ### Motion @@ -97,9 +105,8 @@ Non-interactive things (section titles, labels, data rows that aren't links) nev - **Each glanceable widget is its own top-level section.** weather / calendar / markets are independent single-module roots (not bundled under a "Today" section), each freely placeable and resizable on the layout grid. media and posts remain multi-module roots. -- Section title sits flush-left above its content with **no trailing rule**; the title alone opens - the section. The page has no decorative hairlines between a title and its body, and no rule above - the welcome line. +- Section headers are small spec strips: a non-persistent section code (`A:01 / weather`), a serif + title, and one hairline rule. The code/rule provide location and grouping, not decoration. - Depth comes from spacing, not elevation or outlines. Radius is `rounded-sm` (2px) where anything is rounded; `rounded-full` only for status dots and pills. - A single module root carries its name once — in the section header. The module panel inside it diff --git a/web/src/app/globals.css b/web/src/app/globals.css index e898884..b1c9319 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -31,13 +31,13 @@ --text-secondary: rgba(26, 29, 31, 0.75); --text-tertiary: rgba(26, 29, 31, 0.55); --text-muted: rgba(26, 29, 31, 0.4); - --background: 255 255 255; - --surface: rgba(250, 250, 250, 0.8); - --surface-elevated: 255 255 255; - --surface-sunken: 247 247 247; - --border-light: 237 237 237; - --border-medium: 222 222 222; - --border-strong: 208 208 208; + --background: 250 248 243; + --surface: rgba(248, 246, 240, 0.82); + --surface-elevated: 253 251 246; + --surface-sunken: 243 241 235; + --border-light: 229 226 218; + --border-medium: 214 210 201; + --border-strong: 196 191 181; --ink-100: 247 247 247; --ink-200: 232 232 232; --ink-300: 218 218 218; @@ -47,8 +47,10 @@ --ink-700: 85 85 85; --ink-800: 51 51 51; --ink-900: 17 17 17; - /* The page canvas (one notch off pure white, like aged paper). */ - --page-bg: #FAFAFA; + /* The page canvas: warm off-white, closer to archival paper than UI chrome. */ + --page-bg: #F7F5EF; + --paper-grain: rgba(17, 17, 17, 0.026); + --paper-rule: rgba(17, 17, 17, 0.018); } /* --- Dark palette: ink canvas, paper-light text. Quiet Instrumentality @@ -90,12 +92,19 @@ --ink-800: 214 215 220; --ink-900: 236 237 240; --page-bg: #161719; + --paper-grain: rgba(236, 237, 240, 0.026); + --paper-rule: rgba(236, 237, 240, 0.018); } body { @apply min-h-screen bg-[var(--page-bg)] font-body text-text-primary; /* Numerals never jitter (see DESIGN_SPEC.md — Type). */ font-variant-numeric: tabular-nums; + background-image: + radial-gradient(circle at 1px 1px, var(--paper-grain) 0.65px, transparent 0), + linear-gradient(to bottom, transparent 0, transparent 23px, var(--paper-rule) 24px); + background-size: 18px 18px, 100% 24px; + background-attachment: fixed; } /* Quiet focus: a thin ink outline, visible everywhere, never a glow. @@ -722,14 +731,38 @@ input:-webkit-autofill:active { @apply border-border-medium bg-surface-elevated; } + .opaque-paper-surface { + @apply border border-border-light bg-surface-elevated/70; + } + + .opaque-kicker { + @apply font-condensed text-[10px] font-medium uppercase tracking-[0.16em] text-text-tertiary; + } + + .opaque-spec-label { + @apply font-condensed text-[10px] font-medium uppercase tracking-[0.14em] text-text-tertiary; + } + + .opaque-spec-value { + @apply font-mono text-[10px] text-text-secondary; + } + + .opaque-spec-chip { + @apply inline-flex items-center gap-1.5 rounded-sm border border-border-light bg-surface-sunken/45 px-2 py-1 font-condensed text-[10px] font-medium uppercase tracking-[0.12em] text-text-secondary; + } + + .opaque-technical-rule { + @apply h-px w-full bg-border-light; + } + .opaque-button { height: var(--opaque-header-control-size); - @apply inline-flex items-center justify-center gap-1 rounded-sm border border-border-light bg-surface-elevated px-2 text-[11px] font-medium text-text-secondary transition-colors hover:border-border-medium hover:bg-surface-sunken hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-45; + @apply inline-flex items-center justify-center gap-1 rounded-sm border border-border-light bg-surface-elevated px-2 font-condensed text-[11px] font-medium uppercase tracking-[0.08em] text-text-secondary transition-colors hover:border-border-medium hover:bg-surface-sunken hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-45; } .opaque-button-primary { height: var(--opaque-header-control-size); - @apply inline-flex items-center justify-center gap-1 rounded-sm border border-text-primary bg-text-primary px-2 text-[11px] font-medium text-background transition-colors hover:bg-ink-800 disabled:cursor-not-allowed disabled:border-border-medium disabled:bg-surface-sunken disabled:text-text-muted; + @apply inline-flex items-center justify-center gap-1 rounded-sm border border-text-primary bg-text-primary px-2 font-condensed text-[11px] font-medium uppercase tracking-[0.08em] text-background transition-colors hover:bg-ink-800 disabled:cursor-not-allowed disabled:border-border-medium disabled:bg-surface-sunken disabled:text-text-muted; } .opaque-button svg, @@ -743,7 +776,7 @@ input:-webkit-autofill:active { } .opaque-input { - @apply h-8 rounded-sm border border-border-light bg-surface-elevated px-2 text-xs text-text-primary transition-colors placeholder:text-text-muted focus:border-border-strong focus:ring-0; + @apply h-8 rounded-sm border border-border-light bg-surface-elevated/70 px-2 text-xs text-text-primary transition-colors placeholder:text-text-muted focus:border-border-strong focus:ring-0; } .opaque-toolbar-icon { @@ -759,7 +792,7 @@ input:-webkit-autofill:active { .opaque-toolbar-counter { height: var(--opaque-header-control-size); - @apply hidden items-center gap-1 rounded-sm border border-border-light bg-surface-sunken px-2 font-mono text-[11px] font-medium text-text-primary md:flex; + @apply hidden items-center gap-1 rounded-sm border border-border-light bg-surface-sunken px-2 font-condensed text-[11px] font-medium uppercase tracking-[0.1em] text-text-primary md:flex; } .opaque-toolbar-counter svg { @@ -822,7 +855,7 @@ input:-webkit-autofill:active { } .opaque-menu-badge { - @apply rounded-sm bg-ink-900 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-background; + @apply rounded-sm bg-ink-900 px-1.5 py-0.5 font-condensed text-[9px] font-semibold uppercase tracking-[0.14em] text-background; } .opaque-menu-account { diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 473c216..600a597 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Sorts_Mill_Goudy } from "next/font/google"; +import { IBM_Plex_Sans_Condensed, Sorts_Mill_Goudy } from "next/font/google"; import CookieBanner from "@/components/CookieBanner"; import ThemeWatcher from "@/components/ThemeWatcher"; import { THEME_INIT_SCRIPT } from "@/lib/theme"; @@ -14,6 +14,14 @@ const serif = Sorts_Mill_Goudy({ variable: "--font-serif", }); +const condensed = IBM_Plex_Sans_Condensed({ + subsets: ["latin"], + weight: ["400", "500", "600"], + style: ["normal"], + display: "swap", + variable: "--font-condensed", +}); + export const metadata: Metadata = { title: "OPAQUE", description: "A quiet dashboard for bookmarks, applications, and servers", @@ -29,7 +37,7 @@ export default function RootLayout({ // React hydrates. This suppresses only the element's own attribute // diff, not any child-subtree mismatch. return ( - + {/* Apply the saved theme before first paint so there is no flash. */}