ui: semantic roles, so night and amber can tell severity apart at all

A component now asks for what a colour MEANS -- `danger` -- and the theme
decides how that looks.  Five roles: neutral, info, success, warning, danger.

The names are the ones already in the tree.  `--primary/--success/--warning/
--danger` were declared in tokens.css with ZERO consumers, and `MessageKind`
was already `info|success|warning|error`; this is one vocabulary instead of
three.  `error` stays a valid kind because 26 call sites write it, and `danger`
is the same thing under the token's name for new code.

The token set is the easy half.  Light and dark spend a hue per role.  Night is
red and nothing else and amber is amber and nothing else, and there a role has
to be a step on a BRIGHTNESS ramp -- which is why this was worth doing:

  night  --orange is byte-identical to --text
         --yellow is byte-identical to --amber
  amber  the same collisions, one hue over

A call site picking between hue names in those themes is picking between
synonyms.  That is why all four toast kinds were the same pixel in night, and
why an error and an informational message were indistinguishable.

Both ramps are built rather than copied: every role clears 3:1 on the panel
(neutral is allowed lower -- for pending and disabled, dim IS the signal),
severity is strictly increasing in contrast, and danger reaches that theme's
own --text brightness so the loudest role is never quieter than prose.  Amber
needed its own scale, not night's: its --text is 8.6:1 where night's is 5.0:1,
so night's ramp would have left every role dimmer than ordinary text.

Fixes in passing: night's info was 2.29:1, under the 3:1 floor.  And Status
loses its three `html[data-theme="night"]` overrides entirely -- it was the one
component already encoding severity by brightness by hand, and the roles are
that generalised.

Roles are registered as Mantine colors too, so `color='danger'` works anywhere
`color='red'` does.

`isMonochromeTheme` is a new predicate in themes/names.  It is the same set as
`explicitOnlyThemes` today and deliberately separate: one is about how a theme
is chosen, the other about what a theme has to work with.

Scope: tokens plus the library.  The ~24 app-level `color='...'` call sites are
a follow-up -- they need a judgement per site, and two of them already disagree
(Downloads.js colours the same "Download error" label `orange` on one line and
`red` on another).  The 22 `violet` uses are not semantic at all and stay hues.

Tests: the ramp claims are asserted per theme in a browser, and the ordering
and danger-vs-text claims run ONLY for the monochrome themes -- in light, blue
is darker than green on white, so a brightness ramp there would fail a design
that is perfectly fine.  Every guard checked red against a planted regression,
including the original bug (warning and danger collapsed to one value in night)
and night's info back at its old 2.29:1.

The roles were folded into each theme's own block rather than kept in a
standalone one, so the existing "same tokens in every theme" and "only red hues
in night" guards cover them for free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-01 17:31:44 -06:00 • committed by lrnselfreliance
parent aaf187dfc8
commit 08c5c233c8
9 changed files with 365 additions and 65 deletions

View file

@ -313,6 +313,61 @@ export function ThemeSamplePage() {
</Panel>
</Section>
<Section label='Semantic roles'>
<Panel>
{/*
The roles side by side, which is the only way to see the thing that
matters: in night and amber these are five brightnesses of one hue, and
they have to stay tellable apart with no hue to help.
*/}
{/* 120px so all five usually sit on one row; they still wrap on a phone,
which is why the caption says "in order" and not "left to right". */}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: 12,
}}>
{[
['neutral', 'pending, disabled, unknown'],
['info', 'in progress, informational'],
['success', 'complete, healthy'],
['warning', 'needs attention'],
['danger', 'failed, destructive'],
].map(([role, meaning]) => <div key={role}>
<div style={{
height: 34, background: `var(--${role})`, marginBottom: 6,
}}/>
<div style={{fontSize: 13, fontWeight: 600, color: `var(--${role})`}}>
{role}
</div>
<div style={{fontSize: 11, color: 'var(--muted)'}}>{meaning}</div>
</div>)}
</div>
<p style={{fontSize: 12, color: 'var(--muted)', marginTop: 14, marginBottom: 0}}>
Components ask for a <em>meaning</em> — <code>danger</code> — not a hue.
Light and dark spend a colour on each. Night and amber have only one hue,
so a role is a step on a brightness ramp instead; that is why an error and
an info toast used to be the same pixel there. Severity climbs in the order
shown for those two themes, and <code>danger</code> is never quieter than
ordinary text.
</p>
</Panel>
</Section>
<Section label='Status'>
<Panel>
<div style={{display: 'flex', gap: 18, flexWrap: 'wrap'}}>
<Status kind='complete'>complete</Status>
<Status kind='active'>downloading</Status>
<Status kind='pending'>pending</Status>
<Status kind='failed'>failed</Status>
</div>
<p style={{fontSize: 12, color: 'var(--muted)', marginTop: 12, marginBottom: 0}}>
Four states drawn from four roles. Failure also carries weight, because
brightness is the first thing lost on a dim screen and it is all night has.
</p>
</Panel>
</Section>
<Section label='Messages'>
<div style={{display: 'flex', flexDirection: 'column', gap: 10}}>
<Message kind='info' title='Refresh started'>

View file

@ -1,7 +1,7 @@
import React from 'react';
import {Loader as MLoader, Skeleton} from '@mantine/core';
import {Icon} from './Icon';
import {SemanticColorName} from '../../themes/mantine';
import {RoleName, SemanticColorName} from '../../themes/mantine';
/*
* Feedback: messages, labels, progress, status text, loaders.
@ -12,13 +12,20 @@ import {SemanticColorName} from '../../themes/mantine';
// ------------------------------------------------------------------ Messages
export type MessageKind = 'info' | 'success' | 'warning' | 'error';
/*
* `error` is the original spelling and stays: 26 call sites use it, and renaming those
* is app-level churn for no behaviour change. `danger` is the same thing under the
* name the token uses, and is what new code should write.
*/
export type MessageKind = 'info' | 'success' | 'warning' | 'error' | 'danger';
const messageColors: Record<MessageKind, string> = {
info: 'var(--blue)',
success: 'var(--green)',
warning: 'var(--amber)',
error: 'var(--red)',
/** The role each kind means. Never a hue -- see the note on roles in tokens.css. */
const messageRoles: Record<MessageKind, RoleName> = {
info: 'info',
success: 'success',
warning: 'warning',
error: 'danger',
danger: 'danger',
};
export interface MessageProps {
@ -35,10 +42,10 @@ export interface MessageProps {
export function Message({kind = 'info', title, children, icon, onDismiss, className}: MessageProps) {
return <div
// Errors and warnings interrupt; info and success are announced politely.
role={kind === 'error' ? 'alert' : 'status'}
className={['wrolpi-message', kind === 'error' ? 'wrolpi-message-error' : '', className]
role={messageRoles[kind] === 'danger' ? 'alert' : 'status'}
className={['wrolpi-message', messageRoles[kind] === 'danger' ? 'wrolpi-message-error' : '', className]
.filter(Boolean).join(' ')}
style={{['--message-color' as string]: messageColors[kind]}}
style={{['--message-color' as string]: `var(--${messageRoles[kind]})`}}
>
{icon && <div className='wrolpi-message-icon'>
{typeof icon === 'string' ? <Icon name={icon} size={20}/> : <Icon component={icon} size={20}/>}
@ -61,7 +68,7 @@ export function Message({kind = 'info', title, children, icon, onDismiss, classN
// -------------------------------------------------------------------- Labels
export interface LabelProps {
color?: SemanticColorName | 'black' | 'white';
color?: SemanticColorName | RoleName | 'black' | 'white';
icon?: string | React.ComponentType<any>;
/**
* Draw it as a physical tag — pointed left edge and an eyelet — rather than a plain
@ -100,7 +107,7 @@ export interface ProgressProps {
indeterminate?: boolean;
/** Show the percentage inside the bar. */
showPercent?: boolean;
color?: SemanticColorName;
color?: SemanticColorName | RoleName;
/** Replaces the percentage with arbitrary text (e.g. "2.1 GB / 5.3 GB"). */
label?: React.ReactNode;
className?: string;
@ -137,6 +144,19 @@ export function Progress({
export type StatusKind = 'complete' | 'active' | 'pending' | 'failed';
/*
* Status was the one component already doing this correctly: it had per-theme overrides
* in ui.css because night has no hues to distinguish four states with. Those are gone --
* the roles carry the brightness ramp now, so this table is the whole of it and every
* theme is served by the same rule.
*/
const statusRoles: Record<StatusKind, RoleName> = {
complete: 'success',
active: 'info',
pending: 'neutral',
failed: 'danger',
};
const statusIcons: Record<StatusKind, string> = {
complete: 'check',
active: 'circle notch',
@ -157,7 +177,10 @@ export interface StatusProps {
* the color.
*/
export function Status({kind, children, plain}: StatusProps) {
return <span className={`wrolpi-status wrolpi-status-${kind}`}>
return <span
className={`wrolpi-status wrolpi-status-${kind}`}
style={{['--status-color' as string]: `var(--${statusRoles[kind]})`}}
>
{!plain && <Icon name={statusIcons[kind]} size={14} loading={kind === 'active'}/>}
{children}
</span>

View file

@ -106,16 +106,33 @@ describe('toast', () => {
await waitFor(() => expect(visibleToasts()).toHaveLength(0));
});
it('carries the colour its type maps to', async () => {
it('carries the ROLE its type maps to, never a hue', async () => {
withToasts();
showToast({type: 'error', title: 'Download failed'});
await waitFor(() => expect(visibleToasts()).toHaveLength(1));
// The whole custom property, not a substring: `toContain('red')` would also be
// satisfied by any other declaration that happens to carry those three letters.
/*
* The whole custom property, not a substring: `toContain('danger')` would also be
* satisfied by any other declaration that happens to carry those letters.
*
* A hue here is the bug, not a detail. `red` resolves per theme, but in night
* `--yellow` and `--amber` are the same value and `--orange` is `--text`, so hue
* names cannot keep four kinds of toast apart there. The role can.
*/
expect(visibleToasts()[0].getAttribute('style'))
.toMatch(/--notification-color:\s*var\(--mantine-color-red-/);
.toMatch(/--notification-color:\s*var\(--mantine-color-danger-/);
});
it('accepts `danger` as well as `error`, so new code can use the token name', async () => {
// `error` is what 26 call sites already write; both must reach the same role.
withToasts();
showToast({type: 'danger', title: 'Download failed'});
await waitFor(() => expect(visibleToasts()).toHaveLength(1));
expect(visibleToasts()[0].getAttribute('style'))
.toMatch(/--notification-color:\s*var\(--mantine-color-danger-/);
});
it('gives each type a colour of its own', async () => {
@ -145,7 +162,7 @@ describe('toast', () => {
await waitFor(() => expect(visibleToasts()).toHaveLength(1));
expect(visibleToasts()[0].getAttribute('style'))
.toMatch(/--notification-color:\s*var\(--mantine-color-blue-/);
.toMatch(/--notification-color:\s*var\(--mantine-color-info-/);
});
it('survives being called with nothing at all', () => {

View file

@ -1,5 +1,6 @@
import React from 'react';
import {notifications} from '@mantine/notifications';
import {RoleName} from '../../themes/mantine';
/*
* Toasts.
@ -9,7 +10,8 @@ import {notifications} from '@mantine/notifications';
* ThemeProvider.
*/
export type ToastType = 'info' | 'success' | 'warning' | 'error';
/* `error` is the original spelling and stays; `danger` is the token's name for it. */
export type ToastType = 'info' | 'success' | 'warning' | 'error' | 'danger';
export interface ToastOptions {
type?: ToastType;
@ -25,11 +27,18 @@ export interface ToastOptions {
onClick?: () => void;
}
const toastColors: Record<ToastType, string> = {
info: 'blue',
success: 'green',
warning: 'yellow',
error: 'red',
/*
* Roles, not hues. With hues, all four kinds were the same pixel in night and amber:
* `--yellow` there is byte-identical to `--amber`, and `--orange` to `--text`. An
* error toast was indistinguishable from an informational one, which is the failure
* mode a toast exists to avoid.
*/
const toastRoles: Record<ToastType, RoleName> = {
info: 'info',
success: 'success',
warning: 'warning',
error: 'danger',
danger: 'danger',
};
/**
@ -64,7 +73,7 @@ export function toast({type = 'info', title, description, time = 5000, onClick}:
notifications.show({
title,
message: description,
color: toastColors[type],
color: toastRoles[type],
// Semantic treated 0 as "stay up"; Mantine wants false for that. Its `getAutoClose`
// returns any number as given and the container then calls `setTimeout(hide, 0)`, so
// passing the 0 through dismisses the toast on the next tick.

View file

@ -1,12 +1,12 @@
import React from 'react';
import {
ActionInput, Button, Header, Icon, IconStack, Loading, MultiSelect, Panel, PathInput,
Placeholder, Statistic, StatisticGroup, TabBar, Table, tabClassName, TextInput,
Placeholder, Statistic, StatisticGroup, Status, TabBar, Table, tabClassName, TextInput,
} from './index';
import {contrastingColor} from '../Common';
import {Notifications} from '@mantine/notifications';
import {toast} from './toast';
import {themeNames} from '../../themes/names';
import {monochromeThemes, themeNames} from '../../themes/names';
/* `--panel` is authored as a hex; computed backgrounds come back as rgb(). */
const hexToRgb = (hex) => {
@ -175,6 +175,147 @@ const sampleTable = <Table>
</Table.Body>
</Table>;
describe('semantic roles read as severity in every theme', () => {
/*
* The point of the roles, and the only thing that makes them worth having.
*
* Light and dark spend a hue per role, so this is nearly free there. Night is red and
* nothing else and amber is amber and nothing else, so a role there is a step on a
* brightness ramp -- and it has to be, because `--orange` in night is byte-identical to
* `--text` and `--yellow` to `--amber`. Picking between hue names in night is picking
* between synonyms, which is why all four toast kinds were the same pixel.
*
* None of this is checkable in jsdom: every value is a token behind two levels of
* var(), and the claim is about resolved luminance.
*/
const ROLES = ['neutral', 'info', 'success', 'warning', 'danger'];
const roleColours = () => {
const root = getComputedStyle(document.documentElement);
const panel = hexToRgb(root.getPropertyValue('--panel').trim());
return {
panel,
text: hexToRgb(root.getPropertyValue('--text').trim()),
roles: ROLES.map(role => ({
role, colour: hexToRgb(root.getPropertyValue(`--${role}`).trim()),
})),
};
};
themeNames.forEach((theme) => {
it(`gives every role a colour of its own in ${theme}`, () => {
// True everywhere. Two roles resolving to the same value is the original bug.
cy.mountUI(<Panel>roles</Panel>, {theme});
cy.get('.wrolpi-panel').should(() => {
const {roles} = roleColours();
const colours = roles.map(r => r.colour);
colours.forEach((colour, index) => {
expect(colour, `${roles[index].role} resolved`).to.match(/^rgb\(/);
});
expect(new Set(colours).size, `five distinct roles, got ${colours.join(' ')}`)
.to.equal(5);
});
});
it(`keeps every role legible in ${theme}`, () => {
cy.mountUI(<Panel>roles</Panel>, {theme});
cy.get('.wrolpi-panel').should(() => {
const {panel, roles} = roleColours();
roles.forEach(({role, colour}) => {
/*
* 3:1 is the floor for anything that is not body text. `neutral` is
* allowed to sit at the bottom of the ramp -- for `pending` and disabled,
* being dim IS the signal -- but it still has to be visible at all.
*/
expect(contrast(colour, panel), `${role} against the panel`)
.to.be.at.least(role === 'neutral' ? 2.5 : 3);
});
});
});
it(`gives each Status kind a colour of its own in ${theme}`, () => {
/*
* The end of the chain, through a real component: four states, four distinct
* painted colours. In night these used to need three hand-written
* `html[data-theme="night"]` overrides; the roles replaced all of them.
*/
cy.mountUI(
<Panel>
<Status kind='complete'>complete</Status>
<Status kind='active'>downloading</Status>
<Status kind='pending'>pending</Status>
<Status kind='failed'>failed</Status>
</Panel>,
{theme},
);
cy.get('.wrolpi-status').should(($all) => {
expect($all.length).to.equal(4);
const colours = [...$all].map(el => getComputedStyle(el).color);
expect(new Set(colours).size, `four distinct colours, got ${colours.join(' ')}`)
.to.equal(4);
});
});
});
monochromeThemes.forEach((theme) => {
it(`orders the roles by brightness in ${theme}, which is all it has`, () => {
/*
* Only here. Light and dark spend a hue per role, so their contrast order is
* an accident of which hues were chosen -- in light, blue is DARKER than green
* on white, so asserting a ramp there fails on a design that is perfectly fine.
* With hue gone, though, ordering is the only thing severity can be encoded in.
*/
cy.mountUI(<Panel>roles</Panel>, {theme});
cy.get('.wrolpi-panel').should(() => {
const {panel, roles} = roleColours();
const ratios = roles.map(({role, colour}) => ({role, ratio: contrast(colour, panel)}));
ratios.slice(1).forEach((entry, index) => {
expect(entry.ratio, `${entry.role} is louder than ${ratios[index].role}`)
.to.be.greaterThan(ratios[index].ratio);
});
});
});
it(`makes danger at least as loud as ordinary text in ${theme}`, () => {
/*
* Also only here. A light theme's text is near-black at ~14:1 and no danger
* colour will ever match it; on a single hue the roles and the text are drawn
* from the same ramp, so the loudest role must not be quieter than the prose.
*/
cy.mountUI(<Panel>roles</Panel>, {theme});
cy.get('.wrolpi-panel').should(() => {
const {panel, text, roles} = roleColours();
const danger = roles.find(r => r.role === 'danger').colour;
expect(contrast(danger, panel), 'danger vs --text against the panel')
.to.be.at.least(contrast(text, panel) * 0.95);
});
});
});
it('does not leave failure to colour alone', () => {
// Brightness is the first thing lost on a dim screen, and it is all night has.
cy.mountUI(
<Panel>
<Status kind='failed'>failed</Status>
<Status kind='complete'>complete</Status>
</Panel>,
{theme: 'night'},
);
cy.get('.wrolpi-status-failed').should(($failed) => {
const other = Cypress.$('.wrolpi-status-complete')[0];
expect(parseInt(getComputedStyle($failed[0]).fontWeight, 10), 'failed is heavier')
.to.be.greaterThan(parseInt(getComputedStyle(other).fontWeight, 10));
});
});
});
describe('a table header is a surface, not just bold text', () => {
themeNames.forEach((theme) => {
it(`separates the header from every body row in ${theme}`, () => {

View file

@ -1139,46 +1139,26 @@ html[data-theme="amber"] .wrolpi-card-tag {
/* ---------------------------------------------------------------- Status */
/*
* Status is encoded by hue in light/dark. In night mode hue is unavailable, so
* brightness carries it instead: dim = done, mid = active, brightest + bold =
* failed. Callers use these classes rather than naming a color.
* One rule for four states in four themes. The colour is the role the component
* chose, and the role already knows what it looks like here -- which is what
* replaced the three `html[data-theme="night"]` overrides that used to live below.
*/
.wrolpi-status {
font-size: 12.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--status-color);
}
.wrolpi-status-complete {
color: var(--green);
}
.wrolpi-status-active {
color: var(--blue);
}
.wrolpi-status-pending {
color: var(--muted);
}
/*
* Failure is not left to colour alone. Night and amber distinguish the roles by
* brightness, and brightness is the first thing lost on a dim screen or to a user
* who cannot separate these hues, so the loudest state also carries weight.
*/
.wrolpi-status-failed {
color: var(--red);
}
html[data-theme="night"] .wrolpi-status-complete {
color: var(--muted);
}
html[data-theme="night"] .wrolpi-status-active {
color: var(--text);
}
html[data-theme="night"] .wrolpi-status-failed {
color: var(--red);
font-weight: 500;
font-weight: 600;
}
/* ----------------------------------------------------------------- Tables */

View file

@ -19,6 +19,16 @@ import {createTheme, CSSVariablesResolver, MantineColorsTuple} from '@mantine/co
const tokenColor = (token: string): MantineColorsTuple =>
Array(10).fill(`var(--${token})`) as unknown as MantineColorsTuple;
/**
* The semantic roles. A component asks for what a colour MEANS; the theme decides
* how that looks. Registered as Mantine colors too, so `color='danger'` works on any
* Mantine component the same way `color='red'` does. See the note in tokens.css --
* in night and amber these are brightnesses, not hues.
*/
export const roleNames = ['neutral', 'info', 'success', 'warning', 'danger'] as const;
export type RoleName = typeof roleNames[number];
/** The Semantic UI color names WROLPi uses. Each maps to a token of the same name. */
export const semanticColorNames = [
'red', 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink',
@ -28,7 +38,7 @@ export const semanticColorNames = [
export type SemanticColorName = typeof semanticColorNames[number];
const semanticColors = Object.fromEntries(
semanticColorNames.map(name => [name, tokenColor(name)])
[...semanticColorNames, ...roleNames].map(name => [name, tokenColor(name)])
);
export const mantineTheme = createTheme({

View file

@ -24,6 +24,22 @@ export const darkThemes: ThemeName[] = [darkTheme, nightTheme, amberTheme];
/** Themes a user must choose deliberately; `prefers-color-scheme` never picks them. */
export const explicitOnlyThemes: ThemeName[] = [nightTheme, amberTheme];
/*
* Themes built on a single hue, which is the constraint the semantic roles exist for.
*
* These have no second colour to spend, so a role cannot be a hue there -- it has to be
* a step on a brightness ramp, and `--orange` being byte-identical to `--text` in night
* is what that costs when it is ignored. Light and dark have hues and are free to use
* them, so severity does NOT read as brightness there and must not be asserted to.
*
* The same set as `explicitOnlyThemes` today, deliberately named separately: one is about
* how a theme is chosen, the other about what a theme has to work with.
*/
export const monochromeThemes: ThemeName[] = [nightTheme, amberTheme];
export const isMonochromeTheme = (theme: ThemeName): boolean =>
monochromeThemes.includes(theme);
export const isDarkTheme = (theme: ThemeName): boolean => darkThemes.includes(theme);
export const isThemeName = (value: unknown): value is ThemeName =>

View file

@ -50,19 +50,47 @@ html[data-theme="light"] {
/* Switch thumbs and slider handles. */
--knob: #ffffff;
}
/*
* Role aliases. Declared once: `var()` resolves on <html>, where each theme
* has already redefined the color it points at.
*/
:root {
--primary: var(--blue);
/* Roles: light has hues to spend, so each role is simply the hue that means it. */
--neutral: var(--grey);
--info: var(--blue);
--success: var(--green);
--warning: var(--amber);
--danger: var(--red);
}
/*
* Semantic roles.
*
* What a colour MEANS, separate from what hue it happens to be. A call site asks
* for `--danger`, not `--red`, and each theme decides how danger looks.
*
* This matters because two of the four themes have no hues to spend. Night is red
* and nothing else; amber is amber and nothing else. There, a role is a step on a
* BRIGHTNESS ramp -- and it has to be, because `--orange` in night is byte-identical
* to `--text` and `--yellow` is byte-identical to `--amber`, so a call site choosing
* between hue names there is choosing between synonyms. That is why all four toast
* kinds looked the same in night, and why a warning and an error were the same pixel.
*
* The ramps below are built so that in every theme:
* - the order neutral < info < success < warning < danger is strictly increasing in
* contrast against the panel, so severity reads as brightness even without hue;
* - every role except neutral clears 3:1 against the panel;
* - danger reaches that theme's own `--text` brightness, so the loudest role is
* never quieter than ordinary prose;
* - neutral sits deliberately below the rest -- for `pending` and disabled, being
* dim IS the signal.
*
* `ui-layout.cy.js` asserts all of that, per theme, in a real browser.
*
* `--primary` is deliberately NOT one of these. "The colour of a primary button" and
* "the colour that means informational" are different jobs, and a user who recolours
* their buttons should not thereby recolour their messages.
*/
:root {
--primary: var(--blue);
}
html[data-theme="dark"] {
--bg: #1c1f21;
--panel: #26292b;
@ -91,6 +119,13 @@ html[data-theme="dark"] {
--btn-text: #1c1f21;
--knob: var(--text);
/* Roles: as light -- a hue each. See the note above the :root block. */
--neutral: var(--grey);
--info: var(--blue);
--success: var(--green);
--warning: var(--amber);
--danger: var(--red);
}
/*
@ -126,6 +161,13 @@ html[data-theme="night"] {
--btn-text: #0a0000;
--knob: var(--text);
/* Roles: one hue, five brightnesses; saturation climbs with severity too. */
--neutral: #992c2c;
--info: #c02929;
--success: #e02323;
--warning: #ee3434;
--danger: #ff3e3e;
}
/* Amber: a monochrome amber terminal palette, same flat style as the rest. */
@ -160,6 +202,13 @@ html[data-theme="amber"] {
/* Amber assigns the mono face to the body role; that is its character. */
--font-body: var(--font-system-mono);
/* Roles: scaled to amber's own headroom (--text is 8.6:1, night's is 5.0:1). */
--neutral: #815825;
--info: #b06e1f;
--success: #d37c11;
--warning: #f18806;
--danger: #ff9e29;
}
/* ------------------------------------------------------------------------- */