Files
rfc/app/providers.tsx
zeekayandHanzo Dev b103359659 feat: one button, one theme switch
Two things in this tree were done twice, and the second one was broken.

BUTTON. `.rfc-btn` (a local CSS class) and `<Button>` (@hanzo/ui) were both
"the button". Every call site now uses the component and the CSS is deleted —
variant, size, hover, focus ring and the 44px touch floor come from one
definition, shared with every other Hanzo surface and with native.

Imported per member (`@hanzo/ui/primitives/Button`, which the package publishes
"for hosts that modularize @hanzo/ui imports"), not off the barrel. The barrel
re-exports the whole library, so one button pulled Dialog, Command, Select and
Slider into the page: 270 kB First Load, 1033 kB of raw chunks. Now 168 kB and
726 kB. `experimental.optimizePackageImports` does NOT fix this — it was tried,
and Slider/Toaster/AspectRatio still shipped, because transpilePackages opts the
package out of that transform.

THEME. Two token sources read two different classes: `@hanzo/ui/theme.css` keys
the identity off `.dark`, gui resolves `$color…` off `t_light`/`t_dark`. Only
the gui side was wired, and to `'system'` — a name gui has no theme for — so
<html> carried a `t_system` class that nothing matches. Chromium, before:

  DARK : --background: hsla(0,0%,97%,1)   <- near-white page
         --foreground: oklch(0.985 0 0)   <- near-white text

Dark mode was unreadable. `--background` is the one token gui also declares (at
bare `:root`, injected after the stylesheet, so it beat `.dark` on source order
at equal specificity); the other 30 switched correctly, which is why the text
went white and the page did not.

Now one state writes both classes, one writer each — NextThemeProvider owns
`.dark` via its `value` map, GuiProvider owns `t_dark` via `defaultTheme` — and
next-themes is gone, since @hanzogui/next-theme IS next-themes with the gui hook
already on it. Chromium, after:

  LIGHT     html="t_light light" body=rgb(247,247,247)/rgb(5,5,5)   card=oklch(1 0 0)
  DARK      html="dark t_dark"   body=rgb(20,20,20)/rgb(255,255,255) card=oklch(0.205 0 0)
  RELOADED  html="dark t_dark"   body=rgb(20,20,20)/rgb(255,255,255)
  button    bg rgb(255,255,255) on dark, rgb(5,5,5) on light — inverts with the theme
  /docs, /contribute, /docs/category/core all dark; 0 console errors

  $ pnpm build
  ✓ Compiled successfully in 22.0s
  ✓ Generating static pages (11/11)
  ✓ Exporting (2/2)
  $ pnpm typecheck   # exit 0

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 01:11:16 -07:00

67 lines
2.6 KiB
TypeScript

'use client';
import type { ReactNode } from 'react';
import { NextThemeProvider, useThemeSetting } from '@hanzogui/next-theme';
import { GuiProvider } from '@hanzo/gui';
import { config as gui } from '@hanzo/ui/gui-config';
import { SearchProvider } from '@/components/search';
import type { RFCEntry } from '@/lib/source';
import config from '@/rfc.config';
/**
* The one provider stack, and — the point of this file — the one light/dark
* switch.
*
* There are two token sources on the page and they read different classes:
* `@hanzo/ui/theme.css` keys the Hanzo identity off `.dark`, while gui resolves
* `$color…` through its own root theme class, `t_light`/`t_dark`. Drive one and
* the other silently stays behind — measured, in exactly that state: dark mode
* gave `--foreground: oklch(0.985)` (near-white text) over `--background:
* hsla(0,0%,97%)` (a near-white page).
*
* So both classes get written, by one state, on one user action:
*
* `.dark` ← NextThemeProvider, via the `value` map below
* `.t_dark` ← GuiProvider, from `defaultTheme`
*
* Neither writer touches the other's class, and `resolvedTheme` is the single
* value both read. `NextThemeProvider` is next-themes — gui ships its own copy,
* same API and same pre-paint script — so this is one theme library, not two.
*/
export function Providers({ index, children }: { index: RFCEntry[]; children: ReactNode }) {
return (
<NextThemeProvider
attribute="class"
defaultTheme={config.theme.defaultTheme}
storageKey={config.theme.storageKey}
enableSystem
disableTransitionOnChange
// Left at its default this maps light/dark to `t_light`/`t_dark` — gui's
// class, which GuiProvider already writes. That is two writers for one
// class, and nobody writing the `.dark` that theme.css actually reads.
value={{ light: 'light', dark: 'dark' }}
>
<Gui>
<SearchProvider index={index}>{children}</SearchProvider>
</Gui>
</NextThemeProvider>
);
}
/**
* Separate component because `useThemeSetting` reads the context the provider
* above creates. `defaultTheme` is a gui theme name, never `'system'`: gui has
* no theme by that name, and asking for one wrote a `t_system` class that
* nothing in the injected theme CSS matches. "system" is a *preference*, which
* `resolvedTheme` has already resolved by the time it gets here.
*/
function Gui({ children }: { children: ReactNode }) {
const { resolvedTheme } = useThemeSetting();
return (
<GuiProvider config={gui} defaultTheme={resolvedTheme === 'dark' ? 'dark' : 'light'}>
{children}
</GuiProvider>
);
}