dark-mode-maker

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Skill: dark-mode-maker

Skill: dark-mode-maker

Module độc lập, tách ra từ
docs-site-macos
(feedback 160926: "bê nguyên cái làm hiệu ứng dark/light mode thành 1 skill riêng"). Dùng được cho BẤT KỲ trang HTML nào cần nút chuyển dark/light — không chỉ trang docs-site-macos.
独立模块,拆分自
docs-site-macos
(反馈 160926:“把实现 dark/light mode 效果的全部内容拆成一个独立 skill”)。可用于任何需要 dark/light 切换按钮的 HTML 页面——不只限于
docs-site-macos
页面。

WHAT

WHAT

Purpose và context

目的与上下文

  • Purpose: gắn vào một trang HTML nút gạt dark/light có circle-reveal tỏa từ vị trí con trỏ (kẹp trong biên nút) + crest-glow, palette dark trung tính kiểu thị trường, chống FOUC, nghiệm thu bằng Playwright thật.
  • Trigger (when to use):
    • Trang cần nút chuyển dark/light (mọi trang HTML sinh cho người xem, theo luật
      html-theme-toggle-required
      ).
    • User nói "làm dark mode", "theme toggle", "chuyển sáng tối", "circle reveal", "hiệu ứng đổi giao diện".
    • docs-site-macos
      § Theme Toggle trỏ vào đây thay vì nhúng lại toàn bộ CSS/JS.
  • Non-goals:
    • KHÔNG dùng để chọn xem trang có BẮT BUỘC phải có toggle hay không — đó là luật
      html-theme-toggle-required
      (
      /fdk
      § Rules), skill này chỉ lo PHẦN "chuyển thế nào" sau khi đã quyết có toggle.
    • Không phải sàn trang tài liệu (đó là
      docs-site-macos
      ).
  • **目的:**为 HTML 页面添加一个 dark/light 切换按钮,带有从指针位置扩散的 circle-reveal(限制在按钮边界内)+ crest-glow,采用市场风格的中性 dark palette,防止 FOUC,并通过真实 Playwright 验收。
  • 触发条件(何时使用):
    • 页面需要 dark/light 切换按钮(所有面向访问者生成的 HTML 页面,遵循
      html-theme-toggle-required
      规则)。
    • 用户说“添加 dark mode”“theme toggle”“切换明暗”“circle reveal”“界面切换效果”。
    • docs-site-macos
      的 § Theme Toggle 指向此处,而不是再次嵌入整套 CSS/JS。
  • 非目标:
    • 不用于判断页面是否“必须”提供 toggle;这由
      html-theme-toggle-required
      规则(
      /fdk
      § Rules)负责。本 skill 只负责决定提供 toggle 后“如何切换”。
    • 不是文档站(那是
      docs-site-macos
      )。

Mental model

心智模型

<nav> có sẵn → JS chèn .theme-row > .lbl + .theme-switch → click (clientX/Y kẹp trong biên nút) → overlay .theme-reveal (nền MODE ĐÍCH) clip-path circle lan + drop-shadow crest trên CHÍNH nó → phủ hết → commit data-theme + localStorage → fade → gỡ overlay
. Reduced-motion → crossfade ≤150ms, không spatial.
<nav>
已存在 → JS 插入
.theme-row > .lbl + .theme-switch
→ click(clientX/Y 限制在按钮边界内)→ overlay
.theme-reveal
(目标 MODE 背景)以 clip-path circle 扩散 + 在其自身上添加 drop-shadow crest → 完全覆盖 → commit
data-theme
+
localStorage
→ fade → 移除 overlay。Reduced-motion → ≤150ms crossfade,不使用空间动效。

Input và output contract

输入与输出契约

FieldRequired?Ý nghĩa
Intrang HTML có
<nav>
(hoặc container tương đương) chứa link điều hướng
cónút gạt tự chèn vào cuối bằng JS
In
<tên-trang>
(khoá localStorage) + màu nền light/dark thật của trang
cóthay vào snippet FOUC + JS (§4)
Intrang không có sidebarkhôngđặt cùng hàng footer của trang, vẫn là nút gạt có nhãn
OutCSS switch + script FOUC + JS reveal + palette dark trung tính trong trangcógiữ nguyên tên class
.theme-row
/
.theme-switch
/
.theme-reveal
Outkết quả
verify-theme-motion.mjs
cóassert overlay đúng toạ độ + màu, commit sau phủ, gỡ DOM, nhánh reduced-motion
字段必填?含义
输入包含
<nav>
(或等效 container)的 HTML 页面,其中有导航链接
是切换按钮由 JS 自动插入到末尾
输入
<tên-trang>
(localStorage 键)+ 页面实际使用的 light/dark 背景色
是用于替换 FOUC + JS(§4)片段中的占位值
输入页面没有 sidebar否将按钮放在页面 footer 的同一行,仍然是带标签的切换按钮
输出页面中的 CSS switch + FOUC script + JS reveal + 中性 dark palette是保持 class 名
.theme-row
/
.theme-switch
/
.theme-reveal
不变
输出
verify-theme-motion.mjs
的执行结果
是断言 overlay 坐标与颜色正确、覆盖后才 commit、移除 DOM,并验证 reduced-motion 分支

Rules và capabilities

规则与能力

  • RULE-01 (MUST): KHÔNG spawn phần tử phụ độc lập cho hiệu ứng gợn — dùng
    filter:drop-shadow
    nhiều lớp trên CHÍNH
    .theme-reveal
    (xem §4, lý do lag + lệch đồng bộ đã đo được).
  • RULE-02 (MUST): KHÔNG
    backdrop-filter
    trên
    .theme-row
    (xem §1) và KHÔNG override
    background
    riêng cho nó ở khối dark-mode.
  • RULE-03 (MUST): Điểm tỏa LUÔN kẹp trong biên nút (
    getBoundingClientRect()
    của
    .theme-switch
    ) — không bao giờ vọt ra ngoài dù con trỏ đang ở đâu trên màn hình lúc bấm.
  • RULE-04 (MUST): Palette dark-mode trung tính (§3) — không thêm màu bão hoà vào nền/viền/chữ nền tảng; accent giữ nguyên một màu duy nhất.
  • RULE-05 (MUST):
    docs-site-macos
    (và mọi skill sinh HTML khác cần toggle) trỏ VÀO skill này thay vì nhúng lại CSS/JS — một nguồn, sửa một chỗ.
  • Capabilities: sửa CSS/JS/markup của trang HTML đích; cài tạm dependency trình duyệt headless và chạy script nghiệm thu.
  • RULE-01(必须):禁止为波纹效果生成独立的额外元素——应在
    .theme-reveal
    自身上使用多层
    filter:drop-shadow
    (见 §4;这是经过实测的卡顿与不同步原因)。
  • RULE-02(必须):不得在
    .theme-row
    上使用
    backdrop-filter
    (见 §1),也不得在 dark-mode 代码块中单独覆盖它的
    background
    。
  • RULE-03(必须):扩散原点始终限制在按钮边界内(使用
    .theme-switch
    的
    getBoundingClientRect()
    )——无论点击时指针位于屏幕何处,都绝不能扩散到按钮外。
  • RULE-04(必须):使用中性 dark-mode palette(§3)——不要向基础背景、边框或文字中加入高饱和度颜色;accent 始终只保留一种颜色。
  • RULE-05(必须):
    docs-site-macos
    (以及其他需要 toggle 的 HTML 生成 skill)应指向本 skill,而不是再次嵌入 CSS/JS——保持单一来源,一处修改即可。
  • 能力:修改目标 HTML 页面的 CSS/JS/markup;临时安装 headless browser 依赖并运行验收脚本。

Failure boundaries

失败边界

  • Trang không có
    <nav>
    /container → script
    return
    sớm, không có toggle → clarify container đặt nút (hoặc hàng footer trang).
  • Script nghiệm thu đỏ (overlay sai toạ độ/màu, commit trước khi phủ, overlay không gỡ, reduced-motion còn
    clip-path
    ) → failed, sửa rồi chạy lại.
  • User chê chậm dù đã cắt duration → clarify/chuyển nhánh instant (v5), không cố hạ TOTAL_MS mãi.
  • Câu hỏi "trang có BẮT BUỘC toggle không" → ngoài phạm vi, theo luật
    html-theme-toggle-required
    .
  • 页面没有
    <nav>
    /container → script 提前
    return
    ,不会生成 toggle → clarify 应使用哪个 container 放置按钮(或使用页面 footer 行)。
  • 验收脚本失败(overlay 坐标/颜色错误、覆盖前就 commit、overlay 未移除、reduced-motion 分支仍存在
    clip-path
    )→ failed,修复后重新运行。
  • 用户表示即使缩短 duration 仍然觉得慢 → clarify/切换到 instant 分支(v5),不要持续降低
    TOTAL_MS
    。
  • 用户询问“页面是否必须提供 toggle” → 不在本 skill 范围内,应遵循
    html-theme-toggle-required
    规则。

HOW

HOW

Main workflow

主流程

StepTypeInputsActionOutputs/exitFailure/next
W01deterministictrangKiểm contract markup: có
<nav>
chứa link
container xác địnhkhông có → B02
W02effecttrang§1 Nút gạt: CSS
.theme-row
sticky,
background:transparent
, không
backdrop-filter
switch CSS—
W03effect
<tên-trang>
§2 Chống FOUC: script đọc localStorage đặt
data-theme
sớm
không nháy—
W04effecttrang§3 Palette dark trung tính (bảng token)token dark—
W05effectmàu nền thật§4 Circle-reveal + crest-glow: CSS
.theme-reveal
+ JS (đổi
<tên-trang>
, 2 màu + 2 tint)
toggle chạyuser đòi instant → B03
W06deterministicfile HTMLNghiệm thu:
node skills/dark-mode-maker/scripts/verify-theme-motion.mjs <file.html>
PASSFAIL → sửa, lặp W06
Chi tiết từng bước (nguồn chân lý cho W01–W06):
步骤类型输入操作输出/退出失败/下一步
W01deterministic页面检查 markup 契约:是否存在包含链接的
<nav>
确定 container不存在 → B02
W02effect页面§1 切换按钮:CSS
.theme-row
sticky,
background:transparent
,不使用
backdrop-filter
switch CSS—
W03effect
<tên-trang>
§2 防止 FOUC:script 读取 localStorage 并尽早设置
data-theme
不闪烁—
W04effect页面§3 中性 dark palette(token 表)dark token—
W05effect实际背景色§4 Circle-reveal + crest-glow:CSS
.theme-reveal
+ JS(更改
<tên-trang>
、2 种颜色 + 2 种 tint)
toggle 可运行用户要求 instant → B03
W06deterministicHTML 文件验收:
node skills/dark-mode-maker/scripts/verify-theme-motion.mjs <file.html>
PASSFAIL → 修复并重复 W06
各步骤详情如下(W01–W06 的事实来源):

Contract (markup tối thiểu)

契约(最小 markup)

Cần một
<nav>
(hoặc container tương đương) chứa link điều hướng — nút gạt tự chèn vào cuối bằng JS, không cần viết tay markup:
html
<nav>…link điều hướng…</nav>
JS tự tạo
.theme-row > .lbl + .theme-switch
, đặt
data-theme
lên
<html>
, lưu
localStorage
. Không đổi tên các class này — script nghiệm thu (
scripts/verify-theme-motion.mjs
) bám đúng vào chúng.
需要一个包含导航链接的
<nav>
(或等效 container)——切换按钮会由 JS 自动插入到末尾,无需手动编写 markup:
html
<nav>…link điều hướng…</nav>
JS 自动创建
.theme-row > .lbl + .theme-switch
,在
<html>
上设置
data-theme
,并保存到
localStorage
。不要修改这些 class 名——验收脚本(
scripts/verify-theme-motion.mjs
)严格依赖它们。

1. Nút gạt (switch)

1. 切换按钮(switch)

Hàng footer dính đáy container cha (
position:sticky
), nhãn trái + switch phải, vách ngăn mảnh phía trên — KHÔNG chip icon rải góc:
css
: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
PHẢI
background:transparent
, KHÔNG
backdrop-filter
riêng (bài học 160926, 2 vòng sai đã đo được).
.theme-row
là con trực tiếp của container cha (
nav
), không phải content-card độc lập:
  • Sai lần 1 — fill riêng: cho nó một màu nền phẳng của riêng nó (kể cả gradient "gần giống" nav) luôn lộ thành khối tách rời khỏi lớp kính phía sau — feedback thật: "div này cho transparency luôn luôn đi chứ".
  • Sai lần 2 —
    background:inherit
    :
    tưởng đã sửa, nhưng
    inherit
    chỉ copy CÔNG THỨC gradient rồi vẽ LẠI trên hộp nhỏ riêng của
    .theme-row
    — không phải lộ đúng pixel cha đã vẽ, vẫn lệch tông.
  • Sai lần 3 — giữ
    backdrop-filter:blur
    "cho chắc":
    dù nền đã transparent thật (đo
    rgba(0,0,0,0)
    ), một lớp blur RIÊNG chồng lên đúng vùng cha-đã-tự-blur tạo dải "kính mờ kép" nhìn như khối riêng dù 0 màu — bắt được bằng ảnh chụp thật, không phải suy đoán.
  • Đúng:
    background:transparent
    , KHÔNG
    backdrop-filter
    trên
    .theme-row
    . Để lộ thẳng pixel cha đã render, không tự vẽ/tự blur gì thêm.
footer 行固定在父 container 底部(
position:sticky
),左侧标签 + 右侧 switch,顶部使用细分隔线——不要使用散落在角落的 icon chip:
css
: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 的教训,经过 2 轮实测错误)。
.theme-row
是父 container(
nav
)的直接子元素,不是独立的 content-card:
  • **错误 1——单独填充背景:**为它设置独立的平面背景色(即使是接近 nav 的 gradient),总会显露成与后方 glass layer 分离的独立块——真实反馈:“这个 div 要一直保持 transparency。”
  • **错误 2——
    background:inherit
    :**以为已经修复,但
    inherit
    只是复制父级的 gradient 公式,然后在
    .theme-row
    自己的小盒子上重新绘制——它并不会直接露出父元素已经绘制的准确像素,因此色调仍会偏移。
  • **错误 3——保留
    backdrop-filter:blur
    “以防万一”:**即使背景确实透明(实测为
    rgba(0,0,0,0)
    ),一层单独的 blur 叠在父元素已经模糊的同一区域上,也会形成像独立块体一样的“双重磨砂玻璃”条带——这是通过真实截图发现的,不是推测。
  • **正确做法:**使用
    background:transparent
    ,不要在
    .theme-row
    上设置
    backdrop-filter
    。直接露出父元素已经渲染的像素,不额外绘制或模糊任何内容。

2. Chống FOUC

2. 防止 FOUC

html
<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>
html
<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. Palette dark-mode — trung tính kiểu thị trường, KHÔNG navy-tinted (bài học 160926)

3. Dark-mode palette——市场风格的中性色,禁止 navy-tinted(160926 的教训)

Bản đầu dùng nền
#0c0f16
+ border
rgba(120,160,220,.18)
(xanh navy đậm) — feedback thật: "chọn màu dark mode... vẫn dở tệ, chọn lại bộ màu theo thị trường đi". Tham chiếu 3 sản phẩm dark-mode được đánh giá tốt nhất hiện nay (GitHub Dark, Vercel/Geist, Linear): nền TRUNG TÍNH gần đen (không ngả xanh), viền TRẮNG TRONG SUỐT (không viền màu bão hoà), giữ ĐÚNG MỘT accent màu (ở đây là xanh hệ thống Apple
#0a84ff
) làm điểm nhấn — không rải màu bão hoà vào chrome/border:
TokenTrước (navy-tinted, SAI)Sau (trung tính, thị trường)
nền gốc
#0c0f16
#09090b
gradient nền, stop 2
#0a0d13
/
#0d1017
#08080a
--glass2
(card)
rgba(30,34,44,.72)
rgba(32,32,36,.72)
--glass3
(bảng/data)
rgba(36,40,52,.9)
rgba(39,39,44,.9)
--border
rgba(120,160,220,.18)
(xanh bão hoà)
rgba(255,255,255,.10)
(trắng trong suốt — đúng cách GitHub/Vercel/Linear vẽ viền trên nền tối)
--t1
(chữ chính)
#e7e9ee
/
#e8eaf0
#f4f4f5
--t2
(chữ phụ)
#9aa2b1
(xám-xanh)
#a1a1aa
(xám trung tính)
nav/container cha
rgba(18,21,28,.82)
hay gradient
rgba(24,30,44,…)
rgba(20,20,23,.82)
hay gradient
rgba(28,28,31,…)
toggle track (on)
#2b3040→#191d27
/
rgba(60,70,110,.55)
#2c2c30→#19191b
/
rgba(58,58,64,.6)
màu nền overlay chuyển mode
#0c0f16
#09090b
Accent (
#0a84ff
, tag 6-màu Apple secondary) giữ nguyên — không phải phần "dở tệ", đó là điểm nhấn có chủ đích. Chỉ nền/viền/chữ trung tính là phần cần trung tính hoá.
初版使用背景
#0c0f16
+ border
rgba(120,160,220,.18)
(深蓝 navy)——真实反馈:“dark mode 的颜色还是很难看,按照市面上的产品重新选一套。” 参考目前评价最高的 3 款 dark-mode 产品(GitHub Dark、Vercel/Geist、Linear):接近黑色的中性背景(不偏蓝)、透明白色边框(不使用高饱和度彩色边框),并只保留一种 accent 色(此处为 Apple system blue
#0a84ff
)作为强调——不把高饱和度颜色散布到 chrome/border 中:
Token之前(navy-tinted,错误)之后(中性,市场风格)
基础背景
#0c0f16
#09090b
gradient 背景,stop 2
#0a0d13
/
#0d1017
#08080a
--glass2
(card)
rgba(30,34,44,.72)
rgba(32,32,36,.72)
--glass3
(表格/data)
rgba(36,40,52,.9)
rgba(39,39,44,.9)
--border
rgba(120,160,220,.18)
(高饱和度蓝色)
rgba(255,255,255,.10)
(透明白色——GitHub/Vercel/Linear 在深色背景上绘制边框的正确方式)
--t1
(主文字)
#e7e9ee
/
#e8eaf0
#f4f4f5
--t2
(次文字)
#9aa2b1
(蓝灰色)
#a1a1aa
(中性灰色)
nav/container 父级
rgba(18,21,28,.82)
或 gradient
rgba(24,30,44,…)
rgba(20,20,23,.82)
或 gradient
rgba(28,28,31,…)
toggle track(on)
#2b3040→#191d27
/
rgba(60,70,110,.55)
#2c2c30→#19191b
/
rgba(58,58,64,.6)
模式切换 overlay 背景色
#0c0f16
#09090b
Accent(
#0a84ff
,Apple secondary 六色色板)保持不变——它不是问题所在,而是有意设置的强调色。只需让背景、边框和文字保持中性。

4. Circle-reveal + crest-glow (REQUIRED) — tỏa từ CON TRỎ, kẹp trong biên nút

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

Nguồn gốc điểm tỏa: vị trí con trỏ chuột lúc bấm, kẹp (clamp) trong biên nút (bài học 160926 — trước tỏa từ tâm nút, cố định): dùng toạ độ click thật (
e.clientX/clientY
), giới hạn trong
getBoundingClientRect()
của nút — bấm lệch về mép trái/phải nút thì điểm tỏa theo đúng mép đó, không vọt ra ngoài nút. Phím Enter/Space (không có toạ độ chuột) rơi về tâm nút.
KHÔNG dùng nhiều vòng gợn độc lập đuổi theo dòng chính (bài học 160926, thiết kế ĐẦU bị bỏ). Bản đầu spawn 3
<div>
viền tròn riêng, tỏa NHANH HƠN + XA HƠN dòng chính bằng
backdrop-filter:blur
riêng từng cái — feedback thật: "hiệu ứng lag quá... sóng bay nhanh hơn cả khối trắng đằng sau? không phải tạo hiệu ứng trên khối trắng đằng sau mới đúng à". Hai lỗi chồng nhau:
  • Lag thật:
    backdrop-filter:blur
    trên nhiều
    <div>
    cỡ lớn (đường kính > 2× bán kính phủ màn hình) mỗi frame là cực tốn GPU — không phải cảm giác, là chi phí render thật.
  • Sai concept: gợn tách rời dòng chính, chạy theo timeline riêng (
    setTimeout
    +
    el.animate()
    độc lập) → không đồng bộ hoàn hảo, đúng như user quan sát "sóng bay nhanh hơn khối trắng".
Đúng:
filter:drop-shadow(...)
nhiều lớp NGAY TRÊN chính phần tử dòng chính
—
drop-shadow
vẽ theo đúng RÌA đã bị
clip-path
cắt (khác
box-shadow
, vốn vẽ theo hộp chữ nhật, không theo hình dạng đã clip). Vì là filter trên CÙNG MỘT phần tử, CÙNG MỘT animation — rìa sáng tự động bám đúng dòng chính mỗi frame, không cần đồng bộ, không cần phần tử phụ, không cần
backdrop-filter
:
css
.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)" */
Ba lớp
drop-shadow
xếp từ rìa sáng sắc nét (viền nước) → quầng trắng mềm (khúc xạ) → quầng màu rộng theo
--reveal-tint
(MODE ĐÍCH) — đọc như một gợn sóng/kính lỏng thật đang cuốn theo rìa nước dâng, không phải sóng bay tách rời.
js
(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()})();
⚠️ Bài học easing 160926 (v1, đã thay ở v2 bên dưới):
cubic-bezier(.3,0,.1,1)
(thiết kế đầu) co gần hết chuyển động trong ~20% thời lượng đầu rồi phẳng lì suốt phần còn lại — ngược hẳn ý định "chậm đầu, nhanh cuối". Đo bằng ảnh chụp thật ở nhiều mốc thời gian mới lộ ra (ở 130/560ms tức 23% thời lượng, overlay đã phủ gần hết màn hình). Đổi sang
cubic-bezier(.42,0,1,1)
(ease-in chuẩn CSS, 560ms) trải đều suốt duration — nhưng vẫn CHẬM ở khoảnh khắc ngay sau click.
⚠️ Bài học easing 160926 (v2, đã thay ở v3 bên dưới): feedback thật sau khi ship v1: "vẫn làm nó có cảm giác bị delay khựng". Root cause KHÔNG phải jank — là ease-in (dù trải đều) vẫn có đạo hàm ~0 ngay tại t=0, nên 100-150ms đầu tiên sau click gần như không thấy overlay nhúc nhích → đọc y hệt "app đứng hình". Đổi sang
cubic-bezier(.16,1,.3,1)
(ease-out expo) + cắt
duration
560→280ms (grow) và 180→70ms (fadeOut) — tổng cảm nhận 740ms → 350ms.
⚠️ Bài học easing 160926 (v3): feedback tiếp: "xong trong nửa giây thôi vẫn lâu quá" + "càng gần hết giờ càng nhanh". Hai tín hiệu: (1) 350ms VẪN bị coi là chậm — user không có ngưỡng "đủ nhanh" cố định, cắt tới khi còn đọc được là một hình tròn lan ra (không phải flash) thì dừng; (2) hình dạng đường cong mong muốn là TĂNG TỐC dần tới lúc phủ hết, không phải ease-out (chậm dần cuối, tạo cảm giác "còn dư việc" ở đoạn cuối). Vì duration đã cực ngắn, phần "chậm đầu" của ease-in không còn đủ thời gian để bị cảm nhận như delay nữa — mâu thuẫn v1-vs-v2 chỉ tồn tại khi duration còn dài. Đổi easing sang
cubic-bezier(.55,0,1,.45)
(ease-in-quad, tăng tốc đều, kết thúc ở tốc độ cao nhất). Quy luật rút ra: duration càng ngắn thì hình dạng easing càng ít quan trọng — ưu tiên cắt duration trước, chỉnh easing sau.
⚠️ Bài học 160926 (v4, cấu trúc): user tiếp tục ép duration xuống dần (350→200→50→20ms) qua nhiều lượt — feedback: "cấu trúc tỉ lệ thôi nhập tối đa time vào thì tự chia lại duration". Thay vì 2 số
duration
rời rạc phải sửa tay mỗi lần, khai MỘT
TOTAL_MS
+
GROW_RATIO
cố định (0.8), grow/fadeOut tự tính theo tỉ lệ — đổi tốc độ chỉ sửa một số. Ở TOTAL_MS=20 (growMs=16, fadeMs=4) vẫn PASS ổn định qua script nghiệm thu (§ bên dưới) — nhưng đây là ranh giới: dưới ~20ms tổng, hiệu ứng "hình tròn lan ra" gần như không còn phân biệt được với việc đổi màu tức thời (flash) bằng mắt thường; hạ thêm chỉ còn ý nghĩa lý thuyết.
⚠️ Bài học 160926 (v5, chốt): sau v4 (TOTAL_MS=20) vẫn bị chê "vẫn lâu quá" → user chốt "làm instance [instant] luôn đi". Với hiệu ứng phản hồi trực tiếp theo click, có một NGƯỠNG NGƯỜI DÙNG THỰC không cố định trước được bằng số — an toàn nhất là hỏi trước khi đầu tư thiết kế reveal cầu kỳ (overlay/clip-path/crest-glow) cho một nút bấm tần suất cao như theme-toggle, thay vì mặc định luôn có hiệu ứng. Khi user đòi "instant": bỏ HẲN overlay +
el.animate
,
flip()
chỉ còn
commit(next)
— không lùi về TOTAL_MS nhỏ dần nữa, xoá luôn CSS
.theme-reveal
nếu không còn phần tử nào tạo class đó. Xem
llmwiki/html/overstack.html
làm ví dụ tham chiếu. Circle-reveal trong skill này vẫn là pattern MẶC ĐỊNH tốt cho landing/showcase ít click — không áp dụng cứng nhắc cho mọi nơi.
⚠️ Bẫy khi viết script nghiệm thu cho duration cực ngắn:
await animation.finished
(Promise) có thể resolve TRƯỚC event
'finish'
— mà
onfinish
(nơi code thật gọi
commit()
) lắng nghe qua event, không qua promise. Đo bằng browser thật (
getAnimations()[0].playState
) mới lộ ra: promise resolve xong mà
data-theme
VẪN chưa đổi. Test phải đợi đúng bằng
addEventListener('finish', ...)
(hoặc kiểm
playState==='finished'
trước) mới đồng bộ đúng thời điểm với
onfinish
thật — xem
scripts/verify-theme-motion.mjs
. Bài học chung: KHÔNG hardcode mốc ms để đoán "animation xong chưa" — dùng API/event thật, càng đúng ở mọi duration.
Trang không có sidebar (landing một cột)? Đặt cùng hàng footer của trang, vẫn là NÚT GẠT có nhãn — tuyệt đối không quay lại chip icon trôi nổi ở góc.
扩散原点:点击时的鼠标指针位置,并限制(clamp)在按钮边界内(160926 的教训——此前固定从按钮中心扩散):使用实际点击坐标(
e.clientX/clientY
),并限制在按钮的
getBoundingClientRect()
范围内——点击按钮偏左或偏右的位置时,扩散点准确落在对应边缘,绝不会扩散到按钮外。Enter/Space(没有鼠标坐标)则回落到按钮中心。
不要使用多个独立 ripple 圆环沿主波纹追赶(160926 的教训,最初设计已被放弃)。初版生成 3 个独立的圆形
<div>
,通过各自的
backdrop-filter:blur
比主流扩散得更快、更远——真实反馈:“效果太卡……波浪竟然比后面的白色块还快?不是应该在后面的白色块上做效果才对吗?” 这里叠加了两个问题:
  • **真实卡顿:**在多个大型
    <div>
    (直径超过屏幕覆盖半径的 2 倍)上使用
    backdrop-filter:blur
    ,每一帧都会消耗大量 GPU——这不是主观感受,而是真实的渲染成本。
  • **概念错误:**独立 ripple 脱离主流运行,拥有自己的时间线(
    setTimeout
    + 独立的
    el.animate()
    )→ 无法完美同步,正如用户观察到的“波浪比白色块跑得更快”。
正确做法:直接在主流元素上使用多层
filter:drop-shadow(...)
——
drop-shadow
会沿已被
clip-path
裁切的准确边缘绘制(不同于
box-shadow
,后者沿矩形盒子绘制,不遵循裁切后的形状)。由于 filter 和 animation 位于同一个元素上,亮边会在每一帧自动贴合主流,无需额外同步、无需子元素,也无需
backdrop-filter
:
css
.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
呈现的宽彩色光晕(目标 MODE)——视觉上像真实的水波/液态玻璃沿水边卷动,而不是脱离主体的波浪。
js
(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()})();
⚠️ Easing 经验 160926(v1,已在下方 v2 中替换):
cubic-bezier(.3,0,.1,1)
(最初设计)将几乎所有运动集中在前约 20% 的 duration 内,余下时间保持完全平缓——与“慢启动、快结束”的预期完全相反。只有在多个时间点截取真实画面,才在 130/560ms(即 duration 的 23%)时发现 overlay 已经几乎覆盖整个屏幕。改用
cubic-bezier(.42,0,1,1)
(标准 CSS ease-in,560ms)后,动作均匀分布在整个 duration 中——但在刚点击后的瞬间仍然显得慢。
⚠️ **Easing 经验 160926(v2,已在下方 v3 中替换):**v1 发布后的真实反馈:“还是有延迟卡顿的感觉。” 根因不是 jank——ease-in(即使分布均匀)在 t=0 时的导数仍约为 0,因此点击后的前 100–150ms 几乎看不到 overlay 移动——体验上就像“应用卡死”。改用
cubic-bezier(.16,1,.3,1)
(ease-out expo),并将 duration 从 560→280ms(grow)、180→70ms(fadeOut)——总感知时长从 740ms 降至 350ms。
⚠️ **Easing 经验 160926(v3):后续反馈:“半秒内完成还是太慢”,并且“越接近结束应该越快”。这里有两个信号:(1)350ms 仍被视为缓慢——用户不存在固定的“足够快”阈值,应持续缩短,直到仍能看出是圆形在扩散(而不是 flash)为止;(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)时,验收脚本仍然稳定 PASS——但这已接近边界:当总时长低于约 20ms 时,扩散圆与立即换色(flash)在肉眼看来几乎没有区别;继续缩短只剩理论意义。
⚠️ **Easing 经验 160926(v5,最终方案):v4(
TOTAL_MS=20
)之后仍被反馈为
“还是太慢”
,于是用户最终决定:“直接做成 instant。” 对于点击后需要立即反馈的效果,用户不存在固定的感知阈值——最安全的做法是,在为 theme-toggle 这类高频点击按钮投入复杂的 reveal、overlay、clip-path、crest-glow 设计之前先询问用户,而不是默认始终启用该效果。当用户要求“instant”时:完全移除 overlay +
el.animate
,
flip()
只保留
commit(next)
——如果没有任何元素再创建该 class,同时删除
.theme-reveal
的 CSS。可参考
llmwiki/html/overstack.html
。本 skill 中的 Circle-reveal 仍是 landing/showcase 等低频点击场景的默认良好方案——不要硬性应用到所有位置。
⚠️ 为极短 duration 编写验收脚本时的陷阱:
await animation.finished
(Promise)可能在
'finish'
event 之前 resolve,而真正调用
commit()
的
onfinish
监听的是 event,不是 Promise。只有在真实浏览器中测量(
getAnimations()[0].playState
)才会暴露:Promise 已 resolve,但
data-theme
仍未改变。测试必须使用
addEventListener('finish', ...)
正确等待(或先检查
playState==='finished'
),才能与真实的
onfinish
时刻同步——见
scripts/verify-theme-motion.mjs
。通用经验:不要硬编码 ms 时间点来猜测“动画是否已经结束”——应使用真实 API/event,这样才能适配所有 duration。
页面没有 sidebar(单栏 landing)?将按钮放在页面 footer 的同一行,仍然是带标签的切换按钮——绝对不要退回角落里漂浮的 icon chip。

Nghiệm thu tự động

自动验收

bash
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>
Assert: overlay
.theme-reveal
xuất hiện đúng toạ độ con trỏ (kẹp trong biên nút) + màu nền MODE ĐÍCH;
data-theme
/
localStorage
đổi SAU khi phủ hết viewport; overlay tự gỡ khỏi DOM sau fade; nhánh
prefers-reduced-motion: reduce
không có
clip-path
lan (crossfade phẳng, không spatial motion).
bash
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>
断言:
.theme-reveal
overlay 在正确的指针坐标出现(限制在按钮边界内),并使用目标 MODE 的背景色;
data-theme
/
localStorage
在完全覆盖 viewport 后才切换;overlay 在 fade 后自动从 DOM 移除;
prefers-reduced-motion: reduce
分支不出现扩散式
clip-path
(采用平面 crossfade,不使用空间动效)。

Branches

分支

IDKindGuardHành viSkip / failureRejoin
B01conditional_required
prefers-reduced-motion: reduce
hoặc không có
el.animate
crossfade ≤150ms, không
clip-path
lan, không crest-glow (fallback trong JS §4)
—W06
B02conditional_requiredtrang không có sidebar (landing một cột)đặt cùng hàng footer của trang, vẫn là NÚT GẠT có nhãn, không chip icon góc—W02
B03user_optionaluser đòi "instant" (bài học v5)bỏ HẲN overlay +
el.animate
,
flip()
chỉ còn
commit(next)
, xoá CSS
.theme-reveal
nếu không còn dùng
—W06
ID类型Guard行为跳过/失败汇合
B01conditional_required
prefers-reduced-motion: reduce
或不存在
el.animate
≤150ms crossfade,不使用扩散式
clip-path
,不显示 crest-glow(JS §4 中的 fallback)
—W06
B02conditional_required页面没有 sidebar(单栏 landing)放在页面 footer 的同一行,仍然是带标签的切换按钮,不使用角落 icon chip—W02
B03user_optional用户要求“instant”(经验 v5)完全移除 overlay +
el.animate
,
flip()
只保留
commit(next)
,如果不再使用则删除
.theme-reveal
CSS
—W06

Validation và stopping

验证与停止条件

Nghiệm thu tất định bằng
scripts/verify-theme-motion.mjs
trên trình duyệt thật (không hardcode mốc ms — chờ event
finish
). Dừng khi script PASS; thẩm mỹ (tông palette, tốc độ cảm nhận) cần user duyệt bằng mắt.
使用
scripts/verify-theme-motion.mjs
在真实浏览器中进行确定性验收(不硬编码 ms 时间点——等待
finish
event)。脚本 PASS 后停止;美学效果(palette 色调、感知速度)需要用户肉眼确认。

Examples

示例

  • Positive: trang showcase
    site/index.html
    có
    <nav>
    sidebar → thêm CSS §1 + FOUC §2 (khoá
    site-theme
    ) + palette §3 + JS §4 →
    node skills/dark-mode-maker/scripts/verify-theme-motion.mjs site/index.html
    PASS: overlay tỏa từ đúng điểm click trong nút,
    data-theme
    đổi sau khi phủ hết, overlay gỡ khỏi DOM.
  • Boundary/failure: giữ
    backdrop-filter:blur
    trên
    .theme-row
    "cho chắc" → ảnh chụp lộ dải kính mờ kép như khối riêng → vi phạm RULE-02, bỏ
    backdrop-filter
    , để
    background:transparent
    .
  • **正向示例:**showcase 页面
    site/index.html
    包含
    <nav>
    sidebar → 添加 CSS §1 + FOUC §2(键为
    site-theme
    )+ palette §3 + JS §4 →
    node skills/dark-mode-maker/scripts/verify-theme-motion.mjs site/index.html
    PASS:overlay 从按钮内正确的点击位置扩散,
    data-theme
    在完全覆盖后才切换,overlay 从 DOM 移除。
  • **边界/失败:**在
    .theme-row
    上保留
    backdrop-filter:blur
    “以防万一”→ 真实截图会显示像独立块体一样的双重磨砂玻璃条带 → 违反 RULE-02,应移除
    backdrop-filter
    并使用
    background:transparent
    。