dark-mode-maker について
HTML ページにライト/ダーク切り替えを追加するスキルです。円形リビール、ニュートラルなダークパレット、FOUC 対策を備え、Playwright で位置やテーマ反映のタイミング、動きを減らす設定を検証します。
主な機能
- 01テーマ切り替えコントロールの自動挿入
- 02円形リビールとエッジグロー効果
- 03localStorage のテーマ値による FOUC 防止
- 04ニュートラルなダークパレット
- 05Playwright によるアニメーション検証スクリプト
- 06動きを減らす設定向けの遷移
活用シーン
- 01HTML ページへのライト/ダーク切り替え追加
- 02クリック位置から始まるテーマ切り替え演出
- 03オーバーレイ位置とテーマ反映時刻の検証
- 04動きを減らす設定での切り替え動作確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ rheinmir/ setup --skill dark-mode-makerインストール後、Claude Code で/dark-mode-makerを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
scripts/package-lock.jsonpackage.jsonverify-theme-motion.mjs詳細
スキル:dark-mode-maker
docs-site-macos から切り出した独立モジュールです(フィードバック 160926:「ダーク/ライトモードの効果を丸ごと独立したスキルにする」)。ダーク/ライト切り替えボタンが必要なあらゆる HTML ページで使えます。docs-site-macos のページに限りません。
WHAT
目的とコンテキスト
- 目的: HTML ページにダーク/ライト切り替えボタンを追加します。円形のリビール効果はポインター位置から広がり(ボタンの境界内に制限)、クレストグローを伴います。市場で一般的なニュートラル系のダークパレットを使い、FOUC を防止し、実際の Playwright で検収します。
- 使用するタイミング(トリガー):
html-theme-toggle-requiredルールに従って、閲覧者向けに生成されたすべての HTML ページで、ダーク/ライト切り替えボタンが必要な場合。- ユーザーが「ダークモードにして」「テーマ切り替え」「明暗切り替え」「円形リビール」「テーマ変更エフェクト」と依頼した場合。
docs-site-macos§ Theme Toggle が、CSS/JS 全体を再埋め込みする代わりに、ここを参照する場合。
- 対象外:
- ページにトグルが必須かどうかの判断には使いません。それは
html-theme-toggle-required(/fdk§ Rules)のルールです。このスキルが扱うのは、トグルを設置すると決めた後の「切り替え方法」だけです。 - ドキュメントサイトを作るスキルではありません(それは
docs-site-macosです)。
- ページにトグルが必須かどうかの判断には使いません。それは
メンタルモデル
<nav> が既存 → JS で .theme-row > .lbl + .theme-switch を挿入 → クリック(clientX/Y をボタン境界内に制限)→ overlay .theme-reveal(切り替え先 MODE の背景色)に clip-path circle で広がる + その本体に crest の drop-shadow → 全体を覆う → data-theme + localStorage を commit → fade → overlay を削除。Reduced-motion の場合は ≤150ms のクロスフェードを使い、空間的な動きは加えません。
入力と出力の契約
| Field | Required? | 意味 | |
|---|---|---|---|
| In | <nav>(または同等のコンテナ)にナビゲーションリンクが含まれる HTML ページ | はい | JS がトグルボタンを末尾に自動挿入します |
| In | <ページ名>(localStorage のキー)+ ページのライト/ダークそれぞれの実際の背景色 | はい | FOUC スニペットと JS (§4) に設定します |
| In | サイドバーのないページ | いいえ | ページのフッター行に配置します。ラベル付きのトグルボタンであることは変わりません |
| Out | ページ内の CSS スイッチ + FOUC スクリプト + JS リビール + ニュートラル系ダークパレット | はい | .theme-row/.theme-switch/.theme-reveal のクラス名を変更しないこと |
| Out | verify-theme-motion.mjs の実行結果 | はい | overlay の座標と色、全体を覆った後の commit、DOM からの削除、reduced-motion 分岐を assert します |
ルールと機能
- RULE-01(MUST):波紋エフェクト用に独立した補助要素を生成しません。
.theme-reveal自体に複数レイヤーのfilter:drop-shadowを使います(§4 を参照。遅延と同期ずれの理由は計測済みです)。 - RULE-02(MUST):
.theme-rowにbackdrop-filterを使わず(§1 を参照)、ダークモードのブロックで個別にbackgroundを上書きしません。 - RULE-03(MUST):エフェクトの起点は常にボタンの境界内(
.theme-switchのgetBoundingClientRect())に制限します。クリック時に画面上のどこにポインターがあっても、境界の外にはみ出しません。 - RULE-04(MUST):ダークモードにはニュートラル系のパレットを使います(§3)。背景/境界線/基本テキストに彩度の高い色を加えず、アクセントカラーは一色だけに保ちます。
- RULE-05(MUST):
docs-site-macos(およびトグルが必要な、HTML を生成するすべてのスキル)は、CSS/JS を再埋め込みする代わりに、このスキルを参照します。情報源を一つにし、修正箇所も一つにします。 - 機能:対象 HTML ページの CSS/JS/マークアップを編集し、ヘッドレスブラウザーの依存関係を一時インストールして、検収スクリプトを実行できます。
失敗時の境界
- ページに
<nav>/コンテナがない場合:スクリプトは早期にreturnし、トグルは追加されません。コンテナまたはページのフッター行のどこにボタンを配置するか、確認してください。 - 検収スクリプトが失敗した場合(overlay の座標/色が誤っている、全体を覆う前に commit する、overlay が削除されない、reduced-motion で
clip-pathが残る):失敗です。修正して再実行してください。 - duration を短縮してもユーザーが遅いと感じる場合:確認するか、instant 分岐(v5)に切り替えてください。TOTAL_MS を際限なく短縮しないでください。
- 「このページにトグルは必須ですか」という質問:対象外です。
html-theme-toggle-requiredのルールに従ってください。
HOW
メインワークフロー
| ステップ | 種別 | 入力 | アクション | 出力/終了 | 失敗時/次のステップ |
|---|---|---|---|---|---|
| W01 | 決定論的 | ページ | マークアップの契約を確認します:リンクを含む <nav> があること | コンテナを特定 | ない場合 → B02 |
| W02 | 効果 | ページ | §1 スイッチ:CSS .theme-row を sticky にし、background:transparent、backdrop-filter は設定しません | スイッチ CSS | — |
| W03 | 効果 | <ページ名> | §2 FOUC 対策:スクリプトが localStorage を読み取り、早い段階で data-theme を設定します | フラッシュなし | — |
| W04 | 効果 | ページ | §3 ニュートラルなダークパレット(トークン表) | ダークトークン | — |
| W05 | 効果 | 実際の背景色 | §4 Circle-reveal + crest-glow:CSS .theme-reveal + JS(<ページ名> を変更、2 色 + 2 tint) | トグルが動作 | ユーザーが瞬時の切り替えを求める場合 → B03 |
| W06 | 決定論的 | HTML ファイル | 受け入れ確認:node skills/dark-mode-maker/scripts/verify-theme-motion.mjs <file.html> | PASS | FAIL → 修正して W06 を繰り返す |
各ステップの詳細(W01~W06 の信頼できる情報源):
契約(最小限のマークアップ)
ナビゲーションリンクを含む <nav>(または同等のコンテナ)が必要です。スイッチは JS で末尾に自動挿入されるため、マークアップを手動で記述する必要はありません:
<nav>…link điều hướng…</nav>
JS が .theme-row > .lbl + .theme-switch を自動生成し、<html> に data-theme を設定して、localStorage に保存します。これらのクラス名は変更しないでください。受け入れ確認スクリプト(scripts/verify-theme-motion.mjs)は、これらのクラス名を参照します。
1. スイッチ
フッターの行は親コンテナの底に固定します(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 の教訓。2 回の誤りを計測済み)。 .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 ひとつだけを強調用に維持する構成にします。クロームやボーダーに彩度の高い色を散りばめません。
| トークン | 変更前(ネイビー寄り、誤り) | 変更後(ニュートラル、市場標準) |
|---|---|---|
| ベース背景 | #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) |
--border | rgba(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. 円形リビール+クレストグロー(必須)— ポインターから広がり、ボタンの境界内に収める
広がりの起点:クリック時のマウスポインター位置。ボタンの境界内にクランプします(教訓 160926 — 以前はボタンの中心から固定で広がっていました)。実際のクリック座標(e.clientX/clientY)を使い、ボタンの getBoundingClientRect() の範囲内に制限します。ボタンの左右どちらかの端をクリックした場合は、その端から広がり、ボタンの外にはみ出しません。Enter/Spaceキー(マウス座標がない場合)はボタンの中心を起点にします。
メインの波に追従する独立した複数の波紋は使いません(教訓 160926、初期デザインは不採用)。 初期版では円形ボーダーの <div> を3つ個別に生成し、それぞれに backdrop-filter:blur を適用して、メインの波より速く、遠くまで広げていました。実際のフィードバックは次のとおりです。「エフェクトが重すぎる……波が後ろの白い塊より速く飛んでいる? 後ろの白い塊にエフェクトをかけるべきでは?」。問題が2つ重なっていました。
- 実際の遅延: 大きな
<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)" */
3層の 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標準のイーズイン、560ms)に変更すると、duration全体に動きが分散します。ただし、クリック直後はまだゆっくりです。
⚠️ イージングの教訓 160926(v2、下記のv3で置き換え済み): v1をリリースした後の実際のフィードバック:「まだ引っかかるような遅延感がある」。根本原因はジャンクではありません。均等に広がるイーズインでも、t=0での微分値はほぼ0のため、クリック後の最初の100~150msはオーバーレイがほとんど動いて見えず、「アプリがフリーズした」と感じられるのです。cubic-bezier(.16,1,.3,1)(イーズアウト・エクスポ)に変更し、durationを560→280ms(拡大)、180→70ms(フェードアウト)に短縮しました。体感時間は740msから350msになりました。
⚠️ イージングの教訓 160926(v3): 続くフィードバック:「0.5秒で終わるとしても、まだ長すぎる」、「終わりに近づくほど速くなる」。ここから2つの点が分かりました。(1)350msでも遅いとみなされます。ユーザーにとっての「十分に速い」時間に固定のしきい値はありません。円が広がる様子として認識でき(フラッシュではない)る限界まで短縮し、そこで止めます。(2)求められている曲線は、画面全体を覆うまで徐々に加速する形であり、イーズアウト(終盤に減速し、「まだ作業が残っている」ように感じさせる形)ではありません。durationがすでに極めて短いため、イーズインの「遅い序盤」を遅延として感じるだけの時間がありません。つまり、v1とv2の矛盾はdurationが長い場合にのみ生じます。イージングをcubic-bezier(.55,0,1,.45)(イーズイン・クアドラティック、均等に加速し、最高速度で終了)に変更しました。得られたルール: durationが短いほどイージングの形状は重要ではなくなります。まずdurationを短縮し、その後でイージングを調整してください。
⚠️ 教訓 160926(v4、構成): ユーザーは複数回にわたってdurationの短縮を求めました(350→200→50→20ms)。フィードバック:「比率で構成して、最大時間を入力したらdurationを自動で振り分ける」。毎回手動で修正しなければならない2つの個別のduration値ではなく、単一のTOTAL_MSと固定のGROW_RATIO(0.8)を宣言し、grow/fadeOutの時間を比率から自動計算します。速度の変更は1つの値を修正するだけです。TOTAL_MS=20(growMs=16、fadeMs=4)でも、下記の受け入れスクリプトで安定してPASSしました。ただし、ここが限界です。合計時間が約20msを下回ると、「円が広がる」効果は肉眼ではほぼ瞬時の色変化(フラッシュ)と区別できません。それ以上短くしても、理論上の意味しかありません。
⚠️ 教訓 160926(v5、最終決定): v4(TOTAL_MS=20)の後も*「まだ長すぎる」と指摘され、ユーザーは「常にインスタントにして」*と決定しました。クリックに直接反応する効果には、数値で事前に定められない現実のユーザーしきい値があります。theme-toggleのような高頻度で使うボタンに対して、オーバーレイ/clip-path/crest-glowを使った凝ったリビール効果を設計する前に、ユーザーに確認するのが最も安全です。常に効果を付けると決めつけないでください。ユーザーが「インスタント」を求めた場合は、オーバーレイとel.animateを完全に削除し、flip()はcommit(next)のみを実行するようにします。TOTAL_MSをさらに小さくする方法には戻らず、そのクラスを付与する要素がなくなる場合はCSSの.theme-revealも削除します。参考例としてllmwiki/html/overstack.htmlを参照してください。このスキルの円形リビールは、クリック頻度の低いランディングページやショーケースでは引き続き有用なデフォルトパターンですが、どこにでも一律に適用しないでください。
⚠️ 極短durationの受け入れスクリプトを書く際の落とし穴: await animation.finished(Promise)は、イベントの'finish'より先にresolveされることがあります。実際のコードがcommit()を呼び出すのはonfinishであり、Promiseではなくイベントを介して待ち受けています。実ブラウザーでgetAnimations()[0].playStateを確認すると、Promiseのresolve後もdata-themeがまだ変更されていないことが分かります。onfinishと正しいタイミングで同期するには、addEventListener('finish', ...)で待つ(または先にplayState==='finished'を確認する)必要があります。詳しくはscripts/verify-theme-motion.mjsを参照してください。一般的な教訓:アニメーションが完了したかどうかを判断するために、時間の基準値をハードコードしないでください。実際のAPIやイベントを使えば、あらゆるdurationでより正確に判定できます。
サイドバーのない1カラムのランディングページの場合は、ページのフッターと同じ行に配置してください。ラベル付きのトグルボタンにし、角に浮かぶアイコンチップには絶対に戻さないでください。
自動受け入れテスト
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がカーソルの正確な座標(ボタンの境界内に制限)に表示され、背景色が遷移先のモードになっていること。ビューポート全体を覆った後にdata-theme/localStorageが変更されること。フェード後にオーバーレイがDOMから自動的に削除されること。prefers-reduced-motion: reduceの分岐では、広がるclip-pathが使われず(フラットなクロスフェードで、空間的な動きがないこと)。
ブランチ
| ID | 種類 | 条件 | 動作 | スキップ/失敗 | 再合流 |
|---|---|---|---|---|---|
| B01 | conditional_required | prefers-reduced-motion: reduce、または el.animate がない | ≤150ms のクロスフェードを使用し、広がる clip-path と crest-glow は使わない(JS §4 のフォールバック) | — | W06 |
| B02 | conditional_required | ページにサイドバーがない(1 列のランディングページ) | ページのフッターと同じ行に配置し、ラベル付きの切り替えボタンを維持する。隅にアイコンチップを置かない | — | W02 |
| B03 | user_optional | ユーザーが「instant」を要求した場合(v5 の記事) | オーバーレイと el.animate を完全に削除し、flip() は commit(next) のみを実行する。使われなくなった .theme-reveal の CSS を削除する | — | W06 |
検証と停止条件
実ブラウザー上で scripts/verify-theme-motion.mjs を使って決定論的に受け入れ確認します(ms の基準値をハードコードせず、finish イベントを待ちます)。スクリプトが PASS になったら停止します。見た目(パレットのトーン、体感速度)はユーザーによる目視確認が必要です。
例
- 肯定例:
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が PASS:オーバーレイがボタン内のクリック位置から広がり、覆い終わってからdata-themeが切り替わり、オーバーレイが DOM から削除されます。 - 境界/失敗例: 念のため
.theme-rowにbackdrop-filter:blurを残す → スクリーンショットに独立した二重の曇りガラスの帯が現れる → RULE-02 違反となるため、backdrop-filterを削除し、background:transparentにします。