Appearance
样式与主题
X-Next 的样式由 src/packages/index.scss 汇总。发布后,用户通过下面的方式引入完整样式:
ts
import 'x-next/dist/style.css';类名前缀
组件样式默认使用 x 前缀,例如按钮组件的基础类名为 x-button。
文档站样式
文档站在 .vitepress/theme/index.ts 中引入源码样式:
ts
import '../../../packages/index.scss';这样可以在编写文档时直接渲染当前源码组件,适合组件开发和文档联调。
文档构建通过 VitePress 的 postcssIsolateStyles 隔离正文样式:vp-doc.css 的规则不命中 .example-box、.vp-raw 及其后代。live demo 应放在这些容器中,避免正文的原生 a、table 等规则覆盖组件状态色和结构。
主题 Token 分层
X-Next 后续自定义主题系统以 CSS 变量作为底层协议。当前按三层组织:
- Seed token:基础色、尺寸、圆角、字体、阴影、动效,例如
--x-color-primary、--x-size-medium、--x-border-radius-medium。 - Semantic token:语义化颜色和状态,例如
--x-color-text-primary、--x-color-bg-surface、--x-color-border-default、--x-color-danger-subtle。 - Component alias token:组件级默认值,例如 Button 使用
--x-button-color-bg-default、--x-button-color-text-danger这类变量。
组件样式应优先消费 semantic token 或 component alias token,不应在组件根类里重新定义用户期望从祖先节点覆盖的 public token。
当前语义 Token
文本:
| Token | 用途 |
|---|---|
--x-color-text-primary | 主要文本 |
--x-color-text-secondary | 次级文本 |
--x-color-text-tertiary | 辅助文本、占位提示 |
--x-color-text-disabled | 禁用文本 |
背景:
| Token | 用途 |
|---|---|
--x-color-bg-page | 页面背景 |
--x-color-bg-surface | 卡片、输入框、弹层内容背景 |
--x-color-bg-subtle | 弱背景、浅填充 |
--x-color-bg-elevated | 弹层背景 |
边框:
| Token | 用途 |
|---|---|
--x-color-border-default | 默认边框 |
--x-color-border-subtle | 弱边框、分割线 |
--x-color-border-focus | 聚焦边框 |
状态色:
| Token | 用途 |
|---|---|
--x-color-primary-subtle / --x-color-primary-solid | 主色弱背景 / 实色 |
--x-color-success-subtle / --x-color-success-solid | 成功弱背景 / 实色 |
--x-color-warning-subtle / --x-color-warning-solid | 警告弱背景 / 实色 |
--x-color-danger-subtle / --x-color-danger-solid | 危险弱背景 / 实色 |
--x-color-strong-subtle / --x-color-strong-solid | 强提醒弱背景 / 实色 |
弱背景还有 --x-color-<状态>-subtle-hover / --x-color-<状态>-subtle-active,通过少量混合对应文字色表达交互状态。Button 的 primary 实色 hover/active 也由语义 token 保存混色表达式,运行时换色会重新绑定整条引用链。默认内置配色的普通、悬浮、按下态独立检查对比度;任意自定义品牌色仍需自行检查文字与背景的组合。
状态正文使用 --x-color-primary-text、--x-color-success-text、--x-color-warning-text、--x-color-danger-text、--x-color-strong-text,默认指向各家族的第 9 档。Link 通过 --x-link-color-text 和各状态 text alias 消费这些角色,亮暗与局部品牌色会沿依赖链更新。
Tag 的预设色族另有 --x-tag-color-<色族>-seed,保留原始色供暗色生成;亮色文字独立加深,gray 使用次级文字。单独覆盖 Tag text 不会反向改变暗色 seed。Tag 颜色示例 和 Link 状态示例 可检查实际交互;自定义 CSS 背景与任意品牌色仍需自行验证。
表单输入:
| Token | 用途 |
|---|---|
--x-field-color-bg / --x-field-color-bg-hover / --x-field-color-bg-focus | 输入框、选择器、日期选择器的默认 / 悬浮 / 聚焦背景 |
--x-field-color-bg-disabled | 禁用背景 |
--x-field-color-text / --x-field-color-placeholder / --x-field-color-text-disabled | 输入文本、占位符、禁用文本 |
--x-field-color-border / --x-field-color-border-hover / --x-field-color-border-focus | 默认 / 悬浮 / 聚焦边框 |
--x-field-color-border-error | 错误态边框 |
--x-field-status-success-* / --x-field-status-warning-* / --x-field-status-error-* / --x-field-status-validating-* | Form 校验状态下的背景、边框、阴影、提示文字和图标 |
覆盖示例
下面的示例可直接操作:输入一个颜色后点击应用,会调用 applyThemeColor 写入整条 primary 色阶,按钮、输入框、开关等会一起变色。
vue
<template>
<x-button type="primary" @click="applyBrand">应用主题色</x-button>
<x-button @click="resetBrand">恢复默认</x-button>
<x-switch :model-value="true" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { applyThemeColor } from 'x-next';
const brandInput = ref('#0f766e');
const applyBrand = () => {
// 一次写入 9 档,实色与浅色底同步联动
applyThemeColor(brandInput.value);
};
const resetBrand = () => {
applyThemeColor('#1c61ff');
};
</script>全局覆盖(只改单个 token):
css
:root {
--x-color-primary: rgb(22 93 255 / 100%);
--x-color-border-focus: var(--x-color-primary-6);
}局部主题容器覆盖:
vue
<template>
<section class="brand-scope">
<x-button status="danger">删除</x-button>
<x-input placeholder="局部主题" />
</section>
</template>
<style>
.brand-scope {
--x-color-primary: rgb(15 118 110 / 100%);
--x-color-border-focus: var(--x-color-primary);
--x-field-color-border-focus: var(--x-color-primary);
--x-field-status-error-border-focus: rgb(220 38 38 / 100%);
}
</style>换主色
按需导入项目可直接复制 AI 手册的局部品牌色配方:ConfigProvider 同时包裹组件与浮层宿主,无需全局插件或逐组件维护主色别名。
色阶由种子色经算法派生,构建期生成 _styles/palette-light.scss(不要手改,见「样式检查」)。因此换主色有两条路径:构建期改种子,或运行时调用 applyThemeColor。
构建期:改种子色
修改 src/packages/_styles/base.scss 中的种子色,然后重新生成色阶:
bash
# 1. 改 src/packages/_styles/base.scss
# --x-color-primary: rgb(15 118 110 / 100%);
# 2. 重新生成 9 档色阶
pnpm run build:palette全部档位(-1 ~ -9)会由新种子重新派生,弱背景、悬浮态、按下态、校验提示色随之联动。
运行时:applyThemeColor
需要在运行时切换品牌色时,用 applyThemeColor 一次性写入整条色阶:
ts
import { applyThemeColor } from 'x-next';
// 写入 document.documentElement,整条 primary 色阶联动
applyThemeColor('#0f766e');
// 指定色名与目标元素,可用于局部主题容器
applyThemeColor('#0f766e', { name: 'brand', target: document.querySelector('.brand-scope') });applyThemeColor 内部调用 generatePalette,写入基色、9 档以及受影响的根 alias(保留原始表达式),因此实色、primary-light 弱背景和混色交互态可以一起变化。组件自身的 current/state token 不会写到主题祖先。
之所以要连基色一起写:Switch、Menu 等组件引用的是基色 --x-color-primary 而非档位,只写档位会导致这些组件不跟随换色。
只改单个语义 token
若只想微调某一处,可在默认 token 的声明元素上覆写语义 token。例如全局默认值声明在 :root,暗色容器的受影响 alias 声明在 [data-theme='dark']。组件可能经 field、form 或组件 alias 间接消费这些语义值:
css
:root {
--x-color-primary-subtle: rgb(230 250 246 / 100%);
--x-color-border-focus: var(--x-color-primary-7);
}色阶算法
generatePalette(seed, options?) 由种子色生成 9 档,规则由原有色阶反推校准:
- 明度:以种子色明度为锚点向两端线性插值,第 6 档即种子色本身
- 色相:每档漂移 2.05°(
HUE_STEP);红黄区间(0°~90°)随加深递减,其余区间递增 - 饱和度:沿用种子色
ts
import { generatePalette } from 'x-next';
const shades = generatePalette('#1c61ff');
// shades[5] === '#1c61ff'(第 6 档恒等于种子色)暗色主题使用同一函数的暗色分支,明度阶梯整体反转(低档位更深、高档位更亮):
ts
const darkShades = generatePalette('#1c61ff', { dark: true });
// applyThemeColor 同样支持
applyThemeColor('#1c61ff', { dark: true });支持 #rgb、#rrggbb、八位 hex、rgb() / rgba()(含百分比通道)。alpha 不参与色阶:基色与第六档均写入归一化后的不透明 RGB。黑白或极深/极浅种子的明度端点会限制在种子两侧,避免阶梯折返;相邻档位可能相同。命名颜色、HSL 和 CSS 变量表达式不作为种子接受。
种子色无法解析时(如传入非法色值)会抛出带可读提示的错误,而不是静默产出错误色阶:
ts
generatePalette('not-a-color');
// Error: [x-next] generatePalette: 无法解析种子色 "not-a-color"与原始色阶的偏差
算法输出与改造前手写的 45 个档位存在轻微差异(平均单通道偏差约 7/255,最大 45/255,集中在 success 中段)。运行以下命令查看逐档偏差:
bash
node script/calibrate-palette.mjs对比度(check:contrast)在算法生成的色阶上重新验证通过。
状态实色的文字色
实心按钮统一白字,底色与文字在明暗主题下都恒定不翻转:实心块是强调色,暗色下变浅会同时失去强调感与文字可读性。
| 状态 | 实心底色 | 白字对比度 | 说明 |
|---|---|---|---|
primary | 种子 #1c61ff | 5.00:1 | 达标 |
danger | 种子 #e02225 | 4.75:1 | 种子本身白字即达标 |
info | 恒定中性深色 | 7.10:1 | 达标 |
success | 种子 #00a809 | 3.18:1 | 优于 Arco #00b42a 的 2.78:1 |
strong | 种子 #ff8200 | 2.49:1 | 与 Arco 橙 2.57:1 同水平 |
warning | 种子 #ffb800 | 1.73:1 | 与 AntD #faad14 1.90:1 同水平 |
后三行是亮黄、亮橙底色的固有折中:要让白字达到正文级 4.5:1,只能把底色压深到失去品牌识别,参考实现同样如此。门禁按状态记录实测水平作为回归下限(script/check-contrast.js 的 SOLID_TEXT_MIN),修改后运行 pnpm run check:contrast 验证。
状态文字即主色
状态文字(Link、表单校验提示、Tag、按钮彩色文字、状态图标)统一使用品牌主色,不做压暗,与 Arco 的做法一致。这样同一状态在弱底、描边、实心、文字等所有形态下都是同一个颜色。
代价是这类文字在浅底上的对比度低于通用正文标准(Arco 自身水平:Link 白底 success 2.78 / warning 2.57,Tag 自身底 lime 1.63 / gold 1.70)。x-next 采用同一策略,并把 Arco 同级角色的实测水平作为回归下限:
| 角色 | 用途 | 基准 |
|---|---|---|
--x-color-<family>-text | Link、表单校验、Tag、状态图标、按钮彩色文字 | 不低于 Arco 同级角色(ARCO_BASELINE) |
--x-button-color-text-<family> | 同上(保留别名,指向 -text) | 同上 |
唯一例外是 warning:种子 #ffb800 是纯黄(白底 1.73:1,连 Arco 的 2.57 都不到),因此压到 82% 追平 Arco 基准。
需要正文级可读性时,请用 --x-color-text-primary / --x-color-text-secondary 这类中性文本角色,而不是状态色。
ConfigProvider 已提供 dark 和 themeColor:前者切换包裹元素的暗色 token 层,后者将 primary 色阶和关联别名写入该元素。使用 :tag="null" 时不产生包裹元素,因而不能承载局部暗色或主题色。
暗色主题
暗色通过 [data-theme='dark'] 属性切换,token 层已内置完整覆盖,无需改组件样式。
全局切换
在 html 上打标即可整站生效:
ts
// 开启
document.documentElement.setAttribute('data-theme', 'dark');
// 关闭
document.documentElement.removeAttribute('data-theme');局部切换
ConfigProvider 的 dark 属性会给包裹元素打上同一标记,因此可以只让某个区域变暗:
下面的暗色开关同时控制两个独立容器:第一个使用默认主题色,第二个通过 themeColor 动态切换品牌色。页面使用亮色时,外围输入框仍为亮色;若页面本身已是暗色,请先切回亮色观察这个对照。
开启「覆盖容器语义色」后,两个暗色容器的普通输入框会随容器的背景和文字语义值一起变化。第一个容器内「组件单独背景」通过组件自己的 style 覆盖 alias,始终保留该输入框的独立填充背景。
局部暗色已关闭
覆盖容器语义色
默认主题色容器
标签
动态品牌色容器:#1c61ff
外围对照区域(跟随页面主题)
vue
<template>
<x-space size="small">
<x-switch v-model="darkMode" aria-label="切换两个局部暗色容器" />
<span>局部暗色</span>
<x-switch v-model="customSemantic" aria-label="覆盖容器语义色" />
<span>覆盖容器语义色</span>
</x-space>
<x-config-provider :dark="darkMode" class="theme-scope" :style="localSemanticStyle">
<p>默认主题色容器</p>
<x-input v-model="values.first" placeholder="普通输入框" />
<x-input placeholder="组件单独背景" :style="componentStyle" />
</x-config-provider>
<x-button @click="brand = brand === '#1c61ff' ? '#0f766e' : '#1c61ff'">切换品牌色</x-button>
<x-config-provider :dark="darkMode" :theme-color="brand" class="theme-scope" :style="localSemanticStyle">
<p>动态品牌色容器</p>
<x-button type="primary">局部品牌按钮</x-button>
<x-input v-model="values.second" placeholder="普通输入框" />
</x-config-provider>
<div class="theme-scope">
<p>外围对照区域</p>
<x-input v-model="values.outside" placeholder="外围输入框" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
Button as XButton,
ConfigProvider as XConfigProvider,
Input as XInput,
Space as XSpace,
Switch as XSwitch,
} from 'x-next';
import 'x-next/style.css';
const darkMode = ref(false);
const customSemantic = ref(false);
const brand = ref('#1c61ff');
const values = ref({ first: '', second: '', outside: '' });
const localSemanticStyle = computed(() => customSemantic.value ? {
'--x-color-bg-surface': darkMode.value ? '#30263d' : '#f4edf9',
'--x-color-text-primary': darkMode.value ? '#f0e4fa' : '#402b55',
} : {});
const componentStyle = {
'--x-input-color-bg': 'var(--x-color-fill-3)',
'--x-input-color-bg_hover': 'var(--x-color-fill-3)',
'--x-input-color-bg_focus': 'var(--x-color-fill-3)',
};
</script>
<style scoped>
.theme-scope {
padding: 16px;
border-radius: 8px;
background: var(--x-color-bg-surface);
color: var(--x-color-text-primary);
}
</style>局部覆盖需要注意声明位置:
- 暗色层会在每个
[data-theme='dark']元素上重新声明受暗色覆盖影响的完整 alias 链。因此在同一个容器元素上覆写--x-color-bg-surface、--x-color-text-primary,普通字段可以重新解析这些值。 - 任意亮色子容器或暗色容器内部更深的子容器没有自动重声明这条链。只覆写 semantic token 时,继承的组件 alias 仍可能是祖先已经解析的结果;应一并重声明相关 field/component alias,或直接在目标组件上设置公开 alias。上例的「覆盖容器语义色」主要用于验证开启暗色后的继承链,关闭暗色时不承诺普通输入框跟随局部语义色。
- 每组件的 style 覆盖声明在组件自身,优先于从主题容器继承的 alias。上例同时覆写默认、悬浮和聚焦背景,避免只改默认值而在交互状态恢复原背景;无需修改内部结构类。
themeColor/applyThemeColor会把受 primary 色阶影响的根 alias 写成内联属性,保留 semantic → field → component 引用链和混色表达式。因此在同一元素上另外设置--x-color-border-focus,可以影响依赖它的字段 alias;调用换色函数会重新写入这些 alias,额外覆盖应在调用之后设置。组件自身的 style override 仍优先于祖先值。
颜色交互检查
可切换局部暗色和品牌色,检查实色/弱背景按钮的悬浮与按下态,以及 Tooltip 和 Select 的弹层颜色。这里用 popup-container 将浮层挂到同一主题容器,保持 CSS 变量继承。局部主题默认把浮层传送到 body 时,不会自动带走容器的颜色。
实色与弱背景:#1c61ff,点击次数 0
已选:未选择
vue
<template>
<x-space>
<x-switch v-model="dark" aria-label="切换颜色检查暗色" />
<x-button @click="brand = brand === '#1c61ff' ? '#0f766e' : '#1c61ff'">切换品牌色</x-button>
</x-space>
<x-config-provider id="color-lab" :dark="dark" :theme-color="brand" class="color-lab">
<p>点击次数:{{ clicks }}</p>
<x-space wrap>
<x-button v-for="status in statuses" :key="`solid-${status}`" type="primary" :status="status" @click="clicks++">{{ status }}</x-button>
</x-space>
<x-space wrap>
<x-button v-for="status in statuses" :key="`subtle-${status}`" plain :status="status" @click="clicks++">{{ status }}</x-button>
</x-space>
<x-tooltip content="提示文字与箭头使用同一主题" :trigger="['hover', 'focus']" popup-container="#color-lab">
<x-button>检查提示颜色</x-button>
</x-tooltip>
<x-select v-model="value" :options="options" allow-clear popup-container="#color-lab" placeholder="检查局部浮层" />
<x-input disabled placeholder="禁用颜色" />
<p>已选:{{ value || '未选择' }}</p>
</x-config-provider>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Button as XButton, ConfigProvider as XConfigProvider, Input as XInput, Select as XSelect, Space as XSpace, Switch as XSwitch, Tooltip as XTooltip } from 'x-next';
import 'x-next/style.css';
const dark = ref(true);
const brand = ref('#1c61ff');
const value = ref('');
const clicks = ref(0);
const statuses = ['default', 'success', 'warning', 'strong', 'danger', 'info'] as const;
const options = [{ label: '第一项', value: 'first' }, { label: '第二项', value: 'second' }];
</script>
<style scoped>
.color-lab {
position: relative;
padding: 16px;
background: var(--x-color-bg-surface);
color: var(--x-color-text-primary);
}
</style>全局 applyThemeColor 需要显式传 dark;调用后若再改变 data-theme,应重新调用以更新内联色阶。ConfigProvider 会随自己的 dark 属性重新生成色阶:暗色容器写入的派生别名改用暗色取值(如热力图色阶上移一档、状态文字指向 -7 档),否则内联声明会压过 [data-theme='dark'] 的语义覆盖。dark=false 不会在暗色祖先内建立独立亮色层。
暗色下的取值原则
暗色不是把亮色色值取反,有三类 token 需要区别对待:
| 类别 | 是否随主题翻转 | 示例 |
|---|---|---|
| 层级色(背景、边框、文字) | 翻转 | --x-color-bg-surface、--x-color-text-1 |
| 恒定亮色(彩底之上的内容) | 不翻转 | --x-color-white(实色按钮文字、选中图标) |
| 恒定深色(浅底之上的内容、实色块) | 不翻转 | --x-color-constant-dark(success/warning 实底文字) |
第三类是暗色改造最容易出问题的地方:实色按钮的底色与文字是成对固定的,一旦有一侧跟随主题翻转,就会变成浅字叠浅底。这些取值由 check:contrast 门禁守护(亮色与暗色各自独立校验)。
实现说明
暗色 token 由两个生成文件承载,请勿手改:
_styles/palette-dark.scss:暗色色阶(明度阶梯反转,低档位更深、高档位更亮)_styles/theme-dark.scss:暗色原语、内联暗色色阶、依赖这些值及刻意暗色覆盖的完整根 alias 链,以及需要留在组件作用域的刻意调整
派生 token 之所以要重声明:CSS 自定义属性在声明处解析 var(),:root 上固化的值不会因后代覆盖原语而重算。生成器追踪 semantic → field → form/component 等所有传递引用,并包含 fallback 中的变量依赖。只有受暗色起点影响的根 alias 才会重发,不复制无关的尺寸、字体和圆角;原 var() 表达式及刻意暗色取值均保留,并检查生成图中是否存在循环。组件内部状态变量留在组件上解析。
这项保证针对生成的默认暗色层。它不为任意自定义亮色子容器或更深层的语义覆盖自动生成 alias。运行时换色独立重绑定受色阶影响的根 alias,并保留其表达式。
重新生成:
bash
node script/build-palette.js # 色阶(亮 + 暗)
node script/build-theme-dark.js # 暗色 token 层文档站样式约束
文档站 demo 样式只负责布局,不应直接修补组件内部结构。优先使用 demo 专用类:
vue
<div class="example-box">
<x-input class="demo-field" />
</div>css
.demo-field {
width: 320px;
max-width: 100%;
}不要在文档主题里新增 .example-box .x-input--wrapper、.example-box .x-table--td 这类规则。若必须临时保留,需要在样式体系审查或对应组件 BUG 审查中记录原因。
样式检查
项目提供样式检查脚本,先用于本地审查和改造过程中的回归检查:
bash
pnpm run check:palette
pnpm run check:style-tokens
pnpm run check:style-colors
pnpm run check:doc-style
pnpm run check:contrastcheck:palette:校验亮/暗色阶、运行时色阶别名和暗色 token 层与生成器输出一致,防止手改生成文件。check:style-tokens:扫描var(--x-*)引用是否有定义,并拦截--x-color-white的表面用途误用。check:style-colors:扫描组件和文档主题中的硬编码颜色。check:doc-style:扫描文档主题是否直接命中组件内部结构类。check:contrast:校验六种按钮状态在亮暗主题下的实色/弱背景/透明底文字与三态、Tooltip、校验提示、Tag、Link 与全部组件前景(状态文字按 Arco 基准,焦点环/图形 ≥3:1),并覆盖边框可辨识度、交互明度差、loading 合成与色度保留。检查内置默认值,不覆盖任意用户色值。
后续建议
建议逐步补充:
- 组件尺寸规范
- 任意局部语义覆盖的 alias 重绑定能力(包括亮色子容器和更深层容器)
- 局部主题 Teleport 的自动继承与独立亮色重置能力