Status: spec only. Implement in one pass. Supersedes the component-level parts of REDESIGN.md / REDESIGN-2.md where they conflict; data/route decisions in REDESIGN-2 still hold.
Verified source of every "current" claim: the files listed per section, read 2026-10-06. Tokens: src/styles.css.
Console = technical editorial / instrument panel. Everything sits in a bordered tile with a mono header strip (index number, label, count, keyboard hint). Corners are squared (4/8/10 px; nothing above 10 px except none). Depth comes from borders and a 2 px hard offset shadow on hover, never soft blur shadows. Metadata is indexed and tabular (01, #tag·12, 4 MIN, ▲ 1.2K). Actions are grouped toolbars of labelled keys with counts, not loose ghost icons. People are square monograms + @handle mono labels, not round photos beside a serif name. Reading cards are bento tiles (cover-as-background or split), not hairline rows. Selection is segmented / inverted fill, never underline. Grayscale only. Serif survives only inside .prose-article body text (user-selectable typeface) — nowhere in chrome.
| Medium signature (current location) | Console replacement |
|---|---|
Hairline-ruled list rows: PostCard.tsx:30 border-b py-6 |
Bento tile grid, .tile with header strip |
Round avatars: avatar.tsx:43 rounded-full |
Square monogram, `rounded-[var(--radius-xs |
Pill/black lifted button: button.tsx:9 shadow-soft + -translate-y-px |
Flat "key" button, inset bottom edge, mono caps label |
Ghost icon row bookmark/…/share: ArticlePage.tsx:35-77, PostCard.tsx:105 |
ActionDeck: segmented toolbar, each key = icon + label + count |
Underline tabs: modal.tsx:51-90 Tabs |
Tabs restyled as segmented with inverted fill (role=tab kept) |
Centered auth modal + big serif greeting: AuthDialog.tsx:153-161 |
Split-panel sheet: left spec panel (inverse), right form |
Right-drawer / quiet comment list: Responses.tsx:105 ruled li |
Threaded "log" tiles with line numbers, square monograms, labelled action keys |
Plain text topic pills: TopicChips.tsx:4 |
Bracketed hash tags with count well: #devops 12 |
Sparse white chrome: TopBar.tsx:185 transparent header |
Framed top bar with segmented nav + ⌘K command field + status rail |
Soft-shadow cards .card-premium, .panel |
.tile + .tile-hover (hard offset) |
Use only existing vars; stop using --radius-lg (16) and --radius-xl (20) in components.
| Use | Var | px |
|---|---|---|
| kbd, tags, avatar xs/sm, count wells | --radius-xs |
4 |
| buttons, inputs, menu items, segmented items, avatar md/lg | --radius-sm |
8 |
| tiles, menus, modals, sheets, toasts, avatar xl/2xl | --radius-md |
10 |
One variable reassignment (flagged, not a new value): in the :root block at styles.css:302 change --radius-card: var(--radius-lg) to --radius-card: var(--radius-md). That moves every .panel / .card-premium user to 10 px.
They only combine existing tokens. NEW (flag for design system): .tile, .tile-hover, .tile-head, .kbd, .tag, .tag-count, .seg, .seg-item, .key, .idx, .hatch.
@layer components {
/* Bordered surface. Replaces .panel / .card-premium in components. */
.tile { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius-md); }
/* Hover = border goes solid + 2px hard offset, no blur. */
.tile-hover { transition: transform 160ms var(--ease-out), box-shadow 160ms var(--ease-out), border-color 160ms; }
.tile-hover:hover, .tile-hover:focus-within { border-color: var(--fg); transform: translate(-2px, -2px); box-shadow: 2px 2px 0 0 var(--fg); }
@media (prefers-reduced-motion: reduce) { .tile-hover:hover, .tile-hover:focus-within { transform: none; } }
/* Mono header strip inside a tile. */
.tile-head {
display: flex; align-items: center; gap: 0.75rem; min-height: 2.25rem; padding: 0 0.75rem;
border-bottom: 1px solid var(--line); background: var(--surface-2);
border-radius: calc(var(--radius-md) - 1px) calc(var(--radius-md) - 1px) 0 0;
font-family: var(--font-mono); font-size: 0.6875rem; line-height: 1rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted);
}
/* Zero-padded index numerals: 01, 02 … */
.idx { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--muted); }
/* Keyboard hint. Decorative: always aria-hidden. */
.kbd {
display: inline-grid; place-items: center; height: 1.25rem; min-width: 1.25rem; padding: 0 0.25rem;
border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: var(--radius-xs);
background: var(--card); font-family: var(--font-mono); font-size: 0.6875rem; line-height: 1; color: var(--muted);
}
/* Hash tag with count well. */
.tag {
display: inline-flex; align-items: center; gap: 0.375rem; height: 1.75rem; max-width: 14rem; padding: 0 0.25rem 0 0.5rem;
border: 1px solid var(--line-strong); border-radius: var(--radius-xs); background: var(--card);
font-family: var(--font-mono); font-size: 0.75rem; color: var(--fg); white-space: nowrap; transition: background-color 120ms, border-color 120ms, color 120ms;
}
.tag:hover { border-color: var(--fg); background: var(--surface-2); }
.tag[aria-current="page"] { background: var(--inverse); color: var(--on-inverse); border-color: var(--inverse); }
.tag-count { border-radius: 2px; background: var(--surface-2); padding: 0 0.25rem; font-size: 0.6875rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.tag[aria-current="page"] .tag-count { background: color-mix(in srgb, var(--on-inverse) 18%, transparent); color: var(--on-inverse); }
/* Segmented control shell + item (used by Lens, Tabs, ActionDeck, ReadingPanel). */
.seg { display: inline-flex; align-items: stretch; gap: 2px; padding: 2px; border: 1px solid var(--line-strong); border-radius: var(--radius-sm); background: var(--surface-2); }
.seg-item {
display: inline-flex; align-items: center; justify-content: center; gap: 0.375rem; height: 2rem; padding: 0 0.75rem;
border-radius: calc(var(--radius-sm) - 3px); font-family: var(--font-mono); font-size: 0.75rem; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--muted); transition: background-color 120ms, color 120ms; cursor: pointer;
}
.seg-item:hover { color: var(--fg); background: var(--card); }
.seg-item[aria-checked="true"], .seg-item[aria-selected="true"], .seg-item[aria-pressed="true"] { background: var(--inverse); color: var(--on-inverse); }
/* "Key" = one labelled cell in an action toolbar. */
.key {
display: inline-flex; align-items: center; gap: 0.5rem; height: 2.5rem; padding: 0 0.75rem;
font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums;
transition: background-color 120ms, color 120ms; cursor: pointer;
}
.key:hover { background: var(--surface-2); color: var(--fg); }
.key[aria-pressed="true"] { color: var(--fg); }
.key:disabled, .key[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
/* Diagonal hatch for empty media / placeholders (replaces giant letter). */
.hatch { background: repeating-linear-gradient(-45deg, var(--surface-2) 0 6px, var(--card) 6px 12px); }
}Delete from components (keep CSS definitions until a later cleanup pass so nothing else breaks): card-premium, ring-grad, text-gradient usages in the listed files. Drop cap rule (styles.css:360-370) stays — body copy only.
| Pair | Light | Dark | Requirement |
|---|---|---|---|
text-muted on bg-surface-2 (tile-head, tag-count) |
#525252/#e3e3e3 ≈ 6.1:1 | #a3a3a3/#1e1e1e ≈ 6.6:1 | 4.5:1 text |
text-muted on bg-card |
#525252/#fff ≈ 7.8:1 | #a3a3a3/#141414 ≈ 7.6:1 | 4.5:1 |
on-inverse on inverse (selected seg, primary) |
#f5f5f5/#0a0a0a ≈ 18:1 | #0a0a0a/#ededed ≈ 17:1 | 4.5:1 |
border-line-strong vs bg-card (control edges) |
#8a8a8a/#fff ≈ 3.5:1 | #6b6b6b/#141414 ≈ 3.4:1 | 3:1 non-text |
border-line |
≈ 1.5:1 | ≈ 1.4:1 | decorative only; never the sole state cue |
Rule: any control whose boundary conveys "this is clickable" uses border-line-strong. border-line only on tiles whose content is itself a link with visible text.
Smallest new targets: .tag 28 px high, .seg-item 32 px, .key 40 px, Button sm 32 px, carousel index keys 24×24. All pass AA. Icon-only buttons stay 40×40.
Only .tile-hover offset (160 ms), seg fill (120 ms), existing dialog-pop / menu-pop. Remove hover:-translate-y-px lifts, group-hover:rotate-45 arrow spins, group-hover:scale-[1.04] image zoom. All motion already collapses under prefers-reduced-motion (styles.css:215-224).
Anatomy: [icon?] LABEL [kbd?] — flat key, mono caps label, 2 px inset bottom edge on filled variants (reads as a physical key, not a pill).
const base =
"relative inline-flex items-center justify-center gap-2 rounded-[var(--radius-sm)] font-mono text-[12px] font-medium uppercase tracking-[0.08em] whitespace-nowrap transition-[color,background-color,border-color,box-shadow] duration-150 cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none";
const sizes = { sm: "h-8 px-3", md: "h-10 px-4", lg: "h-12 px-5 text-[13px]" };
const primary =
"bg-inverse text-on-inverse shadow-[inset_0_-2px_0_0_color-mix(in_srgb,var(--on-inverse)_22%,transparent)] hover:bg-accent-hover active:shadow-none active:translate-y-px motion-reduce:active:translate-y-0";
const variants = {
primary,
accent: primary,
outline: "border border-line-strong bg-card text-fg hover:border-fg hover:bg-surface-2 active:bg-surface-2",
"outline-accent": "border border-fg bg-card text-fg hover:bg-inverse hover:text-on-inverse",
link: "h-auto px-0 font-sans text-[15px] normal-case tracking-normal font-medium text-fg underline decoration-line-strong decoration-1 underline-offset-4 hover:decoration-fg rounded-[var(--radius-xs)]",
ghost: "text-muted hover:text-fg hover:bg-surface-2",
"ghost-icon": "size-10 rounded-[var(--radius-sm)] border border-transparent text-muted hover:text-fg hover:border-line-strong hover:bg-surface-2 [&_svg]:size-[18px]",
danger: "bg-danger text-surface hover:opacity-90 shadow-[inset_0_-2px_0_0_rgb(0_0_0/0.2)]",
};| State | Treatment |
|---|---|
| hover | filled: bg-accent-hover; outline: border to fg, bg surface-2. No lift. |
| focus-visible | 2 px outline-accent, 2 px offset (unchanged). |
| active | filled: inset edge removed + 1 px down = key press. |
| disabled | opacity-40, cursor-not-allowed. |
| loading | Behavior change (a11y fix): keep children in the DOM so the accessible name survives; overlay the spinner. Render <span className={loading ? "invisible" : "contents"}>{children}</span> plus, when loading, <Loader2Icon className="absolute size-4 animate-spin" aria-hidden />. Width no longer jumps. aria-busy kept. |
Removed: active:scale-[0.97], hover:-translate-y-px, shadow-[var(--shadow-soft|lift)].
Note: text-transform: uppercase does not change DOM text; Testing Library / Playwright names ("Sign in", "Post note", "Ship it", "Ship now", "Necessary only", "Accept all", "Verify", "Sign up") are unaffected.
Anatomy: square monogram tile. Initials in mono on surface-2, inset line-strong frame, bottom-right 3 px notch (index-card corner). Photos get the same square crop, grayscale.
const sizes = {
xs: "size-5 text-[9px] rounded-[var(--radius-xs)]",
sm: "size-6 text-[10px] rounded-[var(--radius-xs)]",
md: "size-8 text-[11px] rounded-[var(--radius-sm)]",
lg: "size-11 text-sm rounded-[var(--radius-sm)]",
xl: "size-22 text-2xl rounded-[var(--radius-md)]",
"2xl": "size-30 text-3xl rounded-[var(--radius-md)]",
};
// Root
"relative inline-flex shrink-0 overflow-hidden bg-surface-2 ring-1 ring-inset ring-line-strong"
// Image
"size-full object-cover grayscale contrast-[1.05]"
// Fallback (replace hueFor(name) + text-white)
"flex size-full items-center justify-center bg-surface-2 font-mono font-medium tracking-tight text-fg after:absolute after:right-0 after:bottom-0 after:size-[3px] after:bg-fg"hueForstays exported (other files may import it; grep shows it only inavatar.tsxtoday) but is no longer applied. Safe to delete in cleanup.- Remove
rounded-fulleverywhere an Avatar is wrapped (TopBar trigger, skeletons — see their sections). - a11y: unchanged (
labelledlogic,aria-labelon fallback when unlabelled). - Placement rule (new): an Avatar is never the first element of a byline row. Bylines are
@handlemono text; avatars appear only in identity tiles (Profile header, Writer tile, account menu, People list, Composer header).
Modal anatomy: tile with header strip. [● ] TITLE ............ [esc] [×] then body, then optional footer bar.
<Dialog.Overlay className="overlay-fade fixed inset-0 z-50 bg-overlay" /> // drop backdrop-blur: flatter, cheaper
<Dialog.Content className={cn(
"dialog-pop fixed inset-x-0 bottom-0 z-50 max-h-[90dvh] overflow-y-auto border border-line-strong bg-card rounded-t-[var(--radius-md)] md:inset-auto md:top-1/2 md:left-1/2 md:w-[520px] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-[var(--radius-md)] md:shadow-[4px_4px_0_0_var(--fg)]",
className)}>
<div className="tile-head sticky top-0 z-10 justify-between rounded-none md:rounded-t-[calc(var(--radius-md)-1px)]">
<span className="flex items-center gap-2"><span aria-hidden className="size-1.5 bg-fg" /> <span aria-hidden>Dialog</span></span>
<span className="flex items-center gap-2"><kbd className="kbd hidden md:inline-grid" aria-hidden>esc</kbd>
<Dialog.Close asChild><Button variant="ghost-icon" aria-label="Close" className="-mr-2 size-8">…</Button></Dialog.Close></span>
</div>
<div className="p-5 md:p-6">
<Dialog.Title className="font-display text-xl font-bold tracking-[-0.02em]">{title}</Dialog.Title>
<Dialog.Description …/> // unchanged
<div className="mt-5">{children}</div>
</div>
</Dialog.Content>- Remove the mobile grab-handle pill (
modal.tsx:33) — the header strip is the handle. - Hard 4 px offset shadow on desktop is the signature "sheet on a desk" cue. Mobile: none (bottom sheet).
- Title stays
Dialog.Title(accessible name unchanged). "Dialog" strip text isaria-hidden. - Close button name stays
"Close".
Tabs (modal.tsx:51) — keep role="tablist"/"tab", aria-selected, API. Remove the underline motion.span. New:
<div role="tablist" className="seg">
<button role="tab" aria-selected={…} className="seg-item shrink-0">{label}</button>Add roving arrow-key handling identical to Lens.move (currently missing on Tabs; a11y fix: APG tabs pattern needs Left/Right), and tabIndex={selected ? 0 : -1}. e2e uses getByRole("tab", { name: "Latest" }) / "Your lists" — names unchanged.
Lens (modal.tsx:93) — keep radiogroup + keyboard. Shell cn("seg", className); item "seg-item" (drop aria-checked:bg-card aria-checked:shadow-[var(--shadow-lift)]). Selected = inverted fill, not a raised white chip. Add optional index prefix: render <span aria-hidden className="idx text-[10px] opacity-70">{String(i+1).padStart(2,"0")}</span> before text. Accessible name stays text because the prefix is aria-hidden (LandingPage test getByRole("radio", { name: "Newest" }) safe).
Bone: "shimmer rounded-[var(--radius-xs)]" unchanged.
PostCardSkeleton must mirror the new tile (2.6 / 3.1):
<div className="tile overflow-hidden">
<div className="tile-head"><Bone className="h-2.5 w-8" /><Bone className="h-2.5 w-20" /><Bone className="ml-auto h-2.5 w-12" /></div>
<div className="grid gap-4 p-4 sm:grid-cols-[1fr_9rem]">
<div className="space-y-2"><Bone className="h-5 w-11/12" /><Bone className="h-5 w-2/3" /><Bone className="h-3.5 w-full" /></div>
<Bone className="hidden aspect-[4/3] w-full sm:block" />
</div>
<div className="flex gap-2 border-t border-line p-2"><Bone className="h-7 w-20" /><Bone className="h-7 w-16" /><Bone className="ml-auto h-7 w-24" /></div>
</div>Remove the round Bone size-5 rounded-full (states.tsx:35).
EmptyState anatomy: dashed tile with status code line.
<div className="mx-auto my-10 max-w-md rounded-[var(--radius-md)] border border-dashed border-line-strong bg-card px-6 py-12 text-center">
<span className="mx-auto grid size-12 place-items-center rounded-[var(--radius-sm)] border border-line-strong hatch"><Icon className="size-5 text-fg" aria-hidden /></span>
<p className="kicker mt-5" aria-hidden>{code ?? "status · empty"}</p>
<h2 className="mt-2 font-display text-xl font-bold tracking-[-0.02em]">{title}</h2>
{body ? <p className="mt-2 text-sm leading-6 text-muted">{body}</p> : null}
{action ? <div className="mt-6 flex justify-center gap-2">{action}</div> : null}
</div>New optional prop code?: string. Call-site values: Feed error → "err · fetch failed", article 5xx → "err · offline", 404 → "404 · not found", 403 → "403 · no access", Following empty → "0 · circle empty", default → "0 · nothing here". aria-hidden, so headings/text tests ("No stories found.", etc.) unchanged.
Loading unchanged (sr-only "Loading", aria-busy, 150 ms delay).
Anatomy: inset-labelled field. The label sits inside the box as a mono caption on top; input below. Not a floating-label animation (that hides the label behind placeholder state and fails when autofilled) — a fixed inset label, which reads as instrument and keeps label visible always.
┌ EMAIL OR USERNAME ───────────────┐
│ ada@example.com [⌫] │
└──────────────────────────────────┘
↳ error text / hint
// box wraps label + control
const box = "group/field relative rounded-[var(--radius-sm)] border border-line-strong bg-card px-3 pt-2 pb-1.5 transition-colors focus-within:border-fg focus-within:shadow-[2px_2px_0_0_var(--fg)] has-[[aria-invalid=true]]:border-danger";
const labelCls = "block font-mono text-[10.5px] leading-4 uppercase tracking-[0.1em] text-muted group-focus-within/field:text-fg";
export const inputCls = "h-7 w-full bg-transparent text-[15px] text-fg outline-none placeholder:text-muted";Fieldrenders<div className={box}><label htmlFor={id} className={labelCls}>{label}</label>{children(a)}</div>then message. Message: error"mt-1.5 flex items-center gap-1.5 font-mono text-[12px] text-danger"prefixed with<span aria-hidden>!</span>; hint"mt-1.5 font-mono text-[11px] text-muted".inputClscontract change: it is now the bare control inside aFieldbox. Call sites that useinputClsoutsideFieldmust use newexport const inputBoxCls = "h-11 w-full rounded-[var(--radius-sm)] border border-line-strong bg-card px-3 text-[15px] outline-none focus:border-fg focus:shadow-[2px_2px_0_0_var(--fg)] aria-[invalid=true]:border-danger". GrepinputClsand switch any non-Field usage. Textareas inside Field:cn(inputCls, "h-auto min-h-20 py-1 resize-y").PasswordInput: toggle becomes a mono text keySHOW/HIDE("absolute top-1/2 right-0 -translate-y-1/2 h-7 rounded-[var(--radius-xs)] px-2 font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted hover:bg-surface-2 hover:text-fg"). aria-labels"Show password"/"Hide password"unchanged. Inputpr-16.ServerError:"flex items-start gap-2 rounded-[var(--radius-sm)] border border-danger bg-card p-3 font-mono text-[12px] text-danger"with leading<span aria-hidden>ERR</span>;role="alert"kept.- a11y: label remains a real
<label for>, sogetByLabelText("Email or username" | "Password" | "Email" | "Username" | "New password" | …)all hold. Invalid state not by color alone: error text +!glyph. - Disabled field:
has-[:disabled]:opacity-50 has-[:disabled]:bg-surface-2on box.
Anatomy: counter + segmented index + prev/next keys. [01 / 08] ▮▯▯▯▯ [‹][›].
btn:"grid size-9 place-items-center rounded-[var(--radius-sm)] border border-line-strong bg-card text-fg transition-colors hover:bg-inverse hover:text-on-inverse disabled:pointer-events-none disabled:opacity-30"(droprounded-full,shadow-soft).- Dots become index bars, each a 24×24 hit area:
className="grid h-6 w-6 place-items-center"wrapping<span aria-hidden className={cn("h-1 w-4 rounded-[1px] transition-colors", active ? "bg-fg" : "bg-line-strong group-hover:bg-muted")} />. Fixes current 6 px dot target (carousel.tsx:89,h-1.5 w-1.5), which fails SC 2.5.8 unless spacing exception applies. - Add counter before bars:
<span className="idx text-[11px]" aria-hidden>{pad(index+1)} / {pad(pages)}</span>. - Overlay variant:
"absolute top-3 right-3 z-10 rounded-[var(--radius-sm)] border border-line-strong bg-card py-1 pr-1 pl-2"(droprounded-full,bg-card/85 backdrop-blur). - Names unchanged:
"Go to slide N","Previous","Next". - Slide gap:
pr-3→pr-4to read as separate tiles.
Status-bar style. Change <Toaster position="bottom-center" /> to (position top-center decided in 7.4: nothing fixed lives at the top, so it can't collide with CookieBanner or ReadingDock):
<Toaster position="top-center" toastOptions={{ unstyled: true, classNames: {
toast: "flex w-[min(92vw,380px)] items-center gap-3 rounded-[var(--radius-sm)] border border-line-strong bg-inverse px-3 py-2.5 font-mono text-[12px] text-on-inverse shadow-[3px_3px_0_0_var(--line-strong)]",
error: "border-danger",
title: "leading-5",
icon: "hidden",
}}} />And prefix in toast.ts: no API change; the status glyph comes from CSS before: — add toast: "... before:content-['OK'] before:rounded-[2px] before:bg-on-inverse/15 before:px-1 before:text-[10px]" and error: "... before:content-['ERR']". Sonner still announces through its live region.
Kill the ruled index row. Bento tile, two layouts chosen by data:
- A. Split (has cover): text left, cover right (
sm:grid-cols-[1fr_11rem]), cover full-bleed to tile edge. - B. Text-only (no cover): wider excerpt, left
hatchgutter strip (6 px) as the visual anchor.
┌─────────────────────────────────────────────────┐
│ 07 · #DEVOPS 12 MAR 2026 · 4 MIN │ ← tile-head
├──────────────────────────────┬──────────────────┤
│ Title in display bold, 2–3 │ │
│ lines max │ cover (gray) │
│ excerpt muted 2 lines │ │
│ @handle │ │
├──────────────────────────────┴──────────────────┤
│ [#devops 1] [#k8s] ▲ 1.2K ◻ 14 [SAVE][⋯]│ ← action strip
└─────────────────────────────────────────────────┘
<Reveal as="div" y={10}>
<article className="tile tile-hover group relative overflow-hidden">
<header className="tile-head">
{index !== undefined ? <span className="idx text-fg">{String(index + 1).padStart(2, "0")}</span> : null}
{post.tags[0] ? <span className="truncate">#{post.tags[0]}</span> : <span>Story</span>}
<span className="ml-auto flex items-center gap-2 tabular-nums">
<time dateTime={at}>{shortDate(at)}</time><span aria-hidden>·</span><span>{readTime(post.reading_time)}</span>
</span>
</header>
<div className={cn("grid", cover ? "sm:grid-cols-[1fr_11rem]" : "")}>
<div className={cn("min-w-0 p-4 md:p-5", !cover && "border-l-[6px] border-l-transparent [border-image:repeating-linear-gradient(-45deg,var(--line)_0_4px,transparent_4px_8px)_1]")}>
<a href={href} className="block after:absolute after:inset-0 after:z-0"> {/* whole tile clickable */}
<h2 className="line-clamp-3 font-display text-[19px] leading-6 font-bold tracking-[-0.02em] md:line-clamp-2 md:text-[22px] md:leading-7"><Bio>{post.title}</Bio></h2>
{text ? <p className="mt-2 line-clamp-2 text-[14px] leading-[22px] text-muted"><Bio>{text}</Bio></p> : null}
</a>
<div className="relative z-10 mt-3">{byline}</div> {/* byline: font-mono text-[12px] text-muted, "@author" — drop "by " */}
</div>
{cover ? (
<div className="relative order-first aspect-[16/9] border-b border-line bg-surface-2 sm:order-none sm:aspect-auto sm:border-b-0 sm:border-l">
<img … className="absolute inset-0 size-full object-cover grayscale contrast-[1.05]" />
</div>
) : null}
</div>
<footer className="relative z-10 flex flex-wrap items-center gap-2 border-t border-line px-2 py-1.5">
{post.tags.slice(0, 2).map((t) => <Link … className="tag">#{t}</Link>)}
{post.claps ? <span className="key h-7 cursor-default px-2 hover:bg-transparent"><ZapIcon className="size-3.5" aria-hidden />{compact(post.claps)}<span className="sr-only"> sparks</span></span> : null}
{post.comments_count ? <span className="key h-7 cursor-default px-2 hover:bg-transparent"><MessageSquareIcon className="size-3.5" aria-hidden />{compact(post.comments_count)}<span className="sr-only"> responses</span></span> : null}
<div className="ml-auto flex items-center rounded-[var(--radius-sm)] border border-line-strong"><BookmarkButton post={post} compact /><span aria-hidden className="h-6 w-px bg-line" /><PostMenu post={post} /></div>
</footer>
</article>
</Reveal>- New optional prop
index?: number— Feed passes its map index. Pinned / other call sites omit it. - Removed: left 7 rem mono meta column, ArrowUpRight hover badge, cover hover colorize/zoom,
border-b py-6. - Stretched-link pattern (
after:absolute after:inset-0) makes the tile clickable while tags/save/menu sit onz-10. Only one link per tile is the title (no duplicate tab stops). - Image
onErrorhides the cover cell → falls back to layout B; keep handler, change target toclosest("[data-cover]")and adddata-coverto the cover div. Feed.test.tsx:16expects text"1.2K"— preserved (count still rendered as text).- Icon change:
MessageCircleIcon→MessageSquareIcon(square speech = less Medium). Decorative only. - Long-content: title clamps 3/2 lines; tag
max-w-[14rem] truncate;@handletruncates; 2+ tags overflow wraps to second row in footer. - Byline copy change:
"by @ada"→"@ada".
- List:
<div>→<ol className="grid gap-3" aria-label="Stories">with<li>per card; passindex={i}. Loading:<div className="grid gap-3">around skeletons. - "Show more":
<Button variant="outline" size="sm">Show more <span className="idx text-[11px]" aria-hidden>+{pageSize}</span></Button>→ keep text "Show more" first so the name starts with it (name becomes "Show more +20" only if not aria-hidden; it is aria-hidden, so name stays "Show more"). If page size isn't on the query result, omit the badge. - End marker:
<p className="mt-6 flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.12em] text-muted"><span className="h-px flex-1 bg-line" />EOF · {posts.length} loaded<span className="h-px flex-1 bg-line" /></p>. Copy change: "You're all caught up" → "EOF · N loaded". Grep for the old string in tests before shipping (none found in the test grep). - Error / empty: pass
codeper 2.4.
chip→"tag snap-start shrink-0"; content<span className="text-muted" aria-hidden>#</span>{t}.aria-current="page"styling handled by.tag[aria-current].- Accessible name change: currently the link name is
"#devops"; with#aria-hidden it becomes"devops". Listed rename. No test queries tag link names (verified in grep above). If you want zero change, droparia-hiddenon the#. - Optional counts: add prop
counts?: Record<string, number>; when present render<span className="tag-count">{compact(n)}<span className="sr-only"> stories</span></span>. Sidebar passes counts fromtagsQuery(it hast.count, seeHomePage.tsx:170). - Nav wrapper: add a tile-head-style label only when used standalone? No — callers own headings. Keep
nav aria-label="Topics". - Overflow (non-wrap): add right fade
after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:w-8 after:bg-gradient-to-l after:from-surfaceonnav.
Replace divide-y ruled column + border-l with stacked tiles.
<aside aria-label="Recommendations" className="sticky top-[4.5rem] hidden h-[calc(100dvh-5rem)] space-y-3 overflow-y-auto py-6 [scrollbar-width:none] lg:block">
<section className="tile" aria-labelledby="sb-rising">
<h2 id="sb-rising" className="tile-head">Rising <span className="ml-auto idx">24h</span></h2>
<ol className="divide-y divide-line">
<li><a href=… className="group grid grid-cols-[2rem_1fr] gap-2 px-3 py-2.5 hover:bg-surface-2">
<span aria-hidden className="idx pt-0.5 text-[12px] group-hover:text-fg">01</span>
<span className="min-w-0"><span className="line-clamp-2 font-display text-[14px] leading-5 font-bold">{title}</span>
<span className="mt-0.5 block truncate font-mono text-[11px] text-muted">@{author} · {shortDate}</span></span></a></li>- "24h" strip label: unverified that trending is a 24 h window — drop it unless
trendingQueryconfirms; otherwise showtop {n}. - Topics tile:
tile-head"Topics" +<div className="p-3"><TopicChips … wrap counts={…} /></div>. - Writers tile: rows
flex items-center gap-3 px-3 py-2.5withAvatar size="md"(square), nametext-sm font-semibold truncate,@handle · N readersmono 11 px,FollowButton size="sm". - Footer: inside last tile-less row,
font-mono text-[11px] uppercase tracking-[0.12em]. - Empty Rising:
"No signal yet."inpx-3 py-4 font-mono text-[12px] text-muted. Copy change from "Nothing rising yet." - Loading: bones inside the tile body (same structure).
- Error states currently not handled for any section (
Sidebar.tsx:37treats error as empty). Draw: error →"Couldn't load."+Button variant="ghost" size="sm""Retry" callingrefetch(). New behavior, small.
All three become keys (.key) so they compose into the ActionDeck (4.2); they keep working standalone.
BookmarkButton
- New optional prop
compact?: boolean(icon-only, used in PostCard footer). Default renders icon + label. - Full:
<button className={cn("key", className)} aria-pressed={on} aria-label={on ? "Remove from saved" : "Save for later"}>icon +<span aria-hidden>{on ? "Saved" : "Save"}</span>. - Compact:
className="key size-9 justify-center px-0". - On: icon
BookmarkCheckIcon fill-current text-fg, label "Saved", plusaria-pressedstyling (text-fg). Spring scale animation kept. - aria-labels unchanged (
actions.test.tsxuses "Save for later").
ClapButton (Spark)
className="key relative"; content:ZapIcon size-4,<span aria-hidden className="hidden sm:inline">Spark</span>, count in a well<span className="tag-count min-w-6 text-center">.- Own story:
disabled(opacity via.key:disabled), title kept. - Maxed:
aria-disabled, showMAXin the well instead of count? No — count must stay visible; append<span className="idx text-[10px]" aria-hidden>MAX</span>. - Burst: rays become 2×2 px squares (
rounded-none) — on-language. - aria-label unchanged (
"Spark. N sparks total…", tests match/^Spark/).
FollowButton (Track)
- Default (
outline):[+ TRACK]; on:[✓ TRACKING]withvariant="outline"+bg-surface-2. Add leadingPlusIcon size-3.5when off. - Hover-on-tracking reveals intent: label swaps to "Untrack" via
group-hover:(two spans, onehidden group-hover:inline), aria-label already "Stop tracking X". Both spansaria-hidden; name stays from aria-label. linkvariant: keep, but styled per Buttonlink.
Anatomy: one segmented, bordered toolbar with labelled keys and counts. Docked (static) at article end; floating copy is a compact horizontal status bar at bottom center on all breakpoints (remove the xl left vertical rail — that floating vertical icon column is a Medium "clap rail" echo).
┌──────────────────────────────────────────────────────────────────────┐
│ ⚡ SPARK [1.2K] │ ◻ DISCUSS [14] │ ⌑ SAVE │ ≡ LIST │ ⧉ COPY LINK │ ⋯ │
└──────────────────────────────────────────────────────────────────────┘
<div role="toolbar" aria-label="Post actions" data-chrome={sticky ? "" : undefined}
className={cn("flex items-stretch divide-x divide-line overflow-x-auto rounded-[var(--radius-md)] border border-line-strong bg-card [scrollbar-width:none]",
sticky ? "sticky bottom-4 z-30 mx-auto mt-10 w-fit max-w-full shadow-[3px_3px_0_0_var(--fg)]" : "mt-12 w-full")}>
<ClapButton post={post} />
<button type="button" onClick={onResponses} className="key" aria-label={`Discussion, ${post.comments_count}`}>
<MessageSquareIcon className="size-4" aria-hidden /><span aria-hidden className="hidden sm:inline">Discuss</span>
<span aria-hidden className="tag-count">{post.comments_count}</span></button>
<BookmarkButton post={post} />
<SaveToList postId={post.id} /> {/* restyle its trigger to className="key" + label "List" */}
<button type="button" className="key" aria-label="Copy link" …><LinkIcon className="size-4" aria-hidden /><span aria-hidden className="hidden md:inline">Copy link</span></button>
<PostMenu post={post} />
{!sticky ? <span className="ml-auto hidden items-center gap-2 px-3 font-mono text-[11px] text-muted md:flex" aria-hidden><kbd className="kbd">S</kbd> spark <kbd className="kbd">D</kbd> discuss</span> : null}
</div>- Keyboard hints: shown only if shortcuts are implemented. Out of scope to add shortcuts in this pass → omit the hint span unless the implementer also wires
S/D(would need guards likeSearchBox's/). Default: omit. - Toolbar a11y: add roving tabindex (Left/Right) per APG toolbar pattern — currently every button is a tab stop. Optional in this pass; note in Risks.
- Names unchanged: toolbar "Post actions",
/^Spark/,/^Discussion/(aria-label keeps "Discussion, N"), "Copy link", "Save for later". - Count
0shows0(not empty string) in the Discuss well — explicit zero reads as instrument. - Mobile ≤ 360 px: labels hidden below
sm, deck scrolls horizontally; never wraps.
Anatomy: command-list menu with mono section header and leading glyph column.
const item = "flex cursor-pointer items-center gap-3 rounded-[var(--radius-xs)] px-2.5 h-9 text-[13px] text-fg outline-none data-[highlighted]:bg-inverse data-[highlighted]:text-on-inverse [&_svg]:size-4 [&_svg]:text-muted data-[highlighted]:[&_svg]:text-on-inverse";
<DropdownMenu.Content align="end" sideOffset={6} className="menu-pop z-50 w-64 overflow-hidden rounded-[var(--radius-md)] border border-line-strong bg-card shadow-[3px_3px_0_0_var(--fg)]">
<DropdownMenu.Label className="tile-head rounded-none">Tune feed</DropdownMenu.Label>
<div className="p-1">…items with EyeOffIcon / UserXIcon / HashIcon…</div>
<DropdownMenu.Separator className="h-px bg-line" />
<div className="p-1"><DropdownMenu.Item className={cn(item, "text-danger data-[highlighted]:bg-danger data-[highlighted]:text-surface")}><FlagIcon/> Report story…</DropdownMenu.Item></div>- Highlight = inverted row (keyboard-console feel), not gray tint.
- Trigger:
Button variant="ghost-icon"keeps nameMore options for {title}; in PostCard footer it sits inside the segmented pair (3.1); sizesize-9. - Long names: "Hide stories by {author}" →
truncateon a<span className="min-w-0 truncate">. - Copy unchanged.
- Reasons become selectable option tiles (radio cards), 2-col on md:
label:"flex cursor-pointer items-center gap-3 rounded-[var(--radius-sm)] border border-line-strong bg-card px-3 h-11 text-sm has-[:checked]:border-fg has-[:checked]:bg-inverse has-[:checked]:text-on-inverse has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-accent"; input stays a native radio but visuallysr-only; add a leadingidx01…. fieldset:grid gap-2 md:grid-cols-2; legend becomes visiblekicker"Reason" (was sr-only) — clearer.- Note field uses new inset
Field; textareacn(inputCls, "h-auto min-h-24 resize-y py-1"). - Footer:
flex items-center justify-between border-t border-line pt-4withfont-mono text-[11px] text-muted"{n}/1000" counter (watchnote) and Submit (primary). Name "Submit report" unchanged.
Not a drawer today (inline section) — keep inline; restyle as a discussion log.
Section header: tile-head-like bar, not a big serif-ish H2 floating:
<section … className="scroll-mt-24 pt-4">
<div className="flex items-center justify-between gap-4 rounded-t-[var(--radius-md)] border border-line-strong bg-surface-2 px-4 h-11">
<h2 id="discussion-title" className="font-display text-base font-bold tracking-tight">Discussion</h2>
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted tabular-nums">{count} {count === 1 ? "note" : "notes"} · live</span>
</div>
<div className="rounded-b-[var(--radius-md)] border border-t-0 border-line-strong bg-card">
<div className="border-b border-line p-3"><Composer … /></div>
…list / states…
</div>
</section>- "· live" only when
useLiveCommentsis connected; unverified whether that hook exposes status → if not, omit "· live". - Heading text "Discussion" kept (
ArticlePage.test.tsx:26).
Composer:
- Signed out:
<button className="flex w-full items-center justify-between rounded-[var(--radius-sm)] border border-dashed border-line-strong px-3 h-11 font-mono text-[12px] text-muted hover:border-fg hover:text-fg">"Add a note to the discussion…" +<span className="kbd" aria-hidden>↵</span>. Removemx-6 w-[calc(100%-3rem)]hack. - Signed in: inset box
"rounded-[var(--radius-sm)] border border-line-strong bg-card focus-within:border-fg focus-within:shadow-[2px_2px_0_0_var(--fg)]". When open: header rowtile-head rounded-t-[calc(var(--radius-sm)-1px)]withAvatar size="xs"+@{me.username}+ rightidx{text.length}/5000. Textareapx-3 py-2. Footerflex justify-end gap-2 border-t border-line p-2with Cancel (ghost) and "Post note" (primary) +<kbd className="kbd" aria-hidden>⌘↵</kbd>inside the button. - New behavior: Cmd/Ctrl+Enter submits (textarea
onKeyDown). Small; if skipped, remove the kbd. - Name "Post note", textarea labels unchanged.
Item:
0001 ┌ [AD] @ada · 3h · edited ─────────────────┐
│ text │
│ [▲ 4] [↳ REPLY] [◻ 1 reply] … [✎][🗑] │
li(depth 0):"grid grid-cols-[3rem_1fr] border-b border-line last:border-b-0"; left gutter<span aria-hidden className="idx pt-4 pl-3 text-[11px]">{pad4(n)}</span>(n = position in tree). Body"py-4 pr-4".- Header:
Avatar size="sm"(square) +font-mono text-[12px]:<span className="text-fg font-medium">@{author}</span> · <time>{timeAgo}</time>+(edited)astag-count. Removes name-over-date stacked Medium byline. - Text:
mt-2 text-[15px] leading-6(unchanged). - Actions:
<div className="mt-3 inline-flex divide-x divide-line rounded-[var(--radius-sm)] border border-line-strong">keys ath-8: Like (ArrowBigUpIcon+ count well; aria-label kept), Replies toggle (text "{n} reply/replies" kept as visible text —Responses.test.tsx:25expects name "1 reply"; do not add aria-hidden extras inside it), Reply (text "Reply"). Own: Edit / Delete icon keyssize-8with existing aria-labels ("Edit response", "Delete response"). Others: Report key ("Report response"). - Replies:
ul"mt-3 space-y-0 border-l-2 border-dashed border-line-strong pl-4"— dashed rail, not Medium's 3 px solid quote bar. Depth-1 items: no gutter numbers,py-3. - Editing textarea: inset box classes from Composer; remove
font-serif. - Delete confirm: still
confirm()(unchanged behavior; native dialog). Noted in Risks. - States: loading → three Bone rows
h-16inside the log; error → inline row"flex items-center justify-between p-4 font-mono text-[12px] text-muted""ERR · Couldn't load the discussion." + "Try again"; empty →"p-8 text-center font-mono text-[12px] text-muted""0 notes. Start the discussion." (copy change from "No notes yet. Start the discussion.").
Split-panel sheet. Left: inverse "spec panel" (brand, mode, three proof lines). Right: form with segmented mode switch. Desktop 880×560; mobile: full-screen, spec panel collapses to a 56 px inverse header strip.
┌──────────────────────┬────────────────────────────────────┐
│ i Blog v1 │ [ SIGN IN | CREATE ACCOUNT ] [×] │
│ │ │
│ ACCESS / │ Welcome back. │
│ 01 Track writers │ ┌ EMAIL OR USERNAME ───────────┐ │
│ 02 Save for later │ └──────────────────────────────┘ │
│ 03 Spark & discuss │ ┌ PASSWORD ───────────── SHOW ─┐ │
│ │ └──────────────────────────────┘ │
│ ░░░ hatch ░░░░░░░░░ │ [ SIGN IN → ] │
│ terms · privacy │ forgot password? │
└──────────────────────┴────────────────────────────────────┘
<Dialog.Overlay className="overlay-fade fixed inset-0 z-50 bg-overlay" />
<Dialog.Content className="dialog-pop fixed inset-0 z-50 grid grid-rows-[auto_1fr] overflow-y-auto bg-card md:inset-auto md:top-1/2 md:left-1/2 md:h-[560px] md:w-[880px] md:-translate-x-1/2 md:-translate-y-1/2 md:grid-cols-[320px_1fr] md:grid-rows-1 md:overflow-hidden md:rounded-[var(--radius-md)] md:border md:border-line-strong md:shadow-[6px_6px_0_0_var(--fg)]">
{/* spec panel */}
<aside aria-hidden className="flex items-center gap-3 bg-inverse px-5 h-14 text-on-inverse md:h-auto md:flex-col md:items-stretch md:justify-between md:p-7">
<Wordmark className="text-[18px] [&>span:first-child]:bg-on-inverse [&>span:first-child]:text-inverse" />
<ol className="hidden space-y-3 font-mono text-[12px] md:block">
<li className="opacity-60 uppercase tracking-[0.12em] text-[11px]">{mode === "signup" ? "New account /" : "Access /"}</li>
<li><span className="opacity-60">01</span> Track writers you like</li>
<li><span className="opacity-60">02</span> Save stories for later</li>
<li><span className="opacity-60">03</span> Spark and join discussions</li>
</ol>
<div className="hidden h-24 rounded-[var(--radius-sm)] opacity-20 md:block [background:repeating-linear-gradient(-45deg,var(--on-inverse)_0_1px,transparent_1px_8px)]" />
</aside>
{/* form panel */}
<div className="relative flex flex-col px-6 py-8 md:overflow-y-auto md:px-12 md:py-10">
<div className="flex items-center justify-between gap-3">
<div role="radiogroup" aria-label="Account" className="seg">…two radios: Sign in / Create account → authDialog.open(...)</div>
<Dialog.Close asChild><Button variant="ghost-icon" aria-label={t("auth.close")}>…</Button></Dialog.Close>
</div>
<Dialog.Title className="mt-8 font-display text-[26px] leading-8 font-bold tracking-[-0.03em]">{mode === "signup" ? t("auth.join") : t("auth.welcomeBack")}</Dialog.Title>
<Dialog.Description …sr-only unchanged />
<div className="mt-6 w-full max-w-[380px]">{form}</div>
<Agreement /> {/* mt-auto pt-8, font-mono text-[11px] text-muted, max-w-[380px] */}
</div>
</Dialog.Content>- Spec panel copy is new (3 lines, English hard-coded like other new strings; add i18n keys
auth.spec1..3,auth.specAccess,auth.specNewif the implementer wants parity — flagged). - Mode switch conflict (important): the segmented control would expose a radio named "Sign in" while the submit button is also "Sign in".
AuthDialog.test.tsx:20and e2ereader.spec.ts:10usegetByRole("button", { name: "Sign in" })— a radio is roleradio, so no collision for role queries. But the e2e step atreader.spec.ts:7clicks the TopBar "Sign in" button, then line 10 the dialog's — still unique asbutton. Safe. Radios labelled viat("auth.signIn")/t("auth.createOne"). - Remove the bottom "Don't have an account? Create one" line (replaced by the segmented switch). Copy removal —
t("auth.haveAccount"),t("auth.noAccount")become unused (keep keys). - Submit buttons:
size="lg" className="w-full justify-between"with trailingArrowRightIcon size-4 aria-hidden. Names unchanged. - "Forgot password":
font-mono text-[12px] text-muted underline decoration-line-strong underline-offset-4 hover:text-fgleft-aligned under the button. - MFA step: Field label
t("auth.code"); inputfont-mono text-[20px] tracking-[0.4em] tabular-nums; add hint "6-digit code from your app" (unverified code length — checkmfaInputSchema; if not fixed 6, use "Code from your authenticator app"). - Turnstile sits in an inset box
rounded-[var(--radius-sm)] border border-line p-2. Welcome back.text kept (reader.spec.ts:57).- Focus: Radix traps focus; first field keeps
autoFocus. The segmented switch is before the title in DOM — acceptable; autofocus lands on the field. - Spec panel
aria-hidden(decorative/duplicative). Wordmark there has no link.
Anatomy: control panel — rows are LABEL ............ [control] with mono labels; steppers show their value; switches become square toggles with ON/OFF text.
- Container:
"divide-y divide-line"→"divide-y divide-line rounded-[var(--radius-sm)] border border-line";row→"flex items-center justify-between gap-4 px-3 h-12";label→"font-mono text-[11px] uppercase tracking-[0.1em] text-muted". select:"h-9 w-44 rounded-[var(--radius-sm)] border border-line-strong bg-card px-2 font-mono text-[12px] text-fg". LabelsTheme/Fontstay<label htmlFor>(test usesgetByLabelText("Theme"|"Font")).- Size / Margin: replace two loose keys with a stepper
seg:[ − ] 3/7 [ + ]:Margin buttons keep names "Wider margin" / "Narrower margin". Disabled:<div className="seg items-center"> <button className="seg-item w-9 px-0" aria-label="Smaller text" …>A−</button> <span className="idx min-w-10 text-center text-[11px]" aria-live="polite">{p.typeStep - TYPE_STEP.min + 1}/{TYPE_STEP.max - TYPE_STEP.min + 1}</span> <button className="seg-item w-9 px-0" aria-label="Larger text" …>A+</button> </div>
disabled:opacity-40. - Switch: square track, mono state text.
Names unchanged ("Bold text", "Bionic reading", "Focus mode", "Full screen"). State conveyed by position + text, not color alone.
<button role="switch" aria-checked={on} aria-label={name} onClick=… className={cn("relative inline-flex h-7 w-14 items-center rounded-[var(--radius-xs)] border border-line-strong font-mono text-[10px] uppercase transition-colors", on ? "bg-inverse text-on-inverse" : "bg-surface-2 text-muted")}> <span aria-hidden className={cn("absolute top-0.5 size-[22px] rounded-[2px] bg-card border border-line-strong transition-[left]", on ? "left-[calc(100%-24px)]" : "left-0.5")} /> <span aria-hidden className={cn("w-full", on ? "pl-1.5 text-left" : "pr-1.5 text-right")}>{on ? "On" : "Off"}</span> </button>
- Swatches:
grid-cols-6 gap-1.5, eachrounded-[var(--radius-xs)], selected =outline-2 outline-fg outline-offset-2+ a 2 px inner corner tick; removehover:scale-110. Note: swatch fills come from palette data (x.light,x.secondary) — some non-gray palettes (nord, gruvbox, mocha) introduce color. Owner said grayscale only: decision needed (see Risks). Spec default: keep, since these are user-chosen reading themes, not chrome. - ReadingDock trigger:
size-12 rounded-[var(--radius-md)] bg-inverse→ keep size, radius--radius-sm, add hard shadowshadow-[3px_3px_0_0_var(--line-strong)], remove gear rotate on hover (keep on open). Name "Reading settings" unchanged. - Popover:
"menu-pop z-50 max-h-[80dvh] w-[22rem] max-w-[calc(100vw-1.5rem)] overflow-y-auto rounded-[var(--radius-md)] border border-line-strong bg-card shadow-[4px_4px_0_0_var(--fg)]"(drop/95+backdrop-blur-xl). Header:tile-head -mx-0 rounded-t-[…]"Reading settings" +<kbd className="kbd ml-auto" aria-hidden>esc</kbd>; bodyp-3. Footer "Reset to defaults" asButton variant="ghost" size="sm"left + rightidx"prefs · local" (aria-hidden).
Anatomy: framed console bar, always bordered (not transparent-until-scroll):
┌[i]Blog │ [LATEST|SAVED|TOPICS] │ [⌕ Search stories, topics, writers… ⌘K] │ ◐ │ [✎ WRITE] [🔔3] [AD▾] ┐
header:"sticky top-0 z-40 h-14 border-b border-line-strong bg-card"; on scroll addshadow-[0_2px_0_0_var(--line)](no glass).useScrolledkept.- Inner:
"mx-auto flex h-full max-w-shell items-center gap-4 px-4 md:px-6"; vertical separators<span aria-hidden className="hidden h-6 w-px bg-line md:block" />between Logo | nav | search. - Primary nav: wrap links in
<div className="seg">;navLink→"seg-item h-7 data-[status=active]:bg-inverse data-[status=active]:text-on-inverse". Names unchanged. - SearchBox → command field:
- Box:
"hidden h-9 w-72 items-center gap-2 rounded-[var(--radius-sm)] border border-line-strong bg-surface-2 px-2.5 font-mono text-[12px] text-muted focus-within:border-fg focus-within:bg-card focus-within:shadow-[2px_2px_0_0_var(--fg)] md:flex lg:w-96"— no width animation. - Leading
SearchIcon size-4; trailing<kbd className="kbd" aria-hidden>⌘K</kbd>(showCtrl Kon non-Mac vianavigator.platformcheck) and existing/. - New shortcut: Cmd/Ctrl+K focuses the input (extend existing keydown handler;
preventDefaultonly when meta/ctrl+k). Escape blurs + clears. This is a command field, not a full palette — a modal palette with results requires a search-suggest endpoint (not verified to exist) → out of scope; field submits to/searchas today. - Placeholder copy: keep
t("nav.search"); label unchanged.
- Box:
- Bell:
Button ghost-iconwith border on hover (per 2.1); badge:"absolute -top-0.5 -right-0.5 grid h-4 min-w-4 place-items-center rounded-[var(--radius-xs)] bg-inverse px-1 font-mono text-[10px] leading-none text-on-inverse ring-2 ring-card"(square, not round). - UserMenu trigger:
"flex h-9 items-center gap-1.5 rounded-[var(--radius-sm)] border border-line-strong pl-0.5 pr-1.5 hover:border-fg data-[state=open]:border-fg"containingAvatar size="md"(square, sizedsize-7via className) +ChevronDownIcon size-3.5 text-muted. Name "Account menu" (t key) unchanged. - Menu content: same as PostMenu 4.3 (inverted highlight, hard shadow). Add
DropdownMenu.Label className="tile-head":@{me.username}; move the@usernameout of the sign-out row. Add right-alignedkbdhints only where shortcuts exist (none today) → none. - Write link:
Buttonprimarysize="sm"look (inline classes from 2.1 primary), iconPenLineIcon size-4. Removehover:-translate-y-px. - Signed out: "Sign in"
ghost, "Get started" primary. Names unchanged (e2e line 7). - Height change 64 → 56 px: update
top-16sticky offsets inHomePage.tsx:402,ProfilePage.tsx:225,Sidebar.tsx:24totop-14, andh-[calc(100dvh-4rem)]→h-[calc(100dvh-3.5rem)]. ArticlePagescroll-mt-24OK.
Anatomy: status-bar footer + spec grid, not a giant faded wordmark.
- Remove the
text-[clamp(5rem,22vw,18rem)]"iBlog" block (Footer.tsx:73-78) — giant ghost wordmark is decorative fluff. - Layout:
footer"mt-24 border-t border-line-strong bg-card"; inner"mx-auto grid max-w-shell gap-px bg-line md:grid-cols-[1.2fr_1fr_1fr]"with three cellsbg-card p-6: (1) Wordmark + rights line, (2)kicker"Legal" + nav as vertical list, (3)kicker"Preferences" +<Preferences />stacked. - Bottom status bar:
"border-t border-line px-4 md:px-6 h-9 flex items-center justify-between font-mono text-[11px] uppercase tracking-[0.12em] text-muted"→ left"● online"(size-1.5 bg-fgsquare dot, only if there's a connectivity signal; else show build/version if available — unverified, default to"RSS · /api/feed.xml"link), right"© {year}". - Preferences selects:
font-mono text-[12px], radius sm,border-line-strong. Names "Language"/"Theme" (t keys) unchanged; Uzbek "Mavzu" test unaffected. - Legal names unchanged ("Privacy Policy", "Terms of Use", "Cookie settings").
- Mark:
size-7 rounded-[var(--radius-xs)](was sm) +shadow-[2px_2px_0_0_var(--line-strong)]; removegroup-hover:rotate-90→ replace withgroup-hover:translate-x-[-1px] group-hover:translate-y-[-1px] group-hover:shadow-[3px_3px_0_0_var(--fg)]. - Wordmark text:
font-display font-extrabold tracking-[-0.05em]kept; add trailing<span aria-hidden className="ml-1 self-start font-mono text-[9px] font-medium tracking-[0.1em] text-muted">BETA</span>— only if the product is in beta (unverified; default omit). - Link name "iBlog home" unchanged.
Status-bar / system-prompt style docked bottom-left (doesn't overlap ReadingDock bottom-right).
<section data-chrome aria-label={t("cookie.title")}
className="fixed bottom-3 left-3 z-40 w-[min(calc(100vw-1.5rem),420px)] overflow-hidden rounded-[var(--radius-md)] border border-line-strong bg-card shadow-[4px_4px_0_0_var(--fg)] md:bottom-6 md:left-6">
<div className="tile-head rounded-none"><span aria-hidden className="size-1.5 bg-fg" /> <h2 className="font-mono text-[11px]">{t("cookie.title")}</h2><span className="ml-auto idx" aria-hidden>1/1</span></div>
<p className="p-4 text-[13px] leading-5 text-muted">{body} <Link …underline decoration-line-strong>more</Link></p>
<div className="grid grid-cols-2 border-t border-line">
<button className="h-11 font-mono text-[12px] uppercase tracking-[0.08em] text-fg hover:bg-surface-2 border-r border-line">{t("cookie.necessary")}</button>
<button className="h-11 bg-inverse font-mono text-[12px] uppercase tracking-[0.08em] text-on-inverse hover:bg-accent-hover">{t("cookie.accept")}</button>
</div>
</section>- Region name "Cookies", buttons "Necessary only" / "Accept all" unchanged. Equal-weight buttons (same size) — better consent UX than a primary-dominant pair; Accept still visually primary.
- Toast position conflict: Sonner moved bottom-left (2.7). Mitigation: Sonner
offset={{ bottom: 24, left: 24 }}and when banner visible addmobileOffset… simpler: put Toaster atbottom-centeron mobile only, keep bottom-left md+. Pick:position="bottom-center"+ desktop CSS override is fiddly → Decision: Toasterposition="top-center". Status bars at the top don't collide with any fixed bottom element. Use that instead of bottom-left in 2.7.
- Header (
358-394): framed "session bar":"mt-6 grid gap-px overflow-hidden rounded-[var(--radius-md)] border border-line-strong bg-line md:grid-cols-[1fr_auto]"cellsbg-card p-5 md:p-6. Left:kickerdate, H1text-[28px] md:text-[36px](smaller; the 44 px greeting is editorial-magazine). Right cell: actions stacked + mini statdl(Saved N) asfont-mono. Copy unchanged. - Spotlight (
47-137): lead slide = cover-as-background bento:a→"tile tile-hover group relative flex h-full min-h-[380px] flex-col justify-end overflow-hidden"; imageabsolute inset-0 grayscale+ overlayabsolute inset-0 bg-gradient-to-t from-inverse via-inverse/70 to-transparent; text blockrelative p-6 text-on-inverse(title, excerptopacity-80, meta mono). No cover →hatchbackground, text intext-fg(no overlay). Badge#{i+1} today→ tile-head-style chip"absolute top-3 left-3 rounded-[var(--radius-xs)] border border-on-inverse/30 bg-inverse px-2 py-1 font-mono text-[11px] uppercase text-on-inverse". Remove rotating arrow circle; replace with"→ READ"mono text right-aligned. Remove giant first-letter placeholder.- Contrast: a 70 %
inversescrim over a white photo pixel is about #4d4d4d, soon-inversetext on it is only about 7.6:1 in light mode. In dark modeinverseis #ededed and the text is dark, so a black photo pixel under the scrim drops to about 4.3:1, which fails. Fix: keep the text in the bottom 45 %, where the scrim is solid or nearly solid. Usebg-gradient-to-t from-inverse from-35% via-inverse/85 via-55% to-transparent. Design QA should check this on real covers in both modes. - Rising column:
tile+tile-head"Rising"; rows as Sidebar (3.4). Numeralsidx text-[13px](not gianttext-linedisplay numerals — those fail contrast as text at #d4d4d4 on white 1.5:1; they're aria-hidden but still a visual-only cue; switching totext-mutedfixes it).
- Contrast: a 70 %
- TopicRail (
139-177): tiles →.tagwith counts, height 32:className="tag h-8 text-[12.5px]"+tag-count. "All topics →" →Button variant="ghost" size="sm""All topics" +<kbd className="kbd" aria-hidden>→</kbd>. - Desk panels:
panelconst →"tile";panelHead→"tile-head"with theh2inside it as text and link right (ml-auto normal-case tracking-normal font-mono text-[11px] hover:text-fg); bodyp-3. Saved rows: thumbnailsrounded-[var(--radius-xs)], no-cover thumb →hatchsquare. Drafts: addidx01-03 gutter. Writers: square avatars (automatic). - Stream (
398-420): H2 "The stream" →kicker-sized bar? Keep H2 for structure buttext-lg; sticky lens bartop-14,bg-surface(drop/90 backdrop-blur-md→ solid withborder-b border-line).StreamCount→"idx text-[11px]""N stories". - Loading: Spotlight bones
rounded-[var(--radius-md)].
- Kicker (
171):"#tag"→ bordered breadcrumb<nav aria-label="Breadcrumb" className="mb-6 flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.12em] text-muted"><Link to="/">Stream</Link><span aria-hidden>/</span><Link to="/tag/$tag">#{tag}</Link></nav>. New links; adds 2 tab stops. - H1: unchanged sizes (display, extrabold) — title is the one editorial moment.
- Meta
dl(181-220): already tile-grid (good, Console-native). Changerounded-[var(--radius-lg)]→--radius-md,border-line→border-line-strong, addidxprefixes in eachdt(01 Writer,02 Published, …) as aria-hidden spans. Writer cell: addAvatar size="xs"before@handle— allowed here because it's an identity tile, not a byline row. - Cover:
rounded-[var(--radius-md)] border-line-strong grayscale; caption strip belowfont-mono text-[11px] text-muted"FIG. 01 — cover" (aria-hidden). - EngagementBar → ActionDeck (4.2). Remove
sepconst. - TopicChips at end: wrap in
tilewithtile-head"Filed under". - "About the writer" (
254-273): identity tile:"tile grid gap-4 overflow-hidden md:grid-cols-[auto_1fr_auto] md:items-center"→tile-head"Written by" spans full width; bodyp-5withAvatar size="xl"(square 88 px), namefont-display text-xl,@handle · N readersmono, bio, actions right. Name/links unchanged. - Skeleton:
Bone size-11 rounded-full→rounded-[var(--radius-sm)]. - Error/empty: pass
code(2.4). Copy unchanged. - Next up carousel: cards →
tile tile-hoverwithtile-head{pad(i+1)} · {readTime}; body title +@author. Numerals no longertext-line(contrast, see 8.1).
Anatomy: dossier header.
┌ PROFILE / @ada ───────────────────────── joined 12 Mar 2024 ┐
│ ┌────┐ Ada Lovelace [+ TRACK] [⋯] │
│ │ AL │ bio… │
│ └────┘ │
├──────────────┬──────────────┬──────────────┬────────────────┤
│ 01 STORIES 42│ 02 READERS 1.2K │ 03 TRACKING 88 │ │
└──────────────┴──────────────┴──────────────┴────────────────┘
- Merge header card + stats into one tile:
"tile overflow-hidden";tile-head"Profile / @{username}" + right "joined {date}"; bodygrid gap-6 p-6 md:grid-cols-[auto_1fr_auto] md:items-centerwithAvatar size="2xl"(square 120). H1text-[32px] leading-9. Statsdlas bottom rowgrid grid-cols-3 gap-px border-t border-line bg-linecellsbg-card px-4 py-3,dtkicker with idx,ddfont-mono text-2xl tabular-nums. Readers/Tracking buttons keep aria-labels; stylehover:bg-surface-2on the whole cell (buttonw-full text-left) instead of underline. - ProfileMenu content: per 4.3. Name "More options" unchanged.
- Section Lens: sticky
top-14, solidbg-surface border-b border-line. Labels unchanged. - Pinned:
tile-head-style label above card:"mb-2 flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.12em] text-muted"+PinIcon size-3.5; PostCard without index. Removeborder-bwrapper. - People modal rows:
tile-less listdivide-y divide-linerowspy-2.5with square avatars,@handlemono under name. "Show more" →Button variant="outline" size="sm". - Empty lists:
EmptyStateinstead of bare<p>("No public lists.",code="0 · lists"). - Loading skeleton: add square
Bone size-30 rounded-[var(--radius-md)]. - 404:
code="404 · not found".
- Top bar (
374-409):h-12 border-b border-line-strong bg-card(solid). Left: back link (unchanged copy "Stories"), then aseg-styled status cell group:[● SAVED] [312 WORDS] [2 MIN]—"inline-flex h-7 divide-x divide-line rounded-[var(--radius-xs)] border border-line-strong font-mono text-[11px] uppercase text-muted [&>span]:px-2 [&>span]:flex [&>span]:items-center [&>span]:gap-1.5".aria-live="polite"stays only on the status span. "Couldn't save" state: that spantext-danger border-danger. - Right: History
ghost-icon; Preview/Edit as a 2-itemsegwitharia-pressedbuttons (seg-itemstylesaria-pressed) — keep a single toggle button? Keep single button (aria-pressed) styledseg-iteminside asegshell for visual parity — behavior unchanged. Save draftoutline sm; Ship it primary sm +<kbd className="kbd border-on-inverse/30 bg-transparent text-on-inverse/70" aria-hidden>⌘S</kbd>only if ⌘S wired (out of scope → omit). - Writing surface: title
font-display text-[40px] leading-[48px]; subtitle removefont-serif→font-sans text-[22px] leading-8 text-muted; body textarea removefont-serif→font-mono text-[16px] leading-7(Markdown source = code-like, instrument-native; preview shows the serif). Left gutter:maingetsmd:border-l md:border-line md:pl-8and a sticky mono label column"01 TITLE / 02 DEK / 03 BODY"(aria-hidden) onlg. - Markdown help line →
<p className="mt-4 flex flex-wrap gap-2 font-mono text-[11px] text-muted">with each syntax askbd. - Preview: wrap in
tilewithtile-head"Preview · rendered". - PublishDialog (
96-228): right drawer → keep as right sheet (it's a checklist, not comments; acceptable), restyle:bg-card border-l border-line-strong, no soft shadow.step→"tile overflow-hidden",stepHead→"tile-head"withidx01–04 + h3 (text-[11px]inherits) + right status glyph:✓when satisfied (cover set / ≥1 tag / always / time valid) else—, aria-hidden, with sr-only "complete"/"optional" text. Bodyp-3.- Cover drop zone:
border-dashed border-line-strong hatchon empty; focus-within outline on label (input is sr-only — addhas-[:focus-visible]:outline-2 …so keyboard users see focus; current code has no visible focus there — a11y fix). - TopicsInput: container → inset field box (2.5
box); tokens →.tagwith remove×button"grid size-5 place-items-center rounded-[2px] hover:bg-inverse hover:text-on-inverse"(aria-label "Remove {t}" kept — test "Remove go"). Counteridx{n}/5right. - Byline select / datetime:
inputBoxCls. - Footer: sticky bar
bg-card border-t border-line-strong; button full-width primary; names "Ship now" / "Schedule" unchanged.
- Cover drop zone:
- Revisions modal: version list →
seg-like vertical list: buttons"w-full rounded-[var(--radius-xs)] px-3 py-2 text-left font-mono text-[12px] hover:bg-surface-2 aria-[current=true]:bg-inverse aria-[current=true]:text-on-inverse"+aria-currenton selected (a11y: replaces font-weight-only cue). Diff: add line-number gutter,+rowsbg-surface-2 font-medium,−rowstext-muted line-through— removesbg-accent/15andbg-danger/15:accentis near-black so/15is gray anyway; danger red tint conflicts with grayscale-only. Danger red stays only for destructive/error text.
Unchanged (verified against tests): Sign in, Sign up, Verify, Email or username, Password, Username, Email, Close, Reading settings, Bionic reading, Focus mode, Bold text, Full screen, Larger text, Smaller text, Wider margin, Narrower margin, Theme, Font, Language, Post actions, Spark…, Discussion…, Copy link, Save for later, Remove from saved, Post note, Write a note, Write a reply, Edit response, Delete response, Report response, 1 reply / N replies, More options, More options for {title}, Track/Stop tracking {u}, Ship it, Ship now, Schedule, Save draft, Title, Subtitle, Story body (Markdown), Remove {tag}, Add a topic, Revision history, Necessary only, Accept all, Cookie settings, Cookies (region), iBlog home, Privacy Policy, Terms of Use, Show/Hide password, Go to slide N, Previous, Next, Newest (radio), Account menu, Notifications, Submit report, heading "Discussion", text "Welcome back.", text "1.2K".
Renamed / new (explicit):
| Where | Old | New |
|---|---|---|
| TopicChips link | #devops |
devops (if # aria-hidden; optional) |
| TopicChips with counts | #devops |
devops 12 stories |
| AuthDialog | — | new radiogroup "Account" with radios "Sign in" / "Create one" (t("auth.createOne")) |
| AuthDialog | "Don't have an account? Create one" button | removed |
| ArticlePage | — | new nav "Breadcrumb" with links "Stream", "#{tag}" |
| BookmarkButton (non-compact) | icon only | visible "Save"/"Saved" text is aria-hidden; name unchanged |
| Feed end text | "You're all caught up" | "EOF · N loaded" |
| Responses empty | "No notes yet. Start the discussion." | "0 notes. Start the discussion." |
| Sidebar empty | "Nothing rising yet." | "No signal yet." |
| Byline | "by @ada" | "@ada" |
Stale e2e (pre-existing, not caused by this spec): e2e/reader.spec.ts:40 expects tab "Latest" (home now uses Lens radios "Newest"); :67/:70 expect "Publish" / "Publish now" (editor uses "Ship it"/"Ship now"). Flag to QA; unit tests reflect current names.
styles.css§1.2 classes +--radius-cardremap.button.tsx,avatar.tsx,form.tsx(+inputBoxCls, grepinputClscall sites),modal.tsx,states.tsx,carousel.tsx, Toaster inrouter.tsx.TopicChips,PostCard(+index),Feed,Sidebar.actions.tsx,PostMenu,ReportDialog,Responses.AuthDialog,ReadingPanel.TopBar(+top-14offsets),Footer,Logo,CookieBanner.- Pages: Home, Article (ActionDeck), Profile, Editor.
- Run unit tests +
bun runlint; fix only class/structure regressions.
- No
rounded-fullon avatars, buttons, chips, carousel controls, badges (greprounded-fullin the listed files: only Switch knob history and spinners may remain — Switch is now square too). - No
rounded-[var(--radius-lg)]/--radius-xlin listed files. - No
shadow-[var(--shadow-soft)]/--shadow-lift/backdrop-blurin listed files except none. - No
font-serifoutside.prose-article. - No underline indicator in
Tabs. - Every PostCard is a
.tilewithtile-head. - Article actions render as one bordered toolbar with visible labels ≥
sm. - Auth is split-panel on md+.
- All names in §9 "Unchanged" still resolve (unit tests green).
- Keyboard: Tabs and Lens arrow-navigable; ⌘K/Ctrl+K and
/focus search; focus ring visible on cover drop zone. - Light + dark checked for every component (tokens swap; hard shadows use
var(--fg)so they invert correctly).