dark-mode-maker
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSkill: dark-mode-maker
Skill: dark-mode-maker
Module độc lập, tách ra từ (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-macosdocs-site-macosWHAT
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".
- § Theme Toggle trỏ vào đây thay vì nhúng lại toàn bộ CSS/JS.
docs-site-macos
- Trang cần nút chuyển dark/light (mọi trang HTML sinh cho người xem, theo luật
- 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§ Rules), skill này chỉ lo PHẦN "chuyển thế nào" sau khi đã quyết có toggle./fdk - Không phải sàn trang tài liệu (đó là ).
docs-site-macos
- KHÔNG dùng để chọn xem trang có BẮT BUỘC phải có toggle hay không — đó là luật
- **目的:**为 HTML 页面添加一个 dark/light 切换按钮,带有从指针位置扩散的 circle-reveal(限制在按钮边界内)+ crest-glow,采用市场风格的中性 dark palette,防止 FOUC,并通过真实 Playwright 验收。
- 触发条件(何时使用):
- 页面需要 dark/light 切换按钮(所有面向访问者生成的 HTML 页面,遵循 规则)。
html-theme-toggle-required - 用户说“添加 dark mode”“theme toggle”“切换明暗”“circle reveal”“界面切换效果”。
- 的 § Theme Toggle 指向此处,而不是再次嵌入整套 CSS/JS。
docs-site-macos
- 页面需要 dark/light 切换按钮(所有面向访问者生成的 HTML 页面,遵循
- 非目标:
- 不用于判断页面是否“必须”提供 toggle;这由 规则(
html-theme-toggle-required§ Rules)负责。本 skill 只负责决定提供 toggle 后“如何切换”。/fdk - 不是文档站(那是 )。
docs-site-macos
- 不用于判断页面是否“必须”提供 toggle;这由
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<nav>.theme-row > .lbl + .theme-switch.theme-revealdata-themelocalStorageInput và output contract
输入与输出契约
| Field | Required? | Ý nghĩa | |
|---|---|---|---|
| In | trang HTML có | có | nút gạt tự chèn vào cuối bằng JS |
| In | | có | thay vào snippet FOUC + JS (§4) |
| In | trang không có sidebar | không | đặt cùng hàng footer của trang, vẫn là nút gạt có nhãn |
| Out | CSS switch + script FOUC + JS reveal + palette dark trung tính trong trang | có | giữ nguyên tên class |
| Out | kết quả | có | assert overlay đúng toạ độ + màu, commit sau phủ, gỡ DOM, nhánh reduced-motion |
| 字段 | 必填? | 含义 | |
|---|---|---|---|
| 输入 | 包含 | 是 | 切换按钮由 JS 自动插入到末尾 |
| 输入 | | 是 | 用于替换 FOUC + JS(§4)片段中的占位值 |
| 输入 | 页面没有 sidebar | 否 | 将按钮放在页面 footer 的同一行,仍然是带标签的切换按钮 |
| 输出 | 页面中的 CSS switch + FOUC script + JS reveal + 中性 dark palette | 是 | 保持 class 名 |
| 输出 | | 是 | 断言 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 nhiều lớp trên CHÍNH
filter:drop-shadow(xem §4, lý do lag + lệch đồng bộ đã đo được)..theme-reveal - RULE-02 (MUST): KHÔNG trên
backdrop-filter(xem §1) và KHÔNG override.theme-rowriêng cho nó ở khối dark-mode.background - RULE-03 (MUST): Điểm tỏa LUÔN kẹp trong biên nút (của
getBoundingClientRect()) — không bao giờ vọt ra ngoài dù con trỏ đang ở đâu trên màn hình lúc bấm..theme-switch - 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): (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ỗ.
docs-site-macos - 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(见 §4;这是经过实测的卡顿与不同步原因)。filter:drop-shadow - RULE-02(必须):不得在 上使用
.theme-row(见 §1),也不得在 dark-mode 代码块中单独覆盖它的backdrop-filter。background - RULE-03(必须):扩散原点始终限制在按钮边界内(使用 的
.theme-switch)——无论点击时指针位于屏幕何处,都绝不能扩散到按钮外。getBoundingClientRect() - RULE-04(必须):使用中性 dark-mode palette(§3)——不要向基础背景、边框或文字中加入高饱和度颜色;accent 始终只保留一种颜色。
- RULE-05(必须):(以及其他需要 toggle 的 HTML 生成 skill)应指向本 skill,而不是再次嵌入 CSS/JS——保持单一来源,一处修改即可。
docs-site-macos - 能力:修改目标 HTML 页面的 CSS/JS/markup;临时安装 headless browser 依赖并运行验收脚本。
Failure boundaries
失败边界
- Trang không có /container → script
<nav>sớm, không có toggle → clarify container đặt nút (hoặc hàng footer trang).return - Script nghiệm thu đỏ (overlay sai toạ độ/màu, commit trước khi phủ, overlay không gỡ, reduced-motion còn ) → failed, sửa rồi chạy lại.
clip-path - 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
- 页面没有 /container → script 提前
<nav>,不会生成 toggle → clarify 应使用哪个 container 放置按钮(或使用页面 footer 行)。return - 验收脚本失败(overlay 坐标/颜色错误、覆盖前就 commit、overlay 未移除、reduced-motion 分支仍存在 )→ failed,修复后重新运行。
clip-path - 用户表示即使缩短 duration 仍然觉得慢 → clarify/切换到 instant 分支(v5),不要持续降低 。
TOTAL_MS - 用户询问“页面是否必须提供 toggle” → 不在本 skill 范围内,应遵循 规则。
html-theme-toggle-required
HOW
HOW
Main workflow
主流程
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | deterministic | trang | Kiểm contract markup: có | container xác định | không có → B02 |
| W02 | effect | trang | §1 Nút gạt: CSS | switch CSS | — |
| W03 | effect | | §2 Chống FOUC: script đọc localStorage đặt | không nháy | — |
| W04 | effect | trang | §3 Palette dark trung tính (bảng token) | token dark | — |
| W05 | effect | màu nền thật | §4 Circle-reveal + crest-glow: CSS | toggle chạy | user đòi instant → B03 |
| W06 | deterministic | file HTML | Nghiệm thu: | PASS | FAIL → sửa, lặp W06 |
Chi tiết từng bước (nguồn chân lý cho W01–W06):
| 步骤 | 类型 | 输入 | 操作 | 输出/退出 | 失败/下一步 |
|---|---|---|---|---|---|
| W01 | deterministic | 页面 | 检查 markup 契约:是否存在包含链接的 | 确定 container | 不存在 → B02 |
| W02 | effect | 页面 | §1 切换按钮:CSS | switch CSS | — |
| W03 | effect | | §2 防止 FOUC:script 读取 localStorage 并尽早设置 | 不闪烁 | — |
| W04 | effect | 页面 | §3 中性 dark palette(token 表) | dark token | — |
| W05 | effect | 实际背景色 | §4 Circle-reveal + crest-glow:CSS | toggle 可运行 | 用户要求 instant → B03 |
| W06 | deterministic | HTML 文件 | 验收: | PASS | FAIL → 修复并重复 W06 |
各步骤详情如下(W01–W06 的事实来源):
Contract (markup tối thiểu)
契约(最小 markup)
Cần một (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:
<nav>html
<nav>…link điều hướng…</nav>JS tự tạo , đặt lên , lưu . Không đổi tên các class này — script nghiệm thu () bám đúng vào chúng.
.theme-row > .lbl + .theme-switchdata-theme<html>localStoragescripts/verify-theme-motion.mjs需要一个包含导航链接的 (或等效 container)——切换按钮会由 JS 自动插入到末尾,无需手动编写 markup:
<nav>html
<nav>…link điều hướng…</nav>JS 自动创建 ,在 上设置 ,并保存到 。不要修改这些 class 名——验收脚本()严格依赖它们。
.theme-row > .lbl + .theme-switch<html>data-themelocalStoragescripts/verify-theme-motion.mjs1. Nút gạt (switch)
1. 切换按钮(switch)
Hàng footer dính đáy container cha (), 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:
position:stickycss
: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…}⚠️ PHẢI , KHÔNG riêng (bài học 160926, 2 vòng sai đã đo được). là con trực tiếp của container cha (), không phải content-card độc lập:
.theme-rowbackground:transparentbackdrop-filter.theme-rownav- 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 — : tưởng đã sửa, nhưng
background:inheritchỉ copy CÔNG THỨC gradient rồi vẽ LẠI trên hộp nhỏ riêng củainherit— không phải lộ đúng pixel cha đã vẽ, vẫn lệch tông..theme-row - Sai lần 3 — giữ "cho chắc": dù nền đã transparent thật (đo
backdrop-filter:blur), 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.rgba(0,0,0,0) - Đúng: , KHÔNG
background:transparenttrênbackdrop-filter. Để lộ thẳng pixel cha đã render, không tự vẽ/tự blur gì thêm..theme-row
footer 行固定在父 container 底部(),左侧标签 + 右侧 switch,顶部使用细分隔线——不要使用散落在角落的 icon chip:
position:stickycss
: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…}⚠️ 必须使用 ,不得单独设置 (160926 的教训,经过 2 轮实测错误)。 是父 container()的直接子元素,不是独立的 content-card:
.theme-rowbackground:transparentbackdrop-filter.theme-rownav- **错误 1——单独填充背景:**为它设置独立的平面背景色(即使是接近 nav 的 gradient),总会显露成与后方 glass layer 分离的独立块——真实反馈:“这个 div 要一直保持 transparency。”
- **错误 2——:**以为已经修复,但
background:inherit只是复制父级的 gradient 公式,然后在inherit自己的小盒子上重新绘制——它并不会直接露出父元素已经绘制的准确像素,因此色调仍会偏移。.theme-row - **错误 3——保留 “以防万一”:**即使背景确实透明(实测为
backdrop-filter:blur),一层单独的 blur 叠在父元素已经模糊的同一区域上,也会形成像独立块体一样的“双重磨砂玻璃”条带——这是通过真实截图发现的,不是推测。rgba(0,0,0,0) - **正确做法:**使用 ,不要在
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 + border (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 ) làm điểm nhấn — không rải màu bão hoà vào chrome/border:
#0c0f16rgba(120,160,220,.18)#0a84ff| Token | Trước (navy-tinted, SAI) | Sau (trung tính, thị trường) |
|---|---|---|
| nền gốc | | |
| gradient nền, stop 2 | | |
| | |
| | |
| | |
| | |
| | |
| nav/container cha | | |
| toggle track (on) | | |
| màu nền overlay chuyển mode | | |
Accent (, 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á.
#0a84ff初版使用背景 + border (深蓝 navy)——真实反馈:“dark mode 的颜色还是很难看,按照市面上的产品重新选一套。” 参考目前评价最高的 3 款 dark-mode 产品(GitHub Dark、Vercel/Geist、Linear):接近黑色的中性背景(不偏蓝)、透明白色边框(不使用高饱和度彩色边框),并只保留一种 accent 色(此处为 Apple system blue )作为强调——不把高饱和度颜色散布到 chrome/border 中:
#0c0f16rgba(120,160,220,.18)#0a84ff| Token | 之前(navy-tinted,错误) | 之后(中性,市场风格) |
|---|---|---|
| 基础背景 | | |
| gradient 背景,stop 2 | | |
| | |
| | |
| | |
| | |
| | |
| nav/container 父级 | | |
| toggle track(on) | | |
| 模式切换 overlay 背景色 | | |
Accent(,Apple secondary 六色色板)保持不变——它不是问题所在,而是有意设置的强调色。只需让背景、边框和文字保持中性。
#0a84ff4. 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 (), giới hạn trong 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.
e.clientX/clientYgetBoundingClientRect()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 viền tròn riêng, tỏa NHANH HƠN + XA HƠN dòng chính bằng 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:
<div>backdrop-filter:blur- Lag thật: trên nhiều
backdrop-filter:blurcỡ 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.<div> - Sai concept: gợn tách rời dòng chính, chạy theo timeline riêng (+
setTimeoutđộ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".el.animate()
Đúng: nhiều lớp NGAY TRÊN chính phần tử dòng chính — vẽ theo đúng RÌA đã bị cắt (khác , 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 :
filter:drop-shadow(...)drop-shadowclip-pathbox-shadowbackdrop-filtercss
.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 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 (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.
drop-shadow--reveal-tintjs
(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): (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 (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.
cubic-bezier(.3,0,.1,1)cubic-bezier(.42,0,1,1)⚠️ 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 (ease-out expo) + cắt 560→280ms (grow) và 180→70ms (fadeOut) — tổng cảm nhận 740ms → 350ms.
cubic-bezier(.16,1,.3,1)duration⚠️ 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 (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.
cubic-bezier(.55,0,1,.45)⚠️ 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ố rời rạc phải sửa tay mỗi lần, khai MỘT + 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.
durationTOTAL_MSGROW_RATIO⚠️ 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 + , chỉ còn — không lùi về TOTAL_MS nhỏ dần nữa, xoá luôn CSS nếu không còn phần tử nào tạo class đó. Xem 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.
el.animateflip()commit(next).theme-revealllmwiki/html/overstack.html⚠️ Bẫy khi viết script nghiệm thu cho duration cực ngắn: (Promise) có thể resolve TRƯỚC event — mà (nơi code thật gọi ) lắng nghe qua event, không qua promise. Đo bằng browser thật () mới lộ ra: promise resolve xong mà VẪN chưa đổi. Test phải đợi đúng bằng (hoặc kiểm trước) mới đồng bộ đúng thời điểm với thật — xem . 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.
await animation.finished'finish'onfinishcommit()getAnimations()[0].playStatedata-themeaddEventListener('finish', ...)playState==='finished'onfinishscripts/verify-theme-motion.mjsTrang 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 的教训——此前固定从按钮中心扩散):使用实际点击坐标(),并限制在按钮的 范围内——点击按钮偏左或偏右的位置时,扩散点准确落在对应边缘,绝不会扩散到按钮外。Enter/Space(没有鼠标坐标)则回落到按钮中心。
e.clientX/clientYgetBoundingClientRect()不要使用多个独立 ripple 圆环沿主波纹追赶(160926 的教训,最初设计已被放弃)。初版生成 3 个独立的圆形 ,通过各自的 比主流扩散得更快、更远——真实反馈:“效果太卡……波浪竟然比后面的白色块还快?不是应该在后面的白色块上做效果才对吗?” 这里叠加了两个问题:
<div>backdrop-filter:blur- **真实卡顿:**在多个大型 (直径超过屏幕覆盖半径的 2 倍)上使用
<div>,每一帧都会消耗大量 GPU——这不是主观感受,而是真实的渲染成本。backdrop-filter:blur - **概念错误:**独立 ripple 脱离主流运行,拥有自己的时间线(+ 独立的
setTimeout)→ 无法完美同步,正如用户观察到的“波浪比白色块跑得更快”。el.animate()
正确做法:直接在主流元素上使用多层 —— 会沿已被 裁切的准确边缘绘制(不同于 ,后者沿矩形盒子绘制,不遵循裁切后的形状)。由于 filter 和 animation 位于同一个元素上,亮边会在每一帧自动贴合主流,无需额外同步、无需子元素,也无需 :
filter:drop-shadow(...)drop-shadowclip-pathbox-shadowbackdrop-filtercss
.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)" */三层 依次从锐利亮边(水边)→柔和白色光晕(散射)→按 呈现的宽彩色光晕(目标 MODE)——视觉上像真实的水波/液态玻璃沿水边卷动,而不是脱离主体的波浪。
drop-shadow--reveal-tintjs
(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 中替换):(最初设计)将几乎所有运动集中在前约 20% 的 duration 内,余下时间保持完全平缓——与“慢启动、快结束”的预期完全相反。只有在多个时间点截取真实画面,才在 130/560ms(即 duration 的 23%)时发现 overlay 已经几乎覆盖整个屏幕。改用 (标准 CSS ease-in,560ms)后,动作均匀分布在整个 duration 中——但在刚点击后的瞬间仍然显得慢。
cubic-bezier(.3,0,.1,1)cubic-bezier(.42,0,1,1)⚠️ **Easing 经验 160926(v2,已在下方 v3 中替换):**v1 发布后的真实反馈:“还是有延迟卡顿的感觉。” 根因不是 jank——ease-in(即使分布均匀)在 t=0 时的导数仍约为 0,因此点击后的前 100–150ms 几乎看不到 overlay 移动——体验上就像“应用卡死”。改用 (ease-out expo),并将 duration 从 560→280ms(grow)、180→70ms(fadeOut)——总感知时长从 740ms 降至 350ms。
cubic-bezier(.16,1,.3,1)⚠️ **Easing 经验 160926(v3):后续反馈:“半秒内完成还是太慢”,并且“越接近结束应该越快”。这里有两个信号:(1)350ms 仍被视为缓慢——用户不存在固定的“足够快”阈值,应持续缩短,直到仍能看出是圆形在扩散(而不是 flash)为止;(2)期望的曲线是逐渐加速直至完全覆盖,而不是 ease-out(末尾减速会产生“还没做完”的感觉)。由于 duration 已经非常短,ease-in 的慢启动阶段已短到不会被感知为延迟——v1 与 v2 的矛盾仅在 duration 较长时才成立。将 easing 改为 (ease-in-quad,均匀加速,并以最高速度结束)。**推导出的规则:**duration 越短,easing 曲线的的重要性越低——优先缩短 duration,再调整 easing。
cubic-bezier(.55,0,1,.45)⚠️ **Easing 经验 160926(v4,结构):**用户在多轮反馈中持续要求缩短 duration(350→200→50→20ms)——反馈为:“结构上使用比例即可,输入最大时间后自动重新分配 duration。” 与其每次手动修改两个独立的 数值,不如声明一个 + 固定的 (0.8),由 grow/fadeOut 按比例自动计算——调整速度时只需修改一个数值。在 (growMs=16,fadeMs=4)时,验收脚本仍然稳定 PASS——但这已接近边界:当总时长低于约 20ms 时,扩散圆与立即换色(flash)在肉眼看来几乎没有区别;继续缩短只剩理论意义。
durationTOTAL_MSGROW_RATIOTOTAL_MS=20⚠️ **Easing 经验 160926(v5,最终方案):v4()之后仍被反馈为“还是太慢”,于是用户最终决定:“直接做成 instant。” 对于点击后需要立即反馈的效果,用户不存在固定的感知阈值——最安全的做法是,在为 theme-toggle 这类高频点击按钮投入复杂的 reveal、overlay、clip-path、crest-glow 设计之前先询问用户,而不是默认始终启用该效果。当用户要求“instant”时:完全移除 overlay + , 只保留 ——如果没有任何元素再创建该 class,同时删除 的 CSS。可参考 。本 skill 中的 Circle-reveal 仍是 landing/showcase 等低频点击场景的默认良好方案——不要硬性应用到所有位置。
TOTAL_MS=20el.animateflip()commit(next).theme-revealllmwiki/html/overstack.html⚠️ 为极短 duration 编写验收脚本时的陷阱:(Promise)可能在 event 之前 resolve,而真正调用 的 监听的是 event,不是 Promise。只有在真实浏览器中测量()才会暴露:Promise 已 resolve,但 仍未改变。测试必须使用 正确等待(或先检查 ),才能与真实的 时刻同步——见 。通用经验:不要硬编码 ms 时间点来猜测“动画是否已经结束”——应使用真实 API/event,这样才能适配所有 duration。
await animation.finished'finish'commit()onfinishgetAnimations()[0].playStatedata-themeaddEventListener('finish', ...)playState==='finished'onfinishscripts/verify-theme-motion.mjs页面没有 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 xuất hiện đúng toạ độ con trỏ (kẹp trong biên nút) + màu nền MODE ĐÍCH; / đổi SAU khi phủ hết viewport; overlay tự gỡ khỏi DOM sau fade; nhánh không có lan (crossfade phẳng, không spatial motion).
.theme-revealdata-themelocalStorageprefers-reduced-motion: reduceclip-pathbash
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 在正确的指针坐标出现(限制在按钮边界内),并使用目标 MODE 的背景色;/ 在完全覆盖 viewport 后才切换;overlay 在 fade 后自动从 DOM 移除; 分支不出现扩散式 (采用平面 crossfade,不使用空间动效)。
.theme-revealdata-themelocalStorageprefers-reduced-motion: reduceclip-pathBranches
分支
| ID | Kind | Guard | Hành vi | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | conditional_required | | crossfade ≤150ms, không | — | W06 |
| B02 | conditional_required | 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, không chip icon góc | — | W02 |
| B03 | user_optional | user đòi "instant" (bài học v5) | bỏ HẲN overlay + | — | W06 |
| ID | 类型 | Guard | 行为 | 跳过/失败 | 汇合 |
|---|---|---|---|---|---|
| B01 | conditional_required | | ≤150ms crossfade,不使用扩散式 | — | W06 |
| B02 | conditional_required | 页面没有 sidebar(单栏 landing) | 放在页面 footer 的同一行,仍然是带标签的切换按钮,不使用角落 icon chip | — | W02 |
| B03 | user_optional | 用户要求“instant”(经验 v5) | 完全移除 overlay + | — | W06 |
Validation và stopping
验证与停止条件
Nghiệm thu tất định bằng trên trình duyệt thật (không hardcode mốc ms — chờ event ). 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.mjsfinish使用 在真实浏览器中进行确定性验收(不硬编码 ms 时间点——等待 event)。脚本 PASS 后停止;美学效果(palette 色调、感知速度)需要用户肉眼确认。
scripts/verify-theme-motion.mjsfinishExamples
示例
- Positive: trang showcase có
site/index.htmlsidebar → thêm CSS §1 + FOUC §2 (khoá<nav>) + palette §3 + JS §4 →site-themePASS: overlay tỏa từ đúng điểm click trong nút,node skills/dark-mode-maker/scripts/verify-theme-motion.mjs site/index.htmlđổi sau khi phủ hết, overlay gỡ khỏi DOM.data-theme - Boundary/failure: giữ trên
backdrop-filter:blur"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ỏ.theme-row, đểbackdrop-filter.background:transparent
- **正向示例:**showcase 页面 包含
site/index.htmlsidebar → 添加 CSS §1 + FOUC §2(键为<nav>)+ palette §3 + JS §4 →site-themePASS:overlay 从按钮内正确的点击位置扩散,node skills/dark-mode-maker/scripts/verify-theme-motion.mjs site/index.html在完全覆盖后才切换,overlay 从 DOM 移除。data-theme - **边界/失败:**在 上保留
.theme-row“以防万一”→ 真实截图会显示像独立块体一样的双重磨砂玻璃条带 → 违反 RULE-02,应移除backdrop-filter:blur并使用backdrop-filter。background:transparent