Skip to content

style: mute body copy, and let the standfirst lead - #82

Merged
coderdan merged 5 commits into
v2from
docs/text-colour-scheme
Jul 29, 2026
Merged

style: mute body copy, and let the standfirst lead#82
coderdan merged 5 commits into
v2from
docs/text-colour-scheme

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

Body text moves to text-fd-muted-foreground; the text immediately under the heading moves to the foreground colour. Same rule in light and dark.

What was actually happening

Fumadocs ships the exact inverse:

Fumadocs default Now
Body copy (.prose) --color-fd-foreground at 90% --color-fd-muted-foreground
Standfirst (DocsDescription, the <p> under the H1) text-fd-muted-foreground text-fd-foreground

So the lead line was the quietest text on the page and the body the loudest.

Only --tw-prose-body moves. Headings, links, bold, code, quotes and captions each have their own --tw-prose-* variable pointing at the foreground token, so they keep full contrast — and read as more distinct now that paragraph text has stepped back. Both sides reference theme tokens, so light and dark follow automatically with no per-mode override.

One thing worth knowing about

The obvious version of this change is a silent no-op. Fumadocs declares .prose in @layer utilities. Cascade layers beat both specificity and source order, so putting the override in this file's existing @layer components block compiles clean and does nothing — no warning, no error, and it looks correct in the source.

I only caught it by checking the compiled CSS:

fumadocs  pos= 22790  layer=utilities
ours      pos=  9176  layer=components     <- loses

The fix is to leave the rule unlayered, since unlayered declarations outrank every layer — no !important needed, which matters because this file already leans on !important for its other Fumadocs overrides. Re-checked after the fix, the shipped rule lands unlayered. There's a comment in global.css saying so, because the next person to tidy it into the @layer components block below will otherwise quietly revert it.

Also verified in the built HTML that tailwind-merge collapses the class conflict rather than emitting both classes and leaving it to source order — the description renders text-lg mb-0 text-fd-foreground, with the default muted class dropped.

Scope

Applies to both page routes (/[[...slug]] and /stack/[[...slug]]), so the v2 and legacy trees stay consistent.

Worth a look when you review the preview: callout body text inherits the prose colour and is now muted too (callout titles and icons keep their own --callout-color). That reads as consistent to me — a callout is body copy — but it's the one place the change reaches beyond ordinary paragraphs, so it's your call. Cards are unaffected; they set text-fd-card-foreground explicitly.

Build is green at 763 pages.


Note: this will sit at Internal links — Expected until #81 merges, since the workflow only exists on that branch and v2 doesn't have it yet. Same situation as #39.

Fumadocs ships the inverse of what we want: prose body text is
`--color-fd-foreground` at 90%, while DocsDescription — the standfirst
directly under the H1 — renders `text-fd-muted-foreground`. So the lead
line was the quietest text on the page and the body the loudest.

Swaps the two:

- `.prose` sets `--tw-prose-body` to `--color-fd-muted-foreground`. Only
  that one variable moves; headings, links, bold, code, quotes and
  captions each have their own, so they keep full contrast and now read
  as more distinct against the softer paragraph text.
- Both page routes pass `text-fd-foreground` to DocsDescription.

Both sides reference theme tokens, so light and dark follow the same rule
with no per-mode override.

The `.prose` rule is deliberately **unlayered**. Fumadocs declares
`.prose` in `@layer utilities`, and layer order beats both specificity and
source order — the same declaration inside the `@layer components` block
further down this file compiles fine and is silently ignored. Verified
against the built CSS rather than assumed: the shipped rule lands
unlayered, after fumadocs' own, and unlayered declarations outrank every
layer without needing `!important`.

Also verified in the built HTML that tailwind-merge resolves the class
conflict rather than emitting both — the description renders as
`text-lg mb-0 text-fd-foreground`, with the default muted class dropped.

Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
public-docs Ready Ready Preview, Comment Jul 29, 2026 2:25am

Request Review

Two changes to Callout, plus a correction.

**The correction.** I claimed in #82's description that muting `.prose`
body text also muted callouts. That was wrong: Fumadocs hard-codes
`text-fd-muted-foreground` on `CalloutDescription`, set directly on that
div, so it overrides any inherited prose colour — and only `.prose`
itself consumes `--tw-prose-body`. Callout text has always been muted;
the body change did not touch it. The fix is the same either way, but
the reasoning in that description was not.

**Body text** now uses `text-fd-foreground`, matching the title (the
container is `text-fd-card-foreground`, which resolves to the same value
in both themes). A callout reads as one block instead of a heading over
greyed-out copy, and stands out from the surrounding muted body copy,
which is the point of a callout.

**Padding** doubles, `p-3 ps-1` → `p-6 ps-2`. `ps` stays proportionally
small because the accent bar is the container's first child and sits
near the edge.

Both need reaching the description, which Fumadocs' `Callout` does not
expose, so we compose `CalloutContainer` / `CalloutTitle` /
`CalloutDescription` — its exported primitives — instead. The container
still resolves the `warn`/`tip` aliases and picks the icon, so only the
trivial wrapper is reimplemented.

The wrapper also emits `data-callout`, which Fumadocs does not. global.css
has had a `[data-callout]` rule since the theme work that consequently
matched nothing; it now applies (its `border-radius: 2px` was already
coming from the `[class*="rounded-xl"]` rule above it, so nothing moves).

Verified in the built HTML rather than assumed — tailwind-merge collapses
both conflicts, leaving `p-6 ps-2` on the container and
`prose-no-margin empty:hidden text-fd-foreground` on the description, with
no muted class left anywhere in the callout subtree.

Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8
@coderdan

Copy link
Copy Markdown
Contributor Author

Pushed 308f082. Callout body text is back to full contrast and the padding is doubled — but first, a correction to what I told you in this PR's description.

I was wrong that this PR muted the callouts. Fumadocs hard-codes text-fd-muted-foreground on CalloutDescription:

cn("text-fd-muted-foreground prose-no-margin empty:hidden", className)

That's set directly on the description div, so it overrides any inherited prose colour — and only .prose itself consumes --tw-prose-body (one occurrence in the whole stylesheet). Callout text has always been muted; the body-copy change didn't touch it. My note flagging it as a side effect came from a static scan that found no explicit colour rule for callouts, and I didn't check the component. You'd have been looking for a regression that wasn't there.

The fix you asked for is the same either way, so:

Body texttext-fd-foreground, matching the title (the container is text-fd-card-foreground, which resolves to the same value in both themes). A callout now reads as one block rather than a heading over greyed-out copy — and stands out against the surrounding muted body copy, which is rather the point of a callout.

Padding → doubled, p-3 ps-1p-6 ps-2. I kept ps proportionally small because the accent bar is the container's first child and sits near the edge; doubling it to match the rest would have pushed the bar away from the border.

Both required reaching the description, which Fumadocs' Callout doesn't expose, so getMDXComponents now composes CalloutContainer / CalloutTitle / CalloutDescription — its exported primitives — instead of the convenience wrapper. CalloutContainer still resolves the warn/tip aliases and picks the icon, so only the six-line wrapper is reimplemented.

A dead rule found on the way: global.css has had this since the theme work:

[data-callout] { border-radius: 2px !important; }

Fumadocs never emits data-callout, so it has been matching nothing. Our wrapper now sets the attribute, so the rule applies — no visual change, because that 2px was already coming from the [class*="rounded-xl"] rule above it, but it's a real hook now rather than a decoy.

Verified in the built HTML rather than trusting tailwind-merge:

container:    ... bg-fd-card text-sm text-fd-card-foreground shadow-md p-6 ps-2  data-callout="true"
description:  prose-no-margin empty:hidden text-fd-foreground

Both conflicting pairs collapsed (p-3 ps-1 and the muted class are gone), and no muted class survives anywhere in the callout subtree. Build green, types:check and biome clean.

Reverts the `text-fd-foreground` from 308f082. `text-fd-muted-foreground`
— Fumadocs' default for CalloutDescription — is the same token the
sidebar's resting nav items use, so callouts sit at the weight of the
surrounding chrome instead of shouting.

That means the colour lands back where it started, and the net effect of
the callout work is the doubled padding plus the `data-callout` hook.

Dropping the colour override also drops the reason for composing
CalloutContainer / CalloutTitle / CalloutDescription by hand: reaching the
description was the only thing Fumadocs' `Callout` wrapper could not do.
It spreads unknown props onto the container, so the wrapper is now four
lines over the public component, with no copy of its alias resolution,
icon selection or title rendering to drift out of step.

Verified in the built HTML that the callout body and a resting sidebar
item carry the same class, and that the padding survived the
simplification:

  container: … bg-fd-card text-sm text-fd-card-foreground shadow-md p-6 ps-2
  body:      text-fd-muted-foreground prose-no-margin empty:hidden
  sidebar:   … rounded-lg p-2 text-start text-fd-muted-foreground …

Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8
@coderdan

Copy link
Copy Markdown
Contributor Author

Pushed fc360ff. The sidebar's resting nav items are text-fd-muted-foreground:

// fumadocs-ui/dist/layouts/docs/sidebar.js
itemVariants = cva("relative flex flex-row items-center gap-2 rounded-lg p-2
                    text-start text-fd-muted-foreground ")

which is the same token Fumadocs already used for callout body text. So this lands the colour back where it started, and the net effect of the callout work is now just the doubled padding and the data-callout hook.

That also removed the reason for hand-composing CalloutContainer / CalloutTitle / CalloutDescription — reaching the description was the only thing Fumadocs' Callout couldn't do. It spreads unknown props onto the container, so the wrapper is back to four lines over the public component, with no copy of its alias resolution, icon selection or title rendering to drift out of step. Smaller diff than the previous commit, not larger.

Verified in the built HTML that the two now match, and that the padding survived:

container: … bg-fd-card text-sm text-fd-card-foreground shadow-md p-6 ps-2
body:      text-fd-muted-foreground prose-no-margin empty:hidden
sidebar:   … rounded-lg p-2 text-start text-fd-muted-foreground …

Worth flagging for when you look at the preview: callout body and page body copy are now the same colour, since #82 also moves --tw-prose-body to text-fd-muted-foreground. A callout is still set apart by its card background, border, accent bar and icon — but no longer by text weight. If it ends up reading flat, the middle option is the pre-#82 body colour, color-mix(in oklab, var(--color-fd-foreground) 90%, transparent), which sits between the two you've tried. Happy either way; just say.

Build green, types:check and biome clean.

Third setting for callout text, and the one between the two already tried:
the colour prose body copy had before this branch muted it,
`color-mix(in oklab, var(--color-fd-foreground) 90%, transparent)`.

`text-fd-foreground` read as shouting; `text-fd-muted-foreground` matched
the surrounding paragraphs exactly, leaving the callout to carry its
distinction entirely on card background, border, accent bar and icon.
This keeps it a step above the page copy without competing with headings.

Done in CSS rather than by threading a class through the component,
because Fumadocs puts `text-fd-muted-foreground` directly on the
description div — inheritance from the container cannot reach it, so the
element has to be selected either way. Doing it here keeps the wrapper in
mdx-components.tsx at four lines over the public `Callout`, with nothing
of its internals duplicated.

The selector leans on `data-callout` (ours, from that wrapper) and
`prose-no-margin` (Fumadocs' marker on the description's prose block).
Unlayered, for the same reason as the `.prose` rule above it: the utility
class being overridden lives in `@layer utilities`, and layer order beats
specificity.

Verified in the built CSS that the rule ships unlayered with both the srgb
fallback and the themed `color-mix`, and in the built HTML that the
selector matches the rendered description and the doubled padding
survived.

Claude-Session: https://claude.ai/code/session_01NkuQNMvw9BpB4BWWeKtfV8
@coderdan

Copy link
Copy Markdown
Contributor Author

Pushed 818db45 — callout body is now the middle setting: the colour prose body copy had before this branch muted it.

[data-callout] .prose-no-margin {
  color: color-mix(in oklab, var(--color-fd-foreground) 90%, transparent);
}

Where the three land, for the record:

Value (dark) Read
text-fd-foreground hsl(42 18% 92%) shouting
text-fd-muted-foreground (sidebar) hsl(42 8% 55%) identical to surrounding body copy
this foreground @ 90% a step above the page copy, below the headings

Done in CSS rather than threading a class through the component. Fumadocs puts text-fd-muted-foreground directly on the description div, so inheritance from the container can't reach it — the element has to be selected either way, and doing it here keeps the wrapper in mdx-components.tsx at four lines over the public Callout, with none of its internals duplicated.

The selector leans on data-callout (ours, from that wrapper) and prose-no-margin (Fumadocs' marker on the description's prose block). Unlayered, for the same reason as the .prose rule above it — the utility class being overridden lives in @layer utilities, and layer order beats specificity.

Verified rather than assumed, since this is the third attempt at the same line:

built CSS   [data-callout] .prose-no-margin{color:#0a0a0ae6}            <- srgb fallback
            @supports (color-mix) { … color-mix(in oklab,var(--color-fd-foreground)90%,transparent) }
            enclosing layers: UNLAYERED

built HTML  container   … shadow-md p-6 ps-2  data-callout="true"
            description text-fd-muted-foreground prose-no-margin empty:hidden   <- selector matches

Padding is untouched at p-6 ps-2. Build green, biome clean.

@coderdan
coderdan merged commit 3be5d98 into v2 Jul 29, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant