Example Themes
Three example themes, sorted by how much of the reference theme each one keeps. Ocean keeps
all of it and only redefines --hz-* tokens. Docs keeps all of it too and layers
one class-hook override on top, with no palette of its own. Terminal keeps none of it. Ocean
and Terminal are generated from a hyzer.config.ts beside them. Like the base
tokens, they meet WCAG AA on every graded pairing, in
the default theme and in dark. All three, compared:
| Ocean | Docs | Terminal | |
|---|---|---|---|
| Palette via config | yes | none — rides the reference theme | yes |
| Class-hook overrides | none | one (.docs-table) | yes |
| Reference theme | required | layers over it | not imported |
| Adds new intents | no | no | phosphor, amber |
| Scoped to a class | runtime-scoped demo only | unscoped — no class to add anywhere | .hz-theme-terminal |
Ocean is one end: it shows how far tokens alone get you. Terminal is the other end: it never
imports the reference theme, so every rule in it is its own. It also grows the system rather
than only recoloring it, with two intents the library has never heard of, type-checked and
contrast-graded like any built-in. Docs sits between them: the same class-hook contract every
component ships is enough to extend the reference theme without forking it. If you only read one
file, read Terminal's button.css: that is what "headless" actually buys you.
Ocean: tokens only
Not one class hook. Every difference below comes from redefining --hz-* tokens; the reference theme does the rest of the work, unchanged.
import '@hyzer-labs/ui/tokens.css';
import '@hyzer-labs/ui/theme';
import '@hyzer-labs/ui/theme/examples/ocean.css';Course update
Next tee time
Saturday, 8:40, Maple Hill.Score must be a whole number
What is in the bag?
Course conditions
/**
* Ocean: an example theme built from nothing but token overrides.
*
* This is a working config, the kind you would write yourself. Running the
* `hyzer` CLI over it produces `ocean.css` beside it, so edit this file and
* regenerate rather than editing the sheet by hand.
*/
import { defineConfig } from '@hyzer-labs/ui/config';
/** The comment header the generated sheet opens with. */
export const intro = [
'Example theme: Ocean',
'Cool teal accents on slate neutrals. Every difference comes from',
'redefining tokens. No component is restyled by hand.',
'',
'Import order matters: these overrides win by coming last.',
" import '@hyzer-labs/ui/tokens.css';",
" import '@hyzer-labs/ui/theme'; // optional",
" import '@hyzer-labs/ui/theme/examples/ocean.css';"
];
export default defineConfig({
// Everything under `tokens` is the default theme: it is authored into the
// :root block, which is what a page gets with no data-theme attribute.
// Named variants (dark, and any of your own) go under `themes`.
tokens: {
palette: {
// The accent hues. Warning and success are a little deeper than the
// stock ones so they still pass AA on this theme's tinted surface.
primary: '#0f766e',
secondary: '#155e75',
info: '#0369a1',
warning: '#92400e',
success: '#166534'
},
color: {
// The semantic roles: what a color does in the layout.
surface: '#f8fafc',
text: '#0f172a',
textMuted: '#475569',
border: '#64748b'
},
// One intent remapped: neutral takes the slate used for muted text,
// because the stock gray falls just short of AA on this theme's surface.
intent: { neutral: 'var(--hz-color-text-muted)' }
},
themes: {
dark: {
palette: {
// The dark theme brightens the accents that sit on a dark surface.
primary: '#2dd4bf',
secondary: '#22d3ee',
danger: '#fca5a5'
},
color: {
surface: '#0b1120',
text: '#e2e8f0',
textMuted: '#94a3b8'
}
}
}
});
/**
* Example theme: Ocean
* Cool teal accents on slate neutrals. Every difference comes from
* redefining tokens. No component is restyled by hand.
*
* Import order matters: these overrides win by coming last.
* import '@hyzer-labs/ui/tokens.css';
* import '@hyzer-labs/ui/theme'; // optional
* import '@hyzer-labs/ui/theme/examples/ocean.css';
*
* @hyzer-labs/ui token overrides
*
* GENERATED FILE - do not edit by hand (hyzer generate --mode overrides).
* This sheet carries only the tokens your config touched. Import it
* AFTER @hyzer-labs/ui/tokens.css so the overrides win by source order.
*/
:root,
[data-theme='default'] {
--hz-palette-primary: #0f766e;
--hz-palette-secondary: #155e75;
--hz-palette-success: #166534;
--hz-palette-warning: #92400e;
--hz-palette-info: #0369a1;
--hz-color-surface: #f8fafc;
--hz-color-text: #0f172a;
--hz-color-text-muted: #475569;
--hz-color-border: #64748b;
--hz-intent-neutral: var(--hz-color-text-muted);
}
[data-theme='dark'] {
--hz-color-surface: #0b1120;
--hz-color-text: #e2e8f0;
--hz-color-text-muted: #94a3b8;
--hz-palette-primary: #2dd4bf;
--hz-palette-secondary: #22d3ee;
--hz-palette-danger: #fca5a5;
--hz-palette-warning: #fbbf24;
--hz-palette-success: #4ade80;
--hz-palette-info: #22d3ee;
}
Docs: layered on the reference theme
Adds no palette at all. Every rule below resolves through the reference theme’s own role and intent tokens, unchanged. On top it layers page-rhythm scaffold classes and one class-hook override. You import it like any other theme sheet, and it needs no class anywhere.
The full .docs-table walkthrough lives on Styling Components: why unlayered
beats layered, with a demo and the source. This is, verbatim, the sheet design.hyzer.sh imports for its own chrome. You're reading it right now.
import '@hyzer-labs/ui/tokens.css';
import '@hyzer-labs/ui/theme'; // layers under it
import '@hyzer-labs/ui/theme/examples/docs/docs.css';Course update
Next tee time
Saturday, 8:40, Maple Hill.Score must be a whole number
What is in the bag?
Course conditions
/**
* Example theme: Docs.
*
* The look this documentation site runs on, shipped so you can read it or take
* it. It adds no palette and no tokens of its own: every rule below resolves
* through the reference theme's roles and intents. What it adds is the reading
* chrome a docs site needs.
*
* Import it after the reference theme, which it layers on top of:
*
* @import '@hyzer-labs/ui/tokens.css';
* @import '@hyzer-labs/ui/theme';
* @import '@hyzer-labs/ui/theme/examples/docs/docs.css';
*
* There is no class to add anywhere. The sheet is unscoped, and it never
* restyles a bare component hook, so `.hz-button` and friends keep exactly what
* the reference theme gives them.
*
* WHAT YOU GET
*
* Page-rhythm classes you opt into by using them: `.doc-intro h1`,
* `.doc-description`, `.doc-section h2`, `.doc-note`, `.doctrine-note`,
* `.a11y-refs`, and the scaffolding around demo blocks (`.tab-content`,
* `.inner-tab`, `.tab-note`, `.note-list`, `.demo-col`).
*
* `.docs-table`, an opt-in wrapper that flattens a Table. It is also a worked
* lesson in the cascade: the reference theme paints `.hz-table` from
* `@layer hz-theme`, and these rules are unlayered, so they win on every
* property they set with no `!important` and no matching of the theme's
* selectors. A table without the wrapper is left exactly as the theme paints it.
*
* Code chips: `code` inside a paragraph or list item becomes a tinted pill,
* scoped to prose so `<pre>` blocks and table cells stay plain. The dark tint is
* stronger than the light one, because a faint gray disappears on a dark
* surface. The one contrast pairing this introduces is body text on that tint,
* which holds on the surface roles this sheet expects behind prose. Paint the
* chip over something else and checking it is yours.
*
* A focus ring: every `:focus-visible` element gets a 2px outline in the current
* text color, except `.hz-field` inputs, selects, textareas and `.hz-button`,
* which keep the reference theme's own softer ring. That exclusion is
* deliberate. Without it the outline would draw a box straight across every
* field and button border, on top of the ring already there.
*
* WHAT IS NOT HERE
*
* `.sr-only` ships in the reference theme, not here. App-level concerns
* (box-sizing, body margin, the reduced-motion collapse) belong to your app, so
* this sheet does not duplicate them.
*/
/* ------------------------------------------------------------------ */
/* Demo scaffolding — the shared conventions around Example blocks. */
/* ------------------------------------------------------------------ */
.tab-content {
padding-top: 1rem;
}
.inner-tab {
padding-top: 0.5rem;
}
.tab-note {
margin: 0 0 1rem;
font-size: var(--hz-font-size-sm, 0.875rem);
color: var(--hz-color-text-muted, #6b7280);
}
/* Tabs that stack several Example blocks: an example owns no margin of its
* own, so give anything that follows one breathing room. The 1rem matches
* .tab-note's own bottom margin, and collapses with it where both apply. */
.doc-example + .tab-note,
.doc-example + .doc-example {
margin-top: 1rem;
}
/* Alert callouts in demo tabs follow the same convention: the component
* ships no outer margin, so the scaffolding spaces whatever sits against
* one. Direct children only — an Alert rendered INSIDE a demo (the Alert
* page's own examples) must keep its in-demo layout untouched. */
.tab-content > .hz-alert + *,
.tab-content > * + .hz-alert,
.inner-tab > .hz-alert + *,
.inner-tab > * + .hz-alert {
margin-top: 1rem;
}
/* A supporting note that owns no space of its own: the same muted, small
* treatment as .tab-note, with the margin left to the Stack it sits in.
* Pages used to hand-roll this one per file. */
.doc-note {
margin: 0;
font-size: var(--hz-font-size-sm, 0.875rem);
color: var(--hz-color-text-muted, #6b7280);
}
/* A rule worth pausing on: the same muted note, set off by a border on the
* inline-start edge. Three pages hand-rolled this and had already drifted
* apart (one had lost its text color). */
/* Doctrine notes are Blockquotes — the class retunes the component's
* documented font-size hook down to body scale (the accent line keeps the
* component's heavy default) and weights the quote part up a step. A worked
* example of styling through hooks and documented parts rather than reaching
* into component internals. */
.doctrine-note {
--hz-blockquote-font-size: var(--hz-font-size-base, 1rem);
}
.doctrine-note .hz-blockquote-quote {
font-weight: var(--hz-font-weight-medium, 500);
}
/* The list counterpart to .tab-note — a muted supporting list (install
* prerequisites, short caveat lists). Zero top margin: the surrounding
* rhythm owns the spacing above it. */
.note-list {
margin: 0;
padding-left: 1.25rem;
font-size: var(--hz-font-size-sm, 0.875rem);
color: var(--hz-color-text-muted, #6b7280);
line-height: var(--hz-line-height-base, 1.5);
}
.demo-col {
max-width: 24rem;
}
/*
* .docs-table: the worked cascade example. The reference theme paints
* .hz-table from @layer hz-theme, so these unlayered rules win on every
* property they set — no specificity games, no !important.
*/
.docs-table.hz-table-wrap {
border: none;
border-radius: 0;
}
.docs-table .hz-table {
font-size: var(--hz-font-size-sm, 0.875rem);
}
.docs-table .hz-table th,
.docs-table .hz-table td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--hz-color-border, #6b7280);
vertical-align: top;
}
.docs-table .hz-table thead th {
background-color: transparent;
font-weight: var(--hz-font-weight-semibold, 600);
white-space: nowrap;
}
/* ------------------------------------------------------------------ */
/* Page rhythm — the h1/lead/section-heading scaffold shared by every */
/* content page. */
/* ------------------------------------------------------------------ */
.doc-intro h1 {
margin: 0 0 0.5rem;
font-size: var(--hz-font-size-2xl, 2.75rem);
font-weight: var(--hz-font-weight-bold, 700);
line-height: var(--hz-line-height-tight, 1.2);
}
.doc-description {
margin: 0;
font-size: var(--hz-font-size-lg, 1.4rem);
color: var(--hz-color-text, #000);
line-height: var(--hz-line-height-base, 1.5);
}
.doc-section h2 {
margin: 0;
font-size: var(--hz-font-size-xl, 1.65rem);
font-weight: var(--hz-font-weight-semibold, 600);
}
.a11y-refs {
margin: 0;
font-size: var(--hz-font-size-sm, 0.875rem);
color: var(--hz-color-text-muted, #6b7280);
}
/* ------------------------------------------------------------------ */
/* Content focus-visible ring — broad by design; see header comment for */
/* why the exclusion list is load-bearing. */
/* ------------------------------------------------------------------ */
*:focus-visible:not(
:is(.hz-field input, .hz-field select, .hz-field textarea, .hz-button, .hz-card-link)
) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* ------------------------------------------------------------------ */
/* Code chips — inline code in running text. Scoped to paragraph/list */
/* context so pre blocks and table cells stay plain. */
/* ------------------------------------------------------------------ */
p code,
li code {
background-color: color-mix(in srgb, var(--hz-intent-neutral, #6b7280) 14%, transparent);
padding: 0.125em 0.375em;
border-radius: var(--hz-radius-sm, 0.25rem);
font-family: var(--hz-font-family-mono, monospace);
font-size: 0.875em;
}
/* 14% gray is invisible over black — strengthen the chip tint in dark. The
* fallback here mirrors the light rule's (not a dark literal): a fallback
* only fires when --hz-intent-neutral is undefined altogether, which has
* nothing to do with data-theme. */
[data-theme='dark'] p code,
[data-theme='dark'] li code {
background-color: color-mix(in srgb, var(--hz-intent-neutral, #6b7280) 28%, transparent);
}
Terminal: standalone
Every rule below is the example’s own, written against the raw headless hooks. The components ship structure, behavior and ARIA. Nothing here inherits a visual decision from the reference theme.
import '@hyzer-labs/ui/tokens.css';
// NO '@hyzer-labs/ui/theme': that is the point.
import '@hyzer-labs/ui/theme/examples/terminal/terminal.css';Course update
Next tee time
Saturday, 8:40, Maple Hill.Score must be a whole number
What is in the bag?
Course conditions
/**
* Terminal: an example theme that stands on its own.
*
* This is a working config, the kind you would write yourself. Running the
* `hyzer` CLI over it produces `terminal.tokens.css` beside it, so edit this
* file and regenerate rather than editing the sheet by hand.
*/
import { defineConfig } from '@hyzer-labs/ui/config';
/** The comment header the generated sheet opens with. */
export const intro = [
'Example theme: Terminal (tokens)',
'A phosphor-on-black palette for the standalone CRT sheet. It is scoped to',
'the .hz-theme-terminal class rather than :root, so the theme travels with',
'the class. That lets it share a page with anything else.',
'',
'Both looks here are dark. The default is a tube at rest, and the dark',
'theme is lights-out: brighter phosphor on true black.',
'',
'It is an EXTENSION as well. `phosphor` and `amber` are new intents, not',
'remaps: two hues the library does not ship, given intent names of their',
'own. They are typed in intents.d.ts, and the contrast report grades them',
'like any other intent. <Button intent="amber"> type-checks and',
'autocompletes.',
'',
'The theme decides its own TYPE too, not only its color. The mono stack',
"here is the theme's, and it adds an xs step to a scale that ships six.",
'Setting them here lets every component sheet inherit both from the theme',
'root instead of repeating a font-family.',
'',
'This file is the palette and the type. terminal.css imports it along with',
'the component sheets, and documents how to use them.',
'',
'Import order matters: these rules win by coming last.',
" import '@hyzer-labs/ui/tokens.css';",
" // NO '@hyzer-labs/ui/theme': that is the point.",
" import '@hyzer-labs/ui/theme/examples/terminal/terminal.css';"
];
export default defineConfig({
// Root the sheet at a class instead of :root, so the theme travels with
// the class and can share a page with anything else.
selector: '.hz-theme-terminal',
// Everything under `tokens` is the default theme: it is authored into the
// :root block, which is what a page gets with no data-theme attribute.
// Named variants (dark, and any of your own) go under `themes`.
tokens: {
typography: {
// The theme's own stack, and a real difference from the default: it
// leads with the boxy grotesques a terminal emulator ships with
// rather than the system UI mono. System faces only, so it resolves
// wherever it runs and the example never depends on a font it does
// not ship. Name your own face here if you load one.
fontFamily: {
mono: "Menlo, Monaco, Consolas, 'DejaVu Sans Mono', 'Courier New', monospace"
},
fontSize: { xs: '0.75rem' }
},
palette: {
// Saturated signal colors, all bright enough to read on black.
secondary: '#00e5ff',
danger: '#ff3b30',
warning: '#ffd166',
success: '#00ff41',
info: '#00e5ff',
// The two phosphor tubes a real terminal shipped with: P1 green and
// the P3 amber of a later monitor. These are new hues, not remaps,
// and phosphor is the canonical green here. There is deliberately no
// separate palette primary: the intent below points at phosphor, so
// the theme has one green with one name rather than two that look
// alike.
phosphor: '#39ff6a',
amber: '#ffb000'
},
color: {
// The semantic roles. There is no pale variant of this theme: even at
// rest a CRT glows, so the default look is already dark.
surface: '#0b0f0b',
text: '#33ff66',
textMuted: '#00a844',
border: '#00ff41'
},
intent: {
// Neutral takes the dim phosphor, since a gray would be invisible here.
neutral: 'var(--hz-color-text-muted)',
// The stock intent, remapped onto this theme's own hue. A consumer
// writing <Button intent="primary"> gets the tube green, with no
// second near-identical color in the palette to wonder about.
primary: 'var(--hz-palette-phosphor)',
// Two intents the library does not ship. It comes with seven, and
// nothing stops a theme adding more. Each points at a hue defined
// above, so these are new categories all the way down rather than
// second names for colors that already existed. Both are graded by
// the contrast report and typed in ./intents.d.ts, so they behave
// exactly like the built-in intents.
phosphor: 'var(--hz-palette-phosphor)',
amber: 'var(--hz-palette-amber)'
}
},
themes: {
dark: {
// Most of the signal hues were picked for a black tube already, so
// lights-out reuses them rather than inventing a second set. Naming
// them here keeps the library's own dark companions out of this
// theme.
//
// `danger` is the deliberate exception. This theme's red clears AA on
// the true-black surface, but on the muted one it lands at 4.11:1. So
// it stays unpinned, and the library's lighter companion applies in
// lights-out. Readable text wins over one shade of the palette.
palette: {
secondary: '#00e5ff',
warning: '#ffd166',
success: '#00ff41',
info: '#00e5ff'
},
color: {
// Lights-out: true black, with the phosphor pushed hotter.
surface: '#000000',
text: '#4dff80',
textMuted: '#00c853',
border: '#33ff66'
}
}
}
});
/**
* Example theme: Terminal
*
* A STANDALONE class-override theme: phosphor-on-black, sharp one-pixel
* rules, mono caps, and light that blooms instead of falling. It is styled
* directly on the headless hz-* hooks. The reference theme is never imported,
* so every rule here is this sheet's own.
*
* Terminal declines to be a light theme. Both modes are dark: light is the
* tube at rest, dark is lights-out (true black, hotter phosphor). A theme is
* allowed to have opinions like that. Nothing in the library requires a light
* mode, only that both modes hold AA.
*
* It is also the EXTENSION example. `phosphor` and `amber` are intents the
* library never shipped: added in terminal.config.ts, typed in
* ./intents.d.ts, styled in ./components/button.css, and graded by the
* contrast report exactly like `primary`. The intent vocabulary is a theme's
* to grow, not a fixed enum.
*
* Type is the theme's too, and it comes from the same config. The mono stack
* this sheet sets on the theme root below is --hz-font-family-mono, and the
* `xs` step the component sheets read is one terminal.config.ts adds to a
* scale that ships six. No component sheet here declares a font-family of its
* own; they all inherit this one.
*
* Import order matters: these rules win by coming last.
* import '@hyzer-labs/ui/tokens.css';
* // NO '@hyzer-labs/ui/theme': that is the point.
* import '@hyzer-labs/ui/theme/examples/terminal/terminal.css';
*
* Then put the class on whatever subtree it should own:
* <html class="hz-theme-terminal">
*
* Every rule is rooted at .hz-theme-terminal rather than :root, so the theme
* travels with the class. It can own a whole document or a single panel, and
* two themes can share a page. It also means these rules sit at (0,2,0) and
* unlayered, so they beat @layer hz-theme outright. That is why this sheet
* can be demoed on a docs page that loads the reference theme globally.
*
* COVERAGE: the demo set ships Button, Badge, Alert, Card, the Field
* scaffold + TextInput, Toggle, Tabs, Accordion. Components outside that list
* fall back to bare headless structure. Growing to full parity is additive:
* add a sheet under ./components and @import it below.
*
* ALSO NEEDED: Terminal skips the reference theme, and `.sr-only` ships in
* theme/base.css, not in the component sheets. Button's loading label, Link's
* external hint and the Field scaffold's hideLabel all render
* class="sr-only". A Terminal-only app must import base.css or bring its own
* .sr-only rule, or that text becomes visible.
*/
/* The palette (generated from terminal.config.ts — do not edit by hand). */
@import './terminal.tokens.css';
@import './components/button.css';
@import './components/badge.css';
@import './components/alert.css';
@import './components/card.css';
@import './components/field.css';
@import './components/toggle.css';
@import './components/tabs.css';
@import './components/accordion.css';
/* Document-level defaults, scoped to the theme's subtree. The reference
* theme's base.css does this for `body`; Terminal cannot assume it owns the
* document, so it paints its own root instead. */
.hz-theme-terminal {
background-color: var(--hz-color-surface, #0b0f0b);
color: var(--hz-color-text, #33ff66);
font-family: var(--hz-font-family-mono, Menlo, Monaco, Consolas, 'Courier New', monospace);
line-height: var(--hz-line-height-base, 1.5);
}
/**
* Terminal — Button
*
* Standalone: assumes tokens.css only. Every property the reference theme
* sets on .hz-button is set here too, so this sheet renders identically
* whether or not the reference theme is loaded.
*
* The look: a CRT. One-pixel phosphor rules, square corners, mono caps. At
* rest a button is an outline; on hover the phosphor floods the cell and the
* text knocks out to the background — an inversion, exactly like a text-mode
* selection. The glow is a real light source, so it paints in the accent, and
* every color resolves through role tokens: nothing here is a literal, which
* is why lights-out mode (true black, hotter phosphor) just works.
*/
.hz-theme-terminal .hz-button {
/* Per-intent hooks, same contract as the reference theme: override these
* two to restyle every variant. */
--hz-button-accent: var(--hz-intent-primary, #39ff6a);
--hz-button-on-accent: var(--hz-color-surface, #0b0f0b);
--hz-button-glow: 0 0 12px color-mix(in srgb, var(--hz-button-accent) 55%, transparent);
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5em;
border: 1px solid var(--hz-button-accent);
border-radius: 0;
background-color: transparent;
color: var(--hz-button-accent);
font-family: inherit;
font-weight: var(--hz-font-weight-medium, 500);
line-height: var(--hz-line-height-tight, 1.2);
text-transform: uppercase;
letter-spacing: 0.12em;
text-decoration: none;
cursor: pointer;
user-select: none;
box-shadow: none;
translate: 0 0;
/* The tube has no easing, but it does have persistence. */
transition: none;
}
/*
* Intent → accent. Terminal never imports the reference theme, so nothing
* wires this for it automatically — a theme that replaces the reference
* theme maps its own intents, and this is what that looks like: every rule
* below is hand-written, built-ins and extensions alike. `phosphor` and
* `amber` are defined in terminal.config.ts and typed in ../intents.d.ts —
* from here they are ordinary intents, and that is the whole point: the
* component only stamps data-intent, so the set of intents is the theme's
* to decide, not the library's.
*/
.hz-theme-terminal .hz-button[data-intent='secondary'] {
--hz-button-accent: var(--hz-intent-secondary, #00e5ff);
}
.hz-theme-terminal .hz-button[data-intent='danger'] {
--hz-button-accent: var(--hz-intent-danger, #ff3b30);
}
.hz-theme-terminal .hz-button[data-intent='neutral'] {
--hz-button-accent: var(--hz-intent-neutral, #00a844);
}
.hz-theme-terminal .hz-button[data-intent='warning'] {
--hz-button-accent: var(--hz-intent-warning, #ffd166);
}
.hz-theme-terminal .hz-button[data-intent='success'] {
--hz-button-accent: var(--hz-intent-success, #00ff41);
}
.hz-theme-terminal .hz-button[data-intent='info'] {
--hz-button-accent: var(--hz-intent-info, #00e5ff);
}
/* The two extension intents. */
.hz-theme-terminal .hz-button[data-intent='phosphor'] {
--hz-button-accent: var(--hz-intent-phosphor, #39ff6a);
}
.hz-theme-terminal .hz-button[data-intent='amber'] {
--hz-button-accent: var(--hz-intent-amber, #ffb000);
}
/* Sizes — tight, on a character grid. */
.hz-theme-terminal .hz-button[data-size='sm'] {
padding: 0.25rem 0.625rem;
min-height: 2rem;
font-size: var(--hz-font-size-xs, 0.75rem);
}
.hz-theme-terminal .hz-button[data-size='md'] {
padding: 0.5rem 1rem;
min-height: 2.5rem;
font-size: var(--hz-font-size-sm, 0.875rem);
}
.hz-theme-terminal .hz-button[data-size='lg'] {
padding: 0.75rem 1.5rem;
min-height: 3rem;
font-size: var(--hz-font-size-base, 1rem);
}
/* `full` — the md cell, stretched to fill its container. */
.hz-theme-terminal .hz-button[data-size='full'] {
display: flex;
width: 100%;
padding: 0.5rem 1rem;
min-height: 2.5rem;
font-size: var(--hz-font-size-sm, 0.875rem);
}
/* Icon-only — a square cell, never a circle. */
.hz-theme-terminal .hz-button[data-icon-only] {
padding: 0;
border-radius: 0;
}
.hz-theme-terminal .hz-button[data-icon-only][data-size='sm'] {
width: 2rem;
}
.hz-theme-terminal .hz-button[data-icon-only][data-size='md'] {
width: 2.5rem;
}
.hz-theme-terminal .hz-button[data-icon-only][data-size='lg'] {
width: 3rem;
}
/* Variant: solid — the cell is already lit. */
.hz-theme-terminal .hz-button[data-variant='solid'] {
background-color: var(--hz-button-accent);
border-color: var(--hz-button-accent);
color: var(--hz-button-on-accent);
box-shadow: var(--hz-button-glow);
}
/* Variant: outline — the default state of everything here. */
.hz-theme-terminal .hz-button[data-variant='outline'] {
background-color: transparent;
border-color: var(--hz-button-accent);
color: var(--hz-button-accent);
}
/* Variant: ghost — no rule until you touch it. The transparent border keeps
* it the same size as its bordered siblings. */
.hz-theme-terminal .hz-button[data-variant='ghost'] {
background-color: transparent;
border-color: transparent;
color: var(--hz-button-accent);
}
/* Variant: soft — the reference theme's Badge soft recipe, restyled for the
* tube: no tint math, same object as outline/soft but with the fill dropped.
* (Terminal's Badge soft already declines --hz-badge-tint for the same
* reason — no mixing at all, so no contrast surprises.) */
.hz-theme-terminal .hz-button[data-variant='soft'] {
background-color: var(--hz-color-surface, #0b0f0b);
border-color: transparent;
color: var(--hz-button-accent);
}
/* Hover — the phosphor floods the cell and the glyphs knock out. Solid is
* already flooded, so it inverts the other way: the light drops out and the
* outline remains. */
.hz-theme-terminal
.hz-button:is([data-variant='outline'], [data-variant='ghost'], [data-variant='soft']):hover:not(
[data-state]
) {
background-color: var(--hz-button-accent);
border-color: var(--hz-button-accent);
color: var(--hz-button-on-accent);
box-shadow: var(--hz-button-glow);
}
.hz-theme-terminal .hz-button[data-variant='solid']:hover:not([data-state]) {
background-color: transparent;
border-color: var(--hz-button-accent);
color: var(--hz-button-accent);
box-shadow: none;
}
/* Active — the beam surges. */
.hz-theme-terminal
.hz-button:is(
[data-variant='solid'],
[data-variant='outline'],
[data-variant='ghost'],
[data-variant='soft']
):active:not([data-state]) {
background-color: var(--hz-button-accent);
color: var(--hz-button-on-accent);
box-shadow: 0 0 22px color-mix(in srgb, var(--hz-button-accent) 85%, transparent);
}
/* Focus — a hard offset rectangle, not a soft ring. */
.hz-theme-terminal .hz-button:focus-visible {
outline: 2px solid var(--hz-button-accent);
outline-offset: 2px;
box-shadow: var(--hz-button-glow);
}
/* States */
.hz-theme-terminal .hz-button[data-state='disabled'] {
opacity: 1;
cursor: not-allowed;
background-color: transparent;
border-style: dashed;
border-color: var(--hz-color-text-muted, #00a844);
color: var(--hz-color-text-muted, #00a844);
box-shadow: none;
}
.hz-theme-terminal .hz-button[data-state='loading'] {
cursor: progress;
}
/* Namespaced: @keyframes are global, so reusing the reference theme's
* `hz-spin` name here would redefine it for every page that loads both. */
.hz-theme-terminal .hz-button[data-state='loading'] .hz-icon {
animation: hz-terminal-spin 0.8s steps(8, end) infinite;
}
@keyframes hz-terminal-spin {
to {
transform: rotate(360deg);
}
}
/**
* Terminal — Toggle
*
* Standalone: assumes tokens.css only.
*
* CASCADE NOTE — this is the one component where a theme cannot be careless.
* Headless, Toggle is a bare native checkbox, and the reference theme's
* track/thumb rules are deliberately UNLAYERED at (0,2,1)
* (`.hz-field--toggle input.hz-toggle`) so they out-compete the component's
* own scoped structural reset. Layer order cannot help you here: an
* override authored at `.hz-field--toggle input.hz-toggle` would tie on
* specificity and lose on source order whenever the theme is imported after.
* These rules carry the theme's root class for (0,3,1) and win outright.
*
* The look: a two-cell character field. Square track, square block cursor,
* and a stepped slide — no easing, the block is either in this cell or that
* one. Lit means on.
*/
.hz-theme-terminal .hz-field--toggle input.hz-toggle {
--hz-toggle-width: 3rem;
--hz-toggle-height: 1.5rem;
appearance: none;
box-sizing: border-box;
position: relative;
margin: 0;
width: var(--hz-toggle-width);
height: var(--hz-toggle-height);
border: 1px solid var(--hz-color-text-muted, #00a844);
border-radius: 0;
background-color: var(--hz-color-surface, #0b0f0b);
cursor: pointer;
transition: none;
flex-shrink: 0;
}
.hz-theme-terminal .hz-field--toggle input.hz-toggle[data-state='on'] {
border-color: var(--hz-intent-primary, #39ff6a);
background-color: var(--hz-intent-primary, #39ff6a);
box-shadow: 0 0 12px -2px color-mix(in srgb, var(--hz-intent-primary, #39ff6a) 60%, transparent);
}
.hz-theme-terminal .hz-field--toggle input.hz-toggle:disabled {
cursor: not-allowed;
opacity: 0.5;
border-style: dashed;
}
.hz-theme-terminal .hz-field--toggle input.hz-toggle:focus-visible {
outline: 1px solid var(--hz-intent-primary, #39ff6a);
outline-offset: 2px;
box-shadow: 0 0 12px -2px color-mix(in srgb, var(--hz-intent-primary, #39ff6a) 60%, transparent);
}
/*
* The block cursor. The 1px rule means the padding box is (w-2)x(h-2), so a
* (h-4) block at 1px inset sits centred with 1px of clearance all round, and
* the (w-h) travel lands it with the same 1px clearance on the far side.
*/
.hz-theme-terminal .hz-field--toggle input.hz-toggle::before {
content: '';
position: absolute;
top: 1px;
left: 1px;
width: calc(var(--hz-toggle-height) - 4px);
height: calc(var(--hz-toggle-height) - 4px);
border-radius: 0;
background-color: var(--hz-color-text-muted, #00a844);
box-shadow: none;
/* Stepped, not eased — the cursor jumps cells. */
transition: transform 120ms steps(3, end);
}
.hz-theme-terminal .hz-field--toggle input.hz-toggle[data-state='on']::before {
transform: translateX(calc(var(--hz-toggle-width) - var(--hz-toggle-height)));
background-color: var(--hz-color-surface, #0b0f0b);
}
/**
* Terminal — intent registry augmentation.
*
* This is the whole "grow the system" story, and it is four lines.
*
* The library's intents are a token vocabulary, not a fixed enum: a component
* only stamps `data-intent="<name>"` and the theme defines
* `--hz-intent-<name>`. So the TYPE is open too — `Intent` is
* `keyof IntentRegistry`, and augmenting that interface adds your intents to
* every component's `intent` prop with full type-checking and autocomplete.
*
* `phosphor` and `amber` are defined in terminal.config.ts (which is what
* emits --hz-intent-phosphor / --hz-intent-amber and gets them graded by the
* contrast report) and styled in ./components/button.css.
*
* NOTE ON THE MODULE SPECIFIER — augment the module that DECLARES
* IntentRegistry, not one that re-exports it: TypeScript merges an interface
* only into its declaring module. In a consumer project that is
* '@hyzer-labs/ui/types'; in this repo it is the '@hyzer-labs/ui/types' alias that
* resolves to the same file.
*/
declare module '@hyzer-labs/ui/types' {
interface IntentRegistry {
/** Classic green CRT phosphor — Terminal's house accent. */
phosphor: true;
/** The amber tube: the other monitor everyone remembers. */
amber: true;
}
}
export {};
Style one instance by hand
Every demo above uses the same markup for the "Go pro" button: <Button class="cta">. The class prop is merged after the
component's hz-button root class, so it lands on the element ready for you to style.
Sheet-level and instance-level overrides compose. Ocean and Terminal each add one, below. Docs adds
none: an unscoped sheet has no class to hang a demo-only rule on, so its "Go pro" button renders
exactly as the reference theme paints it.
<Button class="cta">Book a round</Button>
<style>
/* Rendered as class="hz-button cta". One instance, styled by you.
The theme sheet never mentions .cta at all. */
:global(.hz-theme-terminal .hz-button.cta) {
box-shadow: 8px 8px 0 var(--hz-intent-danger);
translate: -2px -2px;
}
</style>Note the selector: .hz-theme-terminal .hz-button.cta, not a bare .cta. Terminal is the one example here that roots its overrides at a class. The
section below covers why.
Grow the intent vocabulary
The intents the library ships are a starting set. A component only stamps data-intent="<name>" and lets the theme decide what that name looks like. So the set of intents belongs to your theme.
Terminal adds two: phosphor and amber, the two tubes every real terminal shipped with.
They are the last two buttons in its demo above.
Three steps. With the reference theme, the last one is nothing at all:
- Define the token in your config. The generator emits it as
--hz-intent-amber, and the contrast report grades it exactly likeprimary. A custom intent meets the same AA bar as a built-in one. - Register the type by augmenting
IntentRegistry. Nowintent="amber"type-checks and autocompletes on every component, andintent="ambr"is a compile error. See theintents.d.tstab above. - Nothing to style. With the reference theme, running
hyzer generatewires the intent into every component that takes one (Button,Badge,Alert, and the rest) the moment the token exists.
// hyzer.config.ts: define the token, and it gets contrast-graded
import { defineConfig } from '@hyzer-labs/ui/config';
export default defineConfig({
tokens: {
intent: {
phosphor: 'var(--hz-palette-primary)',
amber: '#ffb000'
}
}
});Augment the module that declares the interface (@hyzer-labs/ui/types),
not the barrel that re-exports it. TypeScript merges an interface only into its declaring
module, so declare module '@hyzer-labs/ui' would silently do nothing.
A theme that replaces the reference theme outright, like Terminal, owns its intent vocabulary and maps it by hand. That means one rule per intent, per component, which is what a built-in intent needs there too:
/* a standalone theme sheet: one rule per intent, same shape as the built-ins */
.hz-button[data-intent='amber'] {
--hz-button-accent: var(--hz-intent-amber);
}How these work
Why does Terminal use a class instead of :root?
Every rule in Terminal is rooted at a class: .hz-theme-terminal .hz-button rather than .hz-button. The engine roots its token block at that class
instead of :root, through the selector config key (set in
Terminal's own hyzer.config tab above) or the --selector flag. Scope the sheet to a class covers
both. So the theme travels with the class: put it on <html> to own a document,
or on one element to own a panel. That is why this page can render an Ocean panel and a Terminal
panel side by side without them fighting. It is also why importing this sheet does not disturb
the docs site's own theme.
Start from one
Copy a config into your project as hyzer.config.ts, run hyzer generate, and iterate. The contrast report grades every change. To go
further than color, copy the theme's components/ folder too and edit the hooks
directly. Styling Components is the reference for what each hook
exposes, and Tokens & Overrides covers the token workflow.
Coverage: Terminal restyles Button, Badge, Alert, Card, the Field scaffold, TextInput, Toggle,
Tabs, and Accordion. It imports no theme, so anything else falls back to bare headless
structure. Docs takes the opposite approach: it restyles no bare component hook, so a
Table with no .docs-table wrapper looks exactly as the reference theme paints it. Over
that theme it adds only scaffold classes, content chrome, and that one opt-in wrapper.