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, and 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. It never imports
the reference theme, so every rule in it is its own. It also grows the system rather than only
recoloring it: 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'; // optional
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).
* Only config-touched tokens are emitted; import this sheet AFTER
* @hyzer-labs/ui/tokens.css so the overrides win by source order.
*/
:root,
[data-theme='light'] {
--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;
}
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. What it layers on top instead: page-rhythm scaffold classes and one class-hook override, imported like any theme sheet and needing no class anywhere.
The full .docs-table lesson lives on Styling Components: the
unlayered-beats-layered walkthrough, its demo, and its 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);
}
/* 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
No reference theme at all. 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 single visual decision from the library.
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.
*
* Two things make it worth reading. It is STANDALONE: the sheets in
* ./components style the headless hooks from scratch, and the reference theme is
* never imported. It is also an EXTENSION: `phosphor` and `amber`, two hues the
* library does not ship, become intents of their own here, typed in
* ./intents.d.ts and styled in ./components/button.css. Nothing about them is
* second class. The contrast report grades them like any other intent, and
* `<Button intent="amber">` type-checks and autocompletes.
*/
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 and can 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.',
'',
'This file is only the palette. terminal.css imports it along with the',
'component sheets, and documents how to use them.'
];
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: {
// 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)',
// Intents the library does not ship. It comes with six; 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.
// 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)',
phosphor: 'var(--hz-palette-phosphor)',
amber: 'var(--hz-palette-amber)'
}
},
themes: {
dark: {
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. 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.
*
* Usage:
* import '@hyzer-labs/ui/tokens.css'; // required
* import '@hyzer-labs/ui/theme/examples/terminal/terminal.css';
* // NO '@hyzer-labs/ui/theme' — that is the point.
*
* 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 — they beat @layer hz-theme outright, which 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", so 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, ui-monospace, 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: var(--hz-font-family-mono, ui-monospace, monospace);
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. The reference theme maps four; Terminal maps everything
* the vocabulary has, INCLUDING two intents the library never shipped.
* `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 {};
One instance, styled 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,
and sheet-level and instance-level overrides compose. Ocean and Terminal each add one, below. Docs
adds none: its "Go pro" button renders exactly as the reference theme paints it, because an unscoped
sheet has no class to hang a demo-only rule on without leaking globally.
<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.
Growing the vocabulary
The intents the library ships are a starting set, not a ceiling. 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, and none of them is a fork:
- Define the token in your config. It is emitted as
--hz-intent-amberand graded by the contrast report exactly likeprimary. A custom intent is held to 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. - Style it with one rule, the same shape the built-in intents use.
// 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'
}
}
});/* your theme sheet: one rule per intent, same shape as the built-ins */
.hz-button[data-intent='amber'] {
--hz-button-accent: var(--hz-intent-amber);
}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.
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. Its token block is generated with the engine's selector option instead of :root. 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,
and 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 one
exposes. The token workflow is documented on Tokens & Overrides.
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 is left exactly as the reference theme paints it.
Over that theme it adds only scaffold classes, content chrome, and that one opt-in wrapper.