@appshell/tokens

中文文档
1.0.0-alpha.148发布于 2026/10/30MITTypeScriptCommonJS零依赖构建来源已验证近 30 天下载 6276

The Appshell design token contract, its presets, and contrast validation

原始使用文档
文档目录7 节
  1. For a package author
  2. With Tailwind v4
  3. The contract
  4. Presets: base × accent
  5. Contrast is the validation that matters
  6. Colour format
  7. Modes

@appshell/tokens

The design token contract an Application supplies and every package it composes consumes.

A catalogue of components that cannot be themed to their host is not composable — visual coherence is the point. This package is the vocabulary that makes it possible: 46 CSS custom properties, a set of presets, and the contrast validation that keeps a theme legible.

For a package author

Author against the names, always with a fallback so the package still renders standalone:

.button {
  background: var(--appshell-primary, #0284c7);
  color: var(--appshell-on-primary, #fff);
  border-radius: var(--appshell-radius-md, 0.5rem);
  padding: var(--appshell-space-sm, 0.5rem) var(--appshell-space-md, 1rem);
}
.button:hover {
  background: var(--appshell-primary-hover);
}
.button:focus-visible {
  outline: 2px solid var(--appshell-focus-ring);
  outline-offset: 2px;
}

Never write a literal colour you expect to be themed. That is the one rule.

With Tailwind v4

A Tailwind package doesn't write the custom properties at all. @appshell/tokens/tailwind.css maps the contract onto Tailwind's theme, so bg-primary, text-on-surface, border-border, rounded-md, text-h2 and p-md paint with whatever the Application supplies:

@layer theme, base, components, utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities);
@import '@appshell/tokens/tailwind.css';
@source "../dist";
  • No preflight. It's a global reset, and the shell owns the page's one reset. Several packages each shipping one is several resets fighting each other and the shell.
  • An @source of your own. Tailwind generates a utility only for a class it finds written down. Name the directory your markup is compiled to, not a glob: Tailwind skips a path the consumer's .gitignore ignores when it's reached through a glob.
  • After your own defaults. A component library with a default palette of its own, such as @navaris/ui-react, imports its defaults first, so the Application's theme wins.

The colour roles keep their names (--color-primary, --color-on-surface), except text-muted, which is --color-muted so the utility reads text-muted, and border-control, which is --color-control so it reads border-control. Families are --font-sans and --font-mono, type sizes --text-h1…--text-small, line heights --leading-tight and --leading-normal, and the scales --spacing-xs…xl and --radius-sm/md/lg. The file is generated from the contract, as TAILWIND_NAMES, and a role added to the contract without a Tailwind name doesn't compile.

The contract

Colour is semantic; dimensions are a scale. --appshell-surface says what a colour means; --appshell-space-md is honestly just a size. Nobody wants --appshell-space-card-padding.

Every colour that carries text has an on- partner. Write background: var(--appshell-primary); color: var(--appshell-on-primary) and you cannot produce an illegible control whatever palette the Application supplies. It is also what makes dark mode a change of values rather than a change of logic.

GroupRoles
Surfacesurface-sunken, surface, surface-raised and their on- partners
Text & linetext-muted, border, border-control
Actionprimary, on-primary, secondary, on-secondary
Derivedprimary-hover, primary-active, secondary-hover, secondary-active, focus-ring
Statusdanger, warning, success, info and their on- partners
Typefont-body, font-mono, font-size-h1…h6, -body, -small, line-height-tight, -normal
Dimensionspace-xs…xl, radius-sm/md/lg

Three surfaces, by elevation. surface is the page; surface-raised sits above it (a card, a dialog, a menu) and surface-sunken below it (the page's chrome: a sidebar, a navigation rail, an inset panel). Put text in the on- partner of the surface it's on.

Two borders, by what they're for. border is a divider's edge, which nothing needs to see to use the page. border-control is a control's edge, which is how a field, a select or an outlined button is found, so it's held to 3:1 on every surface.

Type sizes are named for their role rather than a sm/md/lg scale on purpose. A numeric scale reintroduces the divergence the contract exists to prevent — one author maps h1 to 2xl, another to xl, and headings stop matching across a composed page.

Derived roles cost a theme nothing. Hover and active are a color-mix from their accent, and the focus ring is picked per base and accent. A theme may override any of them; none has to be supplied. So the contract publishes 46 names while an Application authors 41 at most — and usually none, because it picks presets.

Presets: base × accent

Themes are two choices, not 46 values:

import { toCss } from '@appshell/tokens';

toCss({ base: 'midnight', accent: 'ice' });

Bases carry surfaces, text, borders and status, as a light/dark pair: neutral, midnight. Accents carry the two action colours: ice, ember, emerald, violet, steel. Either slot also accepts raw token values, and overrides adjusts a preset without forking it.

Every combination is verified — see below — and midnight/ice is derived from the first application scheduled to migrate.

Contrast is the validation that matters

Syntax checking earns little. It catches --appshell-primary: bananas; it misses --appshell-on-primary: #fff on --appshell-primary: #fff, which is perfectly valid CSS and invisible text.

import { composeTheme, validateTheme, describeFinding } from '@appshell/tokens';

validateTheme(composeTheme({ base: 'neutral', accent: 'ice' }, 'light')).map(describeFinding);

Text pairs are checked at WCAG AA (4.5:1), and so are muted text and the status colours read as text on every surface: a status that passes as a fill under its on- colour can still fail as an error message under a field. The focus ring and the control border are checked at 3:1 (1.4.11, non-text) on every surface, since focus lands in a dialog and a sidebar as well as on the page. A value that will not parse, or a role a stored theme predates, is reported rather than thrown, so one bad token does not hide the rest.

Four things this caught in the presets, each of which would otherwise have shipped:

  • Tailwind's indigo-500 fails against both black and white — 4.28 and 4.22. There is no legible text colour for it. The violet accent uses indigo-600 instead.
  • A focus ring that is simply the accent disappears on 7 of the 20 preset combinations. The rings are derived instead: hue is kept, and lightness moves only as far as it must to clear 3:1.
  • Light-mode danger was 4.36:1 as text on surface-raised: legible as a button, not as the error under a field in a dialog. It moved from 57.7% to 56.7% lightness.
  • The rings were checked on the page alone, and 8 of the 20 combinations fell below 3:1 in a dialog, down to 2.83. Each moved only as far as clearing every surface needed, and the ice and emerald rings in light mode, which sat just outside sRGB, came back inside it.

The presets are held to the same standard as everyone else's themes — the test suite composes all 20 combinations and asserts no findings.

Colour format

Values are oklch. Tailwind v4 consumes them directly and uses color-mix for its opacity modifiers. Tailwind v3 implements bg-primary/50 as rgb(var(--x) / <alpha-value>) and needs channel-separated values, so a v3 package either forgoes opacity modifiers on themed colours or derives channel-split locals in its own stylesheet. The contract is not deformed for the older version.

Modes

Three states, not two — an explicit choice in either direction plus the system default:

:root {
  /* light */
}
@media (prefers-color-scheme: dark) {
  :root:not([data-appshell-theme='light']) {
    /* dark, unless light was chosen */
  }
}
:root[data-appshell-theme='dark'] {
  /* dark, chosen */
}

Only the roles that actually differ are restated in the dark blocks. Type and dimensions never vary, and repeating them would triple the size of something the registry inlines into every page it serves.

版本与发布记录

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

版本发布日期距今
1.0.0-alpha.148latest2026/10/309 天前
1.0.0-alpha.1472026/10/2910 天前
1.0.0-alpha.1462026/10/2810 天前
1.0.0-alpha.1452026/10/2810 天前
1.0.0-alpha.1442026/10/2712 天前
1.0.0-alpha.1432026/10/2613 天前

关于 @appshell/tokens 的常见问题

@appshell/tokens 是什么?

The Appshell design token contract, its presets, and contrast validation

如何安装 @appshell/tokens?

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

@appshell/tokens 支持 TypeScript 吗?

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

@appshell/tokens 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@appshell/tokens 可以商用吗?

@appshell/tokens 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@appshell/tokens 的下载量有多少?

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

@appshell/tokens 还在维护吗?

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