diff --git a/designs/flap.css b/designs/flap.css index 6484e43..4a4b2d1 100644 --- a/designs/flap.css +++ b/designs/flap.css @@ -68,6 +68,7 @@ html[data-theme="flap"] #home, html[data-theme="flap"] #app { position: relative font: 600 var(--fs)/1 "Oswald", "Barlow Condensed", sans-serif; color: var(--c); text-transform: uppercase; letter-spacing: 0; background: #1d1d20; border-radius: calc(var(--fs) * 0.1); box-shadow: 0 1px 0 #000, 0 calc(var(--fs) * 0.06) 0 #000, inset 0 0 0 1px rgba(255, 255, 255, 0.04); + contain: layout style; /* a flipping tile never relayouts the page */ } .fl > span { position: absolute; left: 0; right: 0; height: 50%; overflow: hidden; background: #1d1d20; backface-visibility: hidden; -webkit-backface-visibility: hidden; } .fl .t, .fl .ft { top: 0; border-radius: calc(var(--fs) * 0.1) calc(var(--fs) * 0.1) 0 0; background: linear-gradient(#34343a, #232327); } @@ -80,10 +81,7 @@ html[data-theme="flap"] #home, html[data-theme="flap"] #app { position: relative .fl .ft { transform-origin: 50% 100%; } .fl .fb { transform-origin: 50% 0; } .fl.go .ft, .fl.go .fb { visibility: visible; } -.fl.go .ft { animation: fl-top 70ms ease-in forwards; } -.fl.go .fb { animation: fl-bot 70ms ease-out 70ms both; } -@keyframes fl-top { to { transform: rotateX(-90deg); } } -@keyframes fl-bot { from { transform: rotateX(90deg); } to { transform: rotateX(0); } } +/* the flip itself runs through element.animate() (flap.js) */ .flrow.amber { --c: #ffb42a; } .flrow.amber .fl { background: #1a1710; } .flrow.dim { --c: #b3a98b; } @@ -93,7 +91,7 @@ html[data-theme="flap"] #home .hero { position: relative; text-align: left; max- html[data-theme="flap"] #home .site-link { position: absolute; z-index: 5; right: 16px; top: 62px; margin: 0; padding: 4px 6px; font-size: 13px; letter-spacing: 0.14em; text-transform: uppercase; color: #1a1200; font-weight: 600; background: #ffb42a; border-radius: 4px; } html[data-theme="flap"] #home .site-link:hover { background: #ffc455; color: #000; } .fl-bar { display: flex; align-items: center; gap: 16px; padding: 10px 16px; background: #0a0a0b; border-bottom: 2px solid #000; font: 600 13px/1 var(--font); letter-spacing: 0.22em; color: #ffb42a; text-transform: uppercase; } -.fl-bar span:nth-child(2) { color: #8a7a4f; } +.fl-bar > span:nth-child(2) { color: #8a7a4f; } .fl-bar .grow { flex: 1; } .fl-bar .clock { display: flex; align-items: center; gap: 8px; margin-right: 96px; } .fl-bar .clock .flrow { --fs: 17px; } @@ -143,9 +141,15 @@ html[data-theme="flap"] .controls { background: #19191c; } html[data-theme="flap"] .info-title { color: #ffb42a; letter-spacing: 0.16em; } html[data-theme="flap"] .preset-row select option { background: #1c1c1f; } html[data-theme="flap"] .theme-opt:hover { background: #2a2a2e; } -@media (max-width: 640px) { .fl-bar .clock { margin-right: 0; } html[data-theme="flap"] #home .site-link { top: auto; bottom: 8px; right: 12px; } .fl-bar span:nth-child(2) { display: none; } } -@media (prefers-reduced-motion: reduce) { .fl.go .ft, .fl.go .fb, .fl-bar .colon { animation: none; } } +@media (max-width: 640px) { .fl-bar .clock { margin-right: 0; } html[data-theme="flap"] #home .site-link { top: auto; bottom: 8px; right: 12px; } .fl-bar > span:nth-child(2) { display: none; } } +@media (prefers-reduced-motion: reduce) { .fl-bar .colon { animation: none; } } /* primary button keeps its own colours over the design's generic button look */ html[data-theme="flap"] button.primary { background: var(--primary-bg); color: var(--primary-fg); border-color: var(--primary-border); } html[data-theme="flap"] button.primary:hover { background: var(--primary-hover); color: var(--primary-fg); border-color: var(--primary-border); } + +/* card descriptions: amber board text, revealed in steps (cheap clip animation instead of one flip tile per letter) */ +html[data-theme="flap"] #home .card-desc.fl-desc { color: #ffb42a; text-transform: uppercase; letter-spacing: 0.12em; font: 600 11px/1.5 "Oswald", "Barlow Condensed", sans-serif; opacity: 0; } +html[data-theme="flap"] #home .card-desc.fl-desc.on { opacity: 1; animation: fl-reveal 0.7s steps(24) both; } +@keyframes fl-reveal { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } } +html.perf-idle[data-theme="flap"] .fl-bar .colon, html.perf-hidden[data-theme="flap"] .fl-bar .colon, html.perf-idle[data-theme="flap"] #home .card::after, html.perf-hidden[data-theme="flap"] #home .card::after { animation-play-state: paused !important; } diff --git a/designs/flap.js b/designs/flap.js index 44137f6..0e58f48 100644 --- a/designs/flap.js +++ b/designs/flap.js @@ -13,18 +13,43 @@ function _flNorm(s) { function _flTile(ch) { const t = document.createElement("span"); t.className = "fl"; t.dataset.c = ch; - t.innerHTML = ''; - t._i = [...t.querySelectorAll("i")]; // t, b, ft, fb + t.innerHTML = ''; + t._i = [...t.querySelectorAll("i")]; // t, b (the two moving flaps are added only while the tile is flipping) t._i.forEach((i) => { i.textContent = ch; }); return t; } +// the moving flaps exist only while a tile flips — an idle board is 5 nodes per tile instead of 9 +function _flFlaps(tile) { + if (!tile._ft) { + const ft = document.createElement("span"), fb = document.createElement("span"); + ft.className = "ft"; fb.className = "fb"; ft.innerHTML = fb.innerHTML = ""; + tile.append(ft, fb); tile._ft = ft; tile._fb = fb; + } + return [tile._ft, tile._fb]; +} +function _flDropFlaps(tile) { + if (tile._ft) { tile._ft.remove(); tile._fb.remove(); tile._ft = tile._fb = null; } +} + +// Flip with the Web Animations API: no class toggling + forced reflow (`void offsetWidth`) per flip, which made +// every flip a synchronous layout of the whole page (≈1.5 ms each, ~2 s of blocked main thread for a full board). function _flipOnce(tile, next, done) { - const [ti, bi, fti, fbi] = tile._i, old = tile.dataset.c; - ti.textContent = next; fti.textContent = old; bi.textContent = old; fbi.textContent = next; - tile.classList.remove("go"); void tile.offsetWidth; tile.classList.add("go"); + const [ti, bi] = tile._i, old = tile.dataset.c; + const [ft, fb] = _flFlaps(tile); + if (tile._anims) tile._anims.forEach((a) => a.cancel()); + ti.textContent = next; ft.firstChild.textContent = old; bi.textContent = old; fb.firstChild.textContent = next; + tile.classList.add("go"); + tile._anims = [ + ft.animate([{ transform: "rotateX(0)" }, { transform: "rotateX(-90deg)" }], { duration: 70, easing: "ease-in", fill: "forwards" }), + fb.animate([{ transform: "rotateX(90deg)" }, { transform: "rotateX(0)" }], { duration: 70, delay: 70, easing: "ease-out", fill: "both" }), + ]; tile.dataset.c = next; - setTimeout(() => { bi.textContent = next; tile.classList.remove("go"); if (done) done(); }, _FL_STEP); + setTimeout(() => { + bi.textContent = next; tile.classList.remove("go"); + if (tile._anims) { tile._anims.forEach((a) => a.cancel()); tile._anims = null; } + if (done) done(); + }, _FL_STEP); } // run the tile through (up to `steps`) characters ending on `target` @@ -37,7 +62,7 @@ function _flSettle(tile, target, steps, delay) { const seq = []; for (let k = n - 1; k >= 0; k--) seq.push(_FL_SET[(tgt - k + L) % L]); let i = 0; - const next = () => { if (tile._run !== run || i >= seq.length) return; _flipOnce(tile, seq[i++], next); }; + const next = () => { if (tile._run !== run) return; if (i >= seq.length) { _flDropFlaps(tile); return; } _flipOnce(tile, seq[i++], next); }; setTimeout(() => { if (tile._run === run) next(); }, delay); } @@ -129,18 +154,17 @@ DESIGN_FX.flap = { card(el) { const tEl = el.querySelector(".card-title"), dEl = el.querySelector(".card-desc"); if (!tEl || !dEl) return; - // tiles are built only when the card is near the viewport, not all at page load - let title = null, desc = null; - const build = () => { - if (title) return; - title = _flRow(tEl, tEl.textContent, {}); - desc = _flRow(dEl, dEl.textContent, { cls: "amber" }); - }; - const go = () => { build(); title.play(9, 55, 80); desc.play(5, 16, 400); }; + // title tiles are built only when the card is near the viewport. The description stays plain text (a tile per + // character made ~1400 extra tiles / 12k nodes on the hub) and just reveals left to right. + let title = null; + dEl.classList.add("fl-desc"); + const build = () => { if (!title) title = _flRow(tEl, tEl.textContent, {}); }; + const go = () => { build(); title.play(9, 55, 80); setTimeout(() => dEl.classList.add("on"), 400); }; if ("IntersectionObserver" in window) { const io = new IntersectionObserver((es) => { if (es.some((e) => e.isIntersecting)) { io.disconnect(); go(); } }, { threshold: 0.25, rootMargin: "200px" }); io.observe(el); } else go(); el.addEventListener("pointerenter", () => { if (title) title.shuffle(4); }); + if (matchMedia("(prefers-reduced-motion: reduce)").matches) dEl.classList.add("on"); }, }; diff --git a/designs/kinetic.css b/designs/kinetic.css index c0e17ba..3466266 100644 --- a/designs/kinetic.css +++ b/designs/kinetic.css @@ -176,3 +176,11 @@ html[data-theme="kinetic"] ::selection { background: #ff3b1f; color: #fff; } /* primary button keeps its own colours over the design's generic button look */ html[data-theme="kinetic"] button.primary { background: var(--primary-bg); color: var(--primary-fg); border-color: var(--primary-border); } html[data-theme="kinetic"] button.primary:hover { background: var(--primary-hover); color: var(--primary-fg); border-color: var(--primary-border); } + +/* Hovering widens the title (wdth 62 → 150), so it no longer fit next to the description and the flex row wrapped — + the row jumped taller. On wide screens the row never wraps now: the description shrinks (ellipsis) instead. */ +@media (min-width: 641px) { + html[data-theme="kinetic"] #home .card-text { flex-wrap: nowrap; } + html[data-theme="kinetic"] #home .card-title { flex: 0 0 auto; } + html[data-theme="kinetic"] #home .card-desc { flex: 1 1 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +} diff --git a/designs/liquid.css b/designs/liquid.css index a6eaa40..1a73523 100644 --- a/designs/liquid.css +++ b/designs/liquid.css @@ -59,28 +59,9 @@ html[data-theme="liquid"] { color-scheme: dark; } -/* the blob field (injected by liquid.js): metaballs via an SVG goo filter */ +/* the blob field (injected by liquid.js): metaballs computed on a small canvas */ .liq-fx { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; contain: strict; transform: translateZ(0); } -.liq-goo { position: absolute; inset: 0; filter: url(#liq-goo); } -.liq-goo i, .liq-goo b { - position: absolute; border-radius: 50%; - width: var(--s); height: var(--s); left: var(--x); top: var(--y); - background: radial-gradient(circle at 35% 30%, var(--c1), var(--c2)); - animation: liq-drift var(--t) ease-in-out infinite alternate; - animation-delay: var(--d, 0s); - opacity: 0.5; -} -.liq-goo i:nth-child(1) { --s: 46vmax; --x: -14vmax; --y: -12vmax; --c1: #ff6fb0; --c2: #c2306f; --t: 19s; } -.liq-goo i:nth-child(2) { --s: 38vmax; --x: 62vw; --y: -10vmax; --c1: #7a7bff; --c2: #4a2fd0; --t: 23s; --d: -6s; } -.liq-goo i:nth-child(3) { --s: 34vmax; --x: 10vw; --y: 62vh; --c1: #5ef0c8; --c2: #1aa3b8; --t: 21s; --d: -9s; } -.liq-goo i:nth-child(4) { --s: 30vmax; --x: 70vw; --y: 58vh; --c1: #ffd666; --c2: #ff8a3d; --t: 25s; --d: -3s; } -.liq-goo i:nth-child(5) { --s: 22vmax; --x: 40vw; --y: 30vh; --c1: #ba8cff; --c2: #7b4ae6; --t: 17s; --d: -12s; } -.liq-goo b { --s: 9vmax; left: 0; top: 0; margin: -4.5vmax 0 0 -4.5vmax; --c1: #fff2a8; --c2: #ff8ac8; animation: none; opacity: 0.95; } -@keyframes liq-drift { - 0% { transform: translate(0, 0) scale(1); } - 50% { transform: translate(9vw, 12vh) scale(1.18); } - 100% { transform: translate(-8vw, 6vh) scale(0.9); } -} +.liq-cv { position: absolute; inset: 0; width: 100%; height: 100%; } /* 1/8-resolution metaball canvas, scaled up smoothly */ html[data-theme="liquid"] #home, html[data-theme="liquid"] #app { position: relative; z-index: 1; } /* hero: the title ripples like it's under water */ @@ -89,12 +70,11 @@ html[data-theme="liquid"] #home h1 { line-height: 0.95; background-size: 300% 100%; animation: liq-hue 12s steps(90) infinite; - filter: drop-shadow(0 8px 30px rgba(255, 88, 160, 0.35)); } @keyframes liq-hue { to { background-position: -300% 0; } } html[data-theme="liquid"] #home .subtitle { font-size: 19px; font-weight: 500; } html[data-theme="liquid"] #home .hero { margin-bottom: 56px; } -html[data-theme="liquid"] #app h1 { filter: drop-shadow(0 2px 8px rgba(20, 5, 60, 0.75)); background: var(--hero-fill); background-size: 300% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: liq-hue 12s steps(90) infinite; } +html[data-theme="liquid"] #app h1 { background: var(--hero-fill); background-size: 300% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: liq-hue 12s steps(90) infinite; } html[data-theme="liquid"] .back-link, html[data-theme="liquid"] #home .site-link { color: var(--muted); } @@ -161,11 +141,9 @@ html[data-theme="liquid"] .theme-opt { border-radius: 24px; } :root { --liq-ease: cubic-bezier(0.3, 1.25, 0.4, 1); } @media (max-width: 640px) { html[data-theme="liquid"] #home .grid { gap: 22px; } html[data-theme="liquid"] #home .card { border-radius: 38px 48px 36px 52px / 46px 38px 50px 40px; } } @media (prefers-reduced-motion: reduce) { - .liq-goo i { animation: none; } html[data-theme="liquid"] #home .card, html[data-theme="liquid"] #canvas-wrap, html[data-theme="liquid"] #home h1, html[data-theme="liquid"] #app h1 { animation: none; } - html[data-theme="liquid"] #home h1 { filter: none; } -} + } /* battery: nothing decorative runs while the tab is hidden / the page is idle / in lite mode */ -html.perf-hidden .liq-goo *, html.perf-idle .liq-goo *, html.perf-hidden[data-theme="liquid"] h1, html.perf-idle[data-theme="liquid"] h1 { animation-play-state: paused !important; } -html.perf-lite .liq-goo i, html.perf-lite[data-theme="liquid"] h1 { animation: none !important; } +html.perf-hidden[data-theme="liquid"] h1, html.perf-idle[data-theme="liquid"] h1 { animation-play-state: paused !important; } +html.perf-lite[data-theme="liquid"] h1 { animation: none !important; } diff --git a/designs/liquid.js b/designs/liquid.js index aca9c0c..5413929 100644 --- a/designs/liquid.js +++ b/designs/liquid.js @@ -1,74 +1,113 @@ -// Liquid morph — metaball blob field behind the page, a rippling title, and a -// blob that chases the pointer and melts into the others. +// Liquid morph — metaball blob field behind the page and a blob that chases the pointer and melts into the others. +// +// The field is computed on a tiny canvas (~1/8 resolution) and scaled up by the browser, so the GPU only composites one +// texture. (The previous version ran a full-screen SVG goo filter — blur + colour matrix — on every frame, which is +// extremely slow in Safari / on integrated GPUs.) DESIGN_FX.liquid = { enter() { const fx = document.createElement("div"); fx.className = "liq-fx"; fx.setAttribute("aria-hidden", "true"); - fx.innerHTML = ` - -