Skip to content

Let CodeBlock resolve its theme from a scope - #120

Merged
Shrinks99 merged 13 commits into
mainfrom
feat/code-block-theme-scope
Aug 18, 2026
Merged

Let CodeBlock resolve its theme from a scope#120
Shrinks99 merged 13 commits into
mainfrom
feat/code-block-theme-scope

Conversation

@makew0rld

Copy link
Copy Markdown
Contributor

CodeBlock is the only component that branches on the theme in JavaScript and it reads the page-wide theme state to do it. That value is a single global resolved by walking the document, so it is wrong in two situations:

  • More than one theme on screen. The first root to mount decides, so a dark subtree renders the light palette. Concretely: the light palette is stock Prism, which sets text-shadow: 0 1px white, and a white halo on a dark surface renders every glyph doubled and close to unreadable.
  • Shadow DOM. The fallback resolves the theme with document.querySelector, which cannot see into a shadow tree.

Resolve it as prop, then context, then the existing page-wide state:

<CodeBlock theme="dark" … />                       // explicit
<ThemeProvider theme="dark">…</ThemeProvider>       // via ThemeProvider
<ThemeScopeProvider theme="dark">…</…>              // theming a subtree yourself

Backwards compatible: with no prop and no scope, behaviour is unchanged, so existing consumers are unaffected. ThemeProvider's new theme prop is optional and only supplies the scope — it does not change what it renders.

Longer term this component should not read the theme in JavaScript at all. Every other component themes through CSS custom properties on the nearest [data-equality-theme] ancestor, which is already correct per-instance, inside shadow roots, and on first paint. Expressing the two syntax palettes as tokens and passing var(--…) references to the highlighter would remove this branch, the flash before the theme resolves, and the side effect where reading the page-wide state writes data-equality-theme onto document.documentElement.

PR text written by Claude Code, reviewed by me

makew0rld and others added 2 commits July 30, 2026 15:10
CodeBlock is the only component that branches on the theme in JavaScript — it
picks a syntax palette — and it reads the page-wide theme state to do it. That
value is a single global resolved by walking the document, so it is wrong in
two situations:

- More than one theme on screen. The first root to mount decides, so a dark
  subtree renders the light palette. Concretely: the light palette is stock
  Prism, which sets `text-shadow: 0 1px white`, and a white halo on a dark
  surface renders every glyph doubled and close to unreadable.
- Shadow DOM. The fallback resolves the theme with `document.querySelector`,
  which cannot see into a shadow tree.

Resolve it as prop, then context, then the existing page-wide state:

    <CodeBlock theme="dark" … />                       // explicit
    <ThemeProvider theme="dark">…</ThemeProvider>       // via ThemeProvider
    <ThemeScopeProvider theme="dark">…</…>              // theming a subtree yourself

Backwards compatible: with no prop and no scope, behaviour is unchanged, so
existing consumers are unaffected. `ThemeProvider`'s new `theme` prop is
optional and only supplies the scope — it does not change what it renders.

Longer term this component should not read the theme in JavaScript at all.
Every other component themes through CSS custom properties on the nearest
`[data-equality-theme]` ancestor, which is already correct per-instance, inside
shadow roots, and on first paint. Expressing the two syntax palettes as tokens
and passing `var(--…)` references to the highlighter would remove this branch,
the flash before the theme resolves, and the side effect where reading the
page-wide state writes `data-equality-theme` onto `document.documentElement`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors the "Portalled surfaces" section added for the portal container: the
two failure cases are the same, so they read as a pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@makew0rld
makew0rld requested a review from Shrinks99 July 30, 2026 19:29

@Shrinks99 Shrinks99 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No other elements other than themeprovider have the option to set dark and light on them. I really don't want to set this precedent, all elements should inherit the dark and light mode from the theme provider's setting.

makew0rld and others added 2 commits August 17, 2026 11:28
CodeBlock took a `theme` prop and ThemeScopeProvider was exported, so a
theme could be declared in two places besides ThemeProvider. Drop both:
the scope context is internal now, set only by `<ThemeProvider theme>`,
and CodeBlock reads it through context with the page-wide theme as its
fallback.

A nested provider without a `theme` still inherits the one it sits in
rather than clearing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-scope

# Conflicts:
#	packages/ui/src/theme/theme-provider.tsx
@makew0rld
makew0rld requested a review from Shrinks99 August 17, 2026 16:03
Comment thread packages/demo/src/content/components/code-block.mdx Outdated
Comment thread packages/demo/src/content/components/code-block.mdx Outdated
Comment thread packages/demo/src/pages/getting-started/usage.mdx Outdated
makew0rld and others added 2 commits August 17, 2026 15:00
A host that embeds Equality into its own layout cannot always afford a
second ThemeProvider: it brings the scoped theme CSS and an unstyled
wrapper element with it, and a host's layout may not survive either. What
those hosts want is only the theme declaration, so keep that available on
its own.

ThemeProvider stays the way an app sets a theme, and CodeBlock still has
no theme prop of its own — it reads the scope, so a subtree cannot
disagree with itself about which theme it is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback: don't document things that don't exist or that are
standard behaviour, and don't imply ThemeProvider wraps each component
rather than the app.

Specifically, drop the sentences describing the absence of a `theme` prop
on components, and the example wrapping a lone CodeBlock in a
ThemeProvider. Generally, the same cuts apply to the ThemeProvider and
theme scope JSDoc, and the ThemeScopeProvider example now wraps a subtree
rather than a single code block.

The code block page keeps only what is particular to it — that it
resolves the theme in JavaScript, unlike everything else — and links to
the usage page for how to set one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@makew0rld

Copy link
Copy Markdown
Contributor Author

I've added back ThemeScopeProvider as my VCO-45 works needs it. Hope things are in a good state now, otherwise happy to call. Sorry about all the back and forth on this.

@makew0rld
makew0rld requested a review from Shrinks99 August 17, 2026 19:23
@makew0rld

Copy link
Copy Markdown
Contributor Author

Will be updating this PR after call.

Best case: API surface doesn't change
Worst case: property on ThemeProvider, no ThemeScopeProvider

makew0rld and others added 2 commits August 17, 2026 17:21
CodeBlock was the only component in the library that branched on the theme
in JavaScript. It did so because its palette arrived as a JS object that the
highlighter applied as inline styles on every token span, which meant the
component had to know the theme in order to pick one.

That knowledge came from useTheme, which resolves a single page-wide value
via document.querySelector. It returns the first match in document order, so
a second theme root on the page gets the wrong palette, and it cannot cross a
shadow boundary, so a code block inside a shadow root gets the wrong palette
too.

ThemeScopeProvider addressed that by threading the answer through context,
but it made every embedder declare in JavaScript a theme the DOM already
declares in CSS, and left the inline styles in place. Expressing the palette
as CSS removes the question instead: the dark: variant matches against the
nearest theme root, so multiple themes, shadow roots and nested themes are
all correct by construction, and a theme flip needs no re-render.

Values are ported from the highlighter's prism and a11y-dark themes, grouped
into the twelve roles that share both a light and a dark value. Three of the
palette's properties were quietly winning arguments they should have lost,
and are now fixed by their absence:

  - font-family: Consolas beat .content's var(--font-mono), because that is
    on the parent and only inherited. Code blocks now use the mono token.
  - padding: 1em beat .pre's padding-block, which carried no !important, so
    the intended py-3 had no effect.
  - text-shadow: 0 1px white had nothing competing, and rendered as doubled
    glyphs whenever the JS lookup picked the light palette on a dark surface.

.pre's four !important declarations existed solely to out-rank those inline
styles and are gone with them. Reverts ThemeScopeProvider, useThemeScope and
ThemeProvider's theme prop, none of which have a remaining purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@makew0rld

Copy link
Copy Markdown
Contributor Author

Ok, things are way cleaner now. The tradeoff is it's harder to change themes, since the CSS values are inlined, but this way we get a cascade and scoping automatically. The public API surface is unchanged. If theme switching is not a concern, this PR should be good.

The other option would be to import and build this CSS file at build time instead.

@Shrinks99
Shrinks99 merged commit a1b7657 into main Aug 18, 2026
1 check passed
@Shrinks99
Shrinks99 deleted the feat/code-block-theme-scope branch August 18, 2026 14:41
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.

2 participants