mcpField
D
dark-mode-maker

dark-mode-maker

github.com/rheinmir设计与 UI1079 次安装2 周前更新
代码仓库

关于 dark-mode-maker

用于为 HTML 页面添加明暗主题切换的技能,包含圆形揭示动画、中性暗色调色板和防止 FOUC 的脚本。它还要求通过 Playwright 验证动画位置、主题提交时机及减少动态效果分支。

主要功能

  1. 01自动插入主题切换控件
  2. 02使用圆形揭示与边缘光效
  3. 03以 localStorage 主题值防止 FOUC
  4. 04采用中性暗色调色板
  5. 05提供 Playwright 动画验收脚本
  6. 06支持减少动态效果的过渡

适用场景

  1. 01为 HTML 页面添加明暗主题开关
  2. 02按点击位置触发主题切换揭示动画
  3. 03验证覆盖层位置及主题提交时序
  4. 04检查减少动态效果时的切换行为

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/rheinmir/setup --skill dark-mode-maker

安装后在 Claude Code 中输入/dark-mode-maker确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

scripts/
package-lock.json
package.json
verify-theme-motion.mjs

详细介绍

技能:dark-mode-maker

从 docs-site-macos 中独立拆分出来的模块(反馈 160926:“把实现 dark/light mode 效果的整个东西拿出来,做成一个独立技能”)。适用于任何需要 dark/light 切换按钮的 HTML 页面,不仅限于 docs-site-macos。

WHAT

目的和上下文
  • **目的:**为 HTML 页面添加 dark/light 切换按钮,点击后从光标位置向外扩散 circle-reveal(限制在按钮边界内),并带有 crest-glow;采用符合市场惯例的中性深色调色板,防止 FOUC,并使用 Playwright 进行真实验收。
  • 触发条件(何时使用):
    • 页面需要 dark/light 切换按钮(按照 html-theme-toggle-required 规则,所有面向用户的 HTML 页面都需要)。
    • 用户说“做 dark mode”“theme toggle”“切换亮色和暗色”“circle reveal”“界面切换效果”。
    • docs-site-macos § Theme Toggle 指向此处,而不是重新嵌入全部 CSS/JS。
  • 非目标:
    • 不用于判断页面是否必须提供 toggle——这是 html-theme-toggle-required(/fdk § Rules)的规则;本技能只负责在决定提供 toggle 后处理“如何切换”。
    • 不是文档站点框架(那是 docs-site-macos)。
心智模型

<nav> 已存在 → JS 插入 .theme-row > .lbl + .theme-switch → 点击(将 clientX/Y 限制在按钮边界内)→ overlay .theme-reveal(目标模式的背景)通过 clip-path circle 扩散,并在其自身添加 crest drop-shadow → 覆盖整个页面 → 提交 data-theme + localStorage → 淡出 → 移除 overlay。启用 reduced-motion 时 → 使用 ≤150ms 的交叉淡化,不使用空间动画。

输入和输出约定
字段必填?含义
输入含有 <nav>(或同等容器)且包含导航链接的 HTML 页面是JS 会自动将切换按钮插入末尾
输入<页面名称>(localStorage 键)+ 页面实际的 light/dark 背景色是填入 FOUC 代码片段和 JS(§4)
输入没有侧边栏的页面否放在页面 footer 同一行,仍使用带标签的切换按钮
输出页面内的 CSS switch + FOUC 脚本 + JS reveal + 中性深色调色板是保留 class 名 .theme-row/.theme-switch/.theme-reveal
输出verify-theme-motion.mjs 的结果是断言 overlay 的坐标和颜色正确、覆盖后才提交、移除 DOM,以及 reduced-motion 分支
规则和能力
  • RULE-01 (MUST):不要为涟漪效果单独创建辅助元素——在 .theme-reveal 本身使用多层 filter:drop-shadow(见 §4;已测得单独创建元素会导致延迟和同步偏差)。
  • RULE-02 (MUST):不要在 .theme-row 上使用 backdrop-filter(见 §1),也不要在 dark-mode 代码块中单独覆盖其 background。
  • RULE-03 (MUST):扩散起点始终限制在按钮边界内(.theme-switch 的 getBoundingClientRect())——无论点击时光标位于屏幕何处,都不能扩散到按钮外。
  • RULE-04 (MUST):采用中性 dark-mode 调色板(§3)——平台背景、边框和文字中不得加入高饱和度颜色;accent 始终只保留一种颜色。
  • RULE-05 (MUST):docs-site-macos(以及其他所有需要 toggle 的 HTML 生成技能)都应指向本技能,而不是重新嵌入 CSS/JS——保持单一来源,修改一处即可。
  • 能力:修改目标 HTML 页面的 CSS/JS/markup;临时安装 headless 浏览器依赖并运行验收脚本。
失败边界
  • 页面没有 <nav>/容器 → 脚本提前 return,不会出现 toggle → 澄清应在哪个容器(或页面 footer 行)放置按钮。
  • 验收脚本报错(overlay 坐标/颜色错误、覆盖前就提交、overlay 未移除、reduced-motion 下仍有 clip-path)→ 失败,修复后重新运行。
  • 用户仍觉得慢,即使已缩短 duration → 澄清/切换到 instant 分支(v5),不要一味降低 TOTAL_MS。
  • 关于“页面是否必须提供 toggle”的问题 → 超出范围,遵循 html-theme-toggle-required 规则。

HOW

主工作流
步骤类型输入操作输出/退出失败/后续
W01确定性页面检查标记契约:是否有包含链接的 <nav>已确定容器没有 → B02
W02效果页面§1 开关:CSS .theme-row sticky,background:transparent,无 backdrop-filter开关 CSS—
W03效果<tên-trang>§2 防止 FOUC:脚本读取 localStorage,提前设置 data-theme不闪烁—
W04效果页面§3 中性深色调色板(token 表)深色 token—
W05效果实际背景色§4 圆形揭示 + crest-glow:CSS .theme-reveal + JS(更改 <tên-trang>、2 种颜色 + 2 种 tint)切换正常运行用户要求即时切换 → B03
W06确定性HTML 文件验收:node skills/dark-mode-maker/scripts/verify-theme-motion.mjs <file.html>PASSFAIL → 修复,重复 W06

各步骤详情(W01–W06 的事实来源):

契约(最小标记)

需要一个包含导航链接的 <nav>(或等效容器)——开关会由 JS 自动插入末尾,无需手动编写标记:

<nav>…link điều hướng…</nav>

JS 会自动创建 .theme-row > .lbl + .theme-switch,将 data-theme 设置到 <html> 上,并保存到 localStorage。不要更改这些 class 名称——验收脚本(scripts/verify-theme-motion.mjs)会依赖它们。

1. 开关(switch)

页脚行在父容器底部粘性固定(position:sticky),左侧为标签,右侧为开关,上方有一道细分隔线——不要在角落散布图标胶囊:

:root{--nav-pad-y:18px} /* nguồn chân lý duy nhất cho padding dọc của container cha */
.theme-row{position:sticky;bottom:calc(-1 * var(--nav-pad-y));margin-top:auto;display:flex;
  align-items:center;justify-content:space-between;padding:11px 16px;
  border-top:1px solid rgba(30,90,170,.14);background:transparent}
.theme-switch .track{display:inline-block;position:relative;width:50px;height:26px;border-radius:999px;…}
.theme-switch .track::before{content:'☀️';left:6px;…} .theme-switch .track::after{content:'🌙';right:6px;…}
.theme-switch .knob{position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;transition:left .18s}
.theme-switch.on .knob{left:26px} .theme-switch.on .track{background:…dark…}

⚠️ .theme-row 必须设置 background:transparent,不得单独设置 backdrop-filter(160926 的教训,已实测两轮错误)。 .theme-row 是父容器(nav)的直接子元素,不是独立的 content-card:

  • **错误做法 1——单独填充背景:**给它单独设置纯色背景(即使是“看起来差不多”的 nav 渐变),也会显露为一块与后方玻璃层分离的区域——实际反馈:“这个 div 就一直保持透明吧”。
  • **错误做法 2——background:inherit:**以为已经修好,但 inherit 只会复制渐变的公式,然后在 .theme-row 自己的小盒子上重新绘制——并不会露出父级已绘制的准确像素,色调仍然不一致。
  • **错误做法 3——为了“保险”保留 backdrop-filter:blur:**即使背景确实透明(测得 rgba(0,0,0,0)),额外叠加的一层模糊会覆盖在父级已自行模糊的区域上,形成“二次磨砂玻璃”条带,看起来像独立的区块,尽管没有颜色——这是通过实际截图发现的,并非猜测。
  • 正确做法:.theme-row 设置 background:transparent,不设置 backdrop-filter。直接露出父级已渲染的像素,不要再自行绘制或模糊任何内容。
2. 防止 FOUC
<script>(function(){try{var t=localStorage.getItem("<tên-trang>-theme");
if(t==="dark"||t==="light")document.documentElement.setAttribute("data-theme",t)}catch(e){}})();</script>
3. 暗色模式调色板——市场主流的中性色调,不带藏青色(160926 经验)

初版使用背景色 #0c0f16 + 边框 rgba(120,160,220,.18)(深藏青色)——实际反馈:“暗色模式的配色……还是糟透了,按市场主流重新选一套颜色吧”。参考了目前评价最高的 3 款暗色模式产品(GitHub Dark、Vercel/Geist、Linear):背景采用接近黑色的中性色(不偏蓝),边框采用透明白色(不使用饱和色边框),只保留一种强调色(此处为 Apple 系统蓝 #0a84ff)作为点缀——不要在界面元素和边框中铺开使用饱和色:

Token之前(偏藏青色,错误)之后(中性,市场主流)
基础背景#0c0f16#09090b
背景渐变,stop 2#0a0d13 / #0d1017#08080a
--glass2(卡片)rgba(30,34,44,.72)rgba(32,32,36,.72)
--glass3(表格/数据)rgba(36,40,52,.9)rgba(39,39,44,.9)
--borderrgba(120,160,220,.18)(饱和蓝色)rgba(255,255,255,.10)(透明白色——GitHub/Vercel/Linear 在深色背景上绘制边框的正确方式)
--t1(正文)#e7e9ee / #e8eaf0#f4f4f5
--t2(辅助文字)#9aa2b1(蓝灰色)#a1a1aa(中性灰)
导航/父容器rgba(18,21,28,.82) 或渐变 rgba(24,30,44,…)rgba(20,20,23,.82) 或渐变 rgba(28,28,31,…)
切换开关轨道(开启)#2b3040→#191d27 / rgba(60,70,110,.55)#2c2c30→#19191b / rgba(58,58,64,.6)
模式切换覆盖层背景色#0c0f16#09090b

强调色(#0a84ff,Apple 次要标签中的 6 色之一)保持不变——问题不在于它“糟透了”,它是刻意设计的点缀。需要调整为中性色的只有背景/边框/文字。

4. Circle-reveal + crest-glow(必需)——从光标位置扩散,并限制在按钮边界内

扩散起点:点击时鼠标光标的位置,并限制(clamp)在按钮边界内(160926 经验——之前从按钮中心扩散,位置固定):使用实际点击坐标(e.clientX/clientY),并将其限制在按钮的 getBoundingClientRect() 范围内——点击按钮左侧或右侧边缘时,扩散起点应跟随对应边缘,不得跑出按钮。按 Enter/Space 键时(没有鼠标坐标),则以按钮中心为起点。

不要使用多个独立的波纹圈追赶主波纹(160926 经验,已弃用的初版设计)。 初版生成了 3 个独立的圆形边框 <div>,借助各自的 backdrop-filter:blur 比主波纹扩散得更快、更远——实际反馈:“效果太卡了……波浪比后面的白色块还快?不是应该在后面的白色块上做效果吗?”这两个问题叠加在了一起:

  • 确实卡顿: 每帧对多个大型 <div>(直径超过屏幕覆盖半径的 2 倍)使用 backdrop-filter:blur,会非常消耗 GPU——这不是错觉,而是真实的渲染开销。
  • 概念错误: 波纹脱离主波纹,使用各自的时间轴(独立的 setTimeout + el.animate())运行 → 无法完美同步,正如用户观察到的“波浪比白色块还快”。

正确做法:直接在主波纹元素上叠加多层 filter:drop-shadow(...)——drop-shadow 会沿着已被 clip-path 裁切的边缘绘制(不同于 box-shadow,它沿矩形边界绘制,不会遵循裁切后的形状)。由于这是应用在同一元素上的滤镜,并且使用同一个动画,发光边缘会在每一帧自动紧贴主波纹,不需要同步,不需要辅助元素,也不需要 backdrop-filter:

.theme-reveal{position:fixed;inset:0;z-index:300;pointer-events:none;will-change:clip-path;
  filter:
    drop-shadow(0 0 1px rgba(255,255,255,.95))
    drop-shadow(0 0 5px rgba(255,255,255,.45))
    drop-shadow(0 0 14px var(--reveal-tint,rgba(10,132,255,.35)))}
/* duration/easing: xem §4 khối JS bên dưới + "bài học easing 160926 (v3)" */

三层 drop-shadow 从清晰锐利的发光边缘(水边)→ 柔和的白色光晕(折射)→ 根据 --reveal-tint 显示的宽广彩色光晕(目标模式)依次叠加——看起来像真实的波纹/液态玻璃,随着上涨的水边一起卷动,而不是彼此分离、飞散的波纹。

(function(){var K='<tên-trang>-theme',d=document.documentElement,nav=document.querySelector('nav');if(!nav)return;
function isDark(){var t=d.getAttribute('data-theme');return t?t==='dark':matchMedia('(prefers-color-scheme: dark)').matches}
var sw=document.createElement('div');sw.className='theme-switch';sw.setAttribute('role','switch');sw.setAttribute('tabindex','0');
sw.innerHTML='<span class="track"><span class="knob"></span></span>';
var row=document.createElement('div');row.className='theme-row';
var lb=document.createElement('span');lb.className='lbl';lb.textContent='Giao diện';row.appendChild(lb);row.appendChild(sw);nav.appendChild(row);
function paint(){var dk=isDark();sw.classList.toggle('on',dk);sw.setAttribute('aria-checked',dk?'true':'false');
  sw.setAttribute('aria-label',dk?'Nút gạt giao diện: đang tối — gạt sang sáng':'Nút gạt giao diện: đang sáng — gạt sang tối')}
var busy=false;
function commit(next){d.setAttribute('data-theme',next);try{localStorage.setItem(K,next)}catch(e){}paint()}
function flip(e){
  if(busy)return; var next=isDark()?'light':'dark';
  var r=sw.getBoundingClientRect();
  // điểm tỏa = con trỏ chuột lúc bấm, KẸP trong biên nút; Enter/Space (không toạ độ) -> tâm nút
  var x=(e&&typeof e.clientX==='number')?Math.min(Math.max(e.clientX,r.left),r.right):r.left+r.width/2;
  var y=(e&&typeof e.clientY==='number')?Math.min(Math.max(e.clientY,r.top),r.bottom):r.top+r.height/2;
  var reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
  // <tên-trang>: đổi 2 màu + 2 tint này khớp nền light/dark thật của trang (xem §3 Palette)
  var bg=next==='dark'?'#09090b':'#f7fbff';
  var tint=next==='dark'?'rgba(90,168,255,.28)':'rgba(10,132,255,.20)';
  var canAnimate='animate' in document.createElement('div');
  busy=true;
  var el=document.createElement('div');el.className='theme-reveal';
  el.style.cssText='position:fixed;inset:0;z-index:300;pointer-events:none;will-change:clip-path;background:'+bg;
  el.style.setProperty('--reveal-tint',tint);
  document.body.appendChild(el);
  if(reduced||!canAnimate){
    // fallback bắt buộc (FR-007, chuẩn motion.md của hallmark): KHÔNG bỏ hẳn overlay — collapse
    // về crossfade ngắn ≤150ms thay vì hiệu ứng spatial full-screen, KHÔNG có crest-glow.
    el.style.cssText+=';opacity:0;transition:opacity .075s linear';
    requestAnimationFrame(function(){el.style.opacity='1'});
    setTimeout(function(){commit(next);el.style.opacity='0';setTimeout(function(){el.remove();busy=false},90)},90);
    return;
  }
  var maxR=Math.hypot(Math.max(x,innerWidth-x),Math.max(y,innerHeight-y));
  // cấu trúc TỈ LỆ (bài học 160926 v4): chỉnh MỘT số TOTAL_MS, grow/fadeOut tự chia theo tỉ lệ cố định —
  // không sửa 2 con số rời rạc mỗi lần user đổi ý về tốc độ.
  var TOTAL_MS=20,GROW_RATIO=.8,growMs=Math.round(TOTAL_MS*GROW_RATIO),fadeMs=TOTAL_MS-growMs;
  var grow=el.animate([
    {clipPath:'circle(0px at '+x+'px '+y+'px)'},
    {clipPath:'circle('+maxR+'px at '+x+'px '+y+'px)'}
  ],{duration:growMs,easing:'cubic-bezier(.55,0,1,.45)',fill:'forwards'}); // ease-in tăng tốc (bài học 160926 v3): càng gần lúc phủ hết càng nhanh
  grow.onfinish=function(){
    commit(next); // đổi data-theme ĐÚNG lúc phủ hết, trước fade
    var fadeOut=el.animate([{opacity:1},{opacity:0}],{duration:fadeMs,easing:'ease-out',fill:'forwards'});
    fadeOut.onfinish=function(){el.remove();busy=false};
  };
}
sw.addEventListener('click',flip); // click event tự mang clientX/clientY
sw.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();flip(e)}});
paint()})();

⚠️ 160926 缓动经验(v1,已在下方的 v2 中替换): cubic-bezier(.3,0,.1,1)(初版设计)会在前约 20% 的时长内压缩几乎全部动画,随后剩余时间几乎完全停滞——这与“开始慢、结束快”的意图完全相反。只有通过多个时间点的真实截图测量才发现这一问题(在 130/560ms,即时长的 23% 时,覆盖层已几乎铺满整个屏幕)。改为 cubic-bezier(.42,0,1,1)(标准 CSS ease-in,560ms)后,动画会贯穿整个 duration,但点击后紧接着的瞬间仍然较慢。 ⚠️ Easing 经验教训 160926(v2,已在下方 v3 中替换): v1 发布后的真实反馈:“还是感觉有延迟、卡顿”。根本原因不是卡顿,而是 ease-in(即使过渡均匀)在 t=0 时的导数仍约为 0,因此点击后的前 100-150ms 内,几乎看不到叠加层移动 → 感觉就像“应用卡住了”。改用 cubic-bezier(.16,1,.3,1)(ease-out expo),并将 duration 从 560 缩短到 280ms(grow),从 180 缩短到 70ms(fadeOut)——感知总时长从 740ms 降至 350ms。

⚠️ Easing 经验教训 160926(v3): 后续反馈:“半秒内完成还是太久了” + “越接近结束越快”。这传递出两个信号:(1)350ms 仍然被认为慢——用户并没有固定的“足够快”阈值,应持续缩短,直到仍能看出一个圆形扩展开来(而不是闪一下)为止;(2)想要的曲线形状是逐渐加速,直至覆盖完成,而不是 ease-out(末尾减速,会让人觉得“最后还剩点事没做完”)。由于 duration 已经极短,ease-in 前段的“缓慢”已没有足够时间被感知为延迟,因此 v1 与 v2 的矛盾只会在 duration 较长时出现。将 easing 改为 cubic-bezier(.55,0,1,.45)(ease-in-quad,匀加速,以最高速度结束)。总结出的规律: duration 越短,easing 曲线形状越不重要——优先缩短 duration,再调整 easing。

⚠️ Easing 经验教训 160926(v4,结构): 用户在多轮反馈中不断要求缩短 duration(350→200→50→20ms),反馈是:“用比例结构吧,只输入最大时间,然后自动重新分配 duration”。与其使用两个需要每次手动修改的独立 duration 数值,不如定义一个 TOTAL_MS 和固定的 GROW_RATIO(0.8),让 grow/fadeOut 按比例自动计算——调整速度只需修改一个数值。TOTAL_MS=20(growMs=16,fadeMs=4)时,仍能稳定通过下方脚本的验收测试——但这已经是临界点:总时长低于约 20ms 后,肉眼几乎无法分辨“圆形扩展开来”的效果与即时变色(闪一下);再缩短只剩理论意义。

⚠️ Easing 经验教训 160926(v5,定稿): v4(TOTAL_MS=20)之后仍被嫌 “还是太久了” → 用户最终决定 “直接做成瞬时 [instant] 吧”。对于点击后直接触发的反馈效果,用户实际能够接受的阈值无法事先用数字确定——对于 theme-toggle 这类高频点击按钮,最稳妥的做法是在投入设计复杂的 reveal 效果(overlay/clip-path/crest-glow)之前先询问,而不是默认总要有动画效果。当用户要求“瞬时”时:彻底移除 overlay 和 el.animate,flip() 只保留 commit(next)——不要再把 TOTAL_MS 一点点往下调;如果没有其他元素会创建该 class,也一并删除 CSS .theme-reveal。可参考 llmwiki/html/overstack.html。此技能中的圆形 reveal 仍是适用于点击较少的 landing/showcase 页面的良好默认模式——不要不加区分地用于所有场景。

⚠️ 为极短 duration 编写验收脚本时的陷阱: await animation.finished(Promise)可能会在 'finish' 事件触发之前 resolve——而真实代码中的 onfinish(调用 commit() 的地方)监听的是事件,不是 Promise。通过真实浏览器测量(getAnimations()[0].playState)才能发现:Promise 已经 resolve,但 data-theme 仍未改变。测试必须通过 addEventListener('finish', ...) 等待完成(或先检查 playState==='finished'),才能与真实 onfinish 的触发时机正确同步——参见 scripts/verify-theme-motion.mjs。一般经验:不要硬编码毫秒数来猜测“动画结束了没有”——应使用真实的 API/事件,这样在各种 duration 下都更准确。

没有侧边栏的页面(单栏 landing)?将它放在页面页脚同一行,仍然使用带标签的拨动按钮——绝对不要再改回角落里悬浮的图标芯片。

自动验收
npm install --no-save @playwright/test   # cài tạm, không cần lưu vào package.json
node skills/dark-mode-maker/scripts/verify-theme-motion.mjs <file.html>

断言:overlay .theme-reveal 出现在光标的准确坐标处(限制在按钮边界内)+ 背景色为目标模式;覆盖整个视口后才更改 data-theme/localStorage;淡出后 overlay 自动从 DOM 中移除;prefers-reduced-motion: reduce 分支不会出现扩散的 clip-path(平滑交叉淡入淡出,不产生空间运动)。

分支
ID类型守卫条件行为跳过/失败汇合
B01conditional_requiredprefers-reduced-motion: reduce 或没有 el.animate使用 ≤150ms 的交叉淡化,不使用扩散式 clip-path,也不使用 crest-glow(JS §4 中的回退方案)—W06
B02conditional_required页面没有侧边栏(单列落地页)放在页面页脚同一行,仍为带标签的切换按钮,而不是角落里的图标芯片—W02
B03user_optional用户要求“instant”(v5 课程)完全移除遮罩层和 el.animate,flip() 只保留 commit(next),若 CSS .theme-reveal 不再使用则删除—W06
验证与停止条件

使用 scripts/verify-theme-motion.mjs 在真实浏览器中进行确定性验收(不要硬编码毫秒数——等待 finish 事件)。脚本通过后即停止;美观度(调色板色调、感知速度)需要用户目视确认。

示例
  • 正向示例: 展示页面 site/index.html 有 <nav> 侧边栏 → 添加 CSS §1 + FOUC §2(锁定 site-theme)+ 调色板 §3 + JS §4 → node skills/dark-mode-maker/scripts/verify-theme-motion.mjs site/index.html 通过:遮罩层从按钮内准确的点击位置扩散,覆盖完成后 data-theme 改变,遮罩层从 DOM 中移除。
  • 边界/失败: 为了“保险”,在 .theme-row 上保留 backdrop-filter:blur → 截图中露出一条独立的双层磨砂玻璃带 → 违反 RULE-02,移除 backdrop-filter,设为 background:transparent。

评论

支持 Markdown