Quasar + Material 3 HCT 动态色彩重构:新增一套皮肤从 1 天变 1 分钟

简介: Quasar 2.27 换肤体系重构:16 套手工配色改为 Material 3 HCT 动态色彩——每套一个源色,37 个语义角色算法派生。三族变量分离:明暗自适应 / 恒 light 派生保浅色零回归 / inline 桥接 Quasar;三函数分离根除 watch 循环。三个坑:pinia persist 恢复晚于 setup,暗黑刷新丢失;Sass 注释内 / 序列提前终结注释,报错错位几十行;chroma ≥ 48 下限把炭灰提饱和成鲜蓝。新增一套皮肤仅 1 行数据,8→12 套仅 2 文件 14 行,消费侧零改动。经验:时序假设须实测、描述问题勿复现问题、算法审美假设是隐性约束。

一次前端换肤体系的技术债清理,演变成对 Material 3 HCT 色彩科学的完整实践。本文记录方案设计、三次印象深刻的踩坑(其中一个是「注释把编译器搞炸」),以及最后用「2 个文件、14 行改动」完成 8 套皮肤扩到 12 套的架构收益验证。

一、痛点:手工配色表的维护噩梦

我们的 Quasar 2.27 项目有一套传统的换肤体系:8 套皮肤 × 明暗两态 = 16 套手工配色。每套配色要维护 primary、渐变三色、图标底色、文字色……堆在 store 的一个百余行大对象里。更糟的是消费侧——全局 173 处 var(--skin-*) 变量引用分布在 29 个文件(渐变、图标底、阴影、文字色……),其中文字色每处都要人工判断「这块文字在浅色底还是深色底?暗黑模式下该用什么色?」

新增一套皮肤的完整成本是:设计 6 个颜色 → 改配色表 → 全局走查消费侧 → 暗黑模式逐处核对。这是一个典型的「随皮肤数量线性增长」的维护模型。

二、方案:源色化——让算法接管配色

Material Design 3 的 HCT(Hue-Chroma-Tone)色彩空间提供了一种出路:每套皮肤只需要一个源色(source color),整套配色(37 个语义角色)由算法派生。Google 官方的 @material/material-color-utilities 库实现了这套算法。

版本锁定:本方案基于 @material/material-color-utilities@0.3.0 精确钉版——0.3.0 与 0.4.0+ 的 surfaceContainer 字段集不同,代码需特性检测('surfaceContainer' in scheme)实现前向兼容,实测以 0.3.0 为准。

2.1 数据流

源色(12 套预设 / 自定义 HEX / 图片提取)
  ↓ @material/material-color-utilities
     (themeFromSourceColor;灰系走 SchemeCarbon,见坑三续章。Map 缓存 FIFO 上限 20)
HCT 调色板 → light/dark 双 scheme(37 语义角色)
  ↓ settings.apply() 单头输出
三族 CSS 变量 → 消费侧零感知替换

2.2 三族 CSS 变量架构

落地的关键是理清三族变量的职责边界:

变量族 挂载点 职责
--md-sys-color-* :root M3 标准 37 角色,随明暗切换 scheme
--skin-* :root 项目既有换肤变量名,恒由 light scheme 派生(保持「皮肤系浅色」场景不回归);其中 --skin-primary-text 例外——明暗自适应,专供文字色
--q-primary body inline Quasar 桥接,必须写 body inline:root 会被 Quasar 就近覆盖)
:root                             body inline(Quasar 桥接)
├── --md-sys-color-*   37 角色    ├── --q-primary    (= scheme.primary)
├── --skin-*           恒 light   ├── --q-secondary  (= scheme.secondary)
└── --skin-primary-text           └── --q-accent     (= scheme.tertiary)
    明暗自适应文字色                  随明暗切换 scheme

两个反直觉的设计决策:

--q-negative 不桥接(所有源色统一)。 M3 的 error 色几乎不随源色变化——任何源色下都派生为深红 #ba1a1a 附近,与红系 primary(#b91a24)ΔE < 3,与 Quasar 默认 #C10015 也视觉相近。桥接收益趋零、还得为红系源色转判,不如统一保持 Quasar 默认,守住「主操作 / 危险操作」的可区分性。

② 文字色换肤走判定树而非全局替换。 文字色消费点(实测约 10 处)不能无脑换成明暗自适应变量——背景是恒浅色(白色卡片、皮肤系图标底)的场景保持原样,只有 surface 系背景(随明暗变化的页面/卡片底)和深色背景才替换为 --skin-primary-text。这是「浅色零回归」红线。

2.3 状态管理:三函数分离

换肤 store 的初始化逻辑拆成三个职责单一的函数,避免循环触发:

function syncDarkMode() {
             // 只负责 Quasar Dark 状态
  Dark.set(themeMode.value === 'auto' ? 'auto' : themeMode.value === 'dark')
}
let lastAppliedKey = ''  // 模块级,跨调用缓存

function apply() {
                    // 只负责输出颜色(幂等)
  const key = `${
     source}|${
     Dark.isActive}`
  if (key === lastAppliedKey) return   // 源色+明暗没变就跳过 DOM 重算
  lastAppliedKey = key
  applyMd3Theme(source, Dark.isActive)
}
function setThemeMode(mode) {
         // 串联入口
  themeMode.value = mode
  syncDarkMode()
  apply()
}

auto 模式下系统明暗变化用 watch(() => Dark.isActive) 响应(Quasar 2.27 没有 Dark.setChangeCallback API,响应式 getter 是等效方案)。

三、踩坑实录

坑一:pinia persist 的恢复时序——「同步恢复」≠「早于你的初始化」

现象:优雅紫 + 暗黑模式下刷新页面,localStorage 里 themeMode: "dark" 分毫未动,DOM 却渲染成 light 配色。更诡异的是明暗切换按钮要点两次才能进暗黑——第一次点击落在 light(按钮显示的却是「切换明亮」,说明 store 认为自己在 dark)。

根因:方案设计时的时序假设是「pinia-plugin-persistedstate 在 store 首次实例化时同步恢复,早于 store body 尾部初始化」。运行时实测推翻了它——persist 的 $patch 恢复发生在 store setup body 执行完之后

store setup body 执行:
  ...
  syncDarkMode()   ← 此时 themeMode 还是默认值 'light' → Dark.set(false)
  apply()          ← 输出 light 配色
persist 插件 $patch:themeMode = 'dark' 被恢复
  ← 但没有任何代码再触发 Dark 重同步!

而 boot 兜底只调了 apply()apply()Dark.isActive(false)为明暗真值,输出 light 配色。存储正确、DOM 错位,这类「半静默」缺陷比直接报错隐蔽得多。

修复(双保险)——关键在于补上 §2.3 三函数设计里缺失的一环:syncDarkMode 职责本是「只设 Dark」,但它在 store 尾部执行时 persist 尚未恢复,需要在 boot 层(mount 前)再调一次,拿到的才是已恢复的真值:

// boot/theme.ts —— Quasar boot 标准签名,store 由框架注入
import {
    boot } from 'quasar/wrappers'

export default boot(({
    store }) => {
   
  const settings = useSettingsStore(store)
  settings.syncDarkMode()   // ← 先用已恢复的 themeMode 重同步 Dark 真值
  settings.apply()          // ← 再应用配色
})

// 第二层:store 内 —— 兜 themeMode 任何被动变化
watch(() => themeMode.value, syncDarkMode, {
    flush: 'post' })

Dark.set() 幂等,两层重复调用无副作用。修复后浏览器自动化复测五步场景(建立 dark / 刷新 / 切回 light / 再刷新 / 再切 dark)全过。

需要说清的是:防闪烁不单靠 boot。首屏其实有两层防线——quasar.config.ts 的静态 brandgetQuasarBrandDefaults() 预计算的源色 light 值,先兜「JS 未执行 / 持久化未恢复」的首帧;boot 执行后再由运行时值就近覆盖。两层合力把 FOUC 压到不可感知——但严格说不是「零」:深色用户首帧仍有极短的 light→dark 色差,属可接受范围。

教训:「persist 在首次实例化时同步恢复」这句话是对的,但「恢复早于 setup body 的副作用」是错的——恢复时机在 body 之后。凡是在 store 初始化尾部依赖持久化值执行副作用(同步外部状态类操作),必须在 boot 层显式重做一次。时序假设必须运行时实测,纸面推理会骗人。

坑二:一行注释搞炸 Sass 编译——而且报错位置在千里之外

现象:新增的 md3-bridge.scss(用 @each 循环生成 37 个 M3 角色的工具类)让 vite 启动直接报错:

[sass] Error: expected "{".
   ╷
16 │   surface-container-lowest, surface-container-low, ...;
   │                                                        ^

报错指向一个完全合法的多行逗号列表的行尾分号。vue-tsc 零错、@vue/compiler-sfc parse 零错——它们都不覆盖 sass 编译层,这个问题只有 dev server 启动时才暴露。

根因:二分法定位——逐段删除可疑代码,发现删掉文件头部注释块后编译即通过,保留则失败;再缩小到注释块中的一行。文件头部注释里写了这样一句:

/**
 * .bg-md-* / .text-md-* 与 Quasar bg-*/text-* 同构……
 */

看到问题了吗?bg-*/text-*——星号后面紧跟斜杠,这正是块注释的结束符。Sass 词法层不区分「注释内容」和「注释结束」,注释在 bg-*/ 处提前终止,后半段注释变成裸 SCSS 层层错乱,最终报错落在了几十行之外的合法代码上。

更有戏剧性的是:我修复时在注释里写「注意:块注释内勿写『/』序列」——**警示文字自身又包含了 `/`,二次触雷**。最终改成纯文字描述「星号紧接斜杠的序列」才彻底解决。

教训:Sass 报 expected "{" 但指向正常声明语句时,先检查注释块是否被截断(grep -n '\*/' file.scss 直接定位注释内的星斜杠序列,比 cat -A 更对症);写踩坑警示时不得写出实际触发序列本身。更工程化的防线是在 CI 或 pre-commit 中加一条 lint,检测 SCSS 注释内是否包含「星号斜杠」序列(写在 lint 规则的代码里是安全的——不经 Sass 编译)。同类困境还出现在中文文案里——同一时期,踩坑文档中「兜底」一词被写成码点相邻的形近字(U+5151/U+5155/U+515C)多达 16 处,人工校对几乎全部漏过,修正时新写的警示句里又错了一次——证实目视校验对这类问题近乎无效。「描述问题」和「写出问题」是两回事。

坑三:HCT 的 chroma 下限——炭灰皮肤变鲜蓝

扩展到 12 套皮肤时,产品给了一款「炭灰」(源色 #475569,灰蓝色)。派生结果让人意外:

#475569 (炭灰) → primary #1360a5 (鲜蓝!)  dark 态 #a2c9ff (浅蓝)

这不是 bug。M3 的 primary palette 要求 chroma ≥ 48——HCT 色彩空间中低饱和度的灰色会被结构性提饱和到下限值,色相保留(实测 HCT 色相 256°;注意 CAM16 与 HSV 数值体系不同——该色 HSV 色相约 215°,视觉同为蓝相),饱和度被强行拉高。换了几个灰系替代源色(#575e72#64748b)全部蓝化,这是算法的固有行为。

顺带拆解一下「拉蓝」的机理——跨色相对照实验表明,chroma 下限只拉饱和、不拉色相(Δhue ≤ 0.7°):

低饱和源色 源 hue 派生 primary Δhue
灰红 #76696a 10° #9b4052 玫红 -0.2°
灰黄 #787465 102° #6b5f00 橄榄 -0.1°
灰绿 #6b7566 143° #2d6b27 绿 -0.1°
灰蓝 #6a6e7a 266° #315da8 +0.1°
灰紫 #72687c 307° #714d9f +0.2°

所以并不存在「把灰拉成蓝」——是灰色里隐藏的色相基因被提饱和「显影」了#475569 的 RGB 差(71,85,105)在人眼感知阈下是「灰」,CAM16 却能分辨出 c18.4 的明确蓝相;饱和度一拉,蓝就显形。而「灰总是变蓝」的观感偏差来自选样——设计师偏爱的灰常是冷调灰(slate/steel 系自带蓝基因),真正的无偏灰见下文(噪声抽奖)。

更极端的验证是把源色设成纯中性灰 #808080(chroma ≈ 1.9,色相理论上无定义)——派生出的是青色 primary(light #006874 / dark #4fd8eb),而且色相由数值噪声决定:

源色 HCT hue 派生 primary 色系
#808080 209.5° #006874
#808081(B+1) 233.2° #006689
#818080(R+1) 218.2° #00687b
#80817f(G+1B-1) 169.0° #006c4f 绿

RGB ±1 的微扰就能让派生色相漂移 ±30° 以上,在青/蓝/绿之间跳变——chroma 趋 0 时色相角的计算像罗盘在磁极附近乱转。想靠「选个灰源色」得到灰主题,此路不通;#475569 之所以能「稳定蓝化」,是因为它尚有真实色度(chroma 18.4),灰蓝倾向明确。

启示:Material 3 的色彩生成是「有主见的」——它假设品牌色应该是彩色的。想要真正的无彩灰主色,需要自定义 TonalPalette 的 chroma 参数(0.3.0 为 TonalPalette.fromHueAndChroma(hue, 0),0.4.x 起提供 TonalPalette.of 简写)并构造自定义 Scheme——注意不是复用 neutral palette:那是 surface 系表面色角色(chroma 本就 ≤ 4),对比度校准口径与 primary 不同,拿来当主色属角色误用。这已超出 themeFromSourceColor 的标准用法。本案当时保留了蓝化效果(记入方案风险条目)。选型时要把算法的审美假设当作约束条件评估。

续章:真灰的正确实现(补记)

风险条目里的「真灰需求」两天后真的来了。第一反应是查官方预制——0.3.0 自带 SchemeMonochrome,看似完美,实测却是陷阱:MONOCHROME variant 把 primary 的 tone 强制到 0/100 极值(light 全黑 #000000 / dark 全白 #ffffff),且与 palette chroma 无关——这是 Google 的设计哲学(单色主题即无彩极值),按钮变纯黑,不可用。

正确姿势是继承 DynamicScheme标准 variant + 低 chroma 调色板组,让 primary 走正常的 tone 40/80:

class SchemeCarbon extends DynamicScheme {
   
  constructor(hct: Hct, isDark: boolean) {
   
    super({
   
      sourceColorArgb: hct.toInt(),
      variant: 2, // Variant.TONAL_SPOT(0.3.0 未导出枚举,字面量钉住)
      contrastLevel: 0, isDark,
      primaryPalette: TonalPalette.fromHueAndChroma(hct.hue, 4),
      secondaryPalette: TonalPalette.fromHueAndChroma(hct.hue, 2),
      tertiaryPalette: TonalPalette.fromHueAndChroma(hct.hue, 8),
      neutralPalette: TonalPalette.fromHueAndChroma(hct.hue, 2),
      neutralVariantPalette: TonalPalette.fromHueAndChroma(hct.hue, 4),
      // error 不覆盖:保持默认红(hue25/chroma84),错误可见性不丢
    })
  }
}

实测(#808080 源色):light primary #5c5f5f(中灰冷调)/ dark #c4c7c7 / error #ba1a1a 保留——「炭灰」终于是炭灰。接入侧只需一个 DynamicScheme → 旧 Scheme 的适配层(MaterialDynamicColors[name].getArgb(scheme) 逐角色取 37 键)+ 皮肤预设加 scheme: 'carbon' 标志,其余 11 套零影响。至此坑三闭环:从「灰变蓝」到「纯灰抽奖」再到「真灰落地」,Material 3 不支持灰色品牌色——但支持你亲手进去改。

四、架构收益实证:8 套 → 12 套,2 文件 14 行

方案落地后做了一次直接验证:产品要求皮肤从 8 套扩到 12 套(新增琥珀金/蒂芙尼/暖棕/炭灰 4 套,另调整 2 套既有源色:翡翠绿 #10b981→#22c55e、靛蓝 #6366f1→#4f46e5,含 4 个名称更新)。

实际改动:

// ① 类型联合加 4 个 key
export type SkinKey = 'red' | 'orange' | 'amber' | ... | 'slate'

// ② 预设表加 4 行数据
amber: {
    label: '琥珀金', source: '#f59e0b' },
teal:  {
    label: '蒂芙尼', source: '#14b8a6' },
brown: {
    label: '暖棕',   source: '#92400e' },
slate: {
    label: '炭灰',   source: '#475569' },

注意:其中 slate 的 #475569 即坑三所述「炭灰变鲜蓝」的源色——当时实际效果为蓝系主题(后已改为 #808080 + 真灰方案,见坑三续章)。此处保留原值以忠实记录当时的扩皮肤操作。

总计 2 个文件、+14/-8 行。 三处换肤入口(登录页圆点、导航栏圆点、设置页色板)全部 v-for 遍历预设表,自动扩展到 12 项,零组件改动。每套新皮肤的渐变、明暗双色、图标底色、文字自适应色全部运行时派生。

浏览器自动化验证 12 套逐一切换,实测 --q-primary 与预计算表 12/12 全对;布局在 680px 严苛视口下单行不溢出。对比重构前「新增一套皮肤要全局走查 173 处 --skin-* 消费侧」的成本——标题口径「1 天 → 1 分钟」的量化依据:重构前一次完整新增约一人天(6 色设计 + 173 处走查 + 明暗逐处核对);重构后 1 行数据录入、切换即见效果,这是架构升级最直接的收益证明。

五、验证方法论:把手工回归变成自动化清单

换肤体系的回归验证天然适合自动化——所有断言都是 DOM 可测的。我们把验证固化为清单交给浏览器自动化代理执行(脚本化导航 + DOM 断言,所有断言均为属性/样式值比较):

  • 三族变量联动(--q-primary body inline / --md-sys-* computed / --skin-* 派生值与预计算表逐一对照)
  • 隔离性验证(切肤期间 body--dark 恒定——证明 setSkin 不触碰 Dark)
  • 持久化五步场景(建立 → 刷新 → 切换 → 再刷新 → 再切换)
  • 四方面一致性(CSS 变量值 / class 状态 / 明暗切换按钮显示文案与当前态一致 / localStorage 值)

这份清单在首次运行时就抓到了坑一(dark 刷新丢失)——自动化验证清单的价值不在「证明它对」,而在「用机器的耐心暴露时序类人眼难察的错」。

六、总结

重构前 重构后
新增一套皮肤 补 6 个手工色值 + 全局走查消费侧 1 行源色数据(灰系另加 scheme 标志,见坑三续章)
明暗适配 每处文字人工判断 tone 40/80/90 算法派生
配色一致性 靠设计师自觉 HCT 同源保证和谐
回归验证 人工目视 DOM 断言自动化

对业务侧的直接影响:新增品牌色从「设计师出 6 个色值 + 前端适配」变成「设计师给一个品牌色,前端一行录入」——协作链路缩短一半以上。

三条最想传递的经验:

  1. 时序假设必须运行时实测——「persist 同步恢复」的官方描述没错,错的是我们对「恢复 vs 副作用执行顺序」的推断。boot 层显式初始化 + flush: 'post' watch 兜底的双保险模式值得复制。
  2. 描述问题的文字本身不要复现问题——无论是不小心截断 Sass 注释的 */,还是踩坑文档里的形近字,元层面的复现防不胜防,机械校验(grep 定位星斜杠序列 + 码点检查)比目视可靠。
  3. 算法的审美假设是隐性约束——HCT 的 chroma 下限意味着标准用法内「Material 3 不支持灰色品牌色」,真灰需自定义 Scheme(坑三续章的落地路径),这类特性要在选型阶段就挖出来。

附录:核心实现索引

完整方案共四个核心文件,本文按叙事需要分散引用,此处给出索引供拼装参考:

文件 行数 职责 对应章节
utils/md3-theme.ts 275 算法核心:theme 缓存、三族变量输出、静态 brand 预计算、真灰方案(SchemeCarbon) §2.2 / 坑三及续章
boot/theme.ts 19 mount 前应用持久化皮肤/明暗(防闪烁首道防线之二) 坑一
stores/settings.store.ts 158 换肤 store:三函数分离 + persist + scheme 标志 §2.3 / 坑一
assets/styles/md3-bridge.scss 26 @each 生成 37 角色的 bg/text 工具类 坑二

读者最关心的三族变量输出主函数全文如下(生产代码原样摘录;依赖的常量表 SCHEME_ROLES(30 键)/CONTAINER_ROLES(7 键)与工具函数 camelToKebab/hexToRgb 按命名即可复原;真灰方案相关 SchemeCarbon/适配层见坑三续章):

/** 主入口:输出全部 CSS 变量(settings.apply() 调用;dark = 真实激活态 Dark.isActive;
 *  carbon = 真灰方案(灰系源色专用,见 SchemeCarbon 注释)) */
export function applyMd3Theme(sourceHex: string, dark: boolean, carbon = false): void {
   
  if (!isBrowser) return // SSR 防护(persist 水合等服务端场景)
  try {
   
    const t = getMd3Theme(sourceHex, carbon)
    // 断言为 SCHEME_ROLES 键的映射类型:具名键推断 number(noUncheckedIndexedAccess
    // 下 Record 索引签名会给出 number | undefined,六个 TS2345 根因——A1 修复)
    const scheme = (dark ? t.schemes.dark : t.schemes.light) as unknown as {
   
      [K in (typeof SCHEME_ROLES)[number]]: number
    }
    const root = document.documentElement

    // ① M3 语义变量(随明暗切换 scheme)
    for (const role of SCHEME_ROLES) {
   
      root.style.setProperty(`--md-sys-color-${
     camelToKebab(role)}`, hexFromArgb(scheme[role]))
    }
    // surface 补齐角色:0.4.0+ Scheme 自带 surfaceContainer 字段则直用(基线或微调),
    // 0.3.0 无则按 M3 基线 tone 从 neutral 调色板手工补(版本钉版外的双保险)
    const schemeRecord = scheme as Record<string, number>
    const neutral = t.palettes.neutral
    for (const {
    cssVar, lightTone, darkTone } of CONTAINER_ROLES) {
   
      const field = cssVar.replace('--md-sys-color-', '').replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
      const hex = field in schemeRecord
        // 动态 string 键走索引签名,in 检查的收窄对索引签名不生效,保留非空断言
        ? hexFromArgb(schemeRecord[field]!)
        : hexFromArgb(neutral.tone(dark ? darkTone : lightTone))
      root.style.setProperty(cssVar, hex)
    }

    // ② 皮肤业务变量(恒 light scheme 派生,明暗共用——值同族稳定):
    //    全部走标准 tone(90/80/40/30),非标准插值值易偏离 M3 规范
    const primary = hexFromArgb(t.schemes.light.primary) // tone 40
    const primaryDark = hexFromArgb(t.palettes.primary.tone(30))
    const primaryBright = hexFromArgb(t.palettes.primary.tone(80))
    const iconBg = hexFromArgb(t.schemes.light.primaryContainer) // tone 90 浅底(接替手工配色表)
    root.style.setProperty('--skin-primary', primary)
    root.style.setProperty('--skin-primary-dark', primaryDark)
    root.style.setProperty('--skin-primary-bright', primaryBright)
    // --skin-primary-text  : 普通文字,明暗自适应(浅色=tone40 同 primary,深色=tone80 提对比)
    // --skin-primary-bright: 恒亮 tone80,专用于深色背景上的图标/边框/徽章等强调装饰,不应用于大段文字
    // 两者勿互换:bright 在浅底上对比反降,不可用于浅底文字(P1-9/P1-12)
    root.style.setProperty('--skin-primary-text', hexFromArgb(scheme.primary))
    root.style.setProperty('--skin-primary-rgb', hexToRgb(primary))
    root.style.setProperty('--skin-icon-bg', iconBg)
    root.style.setProperty(
      '--skin-gradient',
      // 三色渐变(亮 → 主 → 深,标准 tone 90/40/30)
      `linear-gradient(135deg, ${
     hexFromArgb(t.palettes.primary.tone(90))} 0%, ${
     primary} 45%, ${
     primaryDark} 100%)`,
    )
    root.style.setProperty(
      '--skin-gradient-hover',
      `linear-gradient(135deg, color-mix(in srgb, ${
     primary} 55%, #ffffff) 0%, ${
     primary} 100%)`,
    )
    root.style.setProperty('--skin-shadow', `0 4px 12px rgba(${
     hexToRgb(primary)}, 0.3)`)

    // ③ Quasar brand 桥接(body inline,随当前 scheme——暗黑下取亮调角色)
    // negative 不桥接(P1-11):M3 error 与红系 primary ΔE<3 视觉雷同,
    // 保持 Quasar 默认 #C10015 保主/危险操作可区分性;quasar.config 静态 brand 同不含
    const body = document.body
    body.style.setProperty('--q-primary', hexFromArgb(scheme.primary))
    body.style.setProperty('--q-secondary', hexFromArgb(scheme.secondary))
    body.style.setProperty('--q-accent', hexFromArgb(scheme.tertiary))
  } catch (e) {
   
    // 无效源色(自定义输入 '#ggg'/空串等)兜底:回退经典红源色(默认方案,
    // 不透传 carbon——回退色是彩色,语义上就不该走灰分支)
    console.warn('[md3] invalid source color:', sourceHex, e)
    if (normalizeSource(sourceHex) !== '#ef4444') applyMd3Theme('#ef4444', dark)
  }
}

本文实践基于 Quasar 2.27 + Vue 3 + @material/material-color-utilities@0.3.0(版本锁定说明见 §2 开头)。你们的换肤方案踩过时序或配色派生的坑吗?欢迎评论区交流。
image.png

相关文章
人工智能 缓存 前端开发
12596 74
人工智能 自然语言处理 安全
1459 0
Web App开发 人工智能 API
1574 2
人工智能 JavaScript 开发工具
4930 0
人工智能 Java BI
1681 1
人工智能 JavaScript 测试技术
2619 2
开发工具 Swift git
2008 6
人工智能 JavaScript 测试技术
1260 4