Status: proposed. Scope: public reading surfaces only (Landing, Home feed, Article, shared chrome). Writer and settings pages inherit the tokens and get no separate spec. Owner constraints: very modern, must not read as Medium, three.js 3D element, zero pink, grayscale palette (monochrome preferred), ReadingDock on every page.
Decision: fully monochrome. --accent is ink (near-black in light mode, near-white in dark mode). I considered and rejected a cool-steel accent (#4b5563-ish). It reads as "disabled" next to gray body text, and it adds a hue the owner did not ask for.
How we avoid looking like Medium. Medium uses a white page, a centred serif column, a green accent, pill buttons and no imagery chrome. Graphite is the opposite on every one of those:
- Tight grotesk display type (Inter Tight).
- Monospace metadata and index numbers.
- Square-ish 10-16 px radii.
- Hairline grid rules.
- An indexed, two-column feed.
- A 3D hero object.
- Dark-first chrome.
| Fact | Source |
|---|---|
Tokens are runtime vars, swapped by .dark and by inline paletteVars on :root |
src/styles.css:62-97, src/contexts/preferences/domain/reading.ts:191-203 |
paletteVars overrides --surface, --surface-2, --line, --subtle, --muted, --fg, --accent, --accent-hover, --on-accent, --inverse, --on-inverse at runtime. --surface-2 is computed as color-mix(lightgray 55%, light). --card, --danger, --overlay and --glow are not overridden |
reading.ts:191-203 |
Pink sources today: --brand-2 mixes #ff4fa3, --brand-3 mixes #ff9d3d, and --grad-brand is used by the Logo tile, drop cap, hr, .text-gradient, .ring-grad, landing CTA band and feature icons |
styles.css:300-303, Logo.tsx:13, LandingPage.tsx:247,259 |
Violet accent #5b3df5 / #a495ff |
styles.css:69,87, reading.ts:119,128 |
Palettes dawn (#fbf3f0 rose page, #9c4a52 accent) and dracula (#bd93f9) are pink/violet-adjacent and visible in the dock swatches (PALETTES.slice(0,12)) |
reading.ts:126,131, ReadingPanel.tsx:154 |
| Fonts load from one Google Fonts URL | index.html:11 |
three is not a dependency |
package.json:16-32 |
ReadingDock is mounted in the root route, outside Shell, and has no data-chrome, so focus mode does not hide it |
src/app/router.tsx:93, styles.css:238 |
All neutrals are pure grays (R = G = B). No hue anywhere except --danger, which is a red with no blue component (not pink).
:root {
--fg: #0a0a0a;
--muted: #525252;
--subtle: #d4d4d4; /* decorative only */
--surface: #f5f5f5;
--surface-2: #e3e3e3; /* = runtime value paletteVars computes for "paper" */
--line: #d4d4d4; /* decorative hairlines */
--line-strong: #8a8a8a; /* NEW: input/control borders that must meet 3:1 */
--accent: #171717;
--accent-hover: #404040;
--on-accent: #fafafa;
--inverse: #0a0a0a;
--on-inverse: #f5f5f5;
--danger: #b91c1c;
--overlay: rgb(10 10 10 / 0.55);
--card: #ffffff;
--glow: color-mix(in srgb, var(--fg) 6%, transparent);
color-scheme: light;
}.dark {
--fg: #ededed;
--muted: #a3a3a3;
--subtle: #2e2e2e;
--surface: #0a0a0a;
--surface-2: #1e1e1e; /* = runtime value for "ink" */
--line: #2e2e2e;
--line-strong: #6b6b6b;
--accent: #f5f5f5;
--accent-hover: #d4d4d4;
--on-accent: #0a0a0a;
--inverse: #ededed;
--on-inverse: #0a0a0a;
--danger: #f87171;
--overlay: rgb(0 0 0 / 0.72);
--card: #141414;
--glow: color-mix(in srgb, var(--fg) 5%, transparent);
color-scheme: dark;
}Add to the first @theme block: --color-line-strong: var(--line-strong);. This is a new token.
{ id: "paper", label: "Paper", mode: "light", light: "#f5f5f5", lightgray: "#d4d4d4", gray: "#525252", dark: "#0a0a0a", secondary: "#171717" },
{ id: "ink", label: "Ink", mode: "dark", light: "#0a0a0a", lightgray: "#2e2e2e", gray: "#a3a3a3", dark: "#ededed", secondary: "#f5f5f5" },What these produce at runtime, derived from paletteVars:
--surface-2: paper#e3e3e3, ink#1e1e1e.--accent-hover = mix(secondary 85%, dark): paper ≈#101010, ink ≈#f4f4f4. That is visually the same as--accent. So hover must never be signalled by colour alone. Every accent hover in this spec also changes translate, underline or background.- Optional follow-up, not required for this pass: change
paletteVarstocolor-mix(in srgb, ${p.secondary} 80%, ${p.light})to restore a visible hover tint.
Other palettes:
- Replace
dawnwith{ id: "graphite", label: "Graphite", mode: "light", light: "#e5e5e5", lightgray: "#c4c4c4", gray: "#4a4a4a", dark: "#0a0a0a", secondary: "#262626" }. Reason: its page#fbf3f0and accent#9c4a52are rose. - Replace
draculawith{ id: "carbon", label: "Carbon", mode: "dark", light: "#171717", lightgray: "#333333", gray: "#a8a8a8", dark: "#f5f5f5", secondary: "#e5e5e5" }. Reason: its accent#bd93f9is violet-pink. normalizePrefsalready falls back to paper/ink for unknown stored ids (reading.ts:177), so users who storeddawn/draculadegrade safely.- The other palettes (sepia, nord, …) stay as reader-chosen options. They are not the site identity.
:root {
--brand-2: #404040;
--brand-3: #737373;
--grad-brand: linear-gradient(120deg, #0a0a0a, var(--brand-2) 50%, var(--brand-3));
}
.dark {
--brand-2: #bdbdbd;
--brand-3: #8a8a8a;
--grad-brand: linear-gradient(120deg, #fafafa, var(--brand-2) 50%, var(--brand-3));
}The lightest stop is chosen so that .text-gradient (used only at ≥ 24 px bold, which counts as large text) stays ≥ 3:1. Ratios are in 1.5.
Body backdrop, replaces styles.css:474-477. Drop the --brand-2 radial blob:
body {
background: radial-gradient(60rem 40rem at 110% -10%, var(--glow), transparent 60%), var(--surface);
background-attachment: fixed;
}| Pair | Light | Dark | Requirement |
|---|---|---|---|
| fg on surface | 18.2:1 (#0a0a0a/#f5f5f5) |
16.9:1 (#ededed/#0a0a0a) |
4.5 ✓ |
| fg on card | 19.8:1 (/#fff) |
15.7:1 (/#141414) |
4.5 ✓ |
| fg on surface-2 | 15.4:1 (/#e3e3e3) |
14.1:1 (/#1e1e1e) |
4.5 ✓ |
| muted on surface | 7.2:1 (#525252) |
7.9:1 (#a3a3a3) |
4.5 ✓ |
| muted on card | 7.8:1 | 7.3:1 | 4.5 ✓ |
| muted on surface-2 | 6.1:1 | 6.5:1 | 4.5 ✓ |
| on-accent on accent | 17.2:1 (#fafafa/#171717) |
18.2:1 (#0a0a0a/#f5f5f5) |
4.5 ✓ |
| on-accent on accent-hover (static CSS) | 9.9:1 (/#404040) |
13.4:1 (/#d4d4d4) |
4.5 ✓ |
| accent (focus ring) on surface | 16.5:1 | 18.2:1 | 3 ✓ |
| line-strong on surface / card | 3.2:1 / 3.5:1 | 3.7:1 / 3.5:1 | 3 (non-text UI) ✓ |
| danger on surface | 5.9:1 (#b91c1c) |
7.2:1 (#f87171) |
4.5 ✓ |
| grad lightest stop on surface (large text) | 4.4:1 (#737373) |
5.7:1 (#8a8a8a) |
3 ✓ |
--line on surface |
1.4:1 | 1.3:1 | decorative only. Never use it as the only border of an input, switch or tab |
Method: L = 0.2126R + 0.7152G + 0.0722B on linearised sRGB, and ratio = (L1 + 0.05) / (L2 + 0.05). These figures are not tool-measured. Design QA should re-run them in a contrast checker; any discrepancy above ±0.1 means a typo in this file.
Replace index.html:11 with:
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500..800&family=Geist:wght@400..700&family=Geist+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,700;1,8..60,400&display=swap" rel="stylesheet" />| Role | Token | Stack | Use |
|---|---|---|---|
| Display | --font-display |
"Inter Tight", "Geist", ui-sans-serif, system-ui, sans-serif |
h1-h3, card titles. Weight 700-800, tracking -0.04em at ≥ 48 px and -0.02em below |
| UI | --font-sans |
"Geist", ui-sans-serif, system-ui, sans-serif |
body chrome, buttons |
| Mono | --font-mono |
"Geist Mono", ui-monospace, Menlo, monospace |
kickers, dates, counts, index numbers, kbd |
| Article | --font-serif |
unchanged, "Source Serif 4" |
default prose-article. The user can switch it via the dock |
Removed: Bricolage Grotesque and Instrument Serif. The italic-serif accent words are a Medium-adjacent editorial cue. Remove every font-italic font-normal italic span in LandingPage.tsx:32,241 and HomePage.tsx:54,58 and render those words in display weight 800 instead. Delete the --font-italic token.
JetBrains Mono stays as a reader typeface option (reading.ts:96). Because it is no longer preloaded, add an href to it: ${GF}JetBrains+Mono:wght@400;600&display=swap.
Type scale (px, line-height):
- Hero:
clamp(3.5rem, 9vw, 8rem), 0.88. - Page h1: 40/44 desktop, 32/36 mobile.
- Article h1: 56/60 desktop, 34/38 mobile.
- Card title: 22/28 desktop, 18/24 mobile.
- Body: 15/24.
- Meta: 13/18 mono.
- Kicker: 12/16 mono, uppercase, tracking
0.12em.
Add to @theme. These are new tokens and replace --radius-card: 1.5rem:
--radius-xs: 0.25rem; /* 4 kbd, inline code */
--radius-sm: 0.5rem; /* 8 chips, inputs */
--radius-md: 0.625rem; /* 10 buttons */
--radius-lg: 1rem; /* 16 cards, panels, menus */
--radius-xl: 1.25rem; /* 20 modals, dock panel */
--radius-card: var(--radius-lg);
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);There are no full pills anywhere except the avatar and the switch track. Pills read as Medium.
Shadows, replacing styles.css:304-305. They are pure black alpha; in dark mode they are a hairline highlight because drop shadows vanish on #0a0a0a.
:root {
--shadow-soft: 0 1px 2px rgb(0 0 0 / 0.05), 0 8px 24px -12px rgb(0 0 0 / 0.14);
--shadow-lift: 0 2px 6px rgb(0 0 0 / 0.06), 0 24px 48px -20px rgb(0 0 0 / 0.24);
}
.dark {
--shadow-soft: 0 0 0 1px rgb(255 255 255 / 0.04) inset, 0 8px 24px -12px rgb(0 0 0 / 0.8);
--shadow-lift: 0 0 0 1px rgb(255 255 255 / 0.07) inset, 0 24px 48px -16px rgb(0 0 0 / 0.9);
}Delete --shadow-[0_8px_24px_-10px_var(--accent)]-style coloured glows everywhere: TopBar.tsx:192, button.tsx:11-12, ReadingPanel.tsx:183, Logo.tsx:12. Use shadow-[var(--shadow-soft)] or shadow-[var(--shadow-lift)].
.ring-grad::before keeps its mechanism but uses background: var(--fg); opacity up to 0.9, so the hover ring is a 1 px ink outline.
Delete animate-blob, the Mesh component and .dots behind the hero. The 3D scene replaces them. Keep .grain at opacity: 0.04.
Below, · separates class groups for readability. Write them as one className string.
- Header:
sticky top-0 z-40 h-16 border-b border-transparent transition-colors duration-300. When scrolled:glass border-line. This is a full-bleed bar, not a floating rounded capsule. - Inner:
mx-auto flex h-full max-w-shell items-center gap-6 px-4 md:px-6. - Nav links (new, desktop only):
hidden md:flex items-center gap-1. Each link:h-9 px-3 rounded-[var(--radius-sm)] font-mono text-[12px] uppercase tracking-[0.12em] text-muted hover:text-fg hover:bg-surface-2 data-[status=active]:text-fg data-[status=active]:bg-surface-2. The items are "Latest" (/?tab=latest) and "Topics" (/search). This is a new element; if out of scope, omit it and nothing else breaks. - SearchBox:
hidden md:flex h-10 w-64 focus-within:w-80 items-center gap-2 rounded-[var(--radius-sm)] border border-line-strong bg-card px-3 text-sm text-muted transition-[width,border-color] duration-300 focus-within:border-fg. Removering-accent/30 focus-within:ring-4. Thekbdusesrounded-[var(--radius-xs)] border border-line bg-surface-2 px-1.5 font-mono text-[11px]. - Write link:
hidden md:inline-flex h-9 items-center gap-2 rounded-[var(--radius-md)] bg-inverse px-3.5 text-sm font-semibold text-on-inverse hover:-translate-y-px transition. - Bell badge:
bg-fg text-surface font-mono text-[10px](was accent). - Avatar trigger:
rounded-full ring-offset-2 ring-offset-surface hover:ring-2 hover:ring-fg/40. - Menu content:
menu-pop z-50 w-64 rounded-[var(--radius-lg)] border border-line bg-card p-1.5 shadow-[var(--shadow-lift)]. Items keepitembut userounded-[var(--radius-sm)]. - Signed-out state: "Sign in" as
Button variant="ghost" size="sm"and "Get started" asButton size="sm"(primary = inverse). - Mobile (< md): Logo, search icon, theme toggle and avatar or "Get started". Every target is ≥ 40 × 40 px (
ghost-iconissize-10). That exceeds the 24 × 24 minimum in WCAG 2.2 2.5.8. - Worst-case content: a 30-character username in the menu footer is already truncated with
max-w-24 truncate, and the unread count caps at "9+".
The wordmark is iBlog with a solid square mark. There is no gradient and no coloured dot.
<span className="inline-flex items-center gap-2 font-display text-[22px] font-extrabold tracking-[-0.05em]">
<span aria-hidden className="relative grid size-7 place-items-center rounded-[var(--radius-sm)] bg-inverse font-mono text-[15px] font-medium text-on-inverse transition-transform duration-500 [transition-timing-function:var(--ease-out)] group-hover:rotate-90">i</span>
<span>Blog</span>
</span>- Remove
<span className="text-accent">.</span>. - Link:
group text-fg active:scale-95 transition-transform. - The
group-hover:rotate-90turn is suppressed by the global reduced-motion rule (styles.css:206-215).
Layout:
- Section:
relative isolate overflow-hidden border-b border-line. - Grid:
mx-auto grid max-w-shell items-center gap-10 px-4 pt-14 pb-16 md:px-6 md:pt-20 md:pb-24 lg:grid-cols-[1.1fr_1fr]. - The
asidescene slot:relative aspect-square w-full max-w-[36rem] justify-self-center lg:justify-self-end. On mobile it shows above the headline atmax-w-[20rem]viaorder-first lg:order-none. Mobile gets the 3D element too, under the reduced budget in 4.3.3. - The badge drops the ping dot. It becomes
inline-flex items-center gap-2 rounded-[var(--radius-sm)] border border-line bg-card px-2.5 py-1 font-mono text-[12px] uppercase tracking-[0.12em] text-mutedwith a 6 pxbg-fgsquare. - Headline:
font-display text-[clamp(3.5rem,9vw,8rem)] leading-[0.88] font-extrabold tracking-[-0.055em]. "Ideas worth" is intext-fg. The rotating word uses.text-gradient, in display weight 800, not italic. - Lede:
mt-8 max-w-lg text-lg leading-7 text-muted md:text-xl md:leading-8. - CTAs:
- Primary:
Button size="lg" className="h-12 px-7"(inverse). - Secondary:
Button variant="outline" size="lg" className="h-12 px-7". - Keep
Magnetic.
- Primary:
- Stats
dl:mt-12 grid grid-cols-3 divide-x divide-line border-y border-line. Each cell:px-4 py-4 first:pl-0.ddisfont-mono text-3xl font-medium tabular-nums.dtiskicker mt-1. - The 3 floating
StoryCards are removed from the hero. They were the "pile of cards" look. Trending moves to the section below unchanged, restyled per 4.5. Index numbers there usefont-mono text-[28px] text-subtle group-hover:text-fg. - Feature tiles:
.panel ring-grad p-6. The icon tile becomesgrid size-10 place-items-center rounded-[var(--radius-sm)] bg-inverse text-on-inverse, replacingstyle={{background: var(--grad-brand)}}. - Final CTA band:
bg-inverse text-on-inverse rounded-[var(--radius-xl)], replacing the gradient. Its buttons:- Primary:
bg-on-inverse text-inverse. - Outline:
border-on-inverse/40 text-on-inverse hover:bg-on-inverse/10.
- Primary:
- Topic marquee chips:
rounded-[var(--radius-sm)] border border-line bg-card px-4 py-2 font-mono text-[13px] hover:border-fg hover:text-fg.
The scene is one faceted metal polyhedron, wrapped by a wire cage and orbited by a sparse particle shell. Everything is grayscale and tinted from runtime tokens.
| Aspect | Spec |
|---|---|
| Dependency | bun add three and bun add -d @types/three (latest) |
| File | src/app/pages/landing/HeroScene.tsx (default export), lazy-loaded |
| Renderer | WebGLRenderer({ antialias: dpr < 2, alpha: true, powerPreference: "low-power" }), setClearColor(0x000000, 0), outputColorSpace = SRGBColorSpace, toneMapping = ACESFilmicToneMapping, toneMappingExposure = 1.0 |
| Camera | PerspectiveCamera(35, aspect, 0.1, 50) at (0, 0, 6.5), looking at the origin |
| Core mesh | IcosahedronGeometry(1.35, 0) (20 faces, flat facets) + MeshStandardMaterial({ color: muted, metalness: 0.85, roughness: 0.32, flatShading: true }) |
| Cage | EdgesGeometry(IcosahedronGeometry(1.9, 1)) + LineBasicMaterial({ color: fg, transparent: true, opacity: 0.18 }) |
| Particles | 900 points (420 on mobile) uniformly on a spherical shell r ∈ [2.6, 4.2] + PointsMaterial({ color: fg, size: 0.018, sizeAttenuation: true, transparent: true, opacity: 0.55, depthWrite: false }) |
| Environment | scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture (three/addons/environments/RoomEnvironment.js). It is generated locally, so there is no network fetch. Dispose pmrem right after |
| Lights | AmbientLight(0xffffff, 0.25) + key DirectionalLight(0xffffff, 2.2) at (3, 4, 5) + rim DirectionalLight(0xffffff, 0.9) at (-4, -2, -3). All white, so they add no hue |
| Colour source | Read getComputedStyle(document.documentElement).getPropertyValue("--fg" / "--muted") on mount, then re-read through a MutationObserver on <html> (attributes: true, attributeFilter: ["class","style"]), because a palette change sets inline vars and .dark. Apply with material.color.set(value). Any palette, including sepia, therefore tints the object to that palette |
| Idle motion | Core rotation.y += 0.12·dt, rotation.x += 0.04·dt. Cage counter-rotates rotation.y -= 0.05·dt. Particles rotation.y += 0.015·dt. Group position.y = 0.08·sin(t·0.6). dt comes from Clock.getDelta(), clamped to ≤ 0.05 s so resuming from a pause does not jump |
| Mouse parallax | pointermove on window (passive) maps nx, ny ∈ [-1, 1] from the canvas rect. Targets: group.rotation.x = ny·0.25, group.rotation.y = nx·0.35, camera.position.x = nx·0.35, camera.position.y = -ny·0.25. Ease each frame with v += (target - v)·0.06. Then camera.lookAt(0,0,0). On touch there is no parallax, only deviceorientation. Do not request iOS motion permission |
| Hover/click | None. The canvas is aria-hidden and pointer-events-none; it is decoration |
| Entry | Scale the group 0.85 → 1 and fade the canvas opacity 0 → 1 over 900 ms with var(--ease-out) after the first rendered frame |
| Resize | ResizeObserver on the container calls renderer.setSize(w, h, false) and camera.aspect → updateProjectionMatrix() |
prefers-reduced-motion: reduce(useuseReducedMotion()from@/shared/motion): render one frame with the core at rotation(0.35, 0.6, 0). There is no rAF loop and no parallax. Listen for media-query changes and start or stop the loop when the setting changes.- No WebGL (
!canvas.getContext("webgl2") && !getContext("webgl")), a lazy-import failure or awebglcontextlostevent: render the static fallback. The fallback is an inline SVG of the icosahedron's outline (12 polygons withstroke="currentColor"andclass="text-subtle", plus 3 faces filledfill-surface-2) in the sameaspect-squarebox. The layout must not shift. - Suspense fallback while the chunk loads is the same static SVG. That gives zero CLS.
| Item | Budget / rule |
|---|---|
| Code split | const HeroScene = lazy(() => import("./landing/HeroScene")). Start the import from requestIdleCallback (falling back to setTimeout(…, 200)) after the hero text paints, so three is never in the entry chunk. Import only the named pieces: import { WebGLRenderer, … } from "three" |
| Chunk size | Target ≤ 160 KB gzip for the HeroScene chunk including three. This is a budget, not a measurement. Verify with bunx --bun vite build output, and fail review if it is over |
| DPR | renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75)). On viewports < 768 px, cap at 1.5 |
| Frame | Target ≤ 4 ms CPU per frame on a mid-range laptop. Validate in a Chrome Performance trace. Draw calls: 3. Triangles: 20 core + 0 for lines and points |
| Offscreen | An IntersectionObserver (threshold 0) on the container stops the rAF loop when it is not intersecting |
| Tab hidden | document.visibilitychange stops the loop on hidden and resumes on visible (call clock.getDelta() once to discard the gap) |
| Cleanup | On unmount: cancel rAF, disconnect observers, geometry.dispose(), material.dispose(), scene.environment.dispose(), renderer.dispose(), renderer.forceContextLoss(), and remove listeners |
| Contexts | Only one WebGL context exists site-wide |
Ambient background, the owner's "maybe": rejected as a global three.js layer. A second always-on WebGL context behind long-form reading costs battery on every article. It competes with text at the contrast levels in 1.5, and it would also have to obey focus mode. Replacement: the existing body radial --glow at 5-6 % plus .grain on the Landing hero only. This needs zero JS.
- Header:
pt-6 pb-4 border-b border-line.- Kicker: the date, in
kicker. - h1:
mt-2 font-display text-[32px] leading-9 font-extrabold tracking-[-0.03em] md:text-[40px] md:leading-[44px]. - Copy, signed in: "Hey {first}, read something good." Copy, signed out: "Fresh on iBlog". There is no accent or italic span; the second clause is
text-muted. - Worst case: a 40-character first name wraps through
text-balance(already set on h1). The name issplit(" ")[0], so it is never truncated.
- Kicker: the date, in
- Sticky nav:
sticky top-16 z-30 -mx-4 px-4 bg-surface/90 backdrop-blur-md border-b border-line md:-mx-0 md:px-0.top-16matches the new 64 px TopBar. - Tabs restyle: underline segmented, not pills.
- Root:
overflow-x-auto [scrollbar-width:none]. - Tablist:
flex gap-6. - Tab:
relative h-12 shrink-0 font-mono text-[12px] uppercase tracking-[0.12em] text-muted hover:text-fg aria-selected:text-fg. - Indicator: the
motion.span layoutIdbecomesabsolute inset-x-0 -bottom-px h-0.5 bg-fg, with the same spring500/38. - Add
aria-controls/idis out of scope; that is behaviour that already exists.
- Root:
- Mobile TopicChips row:
flex gap-2 overflow-x-auto py-3. Chip:h-8 px-3 rounded-[var(--radius-sm)] border border-line bg-card font-mono text-[12px] hover:border-fg.
This replaces the rounded floating card with a ruled row. That is the biggest single move away from both Medium and generic card-feed looks.
- Wrapper:
Reveal y={10}with no padding. article:group relative grid grid-cols-[1fr_auto] gap-x-5 gap-y-3 border-b border-line py-6 md:grid-cols-[7rem_1fr_auto] md:gap-x-6.- Meta column, md+:
hidden md:block font-mono text-[12px] leading-5 text-mutedwith<time>on line 1 andreadTimeon line 2. - Author line, all sizes:
flex items-center gap-2 text-[13px] text-muted. Avatarxs. Name:max-w-[60%] truncate font-medium text-fg hover:underline underline-offset-4. On mobile the date follows asfont-mono text-[12px]. - Title link:
mt-2 block+ h2line-clamp-3 font-display text-lg leading-6 font-bold tracking-[-0.02em] md:line-clamp-2 md:text-[22px] md:leading-7 group-hover:underline decoration-1 underline-offset-[5px]. Hover is an underline, not a colour change. - Excerpt:
mt-2 hidden line-clamp-2 text-[15px] leading-6 text-muted sm:block. - Cover:
relative size-20 shrink-0 overflow-hidden rounded-[var(--radius-sm)] bg-surface-2 md:h-24 md:w-36.- img:
size-full object-cover grayscale contrast-[1.05] transition duration-500 group-hover:grayscale-0 group-hover:scale-[1.04]. This is the open question in Risks; the default keeps the UI monochrome. - The corner arrow chip becomes
bg-inverse/70 text-on-inverse rounded-[var(--radius-xs)].
- img:
- Footer row:
col-span-full flex flex-wrap items-center gap-2 text-[13px] text-muted md:col-start-2.- Tags:
h-7 px-2 rounded-[var(--radius-xs)] border border-line font-mono text-[12px] hover:border-fg hover:text-fgwith the label#{t}. - Claps:
FlameIconbecomestext-fg(was accent), with counts infont-mono tabular-nums. - Bookmark and menu stay at
ml-autoand are 40 px targets.
- Tags:
- States:
- No cover: the grid collapses to
[1fr]/md:[7rem_1fr].onErroralready hides the image (PostCard.tsx:47-49). - No author: the author line is omitted, as today.
- 0 claps and 0 comments: those spans are omitted, as today.
- Long title (140 chars): clamped to 3 lines on mobile and 2 on desktop.
- Long unbroken tag (
#kubernetesoperatorpatterns): addmax-w-[12rem] truncate.
- No cover: the grid collapses to
- Skeleton (
PostCardSkeleton,states.tsx:26): mirror the grid. Useborder-b border-line py-6, not.panel.
- Aside:
sticky top-16 hidden h-[calc(100dvh-4rem)] overflow-y-auto py-8 [scrollbar-width:none] lg:block space-y-0 divide-y divide-line border-l border-line pl-8. There are no panels; sections are ruled. - Section:
py-6 first:pt-0. Title:kicker mb-4. - Trending item:
group grid grid-cols-[2rem_1fr] gap-3.- Index:
font-mono text-[13px] text-muted pt-0.5, values01to03. - Title:
line-clamp-2 font-display text-[15px] leading-5 font-bold group-hover:underline underline-offset-4. - Date:
font-mono text-[12px] text-muted.
- Index:
- Who to follow:
FollowButtonusesButton variant="outline" size="sm". The name truncates. The followers count isfont-mono text-[12px]. - Footer links: these are currently non-link
<span>s (Sidebar.tsx:69-72), which is a pre-existing gap. Either make them<Link>s to existing routes (/terms,/privacy) or delete "Help" and "About", which have no route. Styling:font-mono text-[12px] text-muted hover:text-fg. - Loading: 3 rows of
Bone h-4+Bone h-3 w-1/3. Empty: "Nothing trending yet." intext-sm text-muted, which exists already. Signed out: there is no "Who to follow" section, as today.
- Kicker:
kicker mb-6 flex items-center gap-3showing#{tag}, a 1 px × 12 pxbg-linedivider, then the read time. - h1:
font-display text-[34px] leading-[38px] font-extrabold tracking-[-0.04em] text-balance md:text-[56px] md:leading-[60px]. - Subtitle:
mt-4 font-sans text-lg leading-7 text-muted md:text-[22px] md:leading-8. - Byline block:
mt-10 flex items-center gap-4 border-y border-line py-4.- Avatar
lg. - Name:
text-base font-semibold hover:underline. - Follow:
Button variant="outline" size="sm", replacing thelinkvariant (which used accent text). - "Edit" (owner only):
Button variant="ghost" size="sm". - Meta:
font-mono text-[12px] text-muted. - Status chip (draft/unlisted):
rounded-[var(--radius-xs)] border border-line-strong px-1.5 py-0.5 font-mono text-[11px] uppercase.
- Avatar
- EngagementBar becomes a toolbar pill inside the column, sticky at the bottom on scroll:
role="toolbar"andsticky bottom-20 z-30 mx-auto mt-8 flex h-12 w-fit items-center gap-1 rounded-[var(--radius-lg)] border border-line bg-card/90 px-1.5 shadow-[var(--shadow-lift)] backdrop-blur-md.bottom-20clears the 48 px dock atbottom-4/md:bottom-6.- Groups are separated by
<span aria-hidden className="mx-1 h-5 w-px bg-line" />. - Every action is
ghost-icon(40 px). Counts arefont-mono text-[12px] tabular-nums. - The bottom instance (
ArticlePage.tsx:215) stays static:border-y border-line h-12 justify-between. Only the top instance becomes sticky, so only one toolbar is ever floating. - Clap active state:
text-fgwithanimate-clap-pop. The bubble usesbg-inverse text-on-inverse font-mono.
- Cover image:
mt-10 w-full rounded-[var(--radius-lg)] border border-line. It is not greyscaled, because the article is the user's content. - Drop cap: keeps the
--grad-brandclip, which is now grayscale.hruses grayscale--grad-brand. - Links in prose:
a:hoverbecomestext-decoration-thickness: 2px. The currenttext-accenthover is invisible once accent ≈ fg. - "Keep reading":
border-t border-line pt-12, kicker title, and PostCard rows. - Focus mode: TopBar, footer and "Keep reading" are hidden through
data-chrome(existing). The sticky EngagementBar must also carrydata-chrome, while the dock stays. - States:
- Loading:
ArticleSkeleton(exists). Bones use.shimmeron gray tokens. - Not found (404):
EmptyStatewithFileQuestionIcon, "This story doesn't exist or was removed.", and "Back to home". - Permission denied (403): same component with "You don't have access to this story." This already exists at
ArticlePage.tsx:128. - Error (network or 5xx): this currently falls into the 404 copy, which is wrong. Add a third branch: title "Couldn't load this story.", body "Check your connection and try again.", action
Button variant="outline" onClick={() => q.refetch()}. This is a new copy string; hand it to the Content Designer.
- Loading:
base: replacerounded-xlwithrounded-[var(--radius-md)]. Keep the rest. The focus outline staysoutline-accent, which is now ink: 16.5:1 or better.
| Variant | Classes |
|---|---|
primary |
bg-inverse text-on-inverse hover:-translate-y-px hover:shadow-[var(--shadow-lift)] shadow-[var(--shadow-soft)] |
accent |
alias of primary. Keep the key for call sites, and remove the coloured glow |
outline |
border border-line-strong bg-card text-fg hover:border-fg hover:bg-surface-2 |
outline-accent |
border border-fg text-fg hover:bg-inverse hover:text-on-inverse |
link |
h-auto px-0 text-fg underline-offset-4 hover:underline decoration-1 rounded-none |
ghost |
text-muted hover:text-fg hover:bg-surface-2 |
ghost-icon |
size-10 rounded-[var(--radius-md)] text-muted hover:text-fg hover:bg-surface-2 [&_svg]:size-5 (was size-6, which looked heavy at 1.5 stroke) |
danger (new) |
bg-danger text-surface hover:opacity-90. Destructive confirms currently borrow primary |
Sizes: unchanged (sm h-9 = 36 px, md 40, lg 44). All targets are ≥ 24 px for WCAG 2.2 AA (2.5.8).
States:
- Disabled:
disabled:opacity-50exists. Muted at 50 % falls below 4.5:1. That is allowed, because WCAG exempts inactive components. - Loading: spinner plus
aria-busy(exists).
- Overlay:
overlay-fade fixed inset-0 z-50 bg-overlay backdrop-blur-[2px]. - Content:
dialog-pop fixed inset-x-0 bottom-0 z-50 max-h-[90dvh] overflow-y-auto rounded-t-[var(--radius-xl)] border border-line bg-card p-6 shadow-[var(--shadow-lift)] md:inset-auto md:top-1/2 md:left-1/2 md:w-[480px] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-[var(--radius-xl)]. - Mobile grab handle (new, decorative):
<span aria-hidden className="mx-auto -mt-2 mb-4 block h-1 w-10 rounded-full bg-line-strong md:hidden" />. - Title:
font-display text-xl font-bold tracking-[-0.02em]. Description:mt-1 text-sm text-muted. - Close:
ghost-icon. Escape, overlay click and the focus trap come from Radix (unchanged). - Long content: it scrolls inside
max-h-[90dvh]. The title row does not stick; adding that is out of scope.
- Mount point is unchanged (
router.tsx:93). It must never getdata-chrome. - Trigger:
fixed right-4 bottom-4 z-40 size-12 rounded-[var(--radius-lg)] p-0 bg-inverse text-on-inverse shadow-[var(--shadow-lift)] md:right-6 md:bottom-6. Removeshadow-[…var(--accent)]. The icon rotation on hover/open stays. - Panel:
menu-pop z-50 max-h-[80dvh] w-[22rem] max-w-[calc(100vw-1.5rem)] overflow-y-auto rounded-[var(--radius-xl)] border border-line bg-card/95 p-4 shadow-[var(--shadow-lift)] backdrop-blur-xl. - Header:
kicker mb-3with the text "Reading settings". - Swatches:
grid grid-cols-6 gap-2. Each swatch isaspect-square rounded-[var(--radius-sm)] border border-line-strong. Selected:ring-2 ring-fg ring-offset-2 ring-offset-card. The secondary dot becomessize-2 rounded-[2px]. Show paper, graphite, linen, newsprint, slate, ink, carbon, midnight, nord, one-dark, gruvbox, mocha in that order. That needs a curated id list instead ofPALETTES.slice(0, 12), so the grays lead. - Selects:
h-9 w-44 rounded-[var(--radius-sm)] border border-line-strong bg-card px-2 text-sm text-fg. - Step keys:
h-9 w-11 rounded-[var(--radius-sm)] border border-line-strong. - Switch:
- Track off:
bg-line-strong(wasbg-lineat 1.4:1, which failed the 3:1 non-text rule). - Track on:
bg-fg. - Thumb:
bg-surface. - Track
rounded-fullis allowed. - Hit target: 44 × 24 px. That meets 2.5.8 (≥ 24).
- Track off:
- Collision: the sticky EngagementBar sits at
bottom-20, so it never overlaps the dock.
footer:mt-24 border-t border-line.- Inner:
mx-auto grid max-w-shell gap-8 px-4 py-10 md:grid-cols-[1fr_auto] md:px-6. - Left:
Wordmarkattext-[18px], followed by<p className="mt-2 font-mono text-[12px] text-muted">© {year} iBlog</p>. - Legal nav:
mt-4 flex flex-wrap gap-x-5 gap-y-2 font-mono text-[12px] uppercase tracking-[0.12em] text-muted. Links:hover:text-fg. - Right:
Preferences. Selects useh-9 rounded-[var(--radius-sm)] border border-line-strong bg-card px-2 text-[13px], which takes them from 32 px to 36 px. - Optional giant wordmark row (new, decorative,
aria-hidden):select-none overflow-hidden font-display text-[clamp(5rem,22vw,18rem)] leading-[0.8] font-extrabold tracking-[-0.06em] text-surface-2. Omit it if footer height is a concern.
EmptyState:- Wrapper:
mx-auto max-w-sm py-20 text-center. - Icon in a tile:
mx-auto grid size-14 place-items-center rounded-[var(--radius-lg)] border border-line bg-card, with the iconsize-6 text-mutedinside. - Title:
mt-5 font-display text-xl font-bold tracking-[-0.02em]. - Body:
mt-2 text-sm leading-6 text-muted.
- Wrapper:
Bone:.shimmerwithrounded-[var(--radius-xs)]. The gradient already uses--surface-2/--surface, so it turns gray automatically. Reduced motion stops the shimmer (exists,styles.css:394).Loadingkeeps the 150 msDelayed(exists), so fast loads do not flash.- Feed end of list:
py-10 text-center font-mono text-[12px] uppercase tracking-[0.12em] text-muted, text "You're all caught up". This is a new copy string. - Feed copy per tab, all existing:
followingsigned in with 0 follows:UsersIcon, title "Stories from writers you follow will appear here.", body "Follow writers from their profile or the sidebar."followingsigned out: the tab click opens the sign-in dialog instead (HomePage.tsx:67). That is the permission-denied path; it has no separate screen.latestwith 0 posts: "No stories yet."- Feed error: whatever
Feedrenders today. Checksrc/contexts/reading/ui/Feed.tsx, which I did not read. If it has no retry, addButton variant="outline""Try again".
rg -n "ff4fa3|ff9d3d|5b3df5|a495ff|b8acff|4a2ee0|9c4a52|bd93f9|fbf3f0|text-gradient|grad-brand|accent\)" src index.html
Each remaining hit must resolve to a grayscale value from section 1. The .text-gradient and --grad-brand hits are allowed, because the variables become gray.
- Tokens:
--line-strong/--color-line-strong,--radius-xs|sm|md|lg|xl,--ease-out(referenced but never defined today;styles.css:350usesvar(--ease-out, ease)), and dark-scoped--brand-2/--brand-3/--grad-brand/--shadow-*. - Palettes:
graphitereplacesdawnandcarbonreplacesdracula. The dock gets a curated swatch order. - Components:
HeroScene(three.js) and its static SVG fallback.Buttonvariantdanger.- Modal grab handle.
- TopBar desktop nav links (optional).
- Footer giant wordmark (optional).
- Removed:
Mesh, landingStoryCard,--font-italic, Bricolage Grotesque, Instrument Serif, and every coloured glow shadow. - Copy (to Content Designer): "Couldn't load this story." / "Check your connection and try again." / "You're all caught up".
- Writer editor, settings, stats and publication pages: tokens only.
- Interactive mocks: this deliverable is a spec by request. The HTML mock alternatives are not produced here.
aria-controlson Tabs.- Sticky modal headers.