Files
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

105 lines
3.5 KiB
TypeScript

import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@hanzo/ui/primitives/Button';
import { CategoryIcon } from '@/components/category-icon';
import { ProposalRow } from '@/components/proposal';
import { rfcEntry, source } from '@/lib/source';
import config from '@/rfc.config';
const gap = (value: string) => ({ '--gap': value }) as React.CSSProperties;
/** The lifecycle order a category page groups its proposals into. */
const sections = ['Final', 'Review', 'Draft'] as const;
export default async function CategoryPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const category = source.getCategoryBySlug(slug);
if (!category) notFound();
const grouped = sections.map((status) => ({
status,
rfcs: category.rfcs.filter((rfc) => rfc.data.frontmatter.status === status),
}));
const other = category.rfcs.filter(
(rfc) => !sections.includes(rfc.data.frontmatter.status as (typeof sections)[number]),
);
return (
<div className="rfc-stack" style={{ ...gap('2rem'), maxWidth: 'var(--prose-max)' }} data-accent={category.color}>
<Link href="/docs" className="rfc-cluster rfc-link rfc-small" style={gap('0.25rem')}>
<ArrowLeft size={14} />
All proposals
</Link>
<header className="rfc-cluster" style={gap('1rem')}>
<span className="rfc-tile">
<CategoryIcon name={category.icon} />
</span>
<div>
<h1 className="rfc-heading">{category.name}</h1>
<p className="rfc-small rfc-muted" style={{ margin: '0.25rem 0 0' }}>
{config.shortName}-{category.range[0]} to {config.shortName}-{category.range[1]} ·{' '}
{category.rfcs.length} proposals
</p>
</div>
</header>
<p className="rfc-lead">{category.description}</p>
{category.keyTopics?.length ? (
<div className="rfc-cluster" style={gap('0.375rem')}>
{category.keyTopics.map((topic) => (
<span key={topic} className="rfc-pill">
{topic}
</span>
))}
</div>
) : null}
{[...grouped, { status: 'Other' as const, rfcs: other }].map(
({ status, rfcs }) =>
rfcs.length > 0 && (
<section key={status} className="rfc-stack" style={gap('0.75rem')}>
<h2 className="rfc-subheading">
{status} <span className="rfc-muted">({rfcs.length})</span>
</h2>
<div className="rfc-stack" style={gap('0.5rem')}>
{rfcs.map((rfc) => (
<ProposalRow key={rfc.slug.join('/')} entry={rfcEntry(rfc)} />
))}
</div>
</section>
),
)}
{category.rfcs.length === 0 && (
<p className="rfc-muted">No proposals in this category yet.</p>
)}
{category.learnMore && (
<Button variant="outline" asChild>
<a href={category.learnMore} target="_blank" rel="noreferrer">
Learn more
</a>
</Button>
)}
</div>
);
}
export function generateStaticParams() {
return source.getAllCategorySlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const category = source.getCategoryBySlug(slug);
if (!category) return { title: 'Category not found' };
return {
title: `${category.name} — ${config.name}`,
description: category.description,
};
}