SuperAPI AI Directory

dark-mode-maker

General & other

View repository

Updated 10-16 01:12

About this skill

Skill: dark-mode-maker

Standalone module, split out from docs-site-macos (feedback 160926: "take the whole dark/light mode effect and make it a separate skill"). Usable for ANY HTML page that needs a dark/light toggle — not just the docs-site-macos page.

WHAT

Purpose and context

  • Purpose: attach to an HTML page a dark/light toggle switch with circle-reveal radiating from the pointer position (clamped within the button bounds) + crest-glow, a neutral market-style dark palette, FOUC prevention, and acceptance testing with real Playwright.
  • Trigger (when to use):
    • A page needs a dark/light toggle (any HTML page generated for viewers, per the rule html-theme-toggle-required).
    • User says "make dark mode", "theme toggle", "switch light dark", "circle reveal", "UI change effect".
    • docs-site-macos § Theme Toggle points here instead of re-embedding the entire CSS/JS.
  • Non-goals:
    • NOT used to decide whether a page MUST have a toggle — that is the rule html-theme-toggle-required (/fdk § Rules), this skill only handles the PART "how to switch" once a toggle has been decided on.
    • Not a documentation page floor (that is 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, no spatial.

Input and output contract

Field Required? Meaning
In HTML page with <nav> (or equivalent container) containing navigation links yes toggle switch auto-inserted at the end via JS
In <tên-trang> (localStorage key) + the page's actual light/dark background colors yes replace with the FOUC snippet + JS (§4)
In page has no sidebar no place it on the page's footer row, still a labeled toggle switch
Out Switch CSS + FOUC script + reveal JS + neutral dark palette in the page yes keep the class names unchanged .theme-row/.theme-switch/.theme-reveal
Out result verify-theme-motion.mjs yes assert the overlay's exact coordinates + color, commit after coverage, remove the DOM, reduced-motion branch

Rules and capabilities

  • RULE-01 (MUST): Do NOT spawn a separate auxiliary element for the ripple effect — use filter:drop-shadow multiple layers on THE .theme-reveal itself (see §4, the reason being the measured lag + desync).
  • RULE-02 (MUST): Do NOT backdrop-filter on .theme-row (see §1) and do NOT override background separately for it in the dark-mode block.
  • RULE-03 (MUST): The radiating point is ALWAYS clamped within the button bounds (getBoundingClientRect() of .theme-switch) — never overflow outward no matter where the cursor is on the screen when clicked.
  • RULE-04 (MUST): Neutral dark-mode palette (§3) — do not add saturated colors to foundational background/border/text; accent keeps exactly one hue.
  • RULE-05 (MUST): docs-site-macos (and every other HTML-generating skill that needs a toggle) points TO this skill instead of re-embedding CSS/JS — one source, fix in one place.
  • Capabilities: edit CSS/JS/markup of the target HTML page; temporarily install headless-browser dependencies and run the acceptance script.

Failure boundaries

  • Page has no <nav>/container → script return early, no toggle → clarify container to place the button (or the page footer row).
  • Acceptance script red (overlay wrong coordinates/color, commit before cover, overlay not removed, reduced-motion still clip-path) → failed, fix then rerun.
  • User complains it is slow despite duration already cut → clarify/switch branch to instant (v5), do not keep trying to lower TOTAL_MS forever.
  • Question "is a toggle MANDATORY on the page" → out of scope, follow rule html-theme-toggle-required.

HOW

Main workflow

Step Type Inputs Action Outputs/exit Failure/next
W01 deterministic page Check markup contract: is there <nav> containing a link container identified none → B02
W02 effect page §1 Toggle button: CSS .theme-row sticky, background:transparent, no backdrop-filter switch CSS —
W03 effect <tên-trang> §2 Anti-FOUC: script reads localStorage sets data-theme early no flash —
W04 effect page §3 Neutral dark palette (token table) dark tokens —
W05 effect actual background color §4 Circle-reveal + crest-glow: CSS .theme-reveal + JS (change <tên-trang>, 2 colors + 2 tints) toggle works user demands instant → B03
W06 deterministic HTML file Acceptance: node skills/dark-mode-maker/scripts/verify-theme-motion.mjs <file.html> PASS FAIL → fix, repeat W06

Step-by-step details (source of truth for W01–W06):

Contract (minimum markup)

Need a <nav> (or equivalent container) containing navigation links — the toggle button is auto-inserted at the end by JS, no handwritten markup needed:

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

JS auto-creates .theme-row > .lbl + .theme-switch, sets data-theme onto <html>, saves localStorage. Do not rename these classes — the acceptance script (scripts/verify-theme-motion.mjs) relies on them.

1. Switch

Footer row stuck to the bottom of the parent container (position:sticky), label left + switch right, thin divider above — NO icon chips scattered in the corners:

: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 MUST background:transparent, NOT backdrop-filter separately (lesson 160926, 2 wrong rounds were measured). .theme-row is a direct child of the parent container (nav), not an independent content card:

  • Wrong round 1 — separate fill: giving it its own flat background color (even a nav "nearly identical" gradient) always shows up as a block separate from the glass layer behind — real feedback: "Keep this div transparent all the time.".
  • Wrong round 2 — background:inherit: thought it was fixed, but inherit only copied the gradient FORMULA and then drew it AGAIN on its own small box in .theme-row — not showing the exact pixels the parent drew, still off-tone.
  • Wrong round 3 — keeping backdrop-filter:blur "to be safe": even though the background was truly transparent (measured rgba(0,0,0,0)), a SEPARATE blur layer stacked on the exact region the parent already blurred creates a "double frosted glass" band that looks like a separate block despite 0 color — caught with a real screenshot, not guesswork.
  • Correct: background:transparent, NO backdrop-filter on .theme-row. Directly expose the pixels the parent rendered, do not draw/blur anything extra.

2. FOUC prevention

<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. Dark-mode palette — market-neutral, NOT navy-tinted (lesson 160926)

The first version used background #0c0f16 + border rgba(120,160,220,.18) (dark navy blue) — real feedback: "The dark-mode colors are still terrible; choose a new palette based on what is used in the market.". Referenced the 3 best-rated dark-mode products today (GitHub Dark, Vercel/Geist, Linear): a NEUTRAL near-black background (no blue tint), TRANSLUCENT WHITE borders (no saturated-color borders), keeping EXACTLY ONE accent color (here Apple system blue #0a84ff) as the highlight — do not scatter saturated colors into chrome/border:

Token Before (navy-tinted, WRONG) After (neutral, market)
base background #0c0f16 #09090b
background gradient, stop 2 #0a0d13 / #0d1017 #08080a
--glass2 (card) rgba(30,34,44,.72) rgba(32,32,36,.72)
--glass3 (table/data) rgba(36,40,52,.9) rgba(39,39,44,.9)
--border rgba(120,160,220,.18) (saturated blue) rgba(255,255,255,.10) (translucent white — exactly how GitHub/Vercel/Linear draw borders on dark backgrounds)
--t1 (primary text) #e7e9ee / #e8eaf0 #f4f4f5
--t2 (secondary text) #9aa2b1 (blue-gray) #a1a1aa (neutral gray)
nav/parent container rgba(18,21,28,.82) or gradient rgba(24,30,44,…) rgba(20,20,23,.82) or gradient rgba(28,28,31,…)
toggle track (on) #2b3040→#191d27 / rgba(60,70,110,.55) #2c2c30→#19191b / rgba(58,58,64,.6)
mode-switch overlay background color #0c0f16 #09090b

Accent (#0a84ff, Apple secondary 6-color tag) keep as is — not the "bad" part, it is a deliberate accent. Only the neutral background/border/text is what needs neutralizing.

4. Circle-reveal + crest-glow (REQUIRED) — radiating from the CURSOR, clamped within the button bounds

Origin of the glow: the mouse cursor position at click time, clamped within the button bounds (lesson 160926 — previously it radiated from the button center, fixed): use the real click coordinates (e.clientX/clientY), limited within the getBoundingClientRect() of the button — if you click toward the left/right edge of the button, the glow origin follows that exact edge, it does not shoot outside the button. The Enter/Space key (no mouse coordinates) falls back to the button center.

DO NOT use multiple independent ripple rings chasing the main stream (lesson 160926, INITIAL design was discarded). The first version spawned 3 <div> separate circular borders, radiating FASTER + FARTHER than the main stream using their own backdrop-filter:blur for each one — real feedback: "the effect lags too much... waves flying faster than the white block behind it? shouldn't it be creating the effect on the white block behind it?". Two overlapping errors:

  • Real lag: backdrop-filter:blur on many <div> large sizes (diameter > 2× the screen-covering radius) per frame is extremely GPU-expensive — not a feeling, it is real render cost.
  • Wrong concept: the ripples detached from the main stream, running on their own timeline (setTimeout + el.animate() independent) → not perfectly synchronized, exactly as the user observed "waves flying faster than the white block".

Correct: filter:drop-shadow(...) multiple layers DIRECTLY ON the main-stream element itself — drop-shadow drawn along the exact EDGE that has been clip-path clipped (unlike box-shadow, which draws along the rectangular box, not the clipped shape). Because it is a filter on THE SAME element, THE SAME animation — the glowing edge automatically follows the main stream exactly every frame, no synchronization needed, no extra element, no 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)" */

Three layers of drop-shadow stacked from a sharp bright edge (water rim) → soft white halo (refraction) → wide colored halo following --reveal-tint (TARGET MODE) — reads as a real wave/liquid glass being swept along the rising water edge, not a detached flying wave.

(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 lesson 160926 (v1, replaced in v2 below): cubic-bezier(.3,0,.1,1) (initial design) compressed almost all motion into the first ~20% of the duration and then flatlined for the rest — completely opposite to the intent of "slow start, fast end". It only became apparent when measured with real screenshots at multiple time points (at 130/560ms, i.e. 23% of the duration, the overlay had already covered almost the entire screen). Changed to cubic-bezier(.42,0,1,1) (standard CSS ease-in, 560ms) spread evenly across the duration — but still SLOW at the moment right after the click.

⚠️ Easing lesson 160926 (v2, replaced in v3 below): real feedback after shipping v1: "it still makes it feel like there's a janky delay". The root cause is NOT jank — it's that ease-in (even with uniform distribution) still has a derivative ~0 right at t=0, so the first 100-150ms after the click the overlay barely seems to move → it reads exactly like "the app froze". Switching to cubic-bezier(.16,1,.3,1) (ease-out expo) + cutting duration 560→280ms (grow) and 180→70ms (fadeOut) — total perceived 740ms → 350ms.

⚠️ Easing lesson 160926 (v3): further feedback: "finishing in half a second still feels too long" + "the closer to the end, the faster". Two signals: (1) 350ms is STILL considered slow — the user has no fixed "fast enough" threshold; cut until what remains is still readable as a circle expanding (not a flash) and then stop; (2) the desired curve shape is ACCELERATING gradually until it covers everything, not ease-out (decelerating at the end, creating a feeling of "work left over" in the final stretch). Since the duration is already extremely short, the "slow start" part of ease-in no longer has enough time to be perceived as a delay — the v1-vs-v2 contradiction only exists when the duration is still long. Switching the easing to cubic-bezier(.55,0,1,.45) (ease-in-quad, uniformly accelerating, ending at maximum speed). The rule derived: the shorter the duration, the less the easing shape matters — prioritize cutting duration first, adjust easing afterward.

⚠️ Lesson 160926 (v4, structure): the user kept pushing the duration down progressively (350→200→50→20ms) over many rounds — feedback: "just ratio the structure, enter max time and it re-divides the duration itself". Instead of 2 discrete numbers duration that must be edited by hand each time, declare ONE fixed TOTAL_MS + GROW_RATIO (0.8), grow/fadeOut are computed automatically by ratio — changing the speed only edits one number. At TOTAL_MS=20 (growMs=16, fadeMs=4) it still PASSES stably through the acceptance script (§ below) — but this is the boundary: below ~20ms total, the "circle expanding" effect is nearly indistinguishable to the naked eye from an instantaneous color change (flash); lowering it further is only meaningful in theory.

⚠️ Lesson 160926 (v5, final): after v4 (TOTAL_MS=20) it was still criticized as "still too long" → the user concluded "just make the instance [instant]". With an effect that responds directly to a click, there is a REAL USER THRESHOLD that cannot be fixed in advance by a number — the safest approach is to ask before investing in an elaborate reveal design (overlay/clip-path/crest-glow) for a high-frequency button like theme-toggle, instead of always defaulting to having an effect. When the user demands "instant": drop the overlay ENTIRELY + el.animate, flip() leaving only commit(next) — no longer fall back to ever-smaller TOTAL_MS, and also delete the CSS .theme-reveal if no element creates that class anymore. See llmwiki/html/overstack.html as a reference example. The circle-reveal in this skill is still a good DEFAULT pattern for low-click landing/showcase — it is not to be applied rigidly everywhere.

⚠️ Pitfall when writing acceptance scripts for extremely short durations: await animation.finished (Promise) can resolve BEFORE event 'finish' — while onfinish (where real code calls commit()) listens via event, not via promise. Measuring with a real browser (getAnimations()[0].playState) only reveals: the promise resolves yet data-theme STILL hasn't changed. The test must wait exactly by addEventListener('finish', ...) (or check playState==='finished' first) to synchronize correctly with onfinish real — see scripts/verify-theme-motion.mjs. General lesson: DO NOT hardcode ms milestones to guess "whether the animation is done" — use real APIs/events, which are more accurate at every duration.

Page has no sidebar (single-column landing)? Place it in the page footer row, still a TOGGLE with a label — absolutely never go back to a floating icon chip in the corner.

Automated acceptance

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 appears at the exact pointer coordinates (clamped within the button bounds) + TARGET MODE background color; data-theme/localStorage changes AFTER covering the whole viewport; overlay removes itself from DOM after fade; branch prefers-reduced-motion: reduce has no clip-path spread (flat crossfade, no spatial motion).

Branches

ID Kind Guard Behavior Skip / failure Rejoin
B01 conditional_required prefers-reduced-motion: reduce or no el.animate crossfade ≤150ms, no clip-path spread, no crest-glow (fallback in JS §4) — W06
B02 conditional_required page has no sidebar (single-column landing) place in the page footer row, still a TOGGLE with a label, no corner icon chip — W02
B03 user_optional user demands "instant" (v5 lesson) drop the overlay ENTIRELY + el.animate, flip() only remains commit(next), remove CSS .theme-reveal if no longer used — W06

Validation and stopping

Deterministic acceptance with scripts/verify-theme-motion.mjs on a real browser (no hardcoded ms milestones — wait for event finish). Stop when the script PASSES; aesthetics (palette tone, perceived speed) need the user to approve by eye.

Examples

  • Positive: showcase page site/index.html has <nav> sidebar → add CSS §1 + FOUC §2 (lock site-theme) + palette §3 + JS §4 → node skills/dark-mode-maker/scripts/verify-theme-motion.mjs site/index.html PASS: overlay radiates from the exact click point in the button, data-theme changes after full coverage, overlay is removed from DOM.
  • Boundary/failure: keep backdrop-filter:blur on .theme-row "to be safe" → the screenshot reveals a double blur glass band as a separate block → violates RULE-02, remove backdrop-filter, leave background:transparent.