@basenative/theme

中文文档
3.3.0发布于 2026/11/03Apache-2.0TypeScriptES Module零依赖构建来源已验证近 30 天下载 2029

The theme layer behind @basenative/components — a four-tier token shape, a WCAG contrast harness that fails the build, a one-grid icon discipline and the CTI+ bridge

原始使用文档
文档目录15 节
  1. Starting a new property
  2. 1. What you define
  3. 2. Write the theme
  4. 3. Serve it
  5. 4. Gate it
  6. What CI will fail on
  7. The four decisions behind this package
  8. Why there is no default theme
  9. Signal families vs. the five slots
  10. Dark themes: optional, but the commitment is not
  11. Why the bridge lives here
  12. Icons
  13. Contrast, directly
  14. Lineage
  15. License

@basenative/theme

The theme layer that drives @basenative/components.

One library, many themes. Every DuganLabs property keeps its own identity and stops re-solving contrast, token tiers, icon grids and CSS minification from scratch. This package ships the machinery, not the look — there is no palette in here and there is not going to be one.

Zero production dependencies. Hand-written JS, hand-written types, node --test.

@basenative/theme            defineTheme, validateTheme, ThemeError, toCss, bridgeToCti
@basenative/theme/contrast   relativeLuminance, contrastRatio, parseCustomProperties,
                             resolveColor, AA_TEXT, AA_LARGE, AA_NONTEXT
@basenative/theme/audit      auditPairings, assertPairings, defaultPairings,
                             assertThemeContrast, ContrastError
@basenative/theme/css        minifyCss
@basenative/theme/icons      defineIconSet, renderIcon, validateIconSet, IconSetError
@basenative/theme/bridge.css the CTI+ bridge, as a stylesheet

Starting a new property

1. What you define

A theme is four tiers. Nothing may skip one, and only tier 1 contains a hex.

TierYou writeExample
1. rampsliteral #rrggbb scalesneutral, primary, plus whatever hues your signals need
2. surfaces / inkwhat a thing sits on, and what is written on itsurfaces.sunk: 'neutral.100'
3. roleswhat an element isroles.borderControl: 'neutral.500'
4. signalsfamilies in your vocabulary, assigned to the five component slotsfamilies.stalled, slots.warning: 'stalled'

Tiers 2-4 never carry a hex. They carry '<ramp>.<step>', which is emitted as a var() chain and resolved back to a literal by the validator. Change one ramp step and everything above it moves.

Two ramps are mandatory, because @basenative/components reads them directly: primary (steps 50-900) and neutral (steps 50-950).

2. Write the theme

This is the starter. It is @basenative/components' own default palette expressed as a theme — a translation, not a new look, so forking it and swapping the two ramps is the first thing you do. It is not exported from this package on purpose (see Why there is no default theme); copy it.

import { defineTheme } from '@basenative/theme';

export const theme = defineTheme({
  name: 'starter',
  scheme: 'light',

  // Tier 1 — the only hexes in the file.
  ramps: {
    neutral: {
      50: '#fafafa',
      100: '#f4f4f5',
      200: '#e4e4e7',
      300: '#d4d4d8',
      400: '#a1a1aa',
      500: '#71717a',
      600: '#52525b',
      700: '#3f3f46',
      800: '#27272a',
      900: '#18181b',
      950: '#09090b',
    },
    primary: {
      50: '#eff6ff',
      100: '#dbeafe',
      200: '#bfdbfe',
      300: '#93c5fd',
      400: '#60a5fa',
      500: '#3b82f6',
      600: '#2563eb',
      700: '#1d4ed8',
      800: '#1e40af',
      900: '#1e3a8a',
    },
    success: { 100: '#dcfce7', 600: '#16a34a', 700: '#15803d', 800: '#166534' },
    warning: { 100: '#fef3c7', 600: '#d97706', 700: '#b45309', 800: '#92400e' },
    danger: { 100: '#fee2e2', 500: '#ef4444', 600: '#dc2626', 800: '#991b1b' },
    paper: { white: '#ffffff' },
  },

  // Tier 2 — surfaces, and the ink that goes on them.
  surfaces: {
    base: 'paper.white',
    subtle: 'neutral.50',
    sunk: 'neutral.100',
    raised: 'paper.white',
    inverse: 'neutral.900',
  },
  ink: {
    base: 'neutral.900',
    // Upstream's muted ink is gray-500, which is 4.40:1 on the gray-100 sunk
    // surface. The harness refused it; one step darker clears at 7.03:1.
    muted: 'neutral.600',
    subtle: 'neutral.600',
    inverse: 'paper.white',
    link: 'primary.600',
  },

  // Tier 3 — roles. Every one of these is painted by a component.
  roles: {
    primary: 'primary.600',
    primaryHover: 'primary.700',
    onPrimary: 'paper.white',
    primaryTint: 'primary.50',
    border: 'neutral.300',
    borderStrong: 'neutral.400',
    borderControl: 'neutral.500',
    focus: 'primary.500',
    focusOnInverse: 'primary.300',
    selectedBg: 'primary.50',
    selectedInk: 'primary.700',
  },

  // Tier 4 — signal families, then the five slots they drive.
  signals: {
    families: {
      neutral: { bg: 'neutral.300', ink: 'neutral.900', line: 'neutral.500', solid: 'neutral.600', onSolid: 'paper.white' },
      info: { bg: 'primary.100', ink: 'primary.800', line: 'primary.500', solid: 'primary.600', onSolid: 'paper.white' },
      success: { bg: 'success.100', ink: 'success.800', line: 'success.600', solid: 'success.700', onSolid: 'paper.white' },
      warning: { bg: 'warning.100', ink: 'warning.800', line: 'warning.600', solid: 'warning.700', onSolid: 'paper.white' },
      danger: { bg: 'danger.100', ink: 'danger.800', line: 'danger.500', solid: 'danger.600', onSolid: 'paper.white' },
    },
    slots: {
      default: 'neutral',
      primary: 'info',
      success: 'success',
      warning: 'warning',
      error: 'danger',
    },
  },
});

Geometry is optional and defaulted — font, radius, control, motion. The defaults are @basenative/components' own values, asserted byte-identical by this package's tests, so a change upstream fails a build rather than quietly restyling six properties. A field app raises the tap target:

control: { tapMin: '2.75rem' },   // 44px — WCAG 2.2 SC 2.5.5, for gloves

3. Serve it

import { toCss } from '@basenative/theme';
import { minifyCss } from '@basenative/theme/css';
import { theme } from './theme.js';

// Emits @layer tokens { … } plus the CTI+ bridge. Comments carry the
// rationale for whoever opens the file; strip them at the point of serving.
export const THEME_CSS = minifyCss(toCss(theme));

Load order, matching the declared layer order (@layer reset, tokens, layout, components, states):

<link rel="stylesheet" href="/@basenative/components/css">
<style>/* THEME_CSS */</style>

toCss appends the bridge by default. If you would rather link it, toCss(theme, { bridge: false }) plus @basenative/theme/bridge.css.

4. Gate it

One line, in your own test file:

import { test } from 'node:test';
import { assertThemeContrast } from '@basenative/theme/audit';
import { theme } from '../src/theme.js';

test('every shipped colour pairing clears WCAG AA', () => {
  assertThemeContrast(theme);
});

assertThemeContrast writes the pairing table from your theme's own shape — 55 rows for the starter — measures real relative luminance, and throws listing every failing row with its actual ratio. It runs once per scheme your theme renders in, so a dual theme is measured twice.

Add rows for anything you paint that this package cannot know about:

import { assertPairings, defaultPairings, AA_NONTEXT } from '@basenative/theme/audit';
import { toCss } from '@basenative/theme';

assertPairings(toCss(theme, { only: 'light', bridge: false }), [
  ...defaultPairings(theme),
  ['logo on the header band', '--color-border-theme-signal-info', '--color-bkg-theme-surface-inverse', AA_NONTEXT],
]);

What CI will fail on

FailureThrown byWhat it means
scheme is "undefined"defineThemeYou did not commit to light, dark or dual. See below — this one is not optional.
theme.roles is missingdefineThemeA tier was skipped. All four are required.
theme.ink.link references "brand.600", which the theme does not declaredefineThemeA dangling reference.
ramps.primary.600 is "var(--x)" — a ramp step must be a literaldefineThemeA chain that never terminates in a colour.
theme.signals.slots.warning is unassigneddefineThemerenderBadge({ variant: 'warning' }) would have had no colours.
muted text on sunk surface: … is 4.40:1, below 4.5:1assertThemeContrastA real WCAG miss, with the real number.
a token name in neither CTI_BRIDGED nor UPSTREAM_OWNEDthis package's own bridge.test.js@basenative/components grew a themeable token and the bridge has not caught up.

The four decisions behind this package

Why there is no default theme

A reference theme in the exports becomes the de-facto org look, which is the exact opposite of the point. So there isn't one. The starter above is a document, not an import: forking it is an act of authorship. It is kept honest by starter-guide.test.js, which parses this file, runs defineTheme on the block, and asserts its neutral and primary ramps are byte-identical to @basenative/components/src/tokens-legacy.css. The docs cannot drift from the code and the starter cannot quietly become a brand.

Colour is never defaulted. Geometry is — a radius ladder is ergonomics, not identity.

Signal families vs. the five slots

@basenative/components declares exactly five badge/alert variants: default | primary | success | warning | error. Real domains do not have five statuses called that. Greenput's are idle | move | good | dead; a warehouse app's might be receiving | staged | short | damaged.

So a theme declares families under its own names, then assigns one to each of the five slots:

signals: {
  families: {
    idle: { … }, move: { … }, good: { … }, dead: { … },
  },
  slots: { default: 'idle', primary: 'idle', success: 'good', warning: 'move', error: 'dead' },
}

Families are not limited to five and two slots may share one. Every family gets its own --color-*-theme-signal-<name> properties for your own CSS, and — this is the part that matters — every family gets contrast rows whether or not it is assigned to a slot. There is no way to add a status colour that is never measured.

Each family carries five channels: bg (the tint a badge sits on), ink (text on that tint and on the page), line (the family's rule, gated at 3:1 for SC 1.4.11), solid (the filled version) and onSolid (text on the fill).

Measured while writing this: white on --palette-success-600 (#16a34a) is 3.30:1 and on --palette-warning-600 (#d97706) is 3.19:1, while --palette-error-600 is 4.83:1. Upstream only ever paints text on the error fill, so it ships nothing broken — but a green "Approve" button built from -600 would. The starter's success and warning solids are one step darker.

Dark themes: optional, but the commitment is not

validateTheme does not require a dark tier. It requires scheme, and it is 'light' | 'dark' | 'dual' with no default.

A single-scheme theme emits exactly one block, unconditioned — no media query, no data-theme variants. That is the whole fix for the failure this package was built after: pendingbusiness.com is a dark site that never set data-theme, so a reader whose OS was in light mode fell through @media (prefers-color-scheme: dark) and got --color-bkg-error: #fef2f2 — a near-white alert on a near-black page.

With scheme: 'dark' there is nothing to fall through. The dark values are emitted in every context, color-scheme: dark is pinned, and the data-theme attribute stops being load-bearing. Shipping that bug now requires declaring scheme: 'dual' and writing a light palette on purpose — at which point the harness measures it.

A dual theme mirrors upstream's activation model at matching specificity, so its own dark palette wins, and validateTheme refuses a half-themed dark block whose family list differs from the light one.

Why the bridge lives here

components owns components. Its --bn-* ladder (tokens-legacy.css) is already a complete neutral default and it should stay ignorant that themes exist. The cost — this package's name list can fall behind a components release — is measured, not assumed:

  • CTI_BRIDGED is the list the bridge emits (93 names today).
  • UPSTREAM_OWNED is the list deliberately left alone, each with its reason.
  • bridge.test.js reads every stylesheet in packages/components/src and fails if a var(--*) appears in neither list.

A components release that adds a themeable token breaks this package's build in the same CI run, instead of silently going unthemed in six properties. validateBridge(css, { exclude }) is exported so a consumer can run the same check against whatever version it has installed; exclude carries the component-tier and keyword token names, computed from the token source by file (tokens/components/*.json plus tokens/keyword.json), which are a fact about the consumer's components version rather than about this package.

What the bridge leaves alone: the type scale, font weights, line heights, the space ladder, shadows, easing curves, z-indices, and per-instance geometry knobs. Those are app decisions, not palette ones — override them in your own CSS. Colour, font family, the radius ladder, control heights, the tap-target minimum and the three motion durations are all bridged.

How the bridge wins. The generated token sheet in components activates its dark values at :root:not([data-theme="light"]) inside a media query and at :root[data-theme="dark"] — specificity (0,2,0). A bridge at plain :root (0,1,0) would lose to both, which is how upstream dark values leak past a theme. Both bridge selectors are (0,2,1):

html:root[data-theme],
html:root:not([data-theme="light"]) { … }

Between them they match every value of data-theme, including its absence, and they beat upstream everywhere. The bridge is also UNLAYERED, on purpose: upstream declares its core tokens unlayered, and a layered declaration loses to any unlayered one at any specificity — the scrim token is unlayered, so a bridge inside @layer tokens could never re-point it. There is no separate dark name set to neutralise: dark is a mode carried on the same tokens, so re-pointing a name re-points both of its modes at the theme, and the theme decides what dark means.


Icons

Not an icon library — the discipline that makes a row of icons read as a set.

import { defineIconSet, renderIcon } from '@basenative/theme/icons';

export const icons = defineIconSet({
  grid: 24,
  strokeWidth: 1.75,
  icons: {
    activity: '<path d="M3 12h4l3 7 4-14 3 7h4"/>',
    check: '<path d="m4.5 12.5 5 5 10-11"/>',
  },
});

renderIcon(icons, 'activity');                      // aria-hidden — it sits beside a label
renderIcon(icons, 'check', { title: 'Verified' });  // role="img" — it IS the label

defineIconSet throws on anything that breaks the set's evenness: a fill, a baked-in stroke colour or literal hex, a per-icon stroke-width, an inline style, a nested <svg>, its own viewBox. One grid, one weight, currentColor, decorative by default.

A body is the contents of an <svg>, so width and height inside it are ordinary geometry and are allowed — <rect x="3" y="5" width="18" height="16" rx="2"/> is a calendar. Self-sizing is a property of the outer element, which renderIcon writes and a body cannot have.

Contrast, directly

import { contrastRatio, relativeLuminance, AA_TEXT } from '@basenative/theme/contrast';

contrastRatio('#166534', '#dcfce7');  // 6.49

parseCustomProperties(css) and resolveColor(name, props) are the pieces the audit harness is built from: the resolver follows var() chains to a literal #rrggbb and throws on a dangling reference, a cycle, or a non-literal terminal.

parseCustomProperties reads a stylesheet as one flat map, so measuring a dual theme means emitting one scheme at a time — toCss(theme, { only: 'dark' }). assertThemeContrast does that for you.

Lineage

The contrast engine, the pairing harness, minifyCss and the icon invariants were proven in libs/shared/design in the Greenput repo, where the pairing table was hardcoded and fused to the --gp-* namespace. Here the table is an argument, every token name is a parameter, and Greenput's palette, its four contractor signal families, its webfont and its mark stayed behind, where they belong.

License

Apache-2.0

版本与发布记录

@basenative/theme 共发布 14 个版本,首次发布于 2026/10/18;过去 12 个月发布了 14 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
3.3.0latest2026/11/035 天前
3.2.02026/11/035 天前
3.1.02026/11/017 天前
3.0.02026/10/317 天前
2.4.02026/10/2613 天前
2.3.22026/10/2217 天前

关于 @basenative/theme 的常见问题

@basenative/theme 是什么?

The theme layer behind @basenative/components — a four-tier token shape, a WCAG contrast harness that fails the build, a one-grid icon discipline and the CTI+ bridge

如何安装 @basenative/theme?

在项目目录运行 npm install @basenative/theme,或使用 pnpm add / yarn add / bun add。

@basenative/theme 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@basenative/theme 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@basenative/theme 可以商用吗?

@basenative/theme 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@basenative/theme 的下载量有多少?

根据 npm 官方下载统计,@basenative/theme 在 2026/10/08 – 2026/11/06 这 30 天内被下载 2,029 次,日均约 68 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@basenative/theme 还在维护吗?

最新版本 3.3.0 发布于 2026/11/03(5 天前);至今共发布 14 个版本,其中过去 12 个月发布了 14 个;首次发布于 2026/10/18。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。