Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3b1193c
polish(ui): introduce archival technical style
iblh Jun 25, 2026
0873a5e
Revert "polish(ui): introduce archival technical style"
iblh Jun 26, 2026
836f724
refactor(server): redesign telemetry as a paper spec sheet
iblh Jun 26, 2026
9be49c5
refactor(media): provider spec sheet — stat ledger, borderless chips,…
iblh Jun 26, 2026
b0aa0c0
refactor(weather/calendar/markets): paper-instrument hierarchy
iblh Jun 26, 2026
50eabdd
refactor(posts): technical underlined tabs + tabular meta
iblh Jun 26, 2026
ee3be3b
docs(design): codify spec-sheet primitives for data-dense modules
iblh Jun 26, 2026
8d170fc
feat(calendar): archival month-ledger slice — SpecHeader, registratio…
iblh Jun 26, 2026
673d69f
feat(markets/weather/server): apply archival SpecHeader + Stamp motif
iblh Jun 26, 2026
34a49cb
feat(media/posts): apply archival motif — MED index + Stamp, IDX tabs
iblh Jun 26, 2026
22e5868
feat(states): archival loading / empty / error treatments
iblh Jun 26, 2026
256f8b6
feat(hover): mechanical cursor/guide-rule affordances
iblh Jun 26, 2026
fb1745f
docs(design): codify signature archival system; fix Weather location …
iblh Jun 26, 2026
63d0a85
feat(edit): restrained drafting-mode affordances
iblh Jun 27, 2026
3ef8dc9
feat(type): adopt Geist + Geist Mono for body and data
iblh Jun 29, 2026
8f13392
feat(shell): wrap dashboard in an archival document frame
iblh Jun 29, 2026
4fdee0e
feat(motion): mechanical odometer readouts on live values
iblh Jun 29, 2026
2926ef7
docs(design): document the page shell, Geist type, and mechanical rea…
iblh Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions web/DESIGN_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ 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-body` (system sans) — everything that is prose or UI copy.
- `font-mono` — all data: numbers, timestamps, urls, types, meta lines, status words.
- `font-body` (Geist) — everything that is prose or UI copy. A technical grotesk, not the system
sans — the dashboard must read with a consistent typographic voice across platforms.
- `font-mono` (Geist Mono) — all data: numbers, timestamps, urls, types, meta lines, status words.
This is the workhorse of the archival motif; nearly every SpecHeader / ledger / stamp uses it.
- 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`.

Expand Down Expand Up @@ -107,6 +109,73 @@ Non-interactive things (section titles, labels, data rows that aren't links) nev
- WYSIWYG: edit mode must not reshape view mode. Nothing appears in edit mode that occupies layout
space the view mode doesn't have (edit affordances live in headers, overlays, or handles).

### Spec-sheet primitives (data-dense modules)

Modules that report structured data — media providers, server telemetry — render like a printed
spec sheet, **not** as a stack of nested cards. The shared primitives in
`components/Tree/specPrimitives.tsx` encode this; reach for them before inventing per-module markup.

- **No boxes-in-boxes.** A module never wraps sub-groups in their own bordered/`bg-surface` cards.
Internal structure comes from hairline rules and a shared alignment grid.
- **`SpecCaption`** titles a block: a mono `uppercase tracking-[0.14em]` caption on a single
bottom hairline, with an optional tabular aside on the right. This is the only internal "header".
- **`SpecRow` / `SpecRows`** are the ledger: label left (mono, tertiary), value right (mono,
`tabular-nums`), baseline-aligned, separated by faint dividers. Two ledgers side by side make a
two-column fact sheet.
- **`SpecMetric`** is a glanceable gauge: tiny label + tabular value over an optional **1px** bar on
a hairline track (`bg-border-light`). Tone (ok/warn/crit → moss/amber/rust) appears only under load.
- **`MetaChip` / `MetaChipGroup`** replace bordered "badges": a borderless `surface-sunken` chip,
label and value split by a single hairline. Use for repeated metadata (library counts, facts).
- **`Sparkline`** is the one trend language: a 1.25px non-scaling polyline with an optional 8%-opacity
area fill. `tone` carries meaning (`up`/`down`/`accent`), default `ink` stays quiet. No chart libs.
- Status reads as a bare mono label + dot (`online` moss, `stale` muted) — not a filled pill.

### Signature archival elements (the memory points)

OPAQUE should read as a *private archive instrument*, not a generic dashboard. Three signature
devices (and only these — resist adding more) carry that identity. They come from object-ness —
codes, registration marks, stamps — never from paper grain or scan textures (we add none).

- **`SpecHeader`** — every data module opens with a filed-record index line: a fixed module code +
provenance on the left (`CAL / LOCAL`, `WTHR / LIVE`, `MKT / LIVE`, `MED / <PROVIDER>`, `SRV / LIVE`,
`IDX`), and an optional serial beside a `⌖` registration mark on the right (`2026·06`, `4 SYM`,
`°F`). Codes are stable semantic labels, not derived coordinates. This repeating line is *the*
memory point — keep it on every module.
- **`RegistrationMark`** — four corner crop-ticks framing a value. The site's "current / active"
marker: calendar *today*, and (faintly, on hover via `hoverTickClass`) the cell the cursor is on.
Reserved for genuinely singular current items — never a general highlight. Solid ink when active.
- **`Stamp`** — the one status language, drawn like an inspection stamp (mono uppercase + small dot),
never a filled pill: `online`/`stale`, media `N playing`, market `▲/▼` deltas, `syncing` (pulsing).

Each module also takes a distinct-but-related *archival form*: Calendar = month **ledger** (a `WK`
week-number gutter, registration today); Weather = station **bulletin** (measurement figure + unit
tick + station metadata); Markets = ticker **instrument list** (oscilloscope traces, stamped deltas);
Media = catalog **drawer** per provider; Server = telemetry **instrument**; Posts = **index** with
folder-divider tabs.

State pages stay in-character: **loading** is a blank record filling (hairline value `Slot`s + a calm
`recordSweep`, never a generic pulse); **empty** is a blank catalog card (`NIL` stamp + one terse
line); **error** is an inspection note (`NO READING ✕` over a faint rust rule), never a red alert.

Interaction is mechanical, not web-app: hover slides a guide-rule / reveals registration ticks like a
ruler cursor — not a flat background tint.

**The page is a filed sheet.** The dashboard is wrapped in a document shell so the whole thing reads
as one archived record, not floating modules:
- A **masthead** above the greeting — `OPAQUE` wordmark + `personal archive` on the left, mono
datestamp fields on the right (`SHEET 01/01 · DATE <ISO> · TZ <offset>`), on a hairline rule.
Date/TZ are computed client-side (avoid SSR locale mismatch).
- Faint **registration / crop corners** on the content frame (`DocumentFrame`) — the page edges.
- A **footer colophon** mirroring the masthead: `DOC · SCALE 1:1 · PAGE 01/01 · © OPAQUE`, on the
same page margins.

**Live numbers are mechanical readouts.** Values that change on poll use `RollingNumber` — each digit
is a 0–9 reel that rolls to the new value like a counter/instrument (markets price + delta, weather
temperature, server CPU/mem/disk). Animate only after the first change (no roll-from-zero on load),
honor `prefers-reduced-motion`, keep the string monospaced + tabular so columns never shift. This is
the one place motion is *expressive* rather than purely functional — use it only for genuinely live
figures, never for static labels.

## Litmus tests

Before shipping UI, ask:
Expand Down
34 changes: 34 additions & 0 deletions web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,40 @@ input:-webkit-autofill:active {
animation: subtleFloat 6s ease-in-out infinite;
}

/* Loading: a blank archive record filling. A slow, low-contrast sweep across
the empty value slots — quieter and more "ink filling paper" than a pulse. */
@keyframes recordSweep {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}

.animate-record-sweep {
animation: recordSweep 1.8s ease-in-out infinite;
}

/* Edit mode "drafting surface": while a section is being dragged, a very faint
drafting grid fades in behind the layout — like laying work out on a cutting
mat. Off at rest; never affects view mode. */
.drafting-surface {
background-image: none;
background-size: 24px 24px;
transition: background-color 180ms ease;
}

.drafting-surface[data-drafting] {
background-image:
linear-gradient(to right, rgb(var(--border-light) / 0.5) 1px, transparent 1px),
linear-gradient(to bottom, rgb(var(--border-light) / 0.5) 1px, transparent 1px);
}

@media (prefers-reduced-motion: reduce) {
.animate-record-sweep {
animation: none;
opacity: 0.7;
}
}

@keyframes dragClonePickUp {
from {
transform: scale(1);
Expand Down
25 changes: 22 additions & 3 deletions web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { Sorts_Mill_Goudy } from "next/font/google";
import { Geist, Geist_Mono, Sorts_Mill_Goudy } from "next/font/google";
import CookieBanner from "@/components/CookieBanner";
import ThemeWatcher from "@/components/ThemeWatcher";
import { THEME_INIT_SCRIPT } from "@/lib/theme";
import "./globals.css";

// Serif display face for titles / section headings. Body text stays sans.
// Serif display face for titles / section headings.
const serif = Sorts_Mill_Goudy({
subsets: ["latin"],
weight: ["400"],
Expand All @@ -14,6 +14,21 @@ const serif = Sorts_Mill_Goudy({
variable: "--font-serif",
});

// Technical grotesk for prose/UI, and its companion mono for all data — the
// archival "spec sheet" voice. Replaces the system stacks so the dashboard has a
// consistent typographic personality across platforms.
const sans = Geist({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
});

const mono = Geist_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});

export const metadata: Metadata = {
title: "OPAQUE",
description: "A quiet dashboard for bookmarks, applications, and servers",
Expand All @@ -29,7 +44,11 @@ export default function RootLayout({
// React hydrates. This suppresses only the <html> element's own attribute
// diff, not any child-subtree mismatch.
return (
<html lang="en" className={serif.variable} suppressHydrationWarning>
<html
lang="en"
className={`${serif.variable} ${sans.variable} ${mono.variable}`}
suppressHydrationWarning
>
<head>
{/* Apply the saved theme before first paint so there is no flash. */}
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
Expand Down
71 changes: 70 additions & 1 deletion web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,10 @@ export default function HomePage() {
<div className="relative flex-1 overflow-x-hidden bg-background">
<div className="relative z-10">
<div id="dashboard" className="relative flex min-h-full flex-col py-16">
<DocumentFrame />
<div className="mx-6 animate-fade-in sm:mx-8 lg:mx-12 xl:mx-16 2xl:mx-24">
<p className="font-serif text-sm leading-none text-text-secondary">
<DocumentMasthead />
<p className="mt-5 font-serif text-sm leading-none text-text-secondary">
{timeGreeting()}{displayName ? `, ${displayName}` : ''}
<span className="text-text-muted"> — {todayLabel()}</span>
</p>
Expand Down Expand Up @@ -581,6 +583,73 @@ function todayLabel() {
}).format(new Date())
}

// ISO-8601 date (YYYY-MM-DD) for the masthead — a filed-record datestamp.
function isoDateStamp(date = new Date()) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}

function localTimeZoneCode() {
try {
// e.g. "GMT-7" — a terse station/zone marker, not a long name.
const parts = new Intl.DateTimeFormat(undefined, { timeZoneName: 'shortOffset' }).formatToParts(new Date())
return parts.find((part) => part.type === 'timeZoneName')?.value?.replace(/\s/g, '') || 'LOCAL'
} catch {
return 'LOCAL'
}
}

// The filed-document masthead: a wordmark + provenance on the left and mono
// datestamp / zone fields on the right, on one hairline rule. Makes the whole
// dashboard read as a single archived sheet rather than floating modules.
function DocumentMasthead() {
const [stamp, setStamp] = useState({ date: '', tz: '' })
// Compute client-side only — avoids an SSR/client locale + tz mismatch.
useEffect(() => {
setStamp({ date: isoDateStamp(), tz: localTimeZoneCode() })
}, [])

return (
<div className="flex items-end justify-between gap-4 border-b border-border-medium pb-2">
<div className="flex items-baseline gap-3">
<span className="font-mono text-[11px] font-medium uppercase tracking-[0.3em] text-text-primary">
OPAQUE
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.16em] text-text-muted">
Personal archive
</span>
</div>
<div className="flex items-center gap-4 font-mono text-[9px] uppercase tracking-[0.14em] text-text-muted tabular-nums">
<MastheadField label="Sheet" value="01 / 01" />
<MastheadField label="Date" value={stamp.date || '—'} />
<MastheadField label="TZ" value={stamp.tz || '—'} />
</div>
</div>
)
}

function MastheadField({ label, value }: { label: string; value: string }) {
return (
<span className="flex items-center gap-1.5">
<span className="text-text-muted/70">{label}</span>
<span className="text-text-secondary">{value}</span>
</span>
)
}

// Faint registration / crop corners on the content frame — the page edges of a
// printed sheet. Decorative but cheap and consistent with the archival motif.
function DocumentFrame() {
const corner = 'pointer-events-none absolute h-3 w-3 border-border-medium'
return (
<div aria-hidden className="pointer-events-none absolute inset-x-3 inset-y-6 sm:inset-x-5 lg:inset-x-9 xl:inset-x-[3.25rem] 2xl:inset-x-20">
<span className={`${corner} left-0 top-0 border-l border-t`} />
<span className={`${corner} right-0 top-0 border-r border-t`} />
<span className={`${corner} bottom-0 left-0 border-b border-l`} />
<span className={`${corner} bottom-0 right-0 border-b border-r`} />
</div>
)
}

function newId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
Expand Down
42 changes: 29 additions & 13 deletions web/src/components/DashboardLayoutEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -477,12 +477,14 @@ export default function DashboardLayoutEditor({
<div
ref={containerRef}
data-layout-editor
className="mx-6 flex flex-col gap-10 sm:mx-8 md:gap-12 lg:mx-12 xl:mx-16 xl:gap-14 2xl:mx-24"
data-drafting={isDragActive ? 'true' : undefined}
className="drafting-surface mx-6 flex flex-col gap-10 sm:mx-8 md:gap-12 lg:mx-12 xl:mx-16 xl:gap-14 2xl:mx-24"
>
{rows.map((row) => (
{rows.map((row, rowIndex) => (
<LayoutRow
key={row.rowId}
row={row}
rowIndex={rowIndex}
isEditing={isEditing}
renderSection={renderSection}
draggingRoot={drag?.root ?? null}
Expand Down Expand Up @@ -536,6 +538,7 @@ function NewRowIndicator({ edge }: { edge: RowDropEdge }) {

interface LayoutRowProps {
row: DashboardLayoutRow;
rowIndex: number;
isEditing: boolean;
renderSection: (tree: Tree) => ReactNode;
draggingRoot: string | null;
Expand All @@ -549,6 +552,7 @@ interface LayoutRowProps {

function LayoutRow({
row,
rowIndex,
isEditing,
renderSection,
draggingRoot,
Expand Down Expand Up @@ -589,6 +593,7 @@ function LayoutRow({
tree={cell.tree}
isEditing={isEditing}
isPlaceholder={isPlaceholder}
coordinate={`${String.fromCharCode(65 + colIndex)}:${rowIndex + 1}`}
onGripPointerDown={onGripPointerDown}
/>

Expand Down Expand Up @@ -629,23 +634,34 @@ interface SectionHeaderProps {
tree: Tree;
isEditing: boolean;
isPlaceholder: boolean;
/** Drafting coordinate (e.g. "A:2") shown by the grip in edit mode. */
coordinate?: string;
onGripPointerDown: (event: ReactPointerEvent<HTMLElement>, root: string, label: string) => void;
}

function SectionHeader({ tree, isEditing, isPlaceholder, onGripPointerDown }: SectionHeaderProps) {
function SectionHeader({ tree, isEditing, isPlaceholder, coordinate, onGripPointerDown }: SectionHeaderProps) {
const label = getRootLabel(tree.root);
return (
<div className={`mb-6 flex items-center gap-3 transition-opacity ${isPlaceholder ? 'opacity-30' : ''}`}>
<div className={`mb-6 flex items-center gap-2.5 transition-opacity ${isPlaceholder ? 'opacity-30' : ''}`}>
{isEditing && (
<button
type="button"
onPointerDown={(event) => onGripPointerDown(event, tree.root, label)}
className="inline-flex h-5 w-5 cursor-grab touch-none items-center justify-center text-text-muted transition-colors hover:text-text-primary active:cursor-grabbing"
aria-label={`Drag ${label} section`}
title="Drag to rearrange"
>
<IconGripVertical className="h-3.5 w-3.5" />
</button>
<>
{/* Drafting coordinate — a small mono grid reference, like a plate
number on a technical sheet. */}
{coordinate && (
<span className="select-none font-mono text-[9px] tabular-nums tracking-wider text-text-muted/70">
{coordinate}
</span>
)}
<button
type="button"
onPointerDown={(event) => onGripPointerDown(event, tree.root, label)}
className="inline-flex h-5 w-5 cursor-grab touch-none items-center justify-center text-text-muted transition-colors hover:text-text-primary active:cursor-grabbing"
aria-label={`Drag ${label} section`}
title="Drag to rearrange"
>
<IconGripVertical className="h-3.5 w-3.5" />
</button>
</>
)}
<div className="font-serif text-base leading-none text-text-secondary">
{label}
Expand Down
29 changes: 24 additions & 5 deletions web/src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
// Document footer: a filed-sheet colophon — provenance + scale/ratio on the
// left, page number and © on the right, on one hairline rule. Mirrors the
// masthead so the dashboard reads as a bounded archived sheet.
export default function Footer() {
return (
<footer className="flex flex-col items-end justify-end bg-surface-elevated px-6 py-3 border-t border-border-light">
<p className="cursor-default text-xs text-text-tertiary tracking-wide">
© OPAQUE
</p>
<footer className="border-t border-border-light bg-surface-elevated">
<div className="mx-6 flex items-center justify-between gap-4 py-3 font-mono text-[9px] uppercase tracking-[0.14em] text-text-muted tabular-nums sm:mx-8 lg:mx-12 xl:mx-16 2xl:mx-24">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<span className="text-text-muted/70">Doc</span>
<span className="text-text-secondary">OPAQUE·DASH</span>
</span>
<span className="hidden items-center gap-1.5 sm:flex">
<span className="text-text-muted/70">Scale</span>
<span className="text-text-secondary">1:1</span>
</span>
</div>
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<span className="text-text-muted/70">Page</span>
<span className="text-text-secondary">01 / 01</span>
</span>
<span className="text-text-tertiary">© OPAQUE</span>
</div>
</div>
</footer>
)
}
}
Loading