Skip to content

[diffshub] Add Shiki theme switcher#731

Open
necolas wants to merge 22 commits into
mainfrom
nicolas/diffshub-theme-switcher
Open

[diffshub] Add Shiki theme switcher#731
necolas wants to merge 22 commits into
mainfrom
nicolas/diffshub-theme-switcher

Conversation

@necolas
Copy link
Copy Markdown
Contributor

@necolas necolas commented May 21, 2026

Summary

Adds a light/dark Shiki theme picker to the diffshub header and wires it through
the diff viewer, the file tree, and the surrounding sidebar chrome so a single
selection re-themes everything in view.

  • New header control with Light/Dark menus + Auto/Light/Dark color-mode toggle.
    Choices persist to localStorage via a small usePersistedState hook with an
    allowlist guard so stale keys can't push the picker into an unknown theme.
  • The selected Shiki theme drives the trees sidebar palette (file rows, git
    decorations, hover/selection chrome) and the diffshub sidebar wrapper
    (tabs row, Diff Stats panel, "Powered by" footer) — all via a shared
    useResolvedTreeTheme hook with a module-level cache so re-selecting is
    synchronous.
  • Sidebar primary text is sourced from sideBar.foreground (falling back to
    editor.foreground); muted text prefers descriptionForeground. Avoids the
    slack-ochin case where the editor surface is white but the sidebar is dark
    navy, which previously wrote #000 over #2D3E4C.
  • themeToTreeStyles now rejects list.hoverBackground when its luminance
    sits closer to the sidebar fg than the sidebar bg (again, slack-ochin: a
    near-white hover wash over a dark navy sidebar erased row text). The rgba
    hover fallback is also picked from the sidebar's actual luminance instead
    of theme.type. Covered by focused unit tests.
  • Two diffs caches were keeping the previous theme's :host variables alive
    across theme swaps: DiffHunksRenderer/FileRenderer's per-instance
    renderCache.result (collapsed files never re-tokenize, so their headers
    and indicator colors stayed stale) and FileDiff.applyThemeState's
    hasAdoptedThemeCSS short-circuit on pool reuse. Both now pull fresh
    themeStyles from WorkerPoolManager.getCurrentThemeStyles() /
    always upsertHostThemeStyle once on adoption.

Test plan

  • Switch between several Shiki themes (including slack-ochin and a
    light theme) and confirm the diff viewer, file tree, and sidebar
    chrome all re-theme together
  • Collapse a file, swap themes, confirm the collapsed header repaints
    without expanding the file
  • Scroll past the virtualization window to recycle file containers,
    then swap themes, confirm recycled containers pick up the new theme
  • Reload the page and confirm the previous light/dark picks rehydrate

@necolas necolas requested review from SlexAxton, amadeus and mdo May 21, 2026 16:44
@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

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

Project Deployment Actions Updated (UTC)
pierre-docs-diffshub Ready Ready Preview May 25, 2026 8:55pm
pierre-docs-trees Ready Ready Preview May 25, 2026 8:55pm
pierrejs-diff-demo Ready Ready Preview May 25, 2026 8:55pm
pierrejs-docs Ready Ready Preview May 25, 2026 8:55pm

Request Review

// corresponding row, picks a theme, and is returned to the main view. The
// menu auto-resets to the main view whenever it closes so the next open
// always starts from the top.
function ThemeDropdown({
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.

should we wrap this lad in a memo? I don't fully trust react compiler

Comment thread apps/docs/app/(diffshub)/(view)/_components/CodeViewHeader.tsx Outdated
Comment thread apps/docs/app/(diffshub)/(view)/_components/CodeViewWrapper.tsx Outdated
Comment thread packages/diffs/src/components/File.ts Outdated
Comment thread packages/diffs/src/components/FileDiff.ts Outdated
Comment thread packages/diffs/src/renderers/DiffHunksRenderer.ts Outdated
necolas added 7 commits May 25, 2026 19:56
The chrome and file tree both pulled text color straight from
`sideBar.foreground`. Some themes ship a value that fails on the
surface they're paired with (material-theme-ocean's #525975 on #0F111A
is ~2.6:1; aurora-x doesn't set it at all). Add a contrast-aware
picker that walks `sideBar.foreground` → `editor.foreground` →
`theme.fg` in design-intent order, keeps the first candidate that
clears 3:1 against the sidebar bg, and falls back to the
highest-contrast option when none do. Apply the same gate to
`descriptionForeground` so dim muted tokens (aurora-x's #576daf79,
~1.8:1) fall through to the primary fg instead of fading every label
and icon into the surface.

Mirror the resolved primary fg into the tree's tokens — `color`,
`--trees-theme-sidebar-fg`, and the section-header / active-selection
/ focus-ring tokens when the theme doesn't set them explicitly — so
the file rows track the chrome end-to-end.
When the header's light / dark theme picker opens, the currently
selected row was wherever its alphabetical position placed it — often
off-screen on a list of 40+ themes. Ref the scroll container and the
selected row, and on view change scroll so the row sits one row below
the top of the viewport (computed via bounding rects since the
container isn't a positioned ancestor). The selected row lands right
under the cursor and the row above it makes the prior theme a single
arrow-key away, which helps sequential browsing.
The badge was hardcoded to `bg-neutral-200/700` + `text-neutral-700/200`,
so on dark/themed sidebars it lit up as a neutral gray pill out of step
with the rest of the chrome. Switch to a `color-mix(currentColor 18%,
transparent)` background and drop the explicit text color so the badge
inherits the tab button's color — it now tints with the active Shiki
theme in both the unselected ghost state and the selected outline
state.
A new button in the System Monitor row, left of the autoscroll toggle, drives a
sweep through every light theme then every dark theme (and back), anchored on
whichever theme is currently active so the visible state isn't yanked when the
cycle starts.

Plain click toggles cycling on/off. Shift-click bumps the per-step duration.
Cycling state lives in a new `useThemeCycle` hook in ReviewUI and is
prop-drilled through CodeViewSidebar to WorkerPoolStatus.
…containers

Three load-bearing pieces working together make code-view theme switching
reliable. Each addresses a distinct way theme state can fall out of sync,
and they are not interchangeable.

1. Keep `theme: { dark, light }` on the CodeView options.

   `CodeView.setOptions` clears its element pool via `shouldClearPool`,
   which compares `previousOptions.theme` to `nextOptions.theme`. Without
   `theme` on the option object both sides resolve to `undefined`,
   `areThemesEqual` returns true, and the pool is never cleared on theme
   swaps. The pool then hands out containers carrying the previous
   theme's shadow-root CSS. The option is also what flips
   `areDiffRenderOptionsEqual` in `getRenderOptions`, which is how the
   renderer detects a theme change and sets `forceHighlight=true`.

2. Refresh `themeStyles` and `baseThemeType` from the worker pool in
   `processDiffResult` / `processFileResult` via
   `WorkerPoolManager.getCurrentThemeStyles()`.

   `setRenderOptions` clears the worker-side caches, but each renderer's
   per-instance `renderCache.result` still holds the old themeStyles
   string. Collapsed files never re-tokenize (the async highlight path
   is the only thing that would refresh the cached result), so without
   this override their headers, line numbers, and addition/deletion
   colors stay on the previous theme until the user toggles the file
   open. Pulling themeStyles from the worker manager on every render
   refreshes only the chrome — the cached syntax tokens are left alone
   and get replaced when `highlightDiffAST` completes.

3. Always write fresh CSS on element-pool adoption in `applyThemeState`
   (do not trust the adopted shadow-root contents).

   Theme changes clear the pool synchronously, but the new CSS for the
   currently-visible containers is written by `CodeView.render()`, which
   is queued via `queueRender` rather than running synchronously. A fast
   scroll during a theme swap can release a visible container back into
   the pool before that queued render writes the new CSS, so the pool
   ends up holding containers whose shadow root still carries the
   previous theme. When the next file adopts one of those containers and
   `applyThemeState` shortcuts on `hasAdoptedThemeCSS`, the bookkeeping
   updates but the stale `<style>` textContent never gets overwritten —
   the wrong theme is then permanently locked in by the equality
   fast-path. Falling through to `upsertHostThemeStyle` on first
   adoption is a single cheap textContent write per pool reuse; the
   equality check above it still short-circuits steady-state renders.
The contrast threshold for `descriptionForeground` was 3:1 (WCAG AA
large text), inherited from the primary-fg picker where we deliberately
honour the theme designer's dim sidebar values. That's the wrong bar
for the sidebar's muted labels — "Diff Stats", "System Monitor",
"Comments", file/addition/deletion counts, empty-state copy — which all
render at normal body-text size and need 4.5:1 to stay legible.

Ayu-dark's `descriptionForeground` is `#5a6378` on `#0d1017` (~3.27:1).
It cleared the 3:1 floor and so was silently kept, but the labels read
as dim shadow against the near-black sidebar. The previous fallback,
when the threshold *did* fail, was the theme's primaryFg — flattening
muted up to primary, which loses the visual hierarchy entirely.

Split the muted threshold from the primary one (`MIN_MUTED_RATIO = 4.5`)
and add `deriveMutedFg`: when the theme's value fails the bar, walk a
60% → 90% mix of primaryFg into bg and keep the first blend that clears
4.5:1, falling back to primaryFg only as a last resort. Ayu-dark now
gets `#8a8986` on `#0d1017` (~5.8:1) — distinctly softer than its
`#bfbdb6` primary but firmly readable. Other themes whose
`descriptionForeground` was already legible keep their original value.
A theme change now flips header, file tree, comments, footer, and diff
viewer together in the same paint frame. Previously the chrome trailed
the diff (and on cold themes briefly flashed the pierre-soft fallback),
and the comments cards / footer links visibly interpolated through the
old palette for ~150ms on every swap. Three pieces, each fixing a
distinct desync source:

1. `ReviewUI` worker-pool effect: `useEffect` → `useLayoutEffect`. The
   sidebar's inline-style update lands during React's commit phase, so
   running the worker-pool update in a paint-time effect kept the diff
   one frame behind the chrome on every swap.

2. `useResolvedThemeByName`: return the module cache directly during
   render instead of routing it through useState. `useState`'s
   initializer only runs on mount, so for cycled-through themes the
   hook was returning the *previous* render's value and waiting for
   the post-commit `useEffect` to call `setResolved` — one full React
   commit of chrome lag for every swap. Also keep the previously
   resolved theme visible while a cold theme loads, matching the diff
   side's behaviour (the diff stays on the previous theme during its
   own await), so chrome doesn't flash the pierre-soft fallback in the
   intermediate render.

3. Drop `transition-colors` from comment cards and `color,
   text-decoration-color` from `.inline-link`. Both are Tailwind hover-
   smoothness niceties, but they ALSO interpolate when the underlying
   CSS variables flip on a theme swap — the comment cards stepped
   through ~18 intermediate RGB values over 150ms before landing,
   visibly trailing the header / tree / diff which had no color
   transitions and snapped instantly. There's no CSS-level way to
   distinguish "user hovered" from "CSS variable changed", and a
   JS-driven `transition: none` class can't catch transitions that
   start during commit before any effect fires. Removing the
   transitions at the source costs only the smooth hover bg fade,
   which is far cheaper than a visibly desynchronised theme swap.

Verified live by sampling `--color-foreground`, `--diffshub-card-bg`,
the footer link's computed color, and a diff container's
`--diffs-dark-bg` at 3ms intervals through a 7-second cycle: every
transition lands in a single polling tick across all four surfaces,
the footer no longer interpolates, and the diff is within 16ms of the
chrome (one paint frame).
Apply resolved Shiki chrome tokens to the theme picker, display settings menu,
comment sidebar cards, and inline diff comments.

Scope inline annotation variables so the diff viewport keeps its own border and
scrollbar colors.
Undo diffs related changes
@necolas
Copy link
Copy Markdown
Contributor Author

necolas commented May 26, 2026

See #745 for a clean history version of this PR now that we've simplified some of the changes

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