This documents what's actually been decided/established in the codebase, not an
aspirational design system. Most of the app (admin table, editor, moderation queue,
history views) is still plainly-styled inline React style objects with no unifying
palette — that's fine and not a gap to close. The conventions below are the ones that
are real (used more than once, or deliberately chosen) as of 2026-07-21.
This previously said the reverse (inline as the norm, a stylesheet only once a
pseudo-class or media query forced the issue) and credited that policy to CLAUDE.md's
Conventions section, which doesn't actually say that — CLAUDE.md only points here for
"CSS Modules vs. inline," it doesn't prescribe a default. Inline-first also isn't
standard practice: it skips the cascade, can't be shared or deduped by the bundler, and
loses the pseudo-class/media-query runway immediately, as the exceptions below show.
Default to a CSS Module (co-located; same name as the component/page, unless it's
meant to be shared across several — see AdminTable.module.css below); reserve inline
style={{...}} for values that are genuinely computed per-instance — a color read
from data, a measured width — not for fixed rules.
Most of the existing surface (admin tables, editor, moderation queue, history views)
still carries inline styles written under the old policy and isn't being migrated
retroactively just for this — that's a separate, larger cleanup, not a correction to
make in passing. components/table/AdminTable.module.css, components/CommentsTable.module.css,
and app/comments/page.module.css are the first modules created under the corrected
default (not pseudo-class/media-query driven, unlike everything below); the rest predate
it. The first two are a deliberate pair, not a single file, and illustrate the "same name
as the component, unless shared" clause above concretely:
table/AdminTable.module.cssis named for the shared concept (styling common to the admin tables) rather than after one component. It was written that way whileCommentsTablewas still its only consumer; every admin table uses it now, and it moved intocomponents/table/alongside the kit that owns it (see the admin-table kit section below). Holds what's judged generic: cell/header padding, sortable-column cursor, the soft-deleted-rowopacity, the shared delete/restore icon button, table margins, the filter-row layout, the multi-select filter dropdown, the pagination bar, the row-status border, the bulk toolbar, and the querystring help panel.CommentsTable.module.cssis co-located and named forCommentsTable.tsxin the usual way, holding only what's judged comment-moderation-specific and unlikely to ever generalize: the Approve/Pend/Spam colors and matching status text colors (comment-status semantics that will never apply to a post or a user row), plus.postColumn's claim-the-slack width trick. It originally also held the bulk-action toolbar and the querystring help panel; both turned out to generalize after all and moved to the shared file when every table grew them (PLAN.md §16d) — the "would this also make sense onPostsTable?" test called those two wrong, which is worth remembering as evidence the test is a judgment call rather than a rule.
The dividing line was "would this style also make sense on PostsTable/UsersTable if
they adopted the same pattern?" — not "is this reused more than once" (nothing here
is reused yet, since AdminTable.module.css had one consumer at the time). A style with a plausible
future consumer among the other admin tables goes in the shared file; a style that
encodes something specific to comment moderation doesn't, no matter how tempting it is to
lump every table-adjacent style into one file:
| File | Needs a stylesheet for |
|---|---|
PostEditor.module.css |
:hover (toolbar/quote menu), @media (mobile toolbar) |
styles/prose.module.css |
shared across editor + public rendering; .quote-highlight.pulse keyframe animation; reads the per-thread --thread-color custom property (see Color palette) |
app/page.module.css, app/authors/[id]/page.module.css, app/[slug]/page.module.css |
:hover-only underline on post-title links |
components/CommentSection.module.css, CommentNode.module.css, CommentForm.module.css |
none needed a pseudo-class directly, but were pulled into modules alongside QuoteThreadHeader for consistency when that pass happened (see git history 2026-07-20) |
components/QuoteThreadHeader.module.css |
state-dependent color pairs (.arrowActive/.arrowDetached etc.) previously done as inline conditional values; .arrowActive/.barActive also read --thread-color |
components/PostSettingsPanel.module.css |
same state-dependent-class rationale as QuoteThreadHeader — .draggableRow/.dragOver toggle on drag state, .checkboxRow conditionally combines them; no pseudo-class/media-query is used, but juggling three conditional classes per row as inline style objects would be worse than the module |
app/doc/[slug]/page.module.css |
the doc reading view (PLAN.md §12n). No pseudo-class or media query — created because the alternative was a fresh set of fixed-value inline style objects, which the default above rules out. Also carries a state-dependent class pair (.container plus .containerScrubbable, the latter reserving room for the pinned scrub footer only when it renders), and a .byline mirroring app/[slug]/page.module.css's rather than restating those six declarations inline |
components/DocEditor.module.css, DocSettingsPanel.module.css |
doc-side siblings of PostEditor.module.css/PostSettingsPanel.module.css (PLAN.md §12k) — @media (narrow container) and the same .draggableRow/.dragOver state pair, respectively. Trimmed to only what DocEditor/DocSettingsPanel themselves need: the toolbar/editor-frame/caret rules a doc's editor also uses live in PostEditor.module.css still, since CollabEditorBody (which owns them) is reused unmodified rather than forked. DocEditor.module.css also gained the [data-empty]::before + attr(data-placeholder) title placeholder (PLAN.md §12n, added 2026-07-29) — the same content: attr(...) trick a plain ::before rule needs, which inline style can't express |
Numeric constants that also drive non-CSS geometry (e.g. QuoteThreadHeader's
HEAD_WIDTH/HEAD_HEIGHT, used in both the SVG viewBox and a CSS width) stay as
JS constants passed via inline style, not hardcoded into the module — splitting a
value that two systems depend on invites drift.
- Every color in the app is a token defined once in
globals.css, driven by the OS'sprefers-color-schemevia CSSlight-dark()— see the Dark theme section below for the full model.--foreground/--backgroundare the longest-standing tokens; every other role (surfaces, borders, status colors, the link color, the anchor-highlight tints) has its own. There is no untokenized color left insrc/outsideglobals.cssitself,lib/author-colors.ts(the author palette + theNEUTRAL_THREAD_COLORfallback),lib/safe-css.ts(a validation regex),manifest.ts, andlayout.tsx'sviewport.themeColor— see the grep guard in the Dark theme section for the enforced allow-list. bodyfont:Arial, Helvetica, sans-serif— the sitewide default. Individual page containers currently override this (see Typography below); nothing has unified it.* { margin: 0; padding: 0; box-sizing: border-box; }— a hard reset. Anything rendering list/blockquote content needs to restore spacing explicitly (seeprose.module.css, and the CLAUDE.md gotcha about it).- Links:
a { color: var(--link); text-decoration: none; },a:hover { text-decoration: underline; }— sitewide default for every link except post titles (below).
| Token | Light | Dark | Role | Where |
|---|---|---|---|---|
--background |
#ffffff |
#0a0a0a |
Page background | body |
--surface |
#ffffff |
#1e1e1e |
Elevated panel/popover background | SiteHeader's nav dropdown, EditorChrome's quote menu, every annotation/doc-link popover, AdminTable's sticky filter dropdown |
--surface-muted |
#f6f6f6 |
#161616 |
Recessed panel background | AdminTable's bulk toolbar, DocLinkGroupPanel, OwnDraftsList |
--surface-hover |
#f0f0f0 |
#2b2b2b |
Hover state on an elevated surface | EditorChrome's quote-menu item hover, DocLinkChooser's candidate hover |
--foreground |
#171717 |
#ededed |
Body text | globals.css, .prose, post title links (deliberate override of the default link color) |
--text-secondary |
#666666 |
#a0a0a0 |
Secondary/meta text (dates, bylines, empty states) | pervasive |
--text-muted |
#999999 |
#8a8a8a |
Muted/placeholder text, and the --thread-color/--doc-link-color fallback |
detached-thread notices, the doc editor's "Untitled" placeholder, collab-caret fallback |
--border-subtle |
#eeeeee |
#242424 |
Light divider (between list/article rows, under a byline) | every post-listing <article>, both reading views' byline rule |
--border |
#d4d4d4 |
#3d3d3d |
Standard panel/input border | most 1px solid rules in the app — collapses the old #ccc/#ddd/#e0e0e0 |
--border-strong |
#999999 |
#6a6a6a |
Heavier border where more presence is needed | AdminTable's .actionButton, SiteHeader's nav separator glyphs, dashed drop-target outlines |
--link |
#3366cc |
#7aa7ff |
Default link color | globals.css, sitewide. #3366cc fails AA (~3.4:1) on the dark background; #7aa7ff is ~9:1 |
--accent-wash / --accent-outline |
#eaf1ff / #8888aa |
#16233d / #6b7fb0 |
Drag-over / selected-state highlight | AdminTable/PostSettingsPanel/DocSettingsPanel .dragOver, YdocDebug's snapshot dot |
--success |
#00aa55 |
#3ddc84 |
Success text/status | CommentsTable's Approved status, diff insertions, publish-history "(current)" |
--warning / --warning-text |
#d4a017 / #8a6d00 |
#e0b13a / #e5c351 |
Warning border / warning text | row-status "saving" border / CommentsTable's Pending status text — separate tokens because #d4a017 alone fails AA as text |
--danger |
#cc0000 |
#ff6b6b |
Danger/delete action | soft-delete controls, CommentsTable's Spam status, diff deletions |
--error |
crimson |
#ff7b7b |
Form validation error text | every form's error paragraph |
--fill-success / --fill-warning / --fill-danger |
#d4f5d4 / #faf3c0 / #f8d4d4 |
dark-tinted equivalents | Moderation action fills, diff insertion/deletion backgrounds | CommentsTable.module.css .approve/.pend/.spam, history/[eventId]/page.tsx |
--shadow-color |
rgba(0,0,0,.15) |
rgba(0,0,0,.6) |
Popover box-shadow | every fixed-position popover |
--on-author-color |
#ffffff (both) |
Text/glyph on a solid User.color fill |
quote-indicator badge, Avatar's initials fallback, collab-caret name label — scheme-independent on purpose |
|
--shade-target |
#000000 |
#ffffff |
The "shade toward" endpoint for a hover built with color-mix() |
.quote-indicator:hover — "darken on hover" becomes "lighten on hover" on dark without duplicating the rule |
--anchor-tint* (-weak/plain/-active/-pulse) |
18%/26%/45%/60% | 34%/44%/62%/80%, via calc(base% + var(--dark) * delta%) |
The alpha strength for every quote/annotation/doc-link highlight tint | prose.module.css's 12 color-mix() sites, AuthorHighlightStyles.tsx, AnnotatableArticle.tsx's flash |
--dark (0/1, flipped by the same prefers-color-scheme query) is the one non-color
switch — light-dark() only accepts <color> arguments, so a percentage that needs to shift
with scheme goes through calc() against --dark instead. See the Dark theme section.
Quote-thread coloring was originally one fixed muted amber (#b8935a, itself toned down
from an earlier, more saturated #fff3b0/#d4a017 — see git history), the same for every
thread. It's now one real color per thread — the thread-opener's User.color, or a
seeded fallback for anonymous commenters (PLAN.md §10 item 13) — carried as an inline
--thread-color custom property rather than a CSS Modules class, since there's one value
per thread instance, not a small fixed set of states. --text-muted is what renders when
that property is left unset: either a decoration span shared by threads of different colors
(a single span can only paint one background, so an ambiguous overlap goes neutral rather
than picking one author arbitrarily) or, coincidentally, the same shade used for the
unrelated "detached" state. QuoteThreadHeader.module.css used to fall back to a separate
literal (#b8935a) here instead of the shared muted gray — that inconsistency is gone; both
now fall back to --text-muted.
OS-driven only (prefers-color-scheme) — no toggle, no data-theme, no persisted
preference. Every color token above is declared once, as
light-dark(<light-value>, <dark-value>), on :root { color-scheme: light dark; }. That's
what lets a single declaration carry both values instead of a @media block per token: the
UA resolves light-dark() against the element's used color-scheme, so setting it on
:root (or later, <html>) flips every consumer at once.
light-dark()accepts<color>arguments only —light-dark(26%, 44%)is invalid. The five anchor-tint percentages therefore go through a separate mechanism: a numeric--dark: 0(flipped to1inside the one remaining@media (prefers-color-scheme: dark)block inglobals.css), read viacalc(base% + var(--dark) * delta%). That block is the only scheme-conditional CSS left in the app — everything else is a plainlight-dark()token.- A future toggle is a one-line change, not a redesign: add
html[data-theme="dark"] { color-scheme: dark; --dark: 1; }(and the light equivalent).color-schemeinherits andlight-dark()resolves against the consuming element's used value, not:root's literally, so this flips every token below it with no other edits. - Any rule that sets
backgroundmust also setcolor. A background-only rule (CommentsTable's.approve/.pend/.spam,AdminTable's.dropdownPanel/.bulkToolbar) inverts into unreadable light-on-light the moment the foreground flips independently of it. Every surface token pairing in this codebase follows this rule now; keep it that way for anything new. - Browsers below the
light-dark()floor (Safari <17.5, Chrome <123, Firefox <120) don't fail gracefully. Custom properties parse permissively, so the declarations are stored; the break is at substitution, where "invalid at computed-value time" takes the inherited value for an inherited property (colorsilently inherits — harmless) or the initial value otherwise (backgroundsilently becomestransparent,bordersilently disappears).globals.cssguards against this with an@supports not (color: light-dark(#000, #fff))block restating every token's light value directly — delete it as a unit once the floor is comfortable.color-schemeitself is supported far earlier (Chrome 81/Safari 13/Firefox 96), so without the guard an unsupporting browser would render dark native form controls on top of a broken light page. - The grep guard — this should return nothing outside the allow-listed files named in
the Global baseline section above:
The one intentional literal inside
rg -n -e '#[0-9a-fA-F]{3,8}\b' -e '\b(white|black|crimson|silver|gray|grey|green|red|blue)\b' \ -g 'src/**/*.{css,ts,tsx}' -g '!src/app/globals.css' -g '!src/lib/author-colors.ts' \ -g '!src/lib/safe-css.ts' -g '!src/app/manifest.ts' -g '!src/app/layout.tsx' srcprose.module.cssis the#ffc800amber fallback on.annotation-highlight(three sites) — a deliberate "no author color arrived yet" signal, not an oversight.
Known limitations of the "per-scheme alpha only" approach (author colors and the
AUTHOR_COLOR_PALETTE are unchanged; only the highlight alpha strengthens on dark — no
luminance/contrast computation was added):
- Perceived emphasis varies by author color. A 44%-alpha wash of a dark palette entry
(e.g.
#845ef7) reads noticeably fainter over#0a0a0athan a light one (#fab005) at the same alpha, and the ordering reverses on light backgrounds. If this ever needs correcting, clamp lightness at consumption time —oklch(from var(--thread-color) clamp(0.55, l, 0.8) c h)— rather than editing the palette. - White-on-author-color contrast is unchanged and unfixed.
.quote-indicator,Avatar's initials fallback, and the collab-caret name label all paint--on-author-color(fixed white) over an arbitraryUser.color; a light palette entry can be as low as ~1.8:1. Pre-existing, orthogonal to dark mode — not something this pass addressed. - Stacked highlights get heavier on dark (a span carrying both a quote highlight and an annotation mark composites two strengthened tints), which can cut into text contrast.
--anchor-tint-pulse's collapse (55%/70% → one 60%→80% token) weakens the doc-link pulse flash relative to before (Δ15 vs. the old Δ25). If it reads too thin in practice, the fix is a sixth token used only at that one site, not a broader retune.
Two competing font stacks are in play, both deliberate:
- Reading surfaces (article body, comments heading): serif —
Georgia, "Iowan Old Style", "Palatino Linotype", serif— set on.prose(shared editor/render typography,prose.module.css), the post<h1>([slug]/page.module.css.title), and the comments<h2>(CommentSection.module.css.heading). Chosen for reading comfort;.prosealso setsfont-size: 1.125rem; line-height: 1.5. - UI chrome (nav, forms, byline metadata): system sans-serif —
-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serifon page containers — or simply inherits the globalArial, Helvetica, sans-serifbody default where no container override exists (most admin/editor pages).
Because prose.module.css is shared by the live TipTap editor and the public
rendering, the serif treatment applies to both — editing and reading now look the same
by design, not by accident.
- Centered reading column:
max-width: ...px; margin: 0 auto;— not yet a shared component/class; each page repeats it. Three widths, kept deliberately separate:800pxon pages showing full post text —[slug]/page.module.css(public post display) andPostEditor.module.css(editor).680pxon listing/excerpt pages —authors/[id]/page.tsx,search/page.tsx. These show post previews, not full text, so they weren't widened alongside the two full-text surfaces above.1040pxon the landing page (page.module.css.layout, PLAN.md §17l) — not a fourth, drifted number: it's a two-column CSS grid (minmax(0, 1fr) 280px) built for the contributor sidebar, and the main column inside it still lands at roughly 680px; the extra width is the 280px sidebar plus its2.5remgap. Collapses to a single column under 900px, with the sidebar (second in DOM order) simply flowing below the post list rather than needing anorderoverride.
- Post-listing article block:
padding: 1.5rem 0; border-bottom: 1px solid #eee;— repeated verbatim across home, author, and search listings. - Avatar with a generic-standin fallback (
ContributorCard.module.css.avatarFallback, PLAN.md §17e): whenUser.imageis unset, a 40px circle filled withUser.color(already#rrggbb-validated on write) showingUser.adminInitials— no separate silhouette asset, since both columns already exist and are already treated as general-purpose elsewhere (the admin table just calls the latter "Initials"). The<img>branch reuses theeslint-disable-next-line @next/next/no-img-elementprecedentUsersTable.tsx's own Image column set first: arbitrary remote avatar URLs, not a fixed asset setnext/imagecould optimize without aremotePatternsentry per provider. - Vertical centering of small elements next to a heading (e.g. the
(edit)/(edited)badge beside a post title): preferdisplay: flex; align-items: centeron the heading container oververtical-align: middle. The latter centers against the parent's x-height, not its actual box, so it drifts whenever the heading's font or line-height changes — this caused a real regression (fixed 2026-07-20) where the badge sat visibly low next to the serif<h1>/<h2>titles. Font-size is still set withem(notrem) so the badge scales with whichever heading it's next to — see the em-vs-rem gotcha in CLAUDE.md — but centering itself is flexbox's job now, not font metrics. - Headerless label/value table (
PostSettingsPanel.module.css.detailsTable, added 2026-07-21): a plain<table>with no<thead>, one<tr>per field, label in the first<td>(white-space: nowrap, right-padded) and the value/control in the second. Used when several label+value rows need their values to start at a common x-position — flexbox rows (.fieldRow, tried first) only align a single row's own label/value pair, not siblings' columns against each other. white-space: nowrapon narrow auto-sized table columns (PostsTable.tsx'snowrapTd/nowrapSortableThJS constants, added 2026-07-21; the same pair independently added toUsersTable.tsxfor itscreatedAtcolumn;CommentsTable.tsxnow gets the same pair asAdminTable.module.css's.nowrapCell/.nowrapSortableHeaderCellfor its Created-at/Status-changed columns instead of its own JS constants): a plain<table>with no fixed column widths shrinks a column until its content wraps, and the browser's default line-breaking treats both a space (any multi-word header/value, e.g. "Created at", "Luke Breuer") and a hyphen (ayyyy-MM-dddate) as valid break points — so a 9-column admin table routinely splits a date or a name across two lines well before it's actually out of room. Forcing the column to claim whatever width its content needs instead is applied per-column, not table-wide, so free-text columns (Title, Comment) stay wrappable. Different rationale from the headerless label/value table'snowrapabove — that one aligns a label's own single-line width, this one stops content from breaking at all.- Buttons sized to match an adjacent input's box model:
PostEditor.module.css.actionButtonmatches.changelogInput'spadding/font-size/box-sizingrather than setting an explicitheight— a<button>and<input>with the same font-size, padding, and (default 1px) border compute the same rendered height without one, and an explicit height on a flex/inline sibling is brittle across zoom levels and font fallbacks in a way matching the box model isn't. - Symmetric whitespace above/below a block: match the block's own
margin-topto whatever's providing space below it, rather than leaving the reset's implicit 0 above and a sibling'smargin-topbelow..revisionNote'smargin-top: 12pxwas added to equalPostSettingsPanel's.details { margin-top: 12px }sitting right after it. - Native
<details>/<summary>for a collapsible panel (PostSettingsPanel.tsx): no custom open/close state, animation, or ARIA wiring needed — the browser provides keyboard support and thetoggleevent for free. Reach for a JS-driven collapse only when<details>'s default (no open/close animation, can't be controlled purely by external state without an effect syncingopen) doesn't fit. - Multi-select filter dropdown (
CommentsTable.tsx'sMultiSelectDropdown, used forstatus/threadStatus): also a bare<details>/<summary>, styled (.dropdownWrapper/.dropdownSummary/.dropdownPanel/.dropdownOption, moved toAdminTable.module.css) as a bordered pill (1px solid #ccc,border-radius: 4) with the current selection summarized in the<summary>text itself rather than a separate label. InAdminTable.module.cssdespiteMultiSelectDropdownitself still being a private function local toCommentsTable.tsx, not an exported component — the CSS was judged generic enough (any admin table could plausibly grow a multi-select filter) to place by the "would this also make sense onPostsTable/UsersTable" test rather than waiting for the component itself to be extracted first.<details>doesn't close on an outside click on its own, so amousedownlistener ondocumentsets.open = falsedirectly on a ref to the element whenever the click target falls outside it — the one piece of state here not left to the browser, since nothing else needs to react to open/closed. - Centered empty-state row inside a table (
CommentsTable.tsx, "no comments matching the criteria"): a single<tr>with one<td colSpan={<column count>}>combiningAdminTable.module.css's.cell(the shared padding/vertical-align) and.emptyRow(text-align: centerand#666, the standard secondary/meta color above) — both generic enough thatPostsTable/UsersTablecould reuse them as-is. Keeps the table's header and column widths in place instead of swapping the whole table out for a<p>, so filters/sort/pagination controls around it stay usable while the result set is empty. - Page-level breathing room (
/comments): the<h1>getsmargin-bottom: 1em(.headinginapp/comments/page.module.css) and the main comments table getsmargin-top: 1em/margin-bottom: 1em(folded intoAdminTable.module.css's.table, alongside thewidth/border-collapseevery admin table already sets the same way) — plain fixed spacing around the two biggest visual blocks on the page, not tied to any sibling's own margin the way the "symmetric whitespace" pattern above is.
Every admin table — /posts, /docs, /users, /comments, /annotations, and
/site-settings for the row-status border alone — now renders through one kit
(PLAN.md §16). AdminTable.module.css moved into components/table/ alongside it and is
genuinely shared rather than aspirationally named. This section's earlier TODO listed four
conventions to reconcile before that could happen; all four are settled, and the answers
are worth keeping because they'll come up again:
- Table margins:
.tablecarriesmargin: 1em 0.PostsTablepreviously had none (its spacing came from the search input above it) andUsersTablehad only amargin-top; the shared rule wins and neither special case survived. <tfoot>vs. sibling elements: siblings after</table>.UsersTable's<tfoot><tr><td colSpan={11}>wrapper around its date-format/show-deleted controls is gone. Semantically a<tfoot>is for summary rows of the table's own data, not page controls, and.dateFormatRow/.showDeletedRowalways assumed the sibling form.- Empty state: every table now renders its header row plus a centered
.emptyRow(text-align: center,#666) instead of bailing to a bare<p>No posts yet.</p>. With filters and pagination present this stopped being a matter of taste: the controls that produced an empty result have to stay on screen to undo it. - Pagination and multi-action buttons:
.paginationBarand.actionButtonare in the shared file and used by all of them.CommentsTable.module.csskeeps only what encodes comment-moderation semantics — the.approve/.pend/.spamfills, the status text colors, and.postColumn's width trick.
Two additions the kit brought with it:
- Row status (
.rowStatusCell+.rowStatusEdited/.rowStatusSaving/.rowStatusError/.rowStatusSaved, PLAN.md §16f): a 3px left border on a row's first<td>— transparent when idle,#999edited,#d4a017saving,#c00error,#0a5saved. It replaces thesavedPulsekeyframe animation thatUsersTable.module.cssandSiteSettingsTable.module.csseach held a copy of (both files are gone). The colors are the established palette above rather than new ones; the transparent idle border is load- bearing, since without it a row would shift 3px sideways the first time it was touched. .searchInput,.bulkToolbar,.bulkDangerSpacing, and the.help*panel rules moved fromCommentsTable.module.cssinto the shared file when the kit'sSearchBox, bulk toolbar andFilterHelpbecame components every table uses.