From e01dae4e0229ccae8793a96eb7622fca7a35aab0 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Wed, 24 Jun 2026 19:39:34 -0700 Subject: [PATCH 01/17] Add tw-shimmer credit to demo --- demo/demo.css | 77 +++++++++++++++++++++++++++++++++++++++ demo/index.html | 21 +++++++++++ demo/input.css | 1 + demo/styles.css | 93 +++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 13 ++++++- package.json | 3 +- 6 files changed, 206 insertions(+), 2 deletions(-) diff --git a/demo/demo.css b/demo/demo.css index d1a1a07..b2300d3 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -529,6 +529,83 @@ h3 { font-variant-numeric: tabular-nums slashed-zero; } +.creator-note-link { + --shimmer-color: var(--demo-text-strong); + --shimmer-speed: 40; + --shimmer-repeat-delay: 1400; + --shimmer-spread: 96px; + --shimmer-track-width: 220px; + position: relative; + display: inline-block; + color: color-mix(in oklab, var(--demo-text-primary) 68%, transparent); + text-decoration: none; + transition: + color 160ms ease; +} + +.creator-note-link:hover { + color: color-mix(in oklab, var(--demo-text-primary) 82%, transparent); +} + +.creator-note-tooltip-root { + vertical-align: baseline; +} + +.creator-note-tooltip { + left: 50%; + width: min(16rem, calc(100vw - 2rem)); + transform: translateX(-50%) translateY(0.25rem); +} + +.creator-note-tooltip-root:hover .creator-note-tooltip, +.creator-note-tooltip-root:focus-within .creator-note-tooltip { + transform: translateX(-50%) translateY(0); +} + +.creator-note-underline { + --shimmer-color: color-mix( + in oklab, + var(--demo-text-strong) 48%, + transparent + ); + --shimmer-speed: 40; + --shimmer-repeat-delay: 1400; + --shimmer-spread: 96px; + --shimmer-track-width: 220px; + position: absolute; + right: 0; + bottom: 0.08em; + left: 0; + height: 1px; + border-radius: 999px; + background-color: color-mix( + in oklab, + var(--demo-text-primary) 18%, + transparent + ); + color: var(--demo-text-primary); + pointer-events: none; +} + +.creator-note-link:focus-visible { + outline: 2px solid color-mix(in oklab, var(--demo-focus) 78%, transparent); + outline-offset: 3px; + border-radius: 0.25rem; +} + +@media (prefers-reduced-motion: reduce) { + .creator-note-link.shimmer { + animation: none; + background: none; + -webkit-text-fill-color: currentColor; + } + + .creator-note-underline.shimmer-bg { + background-image: none; + animation: none; + } +} + .fade-option-label { color: var(--demo-text-subtle); } diff --git a/demo/index.html b/demo/index.html index a10dd3a..651ed09 100644 --- a/demo/index.html +++ b/demo/index.html @@ -461,6 +461,27 @@ > Elegant, scroll-driven edge masking. One class. Zero JavaScript. +
+ By one of the creators of + + tw-shimmer + CSS-only shimmer effects, with 475k+ weekly npm + downloads. + .

'; + inherits: true; + initial-value: 200px; +} +@property --shimmer-angle { + syntax: ''; + inherits: true; + initial-value: 15deg; +} @property --tw-glass-blur { syntax: ""; inherits: false; @@ -1360,9 +1446,16 @@ syntax: "*"; inherits: false; } +@keyframes tw-shimmer { + from { + background-position: 100% 0; + } +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { :root, :host { + --shimmer-track-height: 200px; + --shimmer-angle: 15deg; --glass-bg-opacity: 0.08; } *, ::before, ::after, ::backdrop { diff --git a/package-lock.json b/package-lock.json index 6ce7f57..7997bb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "devDependencies": { "@tailwindcss/cli": "^4.0.0", "playwright": "^1.61.0", - "tailwindcss": "^4.3.1" + "tailwindcss": "^4.3.1", + "tw-shimmer": "^0.4.11" }, "peerDependencies": { "tailwindcss": ">=4.0.0" @@ -1224,6 +1225,16 @@ "engines": { "node": ">=8.0" } + }, + "node_modules/tw-shimmer": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/tw-shimmer/-/tw-shimmer-0.4.11.tgz", + "integrity": "sha512-pTpGJzp3xaCPO87WeHETngmZHJYvygiSTt4jqzh2oR3DWBoeudi/ANB304zks9+Cm2vQ1ai3w9fetviYdqY8HQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=4.0.0-0" + } } } } diff --git a/package.json b/package.json index 558f8a3..a89f25b 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "devDependencies": { "@tailwindcss/cli": "^4.0.0", "playwright": "^1.61.0", - "tailwindcss": "^4.3.1" + "tailwindcss": "^4.3.1", + "tw-shimmer": "^0.4.11" } } From 33547f8dbb13a7ceeddb3aee17cc4d5ba20ba512 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Wed, 24 Jun 2026 19:51:43 -0700 Subject: [PATCH 02/17] Smooth demo wave background --- build/verify-demo-runtime.mjs | 2 +- demo/demo-animations.js | 87 +++++++++++++++------------- demo/demo.css | 2 +- demo/wave-lab.html | 105 ++++++++++++++++++---------------- 4 files changed, 106 insertions(+), 90 deletions(-) diff --git a/build/verify-demo-runtime.mjs b/build/verify-demo-runtime.mjs index 760f527..dbf5202 100644 --- a/build/verify-demo-runtime.mjs +++ b/build/verify-demo-runtime.mjs @@ -267,7 +267,7 @@ async function smokePage(browser, options) { assert(state.waveMode === 'svg', `${label}: procedural SVG wave field activates`, state.waveMode) assert(state.waveBeforeDisplay === 'none', `${label}: procedural wave hides static fallback`, state.waveBeforeDisplay) assert(state.waveFieldDisplay === 'block', `${label}: SVG wave field renders`, state.waveFieldDisplay) - assert(state.wavePathD.startsWith('M64 0C124'), `${label}: SVG wave starts at top geometry`, state.wavePathD.slice(0, 20)) + assert(state.wavePathD.startsWith('M64 0C75.781'), `${label}: SVG wave starts at top geometry`, state.wavePathD.slice(0, 24)) assert(state.wavePatternHeight === '1629', `${label}: SVG wave pattern starts at top wavelength`, state.wavePatternHeight) } assert(/transform/.test(state.movingWillChange.railCard), `${label}: rail cards are composited`, state.movingWillChange.railCard) diff --git a/demo/demo-animations.js b/demo/demo-animations.js index 8dc4fbe..655f589 100644 --- a/demo/demo-animations.js +++ b/demo/demo-animations.js @@ -301,58 +301,68 @@ if (!prefersReducedMotion) { const round = (value) => Number(value.toFixed(3)); const lerp = (from, to, amount) => from + (to - from) * amount; const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value); + // Short sine-matched spans remove the center-crossing curvature jump while + // keeping the old half-wave Bezier's tuned 45px visual reach. + const sineSegmentsPerCycle = 8; + const tunedAmplitudeScale = 0.75; const currentScrollProgress = () => { const max = scroller.scrollHeight - scroller.clientHeight; return max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0; }; + const columnCubics = (centerX, sign0, amplitude, wavelength, cycleCount) => { + const visualAmplitude = amplitude * tunedAmplitudeScale; + const segmentCount = sineSegmentsPerCycle * cycleCount; + const segmentHeight = wavelength / sineSegmentsPerCycle; + const phaseStep = (Math.PI * 2) / sineSegmentsPerCycle; + const cubics = []; + + for (let segment = 0; segment < segmentCount; segment += 1) { + const phase0 = segment * phaseStep; + const phase1 = phase0 + phaseStep; + const y0 = segment * segmentHeight; + const y1 = (segment + 1) * segmentHeight; + const x0 = centerX + sign0 * visualAmplitude * Math.sin(phase0); + const x3 = centerX + sign0 * visualAmplitude * Math.sin(phase1); + const dx0 = sign0 * visualAmplitude * Math.cos(phase0) * phaseStep; + const dx1 = sign0 * visualAmplitude * Math.cos(phase1) * phaseStep; + + cubics.push({ + p0: { x: x0, y: y0 }, + p1: { x: x0 + dx0 / 3, y: y0 + segmentHeight / 3 }, + p2: { x: x3 - dx1 / 3, y: y1 - segmentHeight / 3 }, + p3: { x: x3, y: y1 }, + }); + } + + return cubics; + }; + + const cubicsPath = (cubics) => { + let d = `M${round(cubics[0].p0.x)} ${round(cubics[0].p0.y)}`; + for (const c of cubics) { + d += `C${round(c.p1.x)} ${round(c.p1.y)} ${round(c.p2.x)} ${round(c.p2.y)} ${round(c.p3.x)} ${round(c.p3.y)}`; + } + return d; + }; + const buildWave = (amplitude, wavelength) => { - const halfWave = wavelength / 2; - const segmentCount = 2 * Math.round(params.cycles); + const cycleCount = Math.round(params.cycles); const columnCount = Math.round(params.columns); const tileWidth = Math.round(params.spacing) * columnCount; - const tileHeight = Math.round(params.cycles) * wavelength; + const tileHeight = cycleCount * wavelength; let d = ""; for (let column = 0; column < columnCount; column += 1) { const centerX = params.spacing * column + params.spacing / 2; const initialSign = params.stagger && column % 2 === 1 ? -1 : 1; - d += `M${round(centerX)} 0`; - d += `C${round(centerX + initialSign * amplitude)} ${round(0.25 * halfWave)} ${round(centerX + initialSign * amplitude)} ${round(0.75 * halfWave)} ${round(centerX)} ${round(halfWave)}`; - - for (let segment = 1; segment < segmentCount; segment += 1) { - const sign = initialSign * (segment % 2 === 0 ? 1 : -1); - d += `S${round(centerX + sign * amplitude)} ${round(segment * halfWave + 0.75 * halfWave)} ${round(centerX)} ${round((segment + 1) * halfWave)}`; - } + d += cubicsPath( + columnCubics(centerX, initialSign, amplitude, wavelength, cycleCount), + ); } return { d, tileWidth, tileHeight }; }; - // Explicit cubic segments for one wave column (same shape as the stroke - // path), so the negative-space strips trace the exact wave edges. - const columnCubics = (centerX, sign0, amplitude, halfWave, segmentCount) => { - const cubics = [ - { - p0: { x: centerX, y: 0 }, - p1: { x: centerX + sign0 * amplitude, y: 0.25 * halfWave }, - p2: { x: centerX + sign0 * amplitude, y: 0.75 * halfWave }, - p3: { x: centerX, y: halfWave }, - }, - ]; - let prevP2 = cubics[0].p2; - let prevP3 = cubics[0].p3; - for (let segment = 1; segment < segmentCount; segment += 1) { - const sign = sign0 * (segment % 2 === 0 ? 1 : -1); - const cp1 = { x: 2 * prevP3.x - prevP2.x, y: 2 * prevP3.y - prevP2.y }; - const cp2 = { x: centerX + sign * amplitude, y: segment * halfWave + 0.75 * halfWave }; - const end = { x: centerX, y: (segment + 1) * halfWave }; - cubics.push({ p0: prevP3, p1: cp1, p2: cp2, p3: end }); - prevP2 = cp2; - prevP3 = end; - } - return cubics; - }; - const shiftCubics = (cubics, dx) => cubics.map((c) => ({ p0: { x: c.p0.x + dx, y: c.p0.y }, @@ -392,11 +402,10 @@ if (!prefersReducedMotion) { const buildStrips = (amplitude, wavelength, fieldWidth, fieldHeight) => { if (!fieldWidth || !fieldHeight) return ""; - const halfWave = wavelength / 2; const spacing = Math.round(params.spacing); - const segmentCount = 2 * (Math.ceil(fieldHeight / wavelength) + 1); - const totalHeight = segmentCount * halfWave; - const base = columnCubics(0, 1, amplitude, halfWave, segmentCount); + const cycleCount = Math.ceil(fieldHeight / wavelength) + 1; + const totalHeight = cycleCount * wavelength; + const base = columnCubics(0, 1, amplitude, wavelength, cycleCount); const columnMax = Math.ceil(fieldWidth / spacing) + 1; let markup = ""; for (let column = -1; column < columnMax; column += 1) { diff --git a/demo/demo.css b/demo/demo.css index b2300d3..209ff67 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -190,7 +190,7 @@ modern wide-gamut OKLCH/P3 color syntax. */ } html[data-surface] { - --demo-wave-fallback: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'128'%20height%3D'1629'%20viewBox%3D'0%200%20128%201629'%3E%3Cpath%20d%3D'M64%200C124%2067.875%20124%20203.625%2064%20271.5S4%20475.125%2064%20543S124%20746.625%2064%20814.5S4%201018.125%2064%201086S124%201289.625%2064%201357.5S4%201561.125%2064%201629'%20fill%3D'none'%20stroke%3D'%23ffffff'%20stroke-opacity%3D'0.075'%20stroke-width%3D'1'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%20shape-rendering%3D'geometricPrecision'%2F%3E%3C%2Fsvg%3E"); + --demo-wave-fallback: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%221629%22%20viewBox%3D%220%200%20128%201629%22%3E%3Cpath%20d%3D%22M64%200C75.781%2022.625%2087.489%2045.25%2095.82%2067.875C104.15%2090.5%20109%20113.125%20109%20135.75C109%20158.375%20104.15%20181%2095.82%20203.625C87.489%20226.25%2075.781%20248.875%2064%20271.5C52.219%20294.125%2040.511%20316.75%2032.18%20339.375C23.85%20362%2019%20384.625%2019%20407.25C19%20429.875%2023.85%20452.5%2032.18%20475.125C40.511%20497.75%2052.219%20520.375%2064%20543C75.781%20565.625%2087.489%20588.25%2095.82%20610.875C104.15%20633.5%20109%20656.125%20109%20678.75C109%20701.375%20104.15%20724%2095.82%20746.625C87.489%20769.25%2075.781%20791.875%2064%20814.5C52.219%20837.125%2040.511%20859.75%2032.18%20882.375C23.85%20905%2019%20927.625%2019%20950.25C19%20972.875%2023.85%20995.5%2032.18%201018.125C40.511%201040.75%2052.219%201063.375%2064%201086C75.781%201108.625%2087.489%201131.25%2095.82%201153.875C104.15%201176.5%20109%201199.125%20109%201221.75C109%201244.375%20104.15%201267%2095.82%201289.625C87.489%201312.25%2075.781%201334.875%2064%201357.5C52.219%201380.125%2040.511%201402.75%2032.18%201425.375C23.85%201448%2019%201470.625%2019%201493.25C19%201515.875%2023.85%201538.5%2032.18%201561.125C40.511%201583.75%2052.219%201606.375%2064%201629%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-opacity%3D%220.075%22%20stroke-width%3D%221%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20shape-rendering%3D%22geometricPrecision%22%2F%3E%3C%2Fsvg%3E"); /* Color for the stepped wave-strip fills. Matches the white wave strokes on every (dark) surface; overridable per theme for a tinted gradation. */ diff --git a/demo/wave-lab.html b/demo/wave-lab.html index 761303f..4f2e245 100644 --- a/demo/wave-lab.html +++ b/demo/wave-lab.html @@ -436,27 +436,36 @@

wave background lab

// explicit cubic segments (p0..p3) that compose it. The cubics let us // build closed fill shapes — either lobes (curve→tile-edge) or strips // (the negative space *between* two adjacent wave lines). - function columnPath(cx, s0, a, h, segs) { - let stroke = `M${r(cx)} 0`; + // Short sine-matched spans remove the center-crossing curvature jump while + // keeping the old half-wave Bezier's tuned 45px visual reach. + const SINE_SEGMENTS_PER_CYCLE = 8; + const TUNED_AMPLITUDE_SCALE = 0.75; + + function columnPath(cx, s0, amp, wl, cycles) { + const a = amp * TUNED_AMPLITUDE_SCALE; + const segs = SINE_SEGMENTS_PER_CYCLE * cycles; + const segmentH = wl / SINE_SEGMENTS_PER_CYCLE; + const phaseStep = (Math.PI * 2) / SINE_SEGMENTS_PER_CYCLE; + let stroke = ""; const cubics = []; - const p0 = { x: cx, y: 0 }; - const p1 = { x: cx + s0 * a, y: 0.25 * h }; - const p2 = { x: cx + s0 * a, y: 0.75 * h }; - const p3 = { x: cx, y: h }; - stroke += `C${r(p1.x)} ${r(p1.y)} ${r(p2.x)} ${r(p2.y)} ${r(p3.x)} ${r(p3.y)}`; - cubics.push({ p0, p1, p2, p3 }); - - let prevP2 = p2; - let prevP3 = p3; - for (let i = 1; i < segs; i += 1) { - const sign = s0 * (i % 2 === 0 ? 1 : -1); - const cp1 = { x: 2 * prevP3.x - prevP2.x, y: 2 * prevP3.y - prevP2.y }; - const cp2 = { x: cx + sign * a, y: i * h + 0.75 * h }; - const end = { x: cx, y: (i + 1) * h }; - stroke += `S${r(cp2.x)} ${r(cp2.y)} ${r(end.x)} ${r(end.y)}`; - cubics.push({ p0: prevP3, p1: cp1, p2: cp2, p3: end }); - prevP2 = cp2; - prevP3 = end; + + for (let i = 0; i < segs; i += 1) { + const phase0 = i * phaseStep; + const phase1 = phase0 + phaseStep; + const y0 = i * segmentH; + const y1 = (i + 1) * segmentH; + const x0 = cx + s0 * a * Math.sin(phase0); + const x3 = cx + s0 * a * Math.sin(phase1); + const dx0 = s0 * a * Math.cos(phase0) * phaseStep; + const dx1 = s0 * a * Math.cos(phase1) * phaseStep; + const p0 = { x: x0, y: y0 }; + const p1 = { x: x0 + dx0 / 3, y: y0 + segmentH / 3 }; + const p2 = { x: x3 - dx1 / 3, y: y1 - segmentH / 3 }; + const p3 = { x: x3, y: y1 }; + + if (i === 0) stroke = `M${r(p0.x)} ${r(p0.y)}`; + stroke += `C${r(p1.x)} ${r(p1.y)} ${r(p2.x)} ${r(p2.y)} ${r(p3.x)} ${r(p3.y)}`; + cubics.push({ p0, p1, p2, p3 }); } return { stroke, cubics }; @@ -502,11 +511,9 @@

wave background lab

// they stay in a cheap repeating . Per-strip stepped shading can't // tile, so it's built separately as explicit geometry (buildSteppedStrips). function buildWave(amp, wl) { - const a = amp; - const h = wl / 2; - const segs = 2 * Math.round(params.cycles); + const cycles = Math.round(params.cycles); const S = Math.round(params.spacing); - const H = Math.round(params.cycles) * wl; + const H = cycles * wl; const cols = Math.round(params.columns); const tileW = S * cols; let strokeD = ""; @@ -514,7 +521,7 @@

wave background lab

for (let c = 0; c < cols; c += 1) { const cx = S * c + S / 2; const s0 = params.stagger && c % 2 === 1 ? -1 : 1; - const col = columnPath(cx, s0, a, h, segs); + const col = columnPath(cx, s0, amp, wl, cycles); strokeD += col.stroke; fillD += lobeFill(col, cx, S * c, S * (c + 1), H); } @@ -550,12 +557,10 @@

wave background lab

// darker. Built as explicit paths because per-strip shades can't tile. function buildSteppedStrips(amp, wl, Wf, Hf) { if (!Wf || !Hf) return ""; - const a = amp; - const h = wl / 2; const S = Math.round(params.spacing); - const segs = 2 * (Math.ceil(Hf / wl) + 1); // cover full field height + overscan - const totalH = segs * h; - const base = columnPath(0, 1, a, h, segs).cubics; // one wave centered at x=0 + const cycles = Math.ceil(Hf / wl) + 1; // cover full field height + overscan + const totalH = cycles * wl; + const base = columnPath(0, 1, amp, wl, cycles).cubics; // one wave centered at x=0 const kMax = Math.ceil(Wf / S) + 1; let out = ""; for (let k = -1; k < kMax; k += 1) { @@ -875,28 +880,31 @@

wave background lab

` var r = function (v) { return Number(Number(v).toFixed(3)); };\n` + ` var lerp = function (a, b, t) { return a + (b - a) * t; };\n` + ` var clamp01 = function (v) { return v < 0 ? 0 : v > 1 ? 1 : v; };\n` + + ` var SP = 8, AS = 0.75;\n` + + ` function columnCubics(cx, s0, amp, wl, cycles) {\n` + + ` var a = amp * AS, segs = SP * cycles, sh = wl / SP, step = Math.PI * 2 / SP, cs = [];\n` + + ` for (var i = 0; i < segs; i++) {\n` + + ` var p0a = i * step, p1a = p0a + step, y0 = i * sh, y1 = (i + 1) * sh;\n` + + ` var x0 = cx + s0 * a * Math.sin(p0a), x3 = cx + s0 * a * Math.sin(p1a);\n` + + ` var dx0 = s0 * a * Math.cos(p0a) * step, dx1 = s0 * a * Math.cos(p1a) * step;\n` + + ` cs.push({ p0: { x: x0, y: y0 }, p1: { x: x0 + dx0 / 3, y: y0 + sh / 3 }, p2: { x: x3 - dx1 / 3, y: y1 - sh / 3 }, p3: { x: x3, y: y1 } });\n` + + ` }\n` + + ` return cs;\n` + + ` }\n` + + ` function cubicsPath(cs) {\n` + + ` var d = "M" + r(cs[0].p0.x) + " " + r(cs[0].p0.y);\n` + + ` for (var i = 0; i < cs.length; i++) { var c = cs[i]; d += "C" + r(c.p1.x) + " " + r(c.p1.y) + " " + r(c.p2.x) + " " + r(c.p2.y) + " " + r(c.p3.x) + " " + r(c.p3.y); }\n` + + ` return d;\n` + + ` }\n` + ` function buildWave(amp, wl) {\n` + - ` var h = wl / 2, segs = 2 * Math.round(P.cycles), cols = Math.round(P.columns);\n` + - ` var tileW = Math.round(P.spacing) * cols, H = Math.round(P.cycles) * wl, d = "";\n` + + ` var cycles = Math.round(P.cycles), cols = Math.round(P.columns);\n` + + ` var tileW = Math.round(P.spacing) * cols, H = cycles * wl, d = "";\n` + ` for (var c = 0; c < cols; c++) {\n` + ` var cx = P.spacing * c + P.spacing / 2, s0 = P.stagger && c % 2 === 1 ? -1 : 1;\n` + - ` d += "M" + r(cx) + " 0C" + r(cx + s0 * amp) + " " + r(0.25 * h) + " " + r(cx + s0 * amp) + " " + r(0.75 * h) + " " + r(cx) + " " + r(h);\n` + - ` for (var i = 1; i < segs; i++) { var sn = s0 * (i % 2 === 0 ? 1 : -1); d += "S" + r(cx + sn * amp) + " " + r(i * h + 0.75 * h) + " " + r(cx) + " " + r((i + 1) * h); }\n` + + ` d += cubicsPath(columnCubics(cx, s0, amp, wl, cycles));\n` + ` }\n` + ` return { d: d, tileW: tileW, H: H };\n` + ` }\n` + - ` function columnCubics(cx, s0, amp, h, segs) {\n` + - ` var cs = [{ p0: { x: cx, y: 0 }, p1: { x: cx + s0 * amp, y: 0.25 * h }, p2: { x: cx + s0 * amp, y: 0.75 * h }, p3: { x: cx, y: h } }];\n` + - ` var pP2 = cs[0].p2, pP3 = cs[0].p3;\n` + - ` for (var i = 1; i < segs; i++) {\n` + - ` var sn = s0 * (i % 2 === 0 ? 1 : -1);\n` + - ` var cp1 = { x: 2 * pP3.x - pP2.x, y: 2 * pP3.y - pP2.y };\n` + - ` var cp2 = { x: cx + sn * amp, y: i * h + 0.75 * h };\n` + - ` var end = { x: cx, y: (i + 1) * h };\n` + - ` cs.push({ p0: pP3, p1: cp1, p2: cp2, p3: end }); pP2 = cp2; pP3 = end;\n` + - ` }\n` + - ` return cs;\n` + - ` }\n` + ` function shift(cs, dx) { return cs.map(function (c) { return { p0: { x: c.p0.x + dx, y: c.p0.y }, p1: { x: c.p1.x + dx, y: c.p1.y }, p2: { x: c.p2.x + dx, y: c.p2.y }, p3: { x: c.p3.x + dx, y: c.p3.y } }; }); }\n` + ` function stripPath(L, R, cxL, cxR, H) {\n` + ` var d = "M" + r(cxL) + " 0", i, c;\n` + @@ -914,9 +922,8 @@

wave background lab

` }\n` + ` function buildStrips(amp, wl, W, Hh) {\n` + ` if (!W || !Hh) return "";\n` + - ` var h = wl / 2, S = Math.round(P.spacing);\n` + - ` var segs = 2 * (Math.ceil(Hh / wl) + 1), totalH = segs * h;\n` + - ` var base = columnCubics(0, 1, amp, h, segs), out = "", kMax = Math.ceil(W / S) + 1;\n` + + ` var S = Math.round(P.spacing), cycles = Math.ceil(Hh / wl) + 1, totalH = cycles * wl;\n` + + ` var base = columnCubics(0, 1, amp, wl, cycles), out = "", kMax = Math.ceil(W / S) + 1;\n` + ` for (var k = -1; k < kMax; k++) {\n` + ` var cxL = k * S + S / 2, cxR = cxL + S;\n` + ` var d = stripPath(shift(base, cxL), shift(base, cxR), cxL, cxR, totalH);\n` + From 92f0fee1eb87b8b85bfae553d5e537861762aa40 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Wed, 24 Jun 2026 20:42:38 -0700 Subject: [PATCH 03/17] Refine demo container depth --- demo/demo.css | 70 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/demo/demo.css b/demo/demo.css index 209ff67..16a18af 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -191,6 +191,15 @@ modern wide-gamut OKLCH/P3 color syntax. */ html[data-surface] { --demo-wave-fallback: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%221629%22%20viewBox%3D%220%200%20128%201629%22%3E%3Cpath%20d%3D%22M64%200C75.781%2022.625%2087.489%2045.25%2095.82%2067.875C104.15%2090.5%20109%20113.125%20109%20135.75C109%20158.375%20104.15%20181%2095.82%20203.625C87.489%20226.25%2075.781%20248.875%2064%20271.5C52.219%20294.125%2040.511%20316.75%2032.18%20339.375C23.85%20362%2019%20384.625%2019%20407.25C19%20429.875%2023.85%20452.5%2032.18%20475.125C40.511%20497.75%2052.219%20520.375%2064%20543C75.781%20565.625%2087.489%20588.25%2095.82%20610.875C104.15%20633.5%20109%20656.125%20109%20678.75C109%20701.375%20104.15%20724%2095.82%20746.625C87.489%20769.25%2075.781%20791.875%2064%20814.5C52.219%20837.125%2040.511%20859.75%2032.18%20882.375C23.85%20905%2019%20927.625%2019%20950.25C19%20972.875%2023.85%20995.5%2032.18%201018.125C40.511%201040.75%2052.219%201063.375%2064%201086C75.781%201108.625%2087.489%201131.25%2095.82%201153.875C104.15%201176.5%20109%201199.125%20109%201221.75C109%201244.375%20104.15%201267%2095.82%201289.625C87.489%201312.25%2075.781%201334.875%2064%201357.5C52.219%201380.125%2040.511%201402.75%2032.18%201425.375C23.85%201448%2019%201470.625%2019%201493.25C19%201515.875%2023.85%201538.5%2032.18%201561.125C40.511%201583.75%2052.219%201606.375%2064%201629%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-opacity%3D%220.075%22%20stroke-width%3D%221%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20shape-rendering%3D%22geometricPrecision%22%2F%3E%3C%2Fsvg%3E"); + --demo-container-shadow: + 0 1px 1px rgb(0 0 0 / 0.14), + 0 5px 10px -9px rgb(0 0 0 / 0.2); + --demo-inner-border-size: 1px; + --demo-inner-border-light-alpha: 0.16; + --demo-inner-border-mid-alpha: 0.045; + --demo-inner-border-dark-alpha: 0.22; + --demo-inner-border-opacity: 0.78; + --demo-inner-border-blend-mode: soft-light; /* Color for the stepped wave-strip fills. Matches the white wave strokes on every (dark) surface; overridable per theme for a tinted gradation. */ @@ -958,7 +967,41 @@ h3 { border-color 180ms ease; } +:is(.tuning-card, .feature-column, .support-tooltip, .floating-surface-palette) { + position: relative; + isolation: isolate; +} + +:is(.tuning-card, .feature-column, .support-tooltip, .floating-surface-palette)::before { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + padding: var(--demo-inner-border-size); + border-radius: inherit; + pointer-events: none; + background: linear-gradient( + 180deg, + rgb(255 255 255 / var(--demo-inner-border-light-alpha)) 0%, + rgb(255 255 255 / var(--demo-inner-border-mid-alpha)) 38%, + rgb(0 0 0 / var(--demo-inner-border-mid-alpha)) 68%, + rgb(0 0 0 / var(--demo-inner-border-dark-alpha)) 100% + ); + mix-blend-mode: var(--demo-inner-border-blend-mode); + opacity: var(--demo-inner-border-opacity); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; +} + .tuning-card { + --demo-inner-border-light-alpha: 0.18; + --demo-inner-border-dark-alpha: 0.2; border: 0; background-color: color-mix( in oklab, @@ -970,7 +1013,7 @@ h3 { 95%, transparent ); - box-shadow: 0 18px 48px rgb(0 0 0 / 0.22); + box-shadow: var(--demo-container-shadow); transition: background-color 180ms ease, box-shadow 180ms ease; @@ -1345,6 +1388,10 @@ h3 { } .floating-surface-palette { + --demo-inner-border-light-alpha: 0.18; + --demo-inner-border-mid-alpha: 0.04; + --demo-inner-border-dark-alpha: 0.24; + --demo-inner-border-opacity: 0.64; --palette-glass-filter: url(#palette-glass-filter); --palette-glass-blur: 3px; --palette-glass-brightness: 1.12; @@ -1362,10 +1409,8 @@ h3 { isolation: isolate; background: rgb(255 255 255 / 0.05); box-shadow: - inset 0 0 0 1px rgb(255 255 255 / 0.1), - inset 0 1px 0 rgb(255 255 255 / 0.15), - inset 0 -18px 28px rgb(255 255 255 / 0.02), - 0 12px 36px rgb(0 0 0 / 0.16); + 0 1px 1px rgb(0 0 0 / 0.14), + 0 5px 10px -9px rgb(0 0 0 / 0.18); -webkit-backdrop-filter: var(--palette-glass-filter) blur(var(--palette-glass-blur)) brightness(var(--palette-glass-brightness)) @@ -1526,6 +1571,10 @@ h3 { } .support-tooltip { + --demo-inner-border-light-alpha: 0.2; + --demo-inner-border-mid-alpha: 0.04; + --demo-inner-border-dark-alpha: 0.22; + --demo-inner-border-opacity: 0.72; position: absolute; bottom: calc(100% + 0.5rem); left: 0; @@ -1533,8 +1582,11 @@ h3 { width: min(13rem, calc(100vw - 2rem)); pointer-events: none; border-radius: var(--demo-radius-sm); - border: 1px solid var(--demo-border); + border: 0; background: var(--demo-panel-strong); + box-shadow: + 0 1px 1px rgb(0 0 0 / 0.14), + 0 5px 10px -9px rgb(0 0 0 / 0.2); padding: 0.625rem 0.75rem; color: var(--demo-text-primary); font-size: 0.75rem; @@ -2076,6 +2128,10 @@ label.option-control { } .feature-column { + --demo-inner-border-light-alpha: 0.14; + --demo-inner-border-mid-alpha: 0.035; + --demo-inner-border-dark-alpha: 0.18; + --demo-inner-border-opacity: 0.72; --feature-card-bg: color-mix( in oklab, color-mix( @@ -2136,7 +2192,7 @@ label.option-control { padding: 1.5rem; border-radius: var(--demo-radius-lg); background-color: var(--feature-card-bg); - box-shadow: 0 18px 48px rgb(0 0 0 / 0.22); + box-shadow: var(--demo-container-shadow); transition: background-color 180ms ease, box-shadow 180ms ease; From 17c4636b200571ebc33628ca272ad3d1e784c46e Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Wed, 24 Jun 2026 22:49:00 -0700 Subject: [PATCH 04/17] Refine demo depth and motion --- demo/demo-animations.js | 8 ++-- demo/demo-controls.js | 11 +++-- demo/demo.css | 103 ++++++++++++++++++++++++++++------------ demo/index.html | 10 ++-- 4 files changed, 89 insertions(+), 43 deletions(-) diff --git a/demo/demo-animations.js b/demo/demo-animations.js index 655f589..55f353e 100644 --- a/demo/demo-animations.js +++ b/demo/demo-animations.js @@ -85,12 +85,12 @@ const spring = { restDelta: 0.001, }, swatch: { - // Background-picker thumbnails (ζ≈0.66): a single, lively overshoot on + // Background-picker thumbnails (ζ≈0.89): a restrained overshoot on // hover/press, and a pop on selection (driven by a transient over-target, - // not a velocity kick) that settles in one clean bounce. + // not a velocity kick) that settles without feeling rubbery. type: "spring", - stiffness: 560, - damping: 26, + stiffness: 520, + damping: 34, mass: 0.7, restDelta: 0.001, }, diff --git a/demo/demo-controls.js b/demo/demo-controls.js index 4d9c7ff..304f6bd 100644 --- a/demo/demo-controls.js +++ b/demo/demo-controls.js @@ -380,8 +380,8 @@ function setSwatchScale(swatch, scale, animated = true, transition) { transition ?? animation.spring?.swatch ?? { type: "spring", - stiffness: 560, - damping: 26, + stiffness: 520, + damping: 34, mass: 0.7, restDelta: 0.001, }, @@ -393,7 +393,7 @@ function syncSwatchSpring(swatch, animated = true) { const state = swatchStateFor(swatch); const checked = swatch.getAttribute("aria-checked") === "true"; const scale = state.pressed - ? 0.9 + ? 0.95 : checked ? 1.08 : state.hovered @@ -404,8 +404,9 @@ function syncSwatchSpring(swatch, animated = true) { function springSwatchSelection(swatch) { // Pop: spring past the checked rest scale, then settle back to it. The - // transient over-target (1.18 → 1.08) reads as a deliberate selection bounce. - setSwatchScale(swatch, 1.18, true); + // transient over-target (1.13 → 1.08) reads as selection without a rubbery + // rebound. + setSwatchScale(swatch, 1.13, true); window.setTimeout(() => { syncSwatchSpring(swatch, true); }, 130); diff --git a/demo/demo.css b/demo/demo.css index 16a18af..b0958b4 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -192,13 +192,13 @@ modern wide-gamut OKLCH/P3 color syntax. */ html[data-surface] { --demo-wave-fallback: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%221629%22%20viewBox%3D%220%200%20128%201629%22%3E%3Cpath%20d%3D%22M64%200C75.781%2022.625%2087.489%2045.25%2095.82%2067.875C104.15%2090.5%20109%20113.125%20109%20135.75C109%20158.375%20104.15%20181%2095.82%20203.625C87.489%20226.25%2075.781%20248.875%2064%20271.5C52.219%20294.125%2040.511%20316.75%2032.18%20339.375C23.85%20362%2019%20384.625%2019%20407.25C19%20429.875%2023.85%20452.5%2032.18%20475.125C40.511%20497.75%2052.219%20520.375%2064%20543C75.781%20565.625%2087.489%20588.25%2095.82%20610.875C104.15%20633.5%20109%20656.125%20109%20678.75C109%20701.375%20104.15%20724%2095.82%20746.625C87.489%20769.25%2075.781%20791.875%2064%20814.5C52.219%20837.125%2040.511%20859.75%2032.18%20882.375C23.85%20905%2019%20927.625%2019%20950.25C19%20972.875%2023.85%20995.5%2032.18%201018.125C40.511%201040.75%2052.219%201063.375%2064%201086C75.781%201108.625%2087.489%201131.25%2095.82%201153.875C104.15%201176.5%20109%201199.125%20109%201221.75C109%201244.375%20104.15%201267%2095.82%201289.625C87.489%201312.25%2075.781%201334.875%2064%201357.5C52.219%201380.125%2040.511%201402.75%2032.18%201425.375C23.85%201448%2019%201470.625%2019%201493.25C19%201515.875%2023.85%201538.5%2032.18%201561.125C40.511%201583.75%2052.219%201606.375%2064%201629%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-opacity%3D%220.075%22%20stroke-width%3D%221%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20shape-rendering%3D%22geometricPrecision%22%2F%3E%3C%2Fsvg%3E"); --demo-container-shadow: - 0 1px 1px rgb(0 0 0 / 0.14), - 0 5px 10px -9px rgb(0 0 0 / 0.2); + 0 1px 1px rgb(0 0 0 / 0.161), + 0 5px 10px -9px rgb(0 0 0 / 0.23); --demo-inner-border-size: 1px; - --demo-inner-border-light-alpha: 0.16; + --demo-inner-border-light-alpha: 0.18; --demo-inner-border-mid-alpha: 0.045; --demo-inner-border-dark-alpha: 0.22; - --demo-inner-border-opacity: 0.78; + --demo-inner-border-opacity: 0.9; --demo-inner-border-blend-mode: soft-light; /* Color for the stepped wave-strip fills. Matches the white wave strokes @@ -646,6 +646,8 @@ h3 { } .install-snippet { + --demo-inner-border-light-alpha: 0.2; + --demo-inner-border-dark-alpha: 0.2; position: relative; display: inline-flex; align-self: flex-start; @@ -668,9 +670,11 @@ h3 { 95%, transparent ); - box-shadow: none; + box-shadow: var(--demo-container-shadow); isolation: isolate; - transition: background-color 180ms ease; + transition: + background-color 180ms ease, + box-shadow 180ms ease; } .install-snippet-sweep { @@ -906,19 +910,38 @@ h3 { } .social-icon-link { + --demo-inner-border-light-alpha: 0.2; + --demo-inner-border-dark-alpha: 0.2; + --social-icon-bg: color-mix( + in oklab, + color-mix( + in oklab, + var(--demo-page-bg) 92%, + var(--demo-text-primary) 8% + ) + 96%, + transparent + ); + --social-icon-bg-hover: color-mix( + in oklab, + color-mix( + in oklab, + var(--demo-page-bg) 84%, + var(--demo-text-primary) 16% + ) + 96%, + transparent + ); position: relative; display: grid; width: 2.25rem; height: 2.25rem; flex: 0 0 2.25rem; place-items: center; - border: 1px solid var(--demo-border); + border: 0; border-radius: 9999px; - background-color: color-mix( - in oklab, - var(--demo-panel-bg) 54%, - transparent - ); + background-color: var(--social-icon-bg); + box-shadow: var(--demo-container-shadow); color: color-mix(in oklab, var(--demo-text-strong) 68%, transparent); backdrop-filter: blur(12px); cursor: pointer; @@ -927,16 +950,17 @@ h3 { transition: color 180ms ease, background-color 180ms ease, - border-color 180ms ease; + box-shadow 180ms ease; } .social-icon-link:hover { - border-color: var(--demo-border-strong); - background-color: var(--demo-panel-strong); + background-color: var(--social-icon-bg-hover); color: var(--demo-text-strong); } .social-icon-link svg { + position: relative; + z-index: 2; width: 1rem; height: 1rem; fill: currentColor; @@ -967,12 +991,26 @@ h3 { border-color 180ms ease; } -:is(.tuning-card, .feature-column, .support-tooltip, .floating-surface-palette) { +:is( + .install-snippet, + .social-icon-link, + .tuning-card, + .feature-column, + .support-tooltip, + .floating-surface-palette + ) { position: relative; isolation: isolate; } -:is(.tuning-card, .feature-column, .support-tooltip, .floating-surface-palette)::before { +:is( + .install-snippet, + .social-icon-link, + .tuning-card, + .feature-column, + .support-tooltip, + .floating-surface-palette + )::before { content: ""; position: absolute; inset: 0; @@ -1000,7 +1038,7 @@ h3 { } .tuning-card { - --demo-inner-border-light-alpha: 0.18; + --demo-inner-border-light-alpha: 0.2; --demo-inner-border-dark-alpha: 0.2; border: 0; background-color: color-mix( @@ -1154,6 +1192,7 @@ h3 { align-self: center; min-width: 0; color: var(--demo-text-soft); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.875rem; line-height: 1.4; opacity: 0.34; @@ -1259,6 +1298,8 @@ h3 { words dissolve and the pattern shows straight through their edges. A static gradient overlay can only paint one flat color and would leave a band here. */ .anybg-stage { + --anybg-stage-height: 26rem; + position: relative; /* Transparent base so the page background (and its wave) shows through every gap in the lattice above. */ @@ -1278,8 +1319,8 @@ h3 { permanent all-edge fade (no scroll needed). A motif the box slices is already transparent at the cut, so it feathers out instead of being chopped — at any width/height and through live resize, zero JS, and it dogfoods tw-fade. The - default md fade size (3rem) matches the rail's fade-x, so the pattern and the - words dissolve along one shared edge. */ + shared 2xl fade size matches the rail's fade-x, so the pattern and the words + dissolve along one shared edge. */ .anybg-pattern { position: absolute; inset: 0; @@ -1295,9 +1336,9 @@ h3 { z-index: 1; display: flex; align-items: center; - gap: 2.75rem; - height: 13rem; - padding: 0 2.25rem; + gap: clamp(4rem, 7vw, 6.5rem); + height: var(--anybg-stage-height); + padding: 0 clamp(3rem, 6vw, 5rem); background: transparent; } @@ -1306,9 +1347,9 @@ h3 { color: #fafafa; font-family: "Zalando Sans SemiExpanded", ui-sans-serif, system-ui, sans-serif; - font-size: clamp(2.5rem, 1.5rem + 3.2vw, 4rem); + font-size: clamp(4.75rem, 2.75rem + 6.5vw, 8rem); font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; line-height: 1; white-space: nowrap; user-select: none; @@ -1388,10 +1429,10 @@ h3 { } .floating-surface-palette { - --demo-inner-border-light-alpha: 0.18; + --demo-inner-border-light-alpha: 0.2; --demo-inner-border-mid-alpha: 0.04; --demo-inner-border-dark-alpha: 0.24; - --demo-inner-border-opacity: 0.64; + --demo-inner-border-opacity: 0.736; --palette-glass-filter: url(#palette-glass-filter); --palette-glass-blur: 3px; --palette-glass-brightness: 1.12; @@ -1571,10 +1612,10 @@ h3 { } .support-tooltip { - --demo-inner-border-light-alpha: 0.2; + --demo-inner-border-light-alpha: 0.22; --demo-inner-border-mid-alpha: 0.04; --demo-inner-border-dark-alpha: 0.22; - --demo-inner-border-opacity: 0.72; + --demo-inner-border-opacity: 0.828; position: absolute; bottom: calc(100% + 0.5rem); left: 0; @@ -2128,10 +2169,10 @@ label.option-control { } .feature-column { - --demo-inner-border-light-alpha: 0.14; + --demo-inner-border-light-alpha: 0.16; --demo-inner-border-mid-alpha: 0.035; --demo-inner-border-dark-alpha: 0.18; - --demo-inner-border-opacity: 0.72; + --demo-inner-border-opacity: 0.828; --feature-card-bg: color-mix( in oklab, color-mix( diff --git a/demo/index.html b/demo/index.html index 651ed09..348f00a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -2170,9 +2170,9 @@
-
+
Date: Wed, 24 Jun 2026 22:55:58 -0700 Subject: [PATCH 05/17] Rewrite demo chat copy --- demo/index.html | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/demo/index.html b/demo/index.html index 348f00a..97cc279 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1120,83 +1120,77 @@ >

- hey! finally trying tw-fade 👋 + this feed has a hard edge

- oh nice — welcome + add fade-y to the scroll area

- these scroll lists are slick + do i need another wrapper?

- thanks, it’s all CSS + no. use the element that scrolls

- this thread fades at the edges - while i scroll 👀 + at the top, does the fade stay?

- that’s tw-fade ✨ + no, the top fade clears

- some scroll library? + and at the end?

- nope — a pure CSS mask + the bottom fade clears

- zero JavaScript + the newest message stays clear

- so how does it know i’m - scrolling? + horizontal tabs too?

- scroll-driven CSS — the fade is - scroll-gated + use fade-x on the rail

- does it soften both edges? + can i make the fade wider?

- fade-y — one class on the - container + set a larger range

- but the newest message stays - sharp 🔥 + top edge by itself?

- pinned to the bottom, yep — scroll - up and both edges dissolve ↕︎ + use fade-t

From 0a21d81f32bf55b5658fcb3526474398d6450621 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Wed, 24 Jun 2026 22:57:30 -0700 Subject: [PATCH 06/17] Update demo CSS output --- demo/styles.css | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/demo/styles.css b/demo/styles.css index e43f472..2e25910 100644 --- a/demo/styles.css +++ b/demo/styles.css @@ -199,24 +199,6 @@ .static { position: static; } - .container { - width: 100%; - @media (width >= 40rem) { - max-width: 40rem; - } - @media (width >= 48rem) { - max-width: 48rem; - } - @media (width >= 64rem) { - max-width: 64rem; - } - @media (width >= 80rem) { - max-width: 80rem; - } - @media (width >= 96rem) { - max-width: 96rem; - } - } .mx-auto { margin-inline: auto; } From 4de29f40427a36368f333f4e7193a2609b4c0137 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Fri, 26 Jun 2026 15:07:09 -0700 Subject: [PATCH 07/17] Implement plain direction fade API --- MIGRATING.md | 122 +++ README.md | 414 +++++----- build/verify-demo-runtime.mjs | 4 +- build/verify-dist.mjs | 20 +- build/verify-fade.mjs | 26 +- build/verify-horizontal-rtl.mjs | 129 +++ build/verify-page.mjs | 24 +- build/verify.mjs | 8 +- demo/demo-controls.js | 71 +- demo/demo.css | 18 +- demo/index.html | 58 +- demo/input.css | 2 +- demo/styles.css | 925 +++++++++------------ dist/tw-fade.css | 1370 +++++++++++++------------------ package-lock.json | 4 +- package.json | 7 +- scripts/build-css.mjs | 4 +- src/tw-fade.css | 720 +++++++++------- test/plugin.test.mjs | 545 ++++++------ 19 files changed, 2225 insertions(+), 2246 deletions(-) create mode 100644 MIGRATING.md create mode 100644 build/verify-horizontal-rtl.mjs diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..4e7da03 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,122 @@ +# Migrating To The Plain Direction API + +This release is a breaking rename. The implementation still uses a physical four-edge mask internally, but the public API now uses readable directions: + +```txt +fade +fade-y +fade-top +fade-bottom +fade-x +fade-start +fade-end +``` + +`start` and `end` are horizontal and direction-aware. In LTR, start is left and end is right. In RTL, start is right and end is left. RTL routing follows the HTML `dir` attribute on the scroll container or an ancestor, not a CSS-only `direction: rtl` rule. + +## Direction Renames + +| Before | After | +| --- | --- | +| `fade-xy` | `fade` | +| `fade-t` | `fade-top` | +| `fade-b` | `fade-bottom` | +| `fade-l` | `fade-start` for normal LTR horizontal rails | +| `fade-r` | `fade-end` for normal LTR horizontal rails | +| `fade-y` | unchanged | +| `fade-x` | unchanged name, now direction-aware | +| `fade-static` | `fade-always` | + +There are no public `fade-left` or `fade-right` utilities in this API. Use `fade-start` and `fade-end` unless you intentionally need a physical-side escape hatch. That escape hatch is not part of v1. + +## Size Renames + +| Before | After | +| --- | --- | +| `fade-size-t-*` | `fade-size-top-*` | +| `fade-size-b-*` | `fade-size-bottom-*` | +| `fade-size-l-*` | `fade-size-start-*` | +| `fade-size-r-*` | `fade-size-end-*` | +| `fade-size-y-*` | unchanged | +| `fade-size-x-*` | unchanged name, now start + end | +| `fade-size-*` | unchanged | + +The source path still supports arbitrary values: + +```html +
...
+``` + +The prebuilt CSS includes named sizes only. + +## Reveal Renames + +`range` was renamed to `reveal` because the class controls how quickly the fade appears and disappears during scroll. + +| Before | After | +| --- | --- | +| `fade-range-*` | `fade-reveal-*` | +| `fade-range-[80px]` | `fade-reveal-[80px]` | +| `--fade-range-md` | `--fade-reveal-md` | + +`fade-reveal-*` is global only. There are no per-edge reveal utilities. + +## Clear Zone Renames + +| Before | After | +| --- | --- | +| `fade-clear-t-*` | `fade-clear-top-*` | +| `fade-clear-b-*` | `fade-clear-bottom-*` | +| `fade-clear-l-*` | `fade-clear-start-*` | +| `fade-clear-r-*` | `fade-clear-end-*` | +| `fade-clear-xy-*` | `fade-clear-*` | +| `fade-clear-y-*` | unchanged | +| `fade-clear-x-*` | unchanged name, now start + end | + +Dynamic clear variables use the new names too: + +| Before | After | +| --- | --- | +| `--fade-clear-t` | `--fade-clear-top` | +| `--fade-clear-b` | `--fade-clear-bottom` | +| `--fade-clear-l` | `--fade-clear-start` | +| `--fade-clear-r` | `--fade-clear-end` | +| `--fade-clear-xy` | `--fade-clear` | + +## Force Or Disable + +| Before | After | +| --- | --- | +| `fade-static` | `fade-always` | +| none | `fade-always-y` | +| none | `fade-always-x` | +| none | `fade-none` | +| none | `fade-none-y` | +| none | `fade-none-x` | + +Pair these with a direction utility. For example: + +```html +
...
+``` + +## CDN Users + +Pin CDN URLs to the version your HTML was written against. After migrating to this API, a pinned URL looks like this: + +```html + +``` + +Unversioned URLs such as `https://unpkg.com/tw-fade/dist/tw-fade.css` track the latest package and will receive breaking API changes. + +## Demo Release Note + +The demo CSS is generated. After changing the source API or demo markup, run: + +```bash +npm run build:demo +npm run ship-demo +``` + +That keeps the vendored `pete.design` demo in sync with the package docs and generated CSS. diff --git a/README.md b/README.md index d92a8d4..efdaaa0 100644 --- a/README.md +++ b/README.md @@ -1,367 +1,327 @@ # tw-fade -Scroll-aware edge fades for Tailwind CSS v4. Mask the edges of any scroll container so content dissolves into its surface. All CSS. Zero JavaScript. +Scroll-aware edge fades for Tailwind CSS v4. Mask the edges of any scroll container so content dissolves into the surface behind it. All CSS. No runtime JavaScript. -**[Live demo →](https://pete.design/tw-fade)** +**[Live demo](https://pete.design/tw-fade)** ```html -
+
``` -The fade reveals as you scroll into content and recedes as you reach the ends. - ---- +The fade appears only when there is content beyond that edge, then recedes again when you reach the start or end. ## Why -A static edge scrim (a gradient overlay pinned to the top of a scroll area) is always on. It dims content even when nothing is scrolled out of view, and it sits *in front of* your content as a separate element. - -`tw-fade` fixes both: - -- **Scroll-gated.** The fade appears only when content has scrolled past the edge, and retracts when you reach the start or end. It never dims content you can already see in full. -- **Masks the container itself.** The effect is a `mask-image` on the scroll container: no extra DOM, no stacking-context juggling. It reveals whatever surface sits behind the element instead of painting a fixed color on top. - -Add `fade-t`, `fade-b`, `fade-x`, `fade-y`, or `fade-xy` to a scrollable element and you're done. - -### Highlights - -- **No runtime JavaScript.** CSS scroll-driven animations (`animation-timeline: scroll()`) drive the reveal; the fade itself is a composited `mask-image`. -- **Composable per edge.** Fade one edge, an axis, or all four. The utilities compose (`fade-t fade-r` works) instead of overwriting each other, and nested fades stay isolated. -- **Tunable.** Set band thickness, reveal distance, and clearance zones for sticky headers and footers through utilities or theme tokens, with arbitrary values on the Tailwind source path. -- **Smooth curve.** The fade follows an eased sigmoid (S-curve) alpha ramp instead of a flat linear gradient, so opacity changes faster through the middle and eases at the ends. -- **Graceful fallback.** On engines without scroll-driven animations, the effect degrades to a permanent static fade via `@supports`, so content stays readable rather than stranded behind a half-applied mask. +A static gradient overlay is always on. It dims content even when nothing is hidden beyond the edge, and it sits in front of your content as another layer. ---- +`tw-fade` uses a CSS mask on the scroll container itself: -## Requirements - -| Use it as… | You need | -| --- | --- | -| Tailwind v4 source utilities (full JIT, arbitrary values) | Tailwind CSS **v4.0.0+** | -| Prebuilt framework-free stylesheet (`` / CDN, fixed utility set) | **nothing** (no Tailwind, no build step) | - -The package ships a precompiled, self-contained `dist/tw-fade.css` with only the `fade-*` utilities and their `@property` foundations. No Tailwind Preflight, no reset, no Tailwind core `--tw-*` variables. - ---- +- **Scroll-aware.** The fade is gated by the element's own scroll position. +- **No extra DOM.** The mask lives on the scroll container. +- **Surface-neutral.** The mask reveals whatever is behind the element instead of painting a fake background color. +- **Composable.** Direction, size, reveal distance, and clear zones are separate utilities. +- **Graceful fallback.** Browsers without scroll-driven animations get a static fade instead of a broken mask. ## Install -### Tailwind v4 (CSS-first source path) +### Tailwind v4 source path -Use this if you already build with Tailwind v4. You get the full JIT, including arbitrary values like `fade-size-[2rem]`. +Use this when your app already builds with Tailwind v4. This path supports arbitrary values like `fade-size-[2rem]`. ```bash npm install tw-fade ``` -In your Tailwind entry CSS: - ```css @import "tailwindcss"; @import "tw-fade"; ``` -The bare `@import "tw-fade"` resolves to the package's `src/tw-fade.css`, authored for v4's CSS-first pipeline and compiled by *your* Tailwind build. +### Prebuilt CSS -### Prebuilt drop-in (no Tailwind required) - -Use the precompiled stylesheet directly in plain HTML, over a CDN, or through a bundler. +Use this for plain HTML, CDN usage, or bundlers that import CSS directly. ```html - + ``` ```js -// or, via a JS/bundler import: import "tw-fade/css"; ``` -The drop-in ships a fixed, enumerated set of utilities: the named scale only, no arbitrary bracket values. See [Source path vs. prebuilt](#source-path-vs-prebuilt). +The prebuilt file includes only the named utility set. Arbitrary bracket values need Tailwind's source path. ---- +If you use an unversioned CDN URL, you receive the latest package and any breaking API changes that come with it. Pin a version when you need stable HTML. ## Usage -`tw-fade` is plain CSS classes. Apply them however your framework sets `class`. +Put the fade utility on the element that actually scrolls. ```html - -
+ +
...
- -
+ +
...
- -
+ +
...
- -
+ +
...
- -
+ +
...
``` -The fade keys off the element's **own** scroll position, so put the utility on the element that scrolls (the one with `overflow-*: auto/scroll`), not a parent. - ---- - -## API reference - -### Direction utilities - -Each utility masks one or more physical edges. Vertical edges track the element's vertical scroll; horizontal edges track its horizontal scroll. +## Direction Utilities -| Utility | Edges masked | +| Utility | Edges | | --- | --- | -| `fade-t` | top | -| `fade-b` | bottom | -| `fade-l` | left | -| `fade-r` | right | +| `fade` | all four edges | | `fade-y` | top + bottom | -| `fade-x` | left + right | -| `fade-xy` | all four | -| `fade-static` | pins the selected fade(s) fully on, always (see below) | +| `fade-top` | top | +| `fade-bottom` | bottom | +| `fade-x` | horizontal start + horizontal end | +| `fade-start` | horizontal start | +| `fade-end` | horizontal end | -`fade-static` forces the fade on regardless of scroll and disables the scroll-driven reveal. It sets only the fade *amounts*, not which edges are masked, so pair it with a direction utility: `fade-y fade-static` renders a permanent top and bottom fade. (It pins all four edge amounts internally, but only edges selected by a direction utility render a mask.) +`start` and `end` follow semantic reading direction for horizontal scrollers. In LTR, start is the left side and end is the right side. In RTL, start is the right side and end is the left side. Use the HTML `dir` attribute on the scroll container or an ancestor; a CSS-only `direction: rtl` rule does not trigger RTL routing. -### Size: fade band thickness +Vertical names stay plain because they are not affected by text direction. -Controls how thick the faded band is. Accepts the [named scale](#named-scale), a `[length]`, or a `[percentage]`. +## Size + +Size controls how thick the fade band is. | Utility | Affects | | --- | --- | -| `fade-size-*` | global band thickness (all edges) | -| `fade-size-t-*` / `-b-*` / `-l-*` / `-r-*` | a single edge | -| `fade-size-y-*` | both vertical edges | -| `fade-size-x-*` | both horizontal edges | +| `fade-size-*` | all edges | +| `fade-size-y-*` | top + bottom | +| `fade-size-x-*` | start + end | +| `fade-size-top-*` | top | +| `fade-size-bottom-*` | bottom | +| `fade-size-start-*` | horizontal start | +| `fade-size-end-*` | horizontal end | ```html -
+
...
``` -Resolution runs **edge → axis → global → default** (`fade-size-md`, i.e. `3rem`): a per-edge value beats an axis value, which beats the global value, which falls back to the default. See [Resolution precedence](#resolution-precedence). +Resolution is edge, then axis, then global, then the default. For example, top uses `fade-size-top-*` first, then `fade-size-y-*`, then `fade-size-*`. -### Range: scroll reveal distance +The default size is capped at `min(12%, 3rem)`, so small scroll areas do not get swallowed by the fade. Named sizes derive from Tailwind's spacing unit, `--spacing`, with `0.25rem` as the fallback. -Controls the scroll distance over which a fade reveals (as you scroll in) or retracts (as you reach the end). Accepts the named scale, a `[length]`, or a `[percentage]`. +| Step | Value | Step | Value | +| --- | --- | --- | --- | +| `xs` | `1.5rem` | `xl` | `5rem` | +| `sm` | `2rem` | `2xl` | `6rem` | +| `md` | `3rem` | `3xl` | `8rem` | +| `lg` | `4rem` | `4xl` | `10rem` | -| Utility | Affects | -| --- | --- | -| `fade-range-*` | reveal distance (default `fade-range-md`, i.e. `3rem`) | +You can override the tokens: + +```css +@theme { + --fade-size-md: 2.5rem; +} +``` + +On the Tailwind source path, size accepts named values, lengths, and percentages: + +```html +
...
+``` -A short range snaps the fade in; a long range eases it over more scroll travel. +The prebuilt CSS includes the named sizes only. -### Clear: clearance zone before the fade +## Reveal Distance -An opaque, unfaded band held at the edge before the fade ramp begins. Use it behind a sticky header or footer so the pinned UI stays fully visible while content fades beneath it. +Reveal distance controls how much scroll travel is used to bring a fade in or out. | Utility | Affects | | --- | --- | -| `fade-clear-t-*` / `-b-*` / `-l-*` / `-r-*` | a single edge | -| `fade-clear-y-*` | both vertical edges | -| `fade-clear-x-*` | both horizontal edges | -| `fade-clear-xy-*` | all four edges | - -The `*` accepts the named scale, a bare integer (`N` = `spacing × N`, e.g. `fade-clear-t-4` = `1rem`), a `[length]`, or a `[percentage]`. Default clearance is `0`. +| `fade-reveal-*` | all selected edges | ```html - -
-
- … -
+
...
+
...
``` -> On macOS, rubber-band overscroll can briefly reveal a gap between a sticky element and the clear zone, because the mask stays fixed to the scroll container while the sticky element rubber-bands with the content. If that matters, fade only the opposite edge (e.g. `fade-b` under a top-pinned header). +A smaller reveal distance snaps the fade in faster. A larger distance eases it over more scroll. -#### Dynamic clear zones (`-var`) +The named reveal scale is the same as the size scale and can be overridden with `--fade-reveal-{step}`. Arbitrary reveal values are source-path only. -Every clear utility has a `-var` form that reads the clearance from a CSS custom property you set, so you can drive it from JS or layout-dependent values: +## Clear Zones -`fade-clear-t-var`, `fade-clear-b-var`, `fade-clear-l-var`, `fade-clear-r-var`, `fade-clear-y-var`, `fade-clear-x-var`, `fade-clear-xy-var`. +Clear zones keep a fully opaque strip before the fade starts. Use them for sticky headers, sticky footers, or fixed controls inside the scroll container. -Each resolves its property along an **edge → axis → xy → `0px`** chain. The public custom properties you can set: - -| Property | Read by | +| Utility | Affects | | --- | --- | -| `--fade-clear-t` / `-b` / `-l` / `-r` | the matching per-edge `-var` | -| `--fade-clear-y` / `-x` | the matching axis `-var` (and per-edge as a fallback) | -| `--fade-clear-xy` | all `-var` utilities (final fallback before `0px`) | +| `fade-clear-*` | all edges | +| `fade-clear-y-*` | top + bottom | +| `fade-clear-x-*` | start + end | +| `fade-clear-top-*` | top | +| `fade-clear-bottom-*` | bottom | +| `fade-clear-start-*` | horizontal start | +| `fade-clear-end-*` | horizontal end | ```html -
+
+
...
+ ... +
``` -```js -// e.g. keep clearance in sync with a measured sticky header -el.style.setProperty("--fade-clear-t", header.offsetHeight + "px"); -``` +Clear utilities accept named values, lengths, percentages, and bare integers on the Tailwind source path. A bare integer maps to `--spacing * N`, so `fade-clear-top-4` is `1rem` with the default spacing unit. -> The `-var` family is the only one with a dynamic form. `fade-range` has no `-var` variant. +The prebuilt CSS includes named clear values and `-var` forms, not arbitrary values or integer forms. -### Named scale +### Dynamic Clear Zones -Used by every `fade-size-*`, `fade-range-*`, and named `fade-clear-*` utility. Values derive from Tailwind's spacing unit (`--spacing`, default `0.25rem`). +Use `-var` when the clear zone depends on runtime layout: -| Step | Value | Step | Value | -| --- | --- | --- | --- | -| `xs` | `1.5rem` | `xl` | `5rem` | -| `sm` | `2rem` | `2xl` | `6rem` | -| `md` | `3rem` | `3xl` | `8rem` | -| `lg` | `4rem` | `4xl` | `10rem` | +```html +
+ ... +
+``` -Read or override them as theme tokens: `--fade-size-{step}`, `--fade-range-{step}`, `--fade-clear-{step}`. +Available forms: -### Resolution precedence +```txt +fade-clear-var +fade-clear-y-var +fade-clear-x-var +fade-clear-top-var +fade-clear-bottom-var +fade-clear-start-var +fade-clear-end-var +``` -For each edge, the band **size** resolves from most specific to least: +The fallback chain is edge, then axis, then global, then `0px`. -``` -edge-specific → axis → global → default -fade-size-t-* → fade-size-y-* → fade-size-* → 3rem (fade-size-md) +```css +--fade-clear-top: 56px; +--fade-clear-y: 24px; +--fade-clear: 0px; ``` -The top edge takes its top value if set, else the vertical-axis value, else the global value, else the default `3rem`. Horizontal edges follow the same chain through the x-axis value. +## Force Or Disable A Fade -Dynamic **clearance** (`-var`) resolves `edge → axis → xy → 0px`. +These utilities change only the active fade amount. They do not select edges on their own, so pair them with a direction utility. ---- +| Utility | Effect | +| --- | --- | +| `fade-none` | disables all selected fades | +| `fade-none-y` | disables selected vertical fades | +| `fade-none-x` | disables selected horizontal fades | +| `fade-always` | pins all selected fades fully on | +| `fade-always-y` | pins selected vertical fades fully on | +| `fade-always-x` | pins selected horizontal fades fully on | -## Fading the whole page +```html +
...
+
...
+``` -To dissolve content into the top and bottom of the **viewport** as the page scrolls, fade the element that scrolls. Make `` the scroll container over a surface on ``: +## Fading The Whole Page + +Fade the element that scrolls. For a full-page fade, make `` the scroll container and keep the surface behind it on ``. ```html - - - … + ... ``` -Two requirements people miss: - -1. **`` must be the scroll container.** `scroll(self y)` tracks only the element's own scrollport. Give `` `height: 100%` and `overflow-y: auto`, and set `overflow: hidden` on ``. Otherwise the browser propagates the body's overflow up to the viewport, `` never becomes a scroller, and the fade does nothing. -2. **Put a surface behind the mask.** A mask also masks the element's own background, so something has to sit behind `` to reveal. Keep the surface color on `` (or a fixed backdrop) and make `` transparent. - -Masking `` also establishes a stacking context on it, which can change the `z-index` layering of fixed and absolute descendants. - ---- - -## Troubleshooting - -- **Is the class on the scrollable element?** The fade has to live on the element that overflows and scrolls, not a parent. See [Usage](#usage). -- **Can it scroll?** With no overflow on an axis, that axis's timeline stays inactive and the amount holds at its `0` base, so no fade. This is intentional: don't fade what you can't scroll. -- **Is there a contrasting surface behind it?** A mask reveals whatever sits behind the element. With nothing there, or no contrast, there's nothing to fade into. -- **RTL plus horizontal?** See [Writing direction](#writing-direction-rtl). - ---- +Two details matter: -## How it works +1. `` must have a fixed height and `overflow-y-auto`, otherwise the viewport scrolls instead. +2. The scroll container should be transparent if you want the mask to reveal the page surface behind it. -The element gets a `mask-image` built from four comma-separated layers, one per physical edge, combined with `mask-composite: intersect`: +## Source Path Vs. Prebuilt -```css -mask-image: - /* top */ var(--tw-fade-mask-t, linear-gradient(#000, #000)), - /* bottom */ var(--tw-fade-mask-b, linear-gradient(#000, #000)), - /* left */ var(--tw-fade-mask-l, linear-gradient(#000, #000)), - /* right */ var(--tw-fade-mask-r, linear-gradient(#000, #000)); -mask-composite: intersect; -``` - -When an edge is active, its layer holds a real `linear-gradient`. Otherwise it falls back to the opaque identity gradient `linear-gradient(#000, #000)`, which masks nothing. `intersect` is mandatory: the CSS default `add` (union) lets one opaque layer cancel another's transparency and erase the fade. Each direction utility sets only its own edge's layer, so classes compose cleanly. - -**The amount drives both alpha and length.** Each active edge carries a numeric amount from `0` to `1` that controls the gradient's opacity (at `0`, every stop is fully opaque, so the layer is a no-op) and the band's length (at `0`, the band collapses to zero). At `1` you get the full fade over the full band thickness. - -**The curve.** After the clearance zone, the active gradient is a 13-stop ramp. The alpha stops follow an eased, symmetric sigmoid sequence while their positions stay evenly spaced across the band. Eased opacity against linear positions yields a smooth S-curve: denser opacity change near the middle, gentler at the ends. +| | `@import "tw-fade"` | `tw-fade/css` | +| --- | --- | --- | +| Needs Tailwind v4 | yes | no | +| Direction utilities | yes | yes | +| Named size/reveal/clear utilities | yes | yes | +| `fade-clear-*-var` | yes | yes | +| Arbitrary values like `fade-size-[6rem]` | yes | no | +| Integer clear values like `fade-clear-top-14` | yes | no | -**Scroll gating.** Inside `@supports (animation-timeline: scroll())`, each utility binds the four edge amounts to scroll-driven animations: vertical edges to `scroll(self y)`, horizontal edges to `scroll(self x)`. Leading edges (top, left) reveal `0 → 1` over the first `fade-range` of scroll; trailing edges (bottom, right) retract `1 → 0` over the last. When an axis can't scroll, its amount holds at the `0` base, so no fade. That is the "don't fade what you can't scroll past" guard. +The prebuilt file is generated from an explicit safelist. It does not include Tailwind Preflight, core Tailwind utilities, arbitrary values, or integer `fade-size-*` classes. -**Fallback.** Inside `@supports not (animation-timeline: scroll())`, all amounts pin to `1`, so every selected edge renders its full static fade instead of disappearing. +## Exports -> **Public vs. internal surface.** The supported API is the `fade-*` utilities, the `--fade-*` theme tokens, and the `--fade-clear-*` dynamic vars above. The `--tw-fade-*` custom properties in this section are internal implementation details. They register with `inherits: false` so nested fades stay isolated, and they are not a surface to target directly. +| Specifier | Resolves to | +| --- | --- | +| `tw-fade` | `./src/tw-fade.css` | +| `tw-fade/css` | `./dist/tw-fade.css` | +| `tw-fade/dist/tw-fade.css` | `./dist/tw-fade.css` | ---- +`tailwindcss >=4.0.0` is an optional peer dependency. You need it for the source path, but not for the prebuilt CSS. -## Writing direction (RTL) +## How It Works -Behavior keys off **physical** axes, not inline text direction. +Each faded element gets a four-layer `mask-image`, one layer per physical edge. Inactive layers fall back to an opaque identity mask. Active layers use a 13-stop eased gradient. -- **Vertical fades (`fade-t`, `fade-b`, `fade-y`) are RTL-safe.** The vertical axis and the top and bottom gradients stay identical regardless of text direction. -- **Horizontal fades assume LTR.** `fade-l` acts as the leading edge (reveals as you scroll in) and `fade-r` as the trailing edge (retracts at the end). Because the timeline uses the physical `scroll(self x)` axis, an RTL context flips the leading and trailing semantics relative to reading order. For an order-neutral horizontal scroller, set `dir="ltr"` on the scroll container. +The public API uses plain direction names, but the internal engine still keeps four physical edge amounts. Those internal properties are typed numbers, do not inherit, and are driven by scroll animations. Keeping the engine physical makes the mask predictable; the public `start` and `end` utilities route to the correct physical side for LTR or RTL. ---- +Inside browsers with scroll-driven animation support: -## Accessibility +- vertical fades use `scroll(self y)`; +- horizontal fades use `scroll(self inline)`; +- leading edges reveal from `0` to `1` near the start of scroll; +- trailing edges retract from `1` to `0` near the end of scroll. -`tw-fade` is a visual mask only. It animates the opacity and length of an edge gradient, never the position of content, and it adds no scrolling motion beyond the scroll you already perform. Like any edge scrim, it lowers the contrast of content right at the masked edges, so keep band sizes modest where edge text must stay legible, and use `fade-clear-*` to hold critical content fully opaque. +If an axis cannot scroll, that axis stays at `0`, so the fade does not show. This is intentional. ---- +In browsers without scroll-driven animations, selected fades pin fully on as a static fallback. -## Source path vs. prebuilt +The supported public surface is the `fade-*` utilities and the public `--fade-*` tokens described above. Treat `--tw-fade-*` as internal implementation detail. -| | `@import "tw-fade"` (source) | `tw-fade/css` (prebuilt) | -| --- | --- | --- | -| Requires Tailwind v4 | yes (compiles through your build) | no | -| Direction / `fade-static` | yes | yes | -| Named-scale utilities (`xs`–`4xl`) | yes | yes | -| Per-edge & axis size/clear utilities | yes | yes | -| `fade-clear-*-var` dynamic forms | yes | yes | -| **Arbitrary values** (`fade-size-[6rem]`, `fade-range-[80px]`, `fade-clear-t-[56px]`) | **yes** | **no** | +## RTL -The prebuilt drop-in compiles only `tailwindcss/utilities.css`, so it carries no Preflight, no reset, and no Tailwind core `--tw-*` defaults. Just the enumerated `fade-*` utilities and their `@property` registrations. Arbitrary bracket values need Tailwind's JIT and exist only on the source path. +Horizontal fades are direction-aware: -### Exports +```html +
...
+``` -| Specifier | Resolves to | -| --- | --- | -| `tw-fade` (`.`) | `./src/tw-fade.css` (v4 CSS-first source) | -| `tw-fade/css` | `./dist/tw-fade.css` (prebuilt drop-in) | -| `tw-fade/dist/tw-fade.css` | `./dist/tw-fade.css` (explicit path) | +In that example, `fade-start` selects the right edge because the scroll container is RTL. `fade-end` selects the left edge. `fade-x` selects both. -`tailwindcss >=4.0.0` is an **optional** peer dependency. Consumers using only the prebuilt drop-in don't need it; the source path still needs Tailwind v4 to compile. +RTL routing follows semantic direction through `dir`, not a CSS-only `direction: rtl` declaration. ---- +Vertical fades are unchanged by text direction. -## Browser support +## Accessibility -Two CSS features carry different support floors: +`tw-fade` is visual only. It does not move content, add scroll behavior, or change focus order. Like any edge fade, it lowers contrast near the masked edge, so keep band sizes modest and use clear zones for sticky controls or critical text. -- **CSS masking** (`mask-image`, `mask-composite`, unprefixed). Interoperable across every current evergreen browser. See [caniuse: mask-image](https://caniuse.com/mdn-css_properties_mask-image). -- **Scroll-driven animations** (`animation-timeline: scroll()`). Shipped by default in every major engine except Firefox. See [caniuse: scroll-driven animations](https://caniuse.com/mdn-css_properties_animation-timeline_scroll). +## Browser Support -| Engine | Masking | Scroll-driven animation | Result | -| --- | --- | --- | --- | -| Chrome / Edge | 120+ | 115+ | Full scroll-gated fade | -| Safari (WebKit) | 15.4+ / iOS 15.4+ | **26.0+** | Full on 26+; static fade fallback below | -| Firefox (release/beta) | 53+ | **not by default** | Static always-on fade | -| Opera | 106+ | 101+ | Full scroll-gated fade | -| Samsung Internet | 25+ | 23+ | Full scroll-gated fade | +Two CSS features matter: -**Firefox.** Scroll-driven animations are implemented but sit behind the `layout.css.scroll-driven-animations.enabled` flag, default-on only in Nightly (since 136) and default-off in Release, Beta, and Developer Edition. Firefox release users get the static `@supports` fallback (the fade renders fully on, always). Masking works regardless (Firefox 53+). +- CSS masking: available in current evergreen browsers. +- Scroll-driven animations: available in Chromium and current WebKit; Firefox release gets the static fallback. -**Safari.** Scroll-driven animations shipped by default in Safari 26.0; 17.x and 18.x get the static fallback. Older WebKit may also need `-webkit-mask-*`, so run the prebuilt CSS through Autoprefixer if you target it. +Browsers without scroll-driven animations still render selected fades, but they render them as always-on static fades. -The practical masking floor is Chrome/Edge 120, the last major engine to drop the `-webkit-` prefix requirement; Safari and Firefox were already ahead. In every case the `@supports` fallback degrades the effect to a permanent static fade rather than breaking. +## Migrating ---- +See [MIGRATING.md](./MIGRATING.md) for the breaking rename map. ## Development ```bash -npm test # compiled-CSS unit tests, no browser required -npm run build # regenerate dist/tw-fade.css from src/tw-fade.css +npm test +npm run build +npm run build:demo +npm run verify ``` - -`dist/tw-fade.css` is generated. Don't edit it by hand. - ---- - -## License - -MIT © Pete Petrash diff --git a/build/verify-demo-runtime.mjs b/build/verify-demo-runtime.mjs index dbf5202..d86b576 100644 --- a/build/verify-demo-runtime.mjs +++ b/build/verify-demo-runtime.mjs @@ -154,7 +154,7 @@ async function runInteractions(page, label) { const depthAfter = await page.locator('[data-fade-depth-value]').textContent() assert(depthBefore !== depthAfter, `${label}: fade-depth slider updates from keyboard`, `${depthBefore} -> ${depthAfter}`) - const topEdge = page.locator('[data-fade-edge-toggle="t"]') + const topEdge = page.locator('[data-fade-edge-toggle="top"]') await topEdge.focus() await page.keyboard.press('Space') await page.waitForTimeout(160) @@ -249,7 +249,7 @@ async function smokePage(browser, options) { ) } assert(state.verticalRows >= 5, `${label}: vertical list rendered`, String(state.verticalRows)) - assert(state.advancedLabel === 'fade-y fade-size-2xl fade-range-2xl', `${label}: advanced controls initialize class readout`, state.advancedLabel) + assert(state.advancedLabel === 'fade-y fade-size-2xl fade-reveal-2xl', `${label}: advanced controls initialize class readout`, state.advancedLabel) assert(state.edgePressed === 'true,false,true,false', `${label}: top and bottom edge toggles initialize on`, state.edgePressed) assert(state.easedLine && state.scrollAwareThumb && state.composableTokens >= 3, `${label}: key animated graphics rendered`) assert(state.documentOverflowX <= 1, `${label}: no document-level horizontal overflow`, String(state.documentOverflowX)) diff --git a/build/verify-dist.mjs b/build/verify-dist.mjs index 2f57bb5..64982f8 100644 --- a/build/verify-dist.mjs +++ b/build/verify-dist.mjs @@ -23,14 +23,14 @@ const html = `
-
+
-
+
@@ -54,8 +54,8 @@ async function probe(sel, top) { maxScroll: el.scrollHeight - el.clientHeight, t: num('--tw-fade-t'), b: num('--tw-fade-b'), - range: cs.getPropertyValue('--tw-fade-range').trim(), - rangeActive: cs.getPropertyValue('--tw-fade-range-active').trim(), + reveal: cs.getPropertyValue('--tw-fade-reveal').trim(), + revealActive: cs.getPropertyValue('--tw-fade-reveal-active').trim(), animationRangeStart: cs.animationRangeStart, animationRangeEnd: cs.animationRangeEnd, maskComposite: cs.maskComposite, @@ -94,7 +94,7 @@ const r = { top: await probe('#tall', 0), mid: await probe('#tall', Math.round(tallMax / 2)), bottom: await probe('#tall', tallMax), - range: await probe('#range', 0), + reveal: await probe('#reveal', 0), short: await probe('#short', 0), combo: await probeBoth('#combo', 100, 100), } @@ -107,10 +107,10 @@ const checks = [ ['dist: mid → both fades (t≈1,b≈1)', approx(r.mid.t, 1) && approx(r.mid.b, 1)], ['dist: bottom → full top fade (t≈1)', approx(r.bottom.t, 1)], ['dist: bottom → no bottom fade (b≈0)', approx(r.bottom.b, 0)], - ['dist: fade-range-2xl resolves into the scroll animation range', /^96px, 100%, 96px, 100%$/.test(r.range.animationRangeEnd)], + ['dist: fade-reveal-2xl resolves into the selected vertical animation range', /^96px, 100%, normal, normal$/.test(r.reveal.animationRangeEnd)], ['dist: not scrollable → no fade (t≈0,b≈0)', approx(r.short.t, 0) && approx(r.short.b, 0)], - ['dist: mixed fade-t fade-r composes (t≈1,r≈1)', approx(r.combo.t, 1) && approx(r.combo.r, 1)], - ['dist: mixed fade-t fade-r keeps four reveal animations', r.combo.animationNames.length === 4], + ['dist: mixed fade-top fade-end composes (t≈1,r≈1)', approx(r.combo.t, 1) && approx(r.combo.r, 1)], + ['dist: mixed fade-top fade-end keeps four reveal animations', r.combo.animationNames.length === 4], ['dist: mask-composite intersect', /intersect/.test(r.top.maskComposite)], ['dist: mask-image has 4 layers', (r.top.maskImage.match(/gradient/g) || []).length >= 1], ] diff --git a/build/verify-fade.mjs b/build/verify-fade.mjs index 6e6d013..89a5717 100644 --- a/build/verify-fade.mjs +++ b/build/verify-fade.mjs @@ -11,12 +11,12 @@ * 2. horizontal (fade-x), default size — left & right melt to background * 3. an overridden size (inline `--tw-fade-size`, exactly what `fade-size-[120px]` * JIT-emits) — the fade band SCALES to the larger size, not the default - * 4. a scoped edge size (`fade-size-b-lg`) — the bottom band scales without + * 4. a scoped edge size (`fade-size-bottom-lg`) — the bottom band scales without * changing the top band * 5. nested isolation — a DEFAULT fade under an ancestor that sets a larger * `--tw-fade-size` still renders the DEFAULT band, proving the `@property` * `inherits: false` registration stops size/range leaking down (the cascade fix) - * 6. mixed individual edges (fade-t fade-r) — arbitrary edge utilities compose + * 6. mixed individual edges (fade-top fade-end) — arbitrary edge utilities compose * 7. nested single edge under a faded ancestor — mask layers do not leak down * * Usage: node build/verify-fade.mjs [path-to-css] (defaults to the shipped dist) @@ -192,7 +192,7 @@ const bigCross = crossing(bigLum) const scopedLum = await profileLine({ axis: 'y', innerSelector: '#scoped', - body: `
${blocks('vb', 40)}
`, + body: `
${blocks('vb', 40)}
`, }) const scopedTopCross = crossing(scopedLum) const scopedBottomCross = crossing([...scopedLum].reverse()) @@ -204,7 +204,7 @@ const scopedBottomCross = crossing([...scopedLum].reverse()) const nestLum = await profileLine({ axis: 'y', innerSelector: '#inner', - body: `
${blocks('vb', 40)}
`, + body: `
${blocks('vb', 40)}
`, }) const nestCross = crossing(nestLum) @@ -212,14 +212,14 @@ const nestCross = crossing(nestLum) // animation shorthand used to overwrite the other edge's reveal animation. const combo = await samplePanel({ innerSelector: '#combo', - body: `
`, + body: `
`, }) // 7. A page-level/body-level fade used to leak its mask layer variables into // nested single-edge examples. The child should fade only at the top. const nestedSingle = await samplePanel({ innerSelector: '#nested-single', - body: `
${blocks('vb', 40)}
`, + body: `
${blocks('vb', 40)}
`, }) await browser.close() @@ -257,12 +257,12 @@ const checks = [ `inner≈${nestCross}px (default≈${vCross}px, leaked≈${bigCross}px)`, ], - ['mixed fade-t fade-r: top edge masked toward background', combo.top < 60, `top≈${combo.top.toFixed(0)}`], - ['mixed fade-t fade-r: right edge masked toward background', combo.right < 60, `right≈${combo.right.toFixed(0)}`], - ['mixed fade-t fade-r: middle stays bright', combo.mid > 200, `mid≈${combo.mid.toFixed(0)}`], - ['nested fade-t: top edge masked toward background', nestedSingle.top < 60, `top≈${nestedSingle.top.toFixed(0)}`], - ['nested fade-t: bottom edge remains unmasked', nestedSingle.bottom > 200, `bottom≈${nestedSingle.bottom.toFixed(0)}`], - ['nested fade-t: middle stays bright', nestedSingle.mid > 200, `mid≈${nestedSingle.mid.toFixed(0)}`], + ['mixed fade-top fade-end: top edge masked toward background', combo.top < 60, `top≈${combo.top.toFixed(0)}`], + ['mixed fade-top fade-end: right edge masked toward background', combo.right < 60, `right≈${combo.right.toFixed(0)}`], + ['mixed fade-top fade-end: middle stays bright', combo.mid > 200, `mid≈${combo.mid.toFixed(0)}`], + ['nested fade-top: top edge masked toward background', nestedSingle.top < 60, `top≈${nestedSingle.top.toFixed(0)}`], + ['nested fade-top: bottom edge remains unmasked', nestedSingle.bottom > 200, `bottom≈${nestedSingle.bottom.toFixed(0)}`], + ['nested fade-top: middle stays bright', nestedSingle.mid > 200, `mid≈${nestedSingle.mid.toFixed(0)}`], ] console.log(`CSS: ${path.relative(path.resolve(__dirname, '..'), cssPath)}`) @@ -271,7 +271,7 @@ console.log( `horizontal — left:${hLeft.toFixed(0)} mid:${hMid.toFixed(0)} right:${hRight.toFixed(0)} | ` + `crossings — default:${vCross} big:${bigCross} scoped-top:${scopedTopCross} scoped-bottom:${scopedBottomCross} nested:${nestCross} | ` + `combo — top:${combo.top.toFixed(0)} right:${combo.right.toFixed(0)} mid:${combo.mid.toFixed(0)} | ` + - `nested fade-t — top:${nestedSingle.top.toFixed(0)} bottom:${nestedSingle.bottom.toFixed(0)} mid:${nestedSingle.mid.toFixed(0)}`, + `nested fade-top — top:${nestedSingle.top.toFixed(0)} bottom:${nestedSingle.bottom.toFixed(0)} mid:${nestedSingle.mid.toFixed(0)}`, ) console.log('\n=== FADE PIXEL CHECKS ===') let pass = 0 diff --git a/build/verify-horizontal-rtl.mjs b/build/verify-horizontal-rtl.mjs new file mode 100644 index 0000000..24b4386 --- /dev/null +++ b/build/verify-horizontal-rtl.mjs @@ -0,0 +1,129 @@ +import { chromium } from 'playwright' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const css = fs.readFileSync(path.resolve(__dirname, '../dist/tw-fade.css'), 'utf8') + +const html = ` +
+
+
+
+
+
+
+
+` + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 900, height: 700 }, deviceScaleFactor: 1 }) +await page.setContent(html, { waitUntil: 'networkidle' }) + +async function probe(selector, logicalPosition) { + return page.evaluate( + async ([sel, pos]) => { + const el = document.querySelector(sel) + if (!el) throw new Error(`Missing selector: ${sel}`) + const dir = getComputedStyle(el).direction + const max = el.scrollWidth - el.clientWidth + const target = Math.max(0, Math.min(max, pos * max)) + el.scrollLeft = dir === 'rtl' ? -target : target + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) + const cs = getComputedStyle(el) + const num = (v) => Number(cs.getPropertyValue(v).trim() || 'NaN') + const prop = (v) => cs.getPropertyValue(v).trim() + return { + selector: sel, + dir, + requested: target, + scrollLeft: el.scrollLeft, + maxScrollLeft: max, + t: num('--tw-fade-t'), + b: num('--tw-fade-b'), + l: num('--tw-fade-l'), + r: num('--tw-fade-r'), + maskL: prop('--tw-fade-mask-l'), + maskR: prop('--tw-fade-mask-r'), + animationName: cs.animationName, + animationTimeline: cs.animationTimeline, + animationRangeStart: cs.animationRangeStart, + animationRangeEnd: cs.animationRangeEnd, + } + }, + [selector, logicalPosition], + ) +} + +const results = { + ltrStart0: await probe('#ltr-start', 0), + ltrStartMid: await probe('#ltr-start', 0.5), + ltrEnd0: await probe('#ltr-end', 0), + ltrEndMax: await probe('#ltr-end', 1), + ltrX0: await probe('#ltr-x', 0), + ltrXMid: await probe('#ltr-x', 0.5), + ltrXMax: await probe('#ltr-x', 1), + rtlStart0: await probe('#rtl-start', 0), + rtlStartMid: await probe('#rtl-start', 0.5), + rtlEnd0: await probe('#rtl-end', 0), + rtlEndMax: await probe('#rtl-end', 1), + rtlX0: await probe('#rtl-x', 0), + rtlXMid: await probe('#rtl-x', 0.5), + rtlXMax: await probe('#rtl-x', 1), + nestedLtrStart0: await probe('#nested-ltr-start', 0), + nestedLtrStartMid: await probe('#nested-ltr-start', 0.5), + short: await probe('#short', 0), +} + +await browser.close() + +const approx = (a, b, tol = 0.08) => Number.isFinite(a) && Math.abs(a - b) <= tol +const hasLeft = (r) => /to right/.test(r.maskL) +const hasRight = (r) => /to left/.test(r.maskR) +const noLeft = (r) => !/to right/.test(r.maskL) +const noRight = (r) => !/to left/.test(r.maskR) + +const checks = [ + ['LTR fade-start maps to physical left only', hasLeft(results.ltrStart0) && noRight(results.ltrStart0)], + ['LTR fade-start starts inactive then reveals', approx(results.ltrStart0.l, 0) && approx(results.ltrStartMid.l, 1)], + ['LTR fade-end maps to physical right only', hasRight(results.ltrEnd0) && noLeft(results.ltrEnd0)], + ['LTR fade-end starts present then retracts at end', approx(results.ltrEnd0.r, 1) && approx(results.ltrEndMax.r, 0)], + ['LTR fade-x uses both horizontal layers', hasLeft(results.ltrXMid) && hasRight(results.ltrXMid)], + ['LTR fade-x is active in middle and inactive at opposite ends', approx(results.ltrX0.l, 0) && approx(results.ltrX0.r, 1) && approx(results.ltrXMid.l, 1) && approx(results.ltrXMid.r, 1) && approx(results.ltrXMax.r, 0)], + ['RTL fade-start maps to physical right only', hasRight(results.rtlStart0) && noLeft(results.rtlStart0)], + ['RTL fade-start starts inactive then reveals', approx(results.rtlStart0.r, 0) && approx(results.rtlStartMid.r, 1)], + ['RTL fade-end maps to physical left only', hasLeft(results.rtlEnd0) && noRight(results.rtlEnd0)], + ['RTL fade-end starts present then retracts at end', approx(results.rtlEnd0.l, 1) && approx(results.rtlEndMax.l, 0)], + ['RTL fade-x uses both horizontal layers', hasLeft(results.rtlXMid) && hasRight(results.rtlXMid)], + ['RTL fade-x is active in middle and inactive at opposite ends', approx(results.rtlX0.r, 0) && approx(results.rtlX0.l, 1) && approx(results.rtlXMid.l, 1) && approx(results.rtlXMid.r, 1) && approx(results.rtlXMax.l, 0)], + ['nested dir override: LTR scroller inside RTL parent maps start to physical left', hasLeft(results.nestedLtrStart0) && noRight(results.nestedLtrStart0) && approx(results.nestedLtrStart0.l, 0) && approx(results.nestedLtrStartMid.l, 1)], + ['non-scrollable horizontal content leaves both horizontal amounts inactive', approx(results.short.l, 0) && approx(results.short.r, 0)], + ['horizontal timelines use scroll(self inline)', /scroll\(self inline\)/.test(results.ltrXMid.animationTimeline) && /scroll\(self inline\)/.test(results.rtlXMid.animationTimeline)], +] + +console.log(JSON.stringify(results, null, 2)) +console.log('\n=== HORIZONTAL RTL CHECKS ===') +let pass = 0 +for (const [label, ok] of checks) { + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`) + if (ok) pass++ +} +console.log(`\n${pass}/${checks.length} passed`) +process.exit(pass === checks.length ? 0 : 1) diff --git a/build/verify-page.mjs b/build/verify-page.mjs index afb1632..481e657 100644 --- a/build/verify-page.mjs +++ b/build/verify-page.mjs @@ -38,7 +38,7 @@ const structure = await page.evaluate(() => { } }) -const demoEdgeUtilities = ['fade-t', 'fade-b', 'fade-l', 'fade-r'] +const demoEdgeUtilities = ['fade-top', 'fade-bottom', 'fade-start', 'fade-end'] const missingDemoEdgeUtilities = demoEdgeUtilities.filter((className) => { return !new RegExp(`\\.${className}\\s*\\{`).test(demoCss) }) @@ -46,7 +46,7 @@ const singleEdgeSpecimen = await page.evaluate(async () => { const specimen = document.querySelector('[data-demo="type-specimen"]') if (!specimen) return null - specimen.className = 'fade-t fade-size-2xl fade-range-md thin-scroll type-scale-sample h-64 overflow-auto p-5 sm:h-72' + specimen.className = 'fade-top fade-size-2xl fade-reveal-md thin-scroll type-scale-sample h-64 overflow-auto p-5 sm:h-72' specimen.scrollTop = 96 specimen.scrollLeft = 96 await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) @@ -54,10 +54,10 @@ const singleEdgeSpecimen = await page.evaluate(async () => { const cs = getComputedStyle(specimen) return { maskComposite: cs.maskComposite || cs.webkitMaskComposite, - hasMaskT: cs.getPropertyValue('--tw-fade-mask-t').trim() !== '', - hasMaskB: cs.getPropertyValue('--tw-fade-mask-b').trim() !== '', - hasMaskL: cs.getPropertyValue('--tw-fade-mask-l').trim() !== '', - hasMaskR: cs.getPropertyValue('--tw-fade-mask-r').trim() !== '', + maskT: cs.getPropertyValue('--tw-fade-mask-t').trim(), + maskB: cs.getPropertyValue('--tw-fade-mask-b').trim(), + maskL: cs.getPropertyValue('--tw-fade-mask-l').trim(), + maskR: cs.getPropertyValue('--tw-fade-mask-r').trim(), } }) @@ -103,16 +103,16 @@ const checks = [ ['body is the real scroll container', structure.bodyIsScroller, String(structure.bodyIsScroller)], ['surface is on (non-transparent)', !transparent(structure.htmlBg), structure.htmlBg], ['body is transparent (mask reveals the surface)', transparent(structure.bodyBg), structure.bodyBg], - ['body does not own a page-level fade utility', !/\bfade-[tblrxy]/.test(structure.bodyClass), structure.bodyClass], + ['body does not own a page-level fade utility', !/\bfade(?:\b|-)/.test(structure.bodyClass), structure.bodyClass], [ 'advanced demo supports a single top fade', - singleEdgeSpecimen?.hasMaskT === true && - singleEdgeSpecimen.hasMaskB === false && - singleEdgeSpecimen.hasMaskL === false && - singleEdgeSpecimen.hasMaskR === false && + /to bottom/.test(singleEdgeSpecimen?.maskT || '') && + !/to top/.test(singleEdgeSpecimen?.maskB || '') && + !/to right/.test(singleEdgeSpecimen?.maskL || '') && + !/to left/.test(singleEdgeSpecimen?.maskR || '') && /intersect/.test(singleEdgeSpecimen.maskComposite || ''), singleEdgeSpecimen - ? `t:${singleEdgeSpecimen.hasMaskT} b:${singleEdgeSpecimen.hasMaskB} l:${singleEdgeSpecimen.hasMaskL} r:${singleEdgeSpecimen.hasMaskR}` + ? `t:${/to bottom/.test(singleEdgeSpecimen.maskT)} b:${/to top/.test(singleEdgeSpecimen.maskB)} l:${/to right/.test(singleEdgeSpecimen.maskL)} r:${/to left/.test(singleEdgeSpecimen.maskR)}` : 'missing specimen', ], [ diff --git a/build/verify.mjs b/build/verify.mjs index 46e134f..f551e25 100644 --- a/build/verify.mjs +++ b/build/verify.mjs @@ -36,10 +36,10 @@ async function probe(selector, { top, left, noSnap } = {}) { b: num('--tw-fade-b'), l: num('--tw-fade-l'), r: num('--tw-fade-r'), - hasMaskT: cs.getPropertyValue('--tw-fade-mask-t').trim() !== '', - hasMaskB: cs.getPropertyValue('--tw-fade-mask-b').trim() !== '', - hasMaskL: cs.getPropertyValue('--tw-fade-mask-l').trim() !== '', - hasMaskR: cs.getPropertyValue('--tw-fade-mask-r').trim() !== '', + hasMaskT: /to bottom/.test(cs.getPropertyValue('--tw-fade-mask-t')), + hasMaskB: /to top/.test(cs.getPropertyValue('--tw-fade-mask-b')), + hasMaskL: /to right/.test(cs.getPropertyValue('--tw-fade-mask-l')), + hasMaskR: /to left/.test(cs.getPropertyValue('--tw-fade-mask-r')), maskImage: cs.maskImage, maskComposite: cs.maskComposite || cs.webkitMaskComposite, } diff --git a/demo/demo-controls.js b/demo/demo-controls.js index 304f6bd..ec7921a 100644 --- a/demo/demo-controls.js +++ b/demo/demo-controls.js @@ -22,10 +22,10 @@ const installCopyButton = document.querySelector("[data-copy-install]"); const installCopyIconStack = document.querySelector(".install-copy-icon-stack"); const installCopyStatus = document.querySelector("[data-copy-install-status]"); const fadeDepthSlider = document.querySelector("[data-fade-depth-slider]"); -const fadeRangeSlider = document.querySelector("[data-fade-range-slider]"); +const fadeRevealSlider = document.querySelector("[data-fade-reveal-slider]"); const fadeOptionLabel = document.querySelector("[data-fade-option-label]"); const fadeDepthValue = document.querySelector("[data-fade-depth-value]"); -const fadeRangeValue = document.querySelector("[data-fade-range-value]"); +const fadeRevealValue = document.querySelector("[data-fade-reveal-value]"); const fadeSpecimen = document.querySelector('[data-demo="type-specimen"]'); const fadeEdgeButtons = Array.from( document.querySelectorAll("[data-fade-edge-toggle]"), @@ -54,32 +54,32 @@ const fadeDepthSizes = [ aria: "quadruple extra large", }, ]; -const fadeRanges = [ - { className: "fade-range-sm", value: "sm", aria: "small" }, - { className: "fade-range-md", value: "md", aria: "medium" }, - { className: "fade-range-lg", value: "lg", aria: "large" }, +const fadeReveals = [ + { className: "fade-reveal-sm", value: "sm", aria: "small" }, + { className: "fade-reveal-md", value: "md", aria: "medium" }, + { className: "fade-reveal-lg", value: "lg", aria: "large" }, { - className: "fade-range-xl", + className: "fade-reveal-xl", value: "xl", aria: "extra large", }, { - className: "fade-range-2xl", + className: "fade-reveal-2xl", value: "2xl", aria: "double extra large", }, { - className: "fade-range-3xl", + className: "fade-reveal-3xl", value: "3xl", aria: "triple extra large", }, { - className: "fade-range-4xl", + className: "fade-reveal-4xl", value: "4xl", aria: "quadruple extra large", }, ]; -const fadeEdgeOrder = ["t", "r", "b", "l"]; +const fadeEdgeOrder = ["top", "end", "bottom", "start"]; const fadeEdgeClassNames = fadeEdgeOrder.map((edge) => `fade-${edge}`); const faviconSize = 256; const faviconCells = 32; @@ -858,9 +858,12 @@ function selectLexiconRow(nextRow, { animated = true, scroll = true } = {}) { function edgeUtilities(activeEdges) { const has = (edge) => activeEdges.includes(edge); - if (has("t") && has("r") && has("b") && has("l")) return ["fade-xy"]; - if (has("t") && has("b") && activeEdges.length === 2) return ["fade-y"]; - if (has("l") && has("r") && activeEdges.length === 2) return ["fade-x"]; + if (has("top") && has("end") && has("bottom") && has("start")) + return ["fade"]; + if (has("top") && has("bottom") && activeEdges.length === 2) + return ["fade-y"]; + if (has("start") && has("end") && activeEdges.length === 2) + return ["fade-x"]; return activeEdges.map((edge) => `fade-${edge}`); } @@ -887,7 +890,7 @@ function syncEdgeGradientDepth(depthIndex) { function fadeUtilityParts(className) { const prefix = - ["fade-size-", "fade-range-", "fade-"].find((candidate) => + ["fade-size-", "fade-reveal-", "fade-"].find((candidate) => className.startsWith(candidate), ) ?? ""; return { @@ -945,16 +948,16 @@ function flashFadeOptionParts(parts) { } } -function setFadeOptionLabel(edgeClasses, sizeClass, rangeClass) { +function setFadeOptionLabel(edgeClasses, sizeClass, revealClass) { const nextState = { edgeClasses: [...edgeClasses], sizeClass, - rangeClass, + revealClass, }; const previous = fadeOptionReadoutState; const flashTargets = []; const fragment = document.createDocumentFragment(); - const fullClassName = [...edgeClasses, sizeClass, rangeClass].join(" "); + const fullClassName = [...edgeClasses, sizeClass, revealClass].join(" "); const previousEdgeClasses = previous?.edgeClasses ?? []; const edgeStemChanged = previous && @@ -986,8 +989,8 @@ function setFadeOptionLabel(edgeClasses, sizeClass, rangeClass) { appendToken(sizeClass, previous?.sizeClass === sizeClass ? "none" : "stem"); appendToken( - rangeClass, - previous?.rangeClass === rangeClass ? "none" : "stem", + revealClass, + previous?.revealClass === revealClass ? "none" : "stem", ); fadeOptionLabel.replaceChildren(fragment); @@ -999,10 +1002,10 @@ function setFadeOptionLabel(edgeClasses, sizeClass, rangeClass) { function setFadeControls() { if ( !fadeDepthSlider || - !fadeRangeSlider || + !fadeRevealSlider || !fadeOptionLabel || !fadeDepthValue || - !fadeRangeValue || + !fadeRevealValue || !fadeSpecimen ) return; @@ -1011,12 +1014,12 @@ function setFadeControls() { fadeDepthSizes.length - 1, Math.max(0, Number(fadeDepthSlider.value)), ); - const rangeIndex = Math.min( - fadeRanges.length - 1, - Math.max(0, Number(fadeRangeSlider.value)), + const revealIndex = Math.min( + fadeReveals.length - 1, + Math.max(0, Number(fadeRevealSlider.value)), ); const nextSize = fadeDepthSizes[depthIndex]; - const nextRange = fadeRanges[rangeIndex]; + const nextReveal = fadeReveals[revealIndex]; const activeEdges = fadeEdgeOrder.filter((edge) => { const button = fadeEdgeButtons.find( (candidate) => candidate.dataset.fadeEdgeToggle === edge, @@ -1027,25 +1030,25 @@ function setFadeControls() { const utilityClasses = [ ...edgeClasses, nextSize.className, - nextRange.className, + nextReveal.className, ]; fadeSpecimen.classList.remove( ...fadeEdgeClassNames, + "fade", "fade-y", "fade-x", - "fade-xy", ...fadeDepthSizes.map((size) => size.className), - ...fadeRanges.map((range) => range.className), + ...fadeReveals.map((reveal) => reveal.className), ); fadeSpecimen.classList.add(...utilityClasses); - setFadeOptionLabel(edgeClasses, nextSize.className, nextRange.className); + setFadeOptionLabel(edgeClasses, nextSize.className, nextReveal.className); fadeDepthValue.textContent = nextSize.value; - fadeRangeValue.textContent = nextRange.value; + fadeRevealValue.textContent = nextReveal.value; fadeDepthSlider.setAttribute("aria-valuetext", nextSize.aria); - fadeRangeSlider.setAttribute("aria-valuetext", nextRange.aria); + fadeRevealSlider.setAttribute("aria-valuetext", nextReveal.aria); syncSliderKnob(fadeDepthSlider); - syncSliderKnob(fadeRangeSlider); + syncSliderKnob(fadeRevealSlider); syncEdgeGradientDepth(depthIndex); } @@ -1354,7 +1357,7 @@ fadeDepthSlider?.addEventListener("input", (event) => { setFadeControls(); }); -fadeRangeSlider?.addEventListener("input", () => { +fadeRevealSlider?.addEventListener("input", () => { setFadeControls(); }); diff --git a/demo/demo.css b/demo/demo.css index b0958b4..be48509 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -1243,7 +1243,7 @@ h3 { } } -/* Messages thread — the fade-t companion to the lexicon. It starts pinned to +/* Messages thread — the fade-y companion to the lexicon. It starts pinned to the bottom (newest reply crisp) so scrolling up dissolves older messages into the top edge only. Height matches the lexicon so the two cards balance. */ .chat-thread { @@ -1315,7 +1315,7 @@ h3 { A plane-filling lattice can never present only whole motifs behind a hard rectangular edge — any straight container edge cuts through them at almost every size. So rather than hard-clip, we DISSOLVE the pattern's own four edges - using the plugin itself: the SVG carries `fade-xy fade-static`, which pins a + using the plugin itself: the SVG carries `fade fade-always`, which pins a permanent all-edge fade (no scroll needed). A motif the box slices is already transparent at the cut, so it feathers out instead of being chopped — at any width/height and through live resize, zero JS, and it dogfoods tw-fade. The @@ -2061,31 +2061,31 @@ label.option-control { inset 0 0 0 4px color-mix(in oklab, var(--demo-focus) 62%, transparent); } -.edge-toggle-button[data-fade-edge-toggle="t"] { +.edge-toggle-button[data-fade-edge-toggle="top"] { clip-path: polygon(0 0, 100% 0, 50% 50%); } -.edge-toggle-button[data-fade-edge-toggle="r"] { +.edge-toggle-button[data-fade-edge-toggle="end"] { clip-path: polygon(100% 0, 100% 100%, 50% 50%); } -.edge-toggle-button[data-fade-edge-toggle="b"] { +.edge-toggle-button[data-fade-edge-toggle="bottom"] { clip-path: polygon(100% 100%, 0 100%, 50% 50%); } -.edge-toggle-button[data-fade-edge-toggle="l"] { +.edge-toggle-button[data-fade-edge-toggle="start"] { clip-path: polygon(0 100%, 0 0, 50% 50%); } -.edge-toggle-button[data-fade-edge-toggle="r"] .edge-toggle-svg { +.edge-toggle-button[data-fade-edge-toggle="end"] .edge-toggle-svg { --edge-rotation: 90deg; } -.edge-toggle-button[data-fade-edge-toggle="b"] .edge-toggle-svg { +.edge-toggle-button[data-fade-edge-toggle="bottom"] .edge-toggle-svg { --edge-rotation: 180deg; } -.edge-toggle-button[data-fade-edge-toggle="l"] .edge-toggle-svg { +.edge-toggle-button[data-fade-edge-toggle="start"] .edge-toggle-svg { --edge-rotation: -90deg; } diff --git a/demo/index.html b/demo/index.html index c610ca0..c3d0c95 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1180,7 +1180,7 @@

- set a larger range + set a larger reveal

@@ -1190,7 +1190,7 @@

- use fade-t + use fade-top

@@ -1214,15 +1214,15 @@ fade-xyy fade-range-fade-reveal-md2xl @@ -1254,7 +1254,7 @@
@@ -1476,7 +1476,7 @@ type="button" aria-pressed="true" aria-label="Toggle top edge fade" - data-fade-edge-toggle="t" + data-fade-edge-toggle="top" > - xy + top - t + end - r + start - range-2xl + reveal-2xl

- Axis, size, range, and clear-zone utilities compose + Axis, size, reveal, and clear-zone utilities compose directly on the scroll container.

@@ -2166,7 +2166,7 @@
so scroll animation interpolates them; initial 0 = no fade. */ @property --tw-fade-t { @@ -21,7 +21,12 @@ inherits: false; initial-value: 0; } -/* Config: keep "*" with NO initial-value — a typed rejects the rem initial, dropping the registration (fade collapses); inherits:false stops size and mask-layer declarations leaking into nested fades. */ + +/* +Config: keep "*" with NO initial-value. A typed rejects the rem +fallback and drops the registration in browsers; inherits:false stops nested +fades from leaking size, reveal, clearance, mask, or routing state. +*/ @property --tw-fade-size { syntax: "*"; inherits: false; @@ -34,39 +39,51 @@ syntax: "*"; inherits: false; } -@property --tw-fade-size-t { +@property --tw-fade-size-top { + syntax: "*"; + inherits: false; +} +@property --tw-fade-size-bottom { syntax: "*"; inherits: false; } -@property --tw-fade-size-b { +@property --tw-fade-size-start { syntax: "*"; inherits: false; } -@property --tw-fade-size-l { +@property --tw-fade-size-end { syntax: "*"; inherits: false; } -@property --tw-fade-size-r { +@property --tw-fade-reveal { syntax: "*"; inherits: false; } -@property --tw-fade-range { +@property --tw-fade-clear { syntax: "*"; inherits: false; } -@property --tw-fade-clear-t { +@property --tw-fade-clear-y { syntax: "*"; inherits: false; } -@property --tw-fade-clear-b { +@property --tw-fade-clear-x { syntax: "*"; inherits: false; } -@property --tw-fade-clear-l { +@property --tw-fade-clear-top { syntax: "*"; inherits: false; } -@property --tw-fade-clear-r { +@property --tw-fade-clear-bottom { + syntax: "*"; + inherits: false; +} +@property --tw-fade-clear-start { + syntax: "*"; + inherits: false; +} +@property --tw-fade-clear-end { syntax: "*"; inherits: false; } @@ -86,6 +103,70 @@ syntax: "*"; inherits: false; } +@property --tw-fade-ltr-start-layer { + syntax: "*"; + inherits: false; +} +@property --tw-fade-ltr-end-layer { + syntax: "*"; + inherits: false; +} +@property --tw-fade-rtl-start-layer { + syntax: "*"; + inherits: false; +} +@property --tw-fade-rtl-end-layer { + syntax: "*"; + inherits: false; +} +@property --tw-fade-ltr-start-animation { + syntax: "*"; + inherits: false; +} +@property --tw-fade-ltr-end-animation { + syntax: "*"; + inherits: false; +} +@property --tw-fade-rtl-start-animation { + syntax: "*"; + inherits: false; +} +@property --tw-fade-rtl-end-animation { + syntax: "*"; + inherits: false; +} +@property --tw-fade-ltr-start-timeline { + syntax: "*"; + inherits: false; +} +@property --tw-fade-ltr-end-timeline { + syntax: "*"; + inherits: false; +} +@property --tw-fade-rtl-start-timeline { + syntax: "*"; + inherits: false; +} +@property --tw-fade-rtl-end-timeline { + syntax: "*"; + inherits: false; +} +@property --tw-fade-ltr-start-animation-range { + syntax: "*"; + inherits: false; +} +@property --tw-fade-ltr-end-animation-range { + syntax: "*"; + inherits: false; +} +@property --tw-fade-rtl-start-animation-range { + syntax: "*"; + inherits: false; +} +@property --tw-fade-rtl-end-animation-range { + syntax: "*"; + inherits: false; +} @theme { --fade-size-xs: calc(var(--spacing, 0.25rem) * 6); @@ -96,14 +177,14 @@ --fade-size-2xl: calc(var(--spacing, 0.25rem) * 24); --fade-size-3xl: calc(var(--spacing, 0.25rem) * 32); --fade-size-4xl: calc(var(--spacing, 0.25rem) * 40); - --fade-range-xs: calc(var(--spacing, 0.25rem) * 6); - --fade-range-sm: calc(var(--spacing, 0.25rem) * 8); - --fade-range-md: calc(var(--spacing, 0.25rem) * 12); - --fade-range-lg: calc(var(--spacing, 0.25rem) * 16); - --fade-range-xl: calc(var(--spacing, 0.25rem) * 20); - --fade-range-2xl: calc(var(--spacing, 0.25rem) * 24); - --fade-range-3xl: calc(var(--spacing, 0.25rem) * 32); - --fade-range-4xl: calc(var(--spacing, 0.25rem) * 40); + --fade-reveal-xs: calc(var(--spacing, 0.25rem) * 6); + --fade-reveal-sm: calc(var(--spacing, 0.25rem) * 8); + --fade-reveal-md: calc(var(--spacing, 0.25rem) * 12); + --fade-reveal-lg: calc(var(--spacing, 0.25rem) * 16); + --fade-reveal-xl: calc(var(--spacing, 0.25rem) * 20); + --fade-reveal-2xl: calc(var(--spacing, 0.25rem) * 24); + --fade-reveal-3xl: calc(var(--spacing, 0.25rem) * 32); + --fade-reveal-4xl: calc(var(--spacing, 0.25rem) * 40); --fade-clear-xs: calc(var(--spacing, 0.25rem) * 6); --fade-clear-sm: calc(var(--spacing, 0.25rem) * 8); --fade-clear-md: calc(var(--spacing, 0.25rem) * 12); @@ -146,304 +227,387 @@ --tw-fade-r: 0; } } +@keyframes tw-fade-reveal-r-leading { + from { + --tw-fade-r: 0; + } + to { + --tw-fade-r: 1; + } +} +@keyframes tw-fade-reveal-l-trailing { + from { + --tw-fade-l: 1; + } + to { + --tw-fade-l: 0; + } +} @utility fade-size-* { --tw-fade-size: --value(--fade-size-*, [length], [percentage]); } -@utility fade-size-t-* { - --tw-fade-size-t: --value(--fade-size-*, [length], [percentage]); -} -@utility fade-size-b-* { - --tw-fade-size-b: --value(--fade-size-*, [length], [percentage]); -} -@utility fade-size-l-* { - --tw-fade-size-l: --value(--fade-size-*, [length], [percentage]); -} -@utility fade-size-r-* { - --tw-fade-size-r: --value(--fade-size-*, [length], [percentage]); -} @utility fade-size-y-* { --tw-fade-size-y: --value(--fade-size-*, [length], [percentage]); } @utility fade-size-x-* { --tw-fade-size-x: --value(--fade-size-*, [length], [percentage]); } -@utility fade-range-* { - --tw-fade-range: --value(--fade-range-*, [length], [percentage]); +@utility fade-size-top-* { + --tw-fade-size-top: --value(--fade-size-*, [length], [percentage]); +} +@utility fade-size-bottom-* { + --tw-fade-size-bottom: --value(--fade-size-*, [length], [percentage]); } -@utility fade-clear-t-* { - --tw-fade-clear-t: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-t: --value(--fade-clear-*, [length], [percentage]); +@utility fade-size-start-* { + --tw-fade-size-start: --value(--fade-size-*, [length], [percentage]); } -@utility fade-clear-b-* { - --tw-fade-clear-b: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-b: --value(--fade-clear-*, [length], [percentage]); +@utility fade-size-end-* { + --tw-fade-size-end: --value(--fade-size-*, [length], [percentage]); } -@utility fade-clear-l-* { - --tw-fade-clear-l: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-l: --value(--fade-clear-*, [length], [percentage]); +@utility fade-reveal-* { + --tw-fade-reveal: --value(--fade-reveal-*, [length], [percentage]); } -@utility fade-clear-r-* { - --tw-fade-clear-r: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-r: --value(--fade-clear-*, [length], [percentage]); +@utility fade-clear-* { + --tw-fade-clear: calc(var(--spacing, 0.25rem) * --value(integer)); + --tw-fade-clear: --value(--fade-clear-*, [length], [percentage]); } @utility fade-clear-y-* { - --tw-fade-clear-t: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-t: --value(--fade-clear-*, [length], [percentage]); - --tw-fade-clear-b: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-b: --value(--fade-clear-*, [length], [percentage]); + --tw-fade-clear-y: calc(var(--spacing, 0.25rem) * --value(integer)); + --tw-fade-clear-y: --value(--fade-clear-*, [length], [percentage]); } @utility fade-clear-x-* { - --tw-fade-clear-l: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-l: --value(--fade-clear-*, [length], [percentage]); - --tw-fade-clear-r: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-r: --value(--fade-clear-*, [length], [percentage]); + --tw-fade-clear-x: calc(var(--spacing, 0.25rem) * --value(integer)); + --tw-fade-clear-x: --value(--fade-clear-*, [length], [percentage]); } -@utility fade-clear-xy-* { - --tw-fade-clear-t: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-t: --value(--fade-clear-*, [length], [percentage]); - --tw-fade-clear-b: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-b: --value(--fade-clear-*, [length], [percentage]); - --tw-fade-clear-l: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-l: --value(--fade-clear-*, [length], [percentage]); - --tw-fade-clear-r: calc(var(--spacing, 0.25rem) * --value(integer)); - --tw-fade-clear-r: --value(--fade-clear-*, [length], [percentage]); +@utility fade-clear-top-* { + --tw-fade-clear-top: calc(var(--spacing, 0.25rem) * --value(integer)); + --tw-fade-clear-top: --value(--fade-clear-*, [length], [percentage]); } -@utility fade-clear-t-var { - --tw-fade-clear-t: var(--fade-clear-t, var(--fade-clear-y, var(--fade-clear-xy, 0px))); +@utility fade-clear-bottom-* { + --tw-fade-clear-bottom: calc(var(--spacing, 0.25rem) * --value(integer)); + --tw-fade-clear-bottom: --value(--fade-clear-*, [length], [percentage]); } -@utility fade-clear-b-var { - --tw-fade-clear-b: var(--fade-clear-b, var(--fade-clear-y, var(--fade-clear-xy, 0px))); +@utility fade-clear-start-* { + --tw-fade-clear-start: calc(var(--spacing, 0.25rem) * --value(integer)); + --tw-fade-clear-start: --value(--fade-clear-*, [length], [percentage]); } -@utility fade-clear-l-var { - --tw-fade-clear-l: var(--fade-clear-l, var(--fade-clear-x, var(--fade-clear-xy, 0px))); +@utility fade-clear-end-* { + --tw-fade-clear-end: calc(var(--spacing, 0.25rem) * --value(integer)); + --tw-fade-clear-end: --value(--fade-clear-*, [length], [percentage]); } -@utility fade-clear-r-var { - --tw-fade-clear-r: var(--fade-clear-r, var(--fade-clear-x, var(--fade-clear-xy, 0px))); +@utility fade-clear-var { + --tw-fade-clear: var(--fade-clear, 0px); } @utility fade-clear-y-var { - --tw-fade-clear-t: var(--fade-clear-t, var(--fade-clear-y, var(--fade-clear-xy, 0px))); - --tw-fade-clear-b: var(--fade-clear-b, var(--fade-clear-y, var(--fade-clear-xy, 0px))); + --tw-fade-clear-y: var(--fade-clear-y, var(--fade-clear, 0px)); } @utility fade-clear-x-var { - --tw-fade-clear-l: var(--fade-clear-l, var(--fade-clear-x, var(--fade-clear-xy, 0px))); - --tw-fade-clear-r: var(--fade-clear-r, var(--fade-clear-x, var(--fade-clear-xy, 0px))); + --tw-fade-clear-x: var(--fade-clear-x, var(--fade-clear, 0px)); +} +@utility fade-clear-top-var { + --tw-fade-clear-top: var(--fade-clear-top, var(--fade-clear-y, var(--fade-clear, 0px))); +} +@utility fade-clear-bottom-var { + --tw-fade-clear-bottom: var(--fade-clear-bottom, var(--fade-clear-y, var(--fade-clear, 0px))); } -@utility fade-clear-xy-var { - --tw-fade-clear-t: var(--fade-clear-t, var(--fade-clear-y, var(--fade-clear-xy, 0px))); - --tw-fade-clear-b: var(--fade-clear-b, var(--fade-clear-y, var(--fade-clear-xy, 0px))); - --tw-fade-clear-l: var(--fade-clear-l, var(--fade-clear-x, var(--fade-clear-xy, 0px))); - --tw-fade-clear-r: var(--fade-clear-r, var(--fade-clear-x, var(--fade-clear-xy, 0px))); +@utility fade-clear-start-var { + --tw-fade-clear-start: var(--fade-clear-start, var(--fade-clear-x, var(--fade-clear, 0px))); +} +@utility fade-clear-end-var { + --tw-fade-clear-end: var(--fade-clear-end, var(--fade-clear-x, var(--fade-clear, 0px))); } -/* Keep shared defaults on public fade elements, not :root: root aliases would - resolve before local theme overrides, and range aliases would miss - fade-range utilities. */ @layer utilities { - .fade-t, - .fade-b, - .fade-l, - .fade-r, + .fade, .fade-y, + .fade-top, + .fade-bottom, .fade-x, - .fade-xy { - --tw-fade-size-default: var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12)); - --tw-fade-range-active: var(--tw-fade-range, var(--fade-range-md, calc(var(--spacing, 0.25rem) * 12))); + .fade-start, + .fade-end { + --tw-fade-size-default: min(12%, var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12))); + --tw-fade-reveal-active: var(--tw-fade-reveal, var(--fade-reveal-md, calc(var(--spacing, 0.25rem) * 12))); + --tw-fade-identity-mask: linear-gradient(#000, #000); + --tw-fade-edge-size-t: var(--tw-fade-size-top, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); + --tw-fade-edge-size-b: var(--tw-fade-size-bottom, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); + --tw-fade-edge-size-l: var(--tw-fade-size-start, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default)))); + --tw-fade-edge-size-r: var(--tw-fade-size-end, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default)))); + --tw-fade-edge-clear-t: var(--tw-fade-clear-top, var(--tw-fade-clear-y, var(--tw-fade-clear, 0px))); + --tw-fade-edge-clear-b: var(--tw-fade-clear-bottom, var(--tw-fade-clear-y, var(--tw-fade-clear, 0px))); + --tw-fade-edge-clear-l: var(--tw-fade-clear-start, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px))); + --tw-fade-edge-clear-r: var(--tw-fade-clear-end, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px))); + --tw-fade-edge-band-t: calc(var(--tw-fade-edge-size-t) * var(--tw-fade-t)); + --tw-fade-edge-band-b: calc(var(--tw-fade-edge-size-b) * var(--tw-fade-b)); + --tw-fade-edge-band-l: calc(var(--tw-fade-edge-size-l) * var(--tw-fade-l)); + --tw-fade-edge-band-r: calc(var(--tw-fade-edge-size-r) * var(--tw-fade-r)); + --tw-fade-gradient-t: linear-gradient( + to bottom, + #000 0 var(--tw-fade-edge-clear-t), + rgb(0 0 0 / calc(1 - var(--tw-fade-t))) var(--tw-fade-edge-clear-t), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.983)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.08333), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.933)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.16667), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.854)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.25), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.75)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.33333), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.629)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.41667), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.5)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.5), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.371)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.58333), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.25)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.66667), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.146)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.75), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.067)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.83333), + rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.017)) + calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.91667), + rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t)) + ); + --tw-fade-gradient-b: linear-gradient( + to top, + #000 0 var(--tw-fade-edge-clear-b), + rgb(0 0 0 / calc(1 - var(--tw-fade-b))) var(--tw-fade-edge-clear-b), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.983)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.08333), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.933)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.16667), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.854)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.25), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.75)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.33333), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.629)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.41667), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.5)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.5), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.371)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.58333), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.25)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.66667), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.146)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.75), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.067)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.83333), + rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.017)) + calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.91667), + rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b)) + ); + --tw-fade-gradient-l: linear-gradient( + to right, + #000 0 var(--tw-fade-edge-clear-l), + rgb(0 0 0 / calc(1 - var(--tw-fade-l))) var(--tw-fade-edge-clear-l), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.983)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.08333), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.933)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.16667), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.854)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.25), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.75)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.33333), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.629)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.41667), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.5)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.5), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.371)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.58333), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.25)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.66667), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.146)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.75), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.067)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.83333), + rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.017)) + calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.91667), + rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l)) + ); + --tw-fade-gradient-r: linear-gradient( + to left, + #000 0 var(--tw-fade-edge-clear-r), + rgb(0 0 0 / calc(1 - var(--tw-fade-r))) var(--tw-fade-edge-clear-r), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.983)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.08333), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.933)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.16667), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.854)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.25), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.75)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.33333), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.629)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.41667), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.5)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.5), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.371)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.58333), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.25)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.66667), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.146)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.75), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.067)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.83333), + rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.017)) + calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.91667), + rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r)) + ); + --tw-fade-mask-l: var(--tw-fade-ltr-start-layer, var(--tw-fade-identity-mask)); + --tw-fade-mask-r: var(--tw-fade-ltr-end-layer, var(--tw-fade-identity-mask)); + --tw-fade-animation-l: var(--tw-fade-ltr-start-animation, none); + --tw-fade-animation-r: var(--tw-fade-ltr-end-animation, none); + --tw-fade-timeline-l: var(--tw-fade-ltr-start-timeline, none); + --tw-fade-timeline-r: var(--tw-fade-ltr-end-timeline, none); + --tw-fade-animation-range-l: var(--tw-fade-ltr-start-animation-range, normal); + --tw-fade-animation-range-r: var(--tw-fade-ltr-end-animation-range, normal); + mask-image: + var(--tw-fade-mask-t, var(--tw-fade-identity-mask)), + var(--tw-fade-mask-b, var(--tw-fade-identity-mask)), + var(--tw-fade-mask-l, var(--tw-fade-identity-mask)), + var(--tw-fade-mask-r, var(--tw-fade-identity-mask)); + mask-composite: intersect; + mask-repeat: no-repeat; + mask-size: 100% 100%; + } + + .fade:where(:dir(rtl)), + .fade-y:where(:dir(rtl)), + .fade-top:where(:dir(rtl)), + .fade-bottom:where(:dir(rtl)), + .fade-x:where(:dir(rtl)), + .fade-start:where(:dir(rtl)), + .fade-end:where(:dir(rtl)) { + --tw-fade-edge-size-l: var(--tw-fade-size-end, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default)))); + --tw-fade-edge-size-r: var(--tw-fade-size-start, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default)))); + --tw-fade-edge-clear-l: var(--tw-fade-clear-end, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px))); + --tw-fade-edge-clear-r: var(--tw-fade-clear-start, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px))); + --tw-fade-mask-l: var(--tw-fade-rtl-end-layer, var(--tw-fade-identity-mask)); + --tw-fade-mask-r: var(--tw-fade-rtl-start-layer, var(--tw-fade-identity-mask)); + --tw-fade-animation-l: var(--tw-fade-rtl-end-animation, none); + --tw-fade-animation-r: var(--tw-fade-rtl-start-animation, none); + --tw-fade-timeline-l: var(--tw-fade-rtl-end-timeline, none); + --tw-fade-timeline-r: var(--tw-fade-rtl-start-timeline, none); + --tw-fade-animation-range-l: var(--tw-fade-rtl-end-animation-range, normal); + --tw-fade-animation-range-r: var(--tw-fade-rtl-start-animation-range, normal); + } + + .fade, + .fade-y, + .fade-top { + --tw-fade-mask-t: var(--tw-fade-gradient-t); + --tw-fade-animation-t: tw-fade-reveal-t; + --tw-fade-timeline-t: scroll(self y); + --tw-fade-animation-range-t: 0 var(--tw-fade-reveal-active); + } + + .fade, + .fade-y, + .fade-bottom { + --tw-fade-mask-b: var(--tw-fade-gradient-b); + --tw-fade-animation-b: tw-fade-reveal-b; + --tw-fade-timeline-b: scroll(self y); + --tw-fade-animation-range-b: calc(100% - var(--tw-fade-reveal-active)) 100%; + } + + .fade, + .fade-x, + .fade-start { + --tw-fade-ltr-start-layer: var(--tw-fade-gradient-l); + --tw-fade-rtl-start-layer: var(--tw-fade-gradient-r); + --tw-fade-ltr-start-animation: tw-fade-reveal-l; + --tw-fade-rtl-start-animation: tw-fade-reveal-r-leading; + --tw-fade-ltr-start-timeline: scroll(self inline); + --tw-fade-rtl-start-timeline: scroll(self inline); + --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-reveal-active); + --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-reveal-active); + } + + .fade, + .fade-x, + .fade-end { + --tw-fade-ltr-end-layer: var(--tw-fade-gradient-r); + --tw-fade-rtl-end-layer: var(--tw-fade-gradient-l); + --tw-fade-ltr-end-animation: tw-fade-reveal-r; + --tw-fade-rtl-end-animation: tw-fade-reveal-l-trailing; + --tw-fade-ltr-end-timeline: scroll(self inline); + --tw-fade-rtl-end-timeline: scroll(self inline); + --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-reveal-active)) 100%; + --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-reveal-active)) 100%; } -} -/* Private (only @apply'd by fade-*). The gradient must live on the element so var(--tw-fade-t) resolves per-element — a :root copy would bake in --tw-fade-t:0. */ -@utility tw-fade-mask { - mask-image: - var(--tw-fade-mask-t, linear-gradient(#000, #000)), - var(--tw-fade-mask-b, linear-gradient(#000, #000)), - var(--tw-fade-mask-l, linear-gradient(#000, #000)), - var(--tw-fade-mask-r, linear-gradient(#000, #000)); - mask-composite: intersect; - mask-repeat: no-repeat; - mask-size: 100% 100%; @supports (animation-timeline: scroll()) { - animation: - tw-fade-reveal-t auto linear both, - tw-fade-reveal-b auto linear both, - tw-fade-reveal-l auto linear both, - tw-fade-reveal-r auto linear both; - animation-timeline: - scroll(self y), scroll(self y), scroll(self x), scroll(self x); - animation-range: - 0 var(--tw-fade-range-active), - calc(100% - var(--tw-fade-range-active)) 100%, - 0 var(--tw-fade-range-active), - calc(100% - var(--tw-fade-range-active)) 100%; + .fade, + .fade-y, + .fade-top, + .fade-bottom, + .fade-x, + .fade-start, + .fade-end { + animation: + var(--tw-fade-animation-t, none) auto linear both, + var(--tw-fade-animation-b, none) auto linear both, + var(--tw-fade-animation-l, none) auto linear both, + var(--tw-fade-animation-r, none) auto linear both; + animation-timeline: + var(--tw-fade-timeline-t, none), + var(--tw-fade-timeline-b, none), + var(--tw-fade-timeline-l, none), + var(--tw-fade-timeline-r, none); + animation-range: + var(--tw-fade-animation-range-t, normal), + var(--tw-fade-animation-range-b, normal), + var(--tw-fade-animation-range-l, normal), + var(--tw-fade-animation-range-r, normal); + } } + @supports not (animation-timeline: scroll()) { - --tw-fade-t: 1; - --tw-fade-b: 1; - --tw-fade-l: 1; - --tw-fade-r: 1; + .fade, + .fade-y, + .fade-top, + .fade-bottom, + .fade-x, + .fade-start, + .fade-end { + --tw-fade-t: 1; + --tw-fade-b: 1; + --tw-fade-l: 1; + --tw-fade-r: 1; + } } -} -@utility tw-fade-edge-t { - --tw-fade-edge-size-t: var(--tw-fade-size-t, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); - --tw-fade-edge-clear-t: var(--tw-fade-clear-t, 0px); - --tw-fade-edge-band-t: calc(var(--tw-fade-edge-size-t) * var(--tw-fade-t)); - --tw-fade-mask-t: linear-gradient( - to bottom, - #000 0 var(--tw-fade-edge-clear-t), - rgb(0 0 0 / calc(1 - var(--tw-fade-t))) var(--tw-fade-edge-clear-t), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.983)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.933)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.854)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.75)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.629)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.5)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.371)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.25)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.146)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.067)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.017)) - calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.91667), - rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t)) - ); -} -@utility tw-fade-edge-b { - --tw-fade-edge-size-b: var(--tw-fade-size-b, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); - --tw-fade-edge-clear-b: var(--tw-fade-clear-b, 0px); - --tw-fade-edge-band-b: calc(var(--tw-fade-edge-size-b) * var(--tw-fade-b)); - --tw-fade-mask-b: linear-gradient( - to top, - #000 0 var(--tw-fade-edge-clear-b), - rgb(0 0 0 / calc(1 - var(--tw-fade-b))) var(--tw-fade-edge-clear-b), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.983)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.933)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.854)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.75)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.629)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.5)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.371)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.25)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.146)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.067)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.017)) - calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.91667), - rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b)) - ); -} -@utility tw-fade-edge-l { - --tw-fade-edge-size-l: var(--tw-fade-size-l, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default)))); - --tw-fade-edge-clear-l: var(--tw-fade-clear-l, 0px); - --tw-fade-edge-band-l: calc(var(--tw-fade-edge-size-l) * var(--tw-fade-l)); - --tw-fade-mask-l: linear-gradient( - to right, - #000 0 var(--tw-fade-edge-clear-l), - rgb(0 0 0 / calc(1 - var(--tw-fade-l))) var(--tw-fade-edge-clear-l), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.983)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.933)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.854)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.75)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.629)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.5)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.371)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.25)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.146)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.067)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.017)) - calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.91667), - rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l)) - ); -} -@utility tw-fade-edge-r { - --tw-fade-edge-size-r: var(--tw-fade-size-r, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default)))); - --tw-fade-edge-clear-r: var(--tw-fade-clear-r, 0px); - --tw-fade-edge-band-r: calc(var(--tw-fade-edge-size-r) * var(--tw-fade-r)); - --tw-fade-mask-r: linear-gradient( - to left, - #000 0 var(--tw-fade-edge-clear-r), - rgb(0 0 0 / calc(1 - var(--tw-fade-r))) var(--tw-fade-edge-clear-r), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.983)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.933)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.854)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.75)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.629)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.5)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.371)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.25)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.146)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.067)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.017)) - calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.91667), - rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r)) - ); -} -/* Public utilities select mask layers; tw-fade-mask owns the shared scroll animation so edge utilities compose. */ -@utility fade-t { - @apply tw-fade-mask tw-fade-edge-t; -} -@utility fade-b { - @apply tw-fade-mask tw-fade-edge-b; -} -@utility fade-l { - @apply tw-fade-mask tw-fade-edge-l; -} -@utility fade-r { - @apply tw-fade-mask tw-fade-edge-r; -} -@utility fade-y { - @apply tw-fade-mask tw-fade-edge-t tw-fade-edge-b; -} -@utility fade-x { - @apply tw-fade-mask tw-fade-edge-l tw-fade-edge-r; -} -@utility fade-xy { - @apply tw-fade-mask tw-fade-edge-t tw-fade-edge-b tw-fade-edge-l tw-fade-edge-r; -} + .fade-none { + --tw-fade-t: 0 !important; + --tw-fade-b: 0 !important; + --tw-fade-l: 0 !important; + --tw-fade-r: 0 !important; + } + + .fade-none-y { + --tw-fade-t: 0 !important; + --tw-fade-b: 0 !important; + } -/* !important so the static fade outranks the running animation (which itself outranks normal author decls); order-independent vs the edge utility. */ -@utility fade-static { - --tw-fade-t: 1 !important; - --tw-fade-b: 1 !important; - --tw-fade-l: 1 !important; - --tw-fade-r: 1 !important; - animation-name: none; + .fade-none-x { + --tw-fade-l: 0 !important; + --tw-fade-r: 0 !important; + } + + .fade-always { + --tw-fade-t: 1 !important; + --tw-fade-b: 1 !important; + --tw-fade-l: 1 !important; + --tw-fade-r: 1 !important; + } + + .fade-always-y { + --tw-fade-t: 1 !important; + --tw-fade-b: 1 !important; + } + + .fade-always-x { + --tw-fade-l: 1 !important; + --tw-fade-r: 1 !important; + } } diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 9aa3b19..0c35896 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -1,36 +1,26 @@ /** * Unit tests for tw-fade. * - * npm test (runs `node --test`) - * - * tw-fade is now a pure Tailwind v4 CSS plugin: src/tw-fade.css is the single - * source of truth, authored with @utility / @theme / --value(). These tests - * compile that source the same way the shipped dist is built (scripts/build-css - * → the v4 CLI, utilities-only, isolated) and assert on the REAL emitted CSS. - * That keeps them honest: they exercise the exact bytes a consumer's gets, - * not a hand-rolled model of what the plugin "should" produce. - * - * They lock in the structural invariants that make the effect correct: the - * registered custom properties (and the @property computational-independence - * rule that the "only large fades" bug taught us), the scroll-driven reveal - * wiring, the multi-layer intersect-composited mask, per-edge isolation, and the - * 13-stop smoothstep ramp. End-to-end visual behaviour is covered separately by - * build/verify*.mjs (which drive real Chromium). + * These tests compile src/tw-fade.css through the same Tailwind v4 path used by + * scripts/build-css.mjs, then assert on the emitted CSS bytes. Browser-level + * behavior is covered by build/verify*.mjs. */ import { test } from 'node:test' import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { compileCss } from '../scripts/build-css.mjs' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(__dirname, '..') + const css = compileCss() -const integerClearCss = compileCss({ - classes: 'fade-clear-t-14 fade-clear-b-14 fade-clear-l-14 fade-clear-r-14 fade-clear-y-14 fade-clear-x-14 fade-clear-xy-14', +const customCss = compileCss({ + classes: + 'fade-size-[15%] fade-size-top-[56px] fade-reveal-[80px] fade-clear-14 fade-clear-y-14 fade-clear-x-14 fade-clear-top-14 fade-clear-bottom-14 fade-clear-start-14 fade-clear-end-14', }) -/** - * Return the balanced-brace body of the first ` {` block — including any - * nested at-rules (@supports …). A tiny brace scanner beats a regex here because - * the utility blocks legitimately nest braces. - */ function block(selector, source = css) { const at = source.indexOf(selector + ' {') if (at === -1) return null @@ -43,7 +33,6 @@ function block(selector, source = css) { return null } -/** Parse an @property registration into { syntax, inherits, initial-value }. */ function property(name) { const body = block('@property ' + name) if (body == null) return null @@ -54,18 +43,19 @@ function property(name) { return { syntax: field('syntax'), inherits: field('inherits'), 'initial-value': field('initial-value') } } -/** The value of a single declaration `prop: …;` within a block body. */ function declValue(body, prop) { - const m = body.match(new RegExp(`${prop}:\\s*([^;]+);`)) + const m = body?.match(new RegExp(`${prop}:\\s*([^;]+);`)) return m ? m[1].trim() : undefined } -const EDGES = { '.fade-t': 't', '.fade-b': 'b', '.fade-l': 'l', '.fade-r': 'r' } -const ALL = ['.fade-t', '.fade-b', '.fade-l', '.fade-r', '.fade-y', '.fade-x', '.fade-xy'] -const MASK_VARS = ['--tw-fade-mask-t', '--tw-fade-mask-b', '--tw-fade-mask-l', '--tw-fade-mask-r'] -const SIZE_VARS = ['--tw-fade-size', '--tw-fade-size-y', '--tw-fade-size-x', '--tw-fade-size-t', '--tw-fade-size-b', '--tw-fade-size-l', '--tw-fade-size-r'] -const CLEAR_VARS = ['--tw-fade-clear-t', '--tw-fade-clear-b', '--tw-fade-clear-l', '--tw-fade-clear-r'] -const SHARED_DEFAULTS_SELECTOR = '.fade-t, .fade-b, .fade-l, .fade-r, .fade-y, .fade-x, .fade-xy' +function classSelectors(source = css) { + return new Set( + [...source.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)] + .map((m) => m[1]) + .filter((name) => name === 'fade' || name.startsWith('fade-')), + ) +} + const SCALE = [ ['xs', 6], ['sm', 8], @@ -76,15 +66,69 @@ const SCALE = [ ['3xl', 32], ['4xl', 40], ] -const DEFAULT_SIZE_DECL = 'var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12))' -const DEFAULT_RANGE_DECL = 'var(--fade-range-md, calc(var(--spacing, 0.25rem) * 12))' -const RANGE_ACTIVE_DECL = `var(--tw-fade-range, ${DEFAULT_RANGE_DECL})` -const EDGE_SIZE_DECLS = { - t: 'var(--tw-fade-size-t, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default))))', - b: 'var(--tw-fade-size-b, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default))))', - l: 'var(--tw-fade-size-l, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))))', - r: 'var(--tw-fade-size-r, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))))', -} + +const PUBLIC_DIRECTIONS = ['fade', 'fade-y', 'fade-top', 'fade-bottom', 'fade-x', 'fade-start', 'fade-end'] +const REJECTED_CLASSES = [ + 'fade-t', + 'fade-b', + 'fade-l', + 'fade-r', + 'fade-xy', + 'fade-left', + 'fade-right', + 'fade-block', + 'fade-block-start', + 'fade-block-end', + 'fade-inline', + 'fade-inline-start', + 'fade-inline-end', + 'fade-range-sm', + 'fade-static', +] +const SHARED_SELECTOR = '.fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end' +const RTL_SELECTOR = + '.fade:where(:dir(rtl)), .fade-y:where(:dir(rtl)), .fade-top:where(:dir(rtl)), .fade-bottom:where(:dir(rtl)), .fade-x:where(:dir(rtl)), .fade-start:where(:dir(rtl)), .fade-end:where(:dir(rtl))' +const TOP_SELECTOR = '.fade, .fade-y, .fade-top' +const BOTTOM_SELECTOR = '.fade, .fade-y, .fade-bottom' +const START_SELECTOR = '.fade, .fade-x, .fade-start' +const END_SELECTOR = '.fade, .fade-x, .fade-end' +const MASK_VARS = ['--tw-fade-mask-t', '--tw-fade-mask-b', '--tw-fade-mask-l', '--tw-fade-mask-r'] +const SIZE_VARS = [ + '--tw-fade-size', + '--tw-fade-size-y', + '--tw-fade-size-x', + '--tw-fade-size-top', + '--tw-fade-size-bottom', + '--tw-fade-size-start', + '--tw-fade-size-end', +] +const CLEAR_VARS = [ + '--tw-fade-clear', + '--tw-fade-clear-y', + '--tw-fade-clear-x', + '--tw-fade-clear-top', + '--tw-fade-clear-bottom', + '--tw-fade-clear-start', + '--tw-fade-clear-end', +] +const ROUTING_VARS = [ + '--tw-fade-ltr-start-layer', + '--tw-fade-ltr-end-layer', + '--tw-fade-rtl-start-layer', + '--tw-fade-rtl-end-layer', + '--tw-fade-ltr-start-animation', + '--tw-fade-ltr-end-animation', + '--tw-fade-rtl-start-animation', + '--tw-fade-rtl-end-animation', + '--tw-fade-ltr-start-timeline', + '--tw-fade-ltr-end-timeline', + '--tw-fade-rtl-start-timeline', + '--tw-fade-rtl-end-timeline', + '--tw-fade-ltr-start-animation-range', + '--tw-fade-ltr-end-animation-range', + '--tw-fade-rtl-start-animation-range', + '--tw-fade-rtl-end-animation-range', +] const ALPHA_MULTIPLIERS = [ '0.983', '0.933', @@ -112,26 +156,16 @@ const POSITION_MULTIPLIERS = [ '0.91667', ] -test('registers the amounts as typed and config/mask layers as universal, non-inheriting', () => { +test('registers amounts as typed numbers and config/routing vars as universal non-inheriting', () => { for (const amt of ['--tw-fade-t', '--tw-fade-b', '--tw-fade-l', '--tw-fade-r']) { const prop = property(amt) assert.ok(prop, `missing @property ${amt}`) - // A typed (not `*`) is what lets scroll-driven animation interpolate - // the amount SMOOTHLY instead of snapping between keyframes. assert.equal(prop.syntax, '""') assert.equal(prop.inherits, 'false') - // Initial 0 = no fade: the correct base state when the timeline is inactive. assert.equal(prop['initial-value'], '0') } - // --tw-fade-size* / --tw-fade-range / --tw-fade-clear-* / --tw-fade-mask-* ARE registered, but with the UNIVERSAL - // syntax and NO initial-value. A typed @property would reject the - // font-relative (rem) default and silently drop the registration — the "only - // large fades" bug. The universal syntax sidesteps that rule; the omitted - // initial-value keeps an unset value guaranteed-invalid, so `var(--tw-fade-size, - // )` still falls back to the default. The point of registering at all is - // `inherits: false`, which stops a nested fade from leaking its parent's - // size/range/clearance or mask layers. - for (const v of [...SIZE_VARS, '--tw-fade-range', ...CLEAR_VARS, ...MASK_VARS]) { + + for (const v of [...SIZE_VARS, '--tw-fade-reveal', ...CLEAR_VARS, ...MASK_VARS, ...ROUTING_VARS]) { const prop = property(v) assert.ok(prop, `missing @property ${v}`) assert.equal(prop.syntax, '"*"') @@ -141,16 +175,9 @@ test('registers the amounts as typed and config/mask layers as universa }) test('every typed @property has a computationally-independent initial-value', () => { - // Regression guard for the "only the large panel fades" bug: a registered - // @property with a TYPED syntax (e.g. ) whose initial-value uses a - // context-relative unit (rem/em/%/vh/cqw/lh/…) is REJECTED by the browser, - // leaving the variable unset. We assert the REAL invariant — typed syntax pairs - // with an independent initial — via an ABSOLUTE-unit allowlist (case- - // insensitive, future-proof against new relative units). The universal syntax - // (`*`) carries no such rule and legitimately omits initial-value, so it's exempt. const ABSOLUTE = /^-?(?:\d+\.?\d*|\.\d+)(px|cm|mm|in|pt|pc|q)?$/i const names = [...css.matchAll(/@property\s+(--[\w-]+)\s*\{/g)].map((m) => m[1]) - assert.ok(names.length >= 10, 'expected at least 10 registered properties') + assert.ok(names.length >= 20, 'expected registered properties') for (const name of names) { const decl = property(name) if (decl.syntax === '"*"') { @@ -166,7 +193,20 @@ test('every typed @property has a computationally-independent initial-value', () } }) -test('leading edges reveal 0→1, trailing edges hide 1→0', () => { +test('direction inventory matches the plain public API and stays within budget', () => { + const selectors = classSelectors() + for (const name of PUBLIC_DIRECTIONS) assert.ok(selectors.has(name), `missing .${name}`) + for (const name of REJECTED_CLASSES) assert.equal(selectors.has(name), false, `must not emit .${name}`) + assert.ok(selectors.size <= 150, `selector budget exceeded: ${selectors.size}`) + assert.ok(css.length <= 55_000, `byte budget exceeded: ${css.length}`) +}) + +test('prebuilt dist is generated from the current default compile output', () => { + const dist = fs.readFileSync(path.join(root, 'dist', 'tw-fade.css'), 'utf8') + assert.equal(dist, css) +}) + +test('leading and trailing keyframes include RTL horizontal variants', () => { const kf = (n) => block('@keyframes ' + n) assert.match(kf('tw-fade-reveal-t'), /from\s*\{[^}]*--tw-fade-t:\s*0/) assert.match(kf('tw-fade-reveal-t'), /to\s*\{[^}]*--tw-fade-t:\s*1/) @@ -174,292 +214,213 @@ test('leading edges reveal 0→1, trailing edges hide 1→0', () => { assert.match(kf('tw-fade-reveal-b'), /to\s*\{[^}]*--tw-fade-b:\s*0/) assert.match(kf('tw-fade-reveal-l'), /from\s*\{[^}]*--tw-fade-l:\s*0/) assert.match(kf('tw-fade-reveal-r'), /from\s*\{[^}]*--tw-fade-r:\s*1/) + assert.match(kf('tw-fade-reveal-r-leading'), /from\s*\{[^}]*--tw-fade-r:\s*0/) + assert.match(kf('tw-fade-reveal-r-leading'), /to\s*\{[^}]*--tw-fade-r:\s*1/) + assert.match(kf('tw-fade-reveal-l-trailing'), /from\s*\{[^}]*--tw-fade-l:\s*1/) + assert.match(kf('tw-fade-reveal-l-trailing'), /to\s*\{[^}]*--tw-fade-l:\s*0/) }) -test('every edge utility carries the composited multi-layer mask base', () => { - for (const cls of ALL) { - const u = block(cls) - assert.ok(u, `missing ${cls}`) - // intersect is mandatory: the initial `add` (union) would erase the fade. - assert.equal(declValue(u, 'mask-composite'), 'intersect') - assert.equal(declValue(u, 'mask-repeat'), 'no-repeat') - assert.equal(declValue(u, 'mask-size'), '100% 100%') - // Four layers, each defaulting to the opaque identity (no-op) gradient. - const layers = u.split('linear-gradient(#000, #000)').length - 1 - assert.equal(layers, 4, `${cls} should have 4 identity fallbacks`) - } -}) - -test('each directional utility sets ONLY its own mask layer', () => { - for (const [cls, edge] of Object.entries(EDGES)) { - const u = block(cls) - const mine = `--tw-fade-mask-${edge}` - for (const v of MASK_VARS) { - // A real declaration is `--tw-fade-mask-x: …`; a var() reference is `--tw-fade-mask-x, …`. - // Matching the colon distinguishes the two, so the shared 4-layer fallback - // (which references all four) doesn't read as "this utility sets all four". - if (v === mine) assert.ok(declValue(u, v), `${cls} must set ${v}`) - else assert.equal(declValue(u, v), undefined, `${cls} must NOT set ${v}`) - } - } -}) - -test('axis shorthands set exactly their constituent mask layers', () => { - const y = block('.fade-y') - assert.ok(declValue(y, '--tw-fade-mask-t') && declValue(y, '--tw-fade-mask-b')) - assert.equal(declValue(y, '--tw-fade-mask-l'), undefined) - const x = block('.fade-x') - assert.ok(declValue(x, '--tw-fade-mask-l') && declValue(x, '--tw-fade-mask-r')) - assert.equal(declValue(x, '--tw-fade-mask-t'), undefined) - const xy = block('.fade-xy') - for (const v of MASK_VARS) assert.ok(declValue(xy, v), `xy must set ${v}`) +test('the shared mask setup owns four physical layers and scroll animation wiring', () => { + const shared = block(SHARED_SELECTOR) + assert.ok(shared, 'missing shared direction block') + assert.equal(declValue(shared, 'mask-composite'), 'intersect') + assert.equal(declValue(shared, 'mask-repeat'), 'no-repeat') + assert.equal(declValue(shared, 'mask-size'), '100% 100%') + const maskImage = declValue(shared, 'mask-image') + for (const v of MASK_VARS) assert.match(maskImage, new RegExp(`var\\(${v.replaceAll('-', '\\-')}`), `${v} in mask-image`) + + const supports = block('@supports (animation-timeline: scroll())') + const animated = block(SHARED_SELECTOR, supports) + assert.ok(animated, 'missing animation support block') + assert.equal((declValue(animated, 'animation').match(/auto linear both/g) || []).length, 4) + assert.equal((declValue(animated, 'animation-timeline').match(/var\(--tw-fade-timeline-/g) || []).length, 4) + assert.equal((declValue(animated, 'animation-range').match(/var\(--tw-fade-animation-range-/g) || []).length, 4) }) -test('mask gradients point the right way and are driven by their amount', () => { - assert.match(declValue(block('.fade-t'), '--tw-fade-mask-t'), /^linear-gradient\(\s*to bottom/) - assert.match(declValue(block('.fade-b'), '--tw-fade-mask-b'), /^linear-gradient\(\s*to top/) - assert.match(declValue(block('.fade-l'), '--tw-fade-mask-l'), /^linear-gradient\(\s*to right/) - assert.match(declValue(block('.fade-r'), '--tw-fade-mask-r'), /^linear-gradient\(\s*to left/) - // The amount scales both the gradient alpha (1 - amount * …) and its length - // (size * amount), so at amount 0 the layer collapses to fully opaque. - const tBlock = block('.fade-t') - const t = declValue(tBlock, '--tw-fade-mask-t') - assert.match(t, /calc\(1 - var\(--tw-fade-t\)\)/) - // Size is resolved through a shared default alias, then multiplied by the - // scroll-gated amount. This keeps the gradient readable without weakening the - // "no size utility still fades" invariant. - assert.equal(declValue(block(SHARED_DEFAULTS_SELECTOR), '--tw-fade-size-default'), DEFAULT_SIZE_DECL) - assert.equal(declValue(tBlock, '--tw-fade-edge-size-t'), EDGE_SIZE_DECLS.t) - assert.equal(declValue(tBlock, '--tw-fade-edge-band-t'), 'calc(var(--tw-fade-edge-size-t) * var(--tw-fade-t))') - // Clearance is an opaque edge band before the fade ramp. This lets sticky - // headers/footers stay unmasked while rows beneath still dissolve at the edge. - assert.equal(declValue(tBlock, '--tw-fade-edge-clear-t'), 'var(--tw-fade-clear-t, 0px)') - assert.match(t, /#000 0 var\(--tw-fade-edge-clear-t\)/) - assert.match(t, /rgb\(0 0 0 \/ calc\(1 - var\(--tw-fade-t\)\)\) var\(--tw-fade-edge-clear-t\)/) +test('direction classes select the expected layers and central RTL routing', () => { + const top = block(TOP_SELECTOR) + assert.equal(declValue(top, '--tw-fade-mask-t'), 'var(--tw-fade-gradient-t)') + assert.equal(declValue(top, '--tw-fade-animation-t'), 'tw-fade-reveal-t') + assert.equal(declValue(top, '--tw-fade-timeline-t'), 'scroll(self y)') + + const bottom = block(BOTTOM_SELECTOR) + assert.equal(declValue(bottom, '--tw-fade-mask-b'), 'var(--tw-fade-gradient-b)') + assert.equal(declValue(bottom, '--tw-fade-animation-b'), 'tw-fade-reveal-b') + assert.equal(declValue(bottom, '--tw-fade-timeline-b'), 'scroll(self y)') + + const start = block(START_SELECTOR) + assert.equal(declValue(start, '--tw-fade-ltr-start-layer'), 'var(--tw-fade-gradient-l)') + assert.equal(declValue(start, '--tw-fade-rtl-start-layer'), 'var(--tw-fade-gradient-r)') + assert.equal(declValue(start, '--tw-fade-ltr-start-animation'), 'tw-fade-reveal-l') + assert.equal(declValue(start, '--tw-fade-rtl-start-animation'), 'tw-fade-reveal-r-leading') + assert.equal(declValue(start, '--tw-fade-ltr-start-timeline'), 'scroll(self inline)') + assert.equal(declValue(start, '--tw-fade-rtl-start-timeline'), 'scroll(self inline)') + + const end = block(END_SELECTOR) + assert.equal(declValue(end, '--tw-fade-ltr-end-layer'), 'var(--tw-fade-gradient-r)') + assert.equal(declValue(end, '--tw-fade-rtl-end-layer'), 'var(--tw-fade-gradient-l)') + assert.equal(declValue(end, '--tw-fade-ltr-end-animation'), 'tw-fade-reveal-r') + assert.equal(declValue(end, '--tw-fade-rtl-end-animation'), 'tw-fade-reveal-l-trailing') + + const rtl = block(RTL_SELECTOR) + assert.ok(rtl, 'missing central RTL routing block') + assert.equal(declValue(rtl, '--tw-fade-mask-l'), 'var(--tw-fade-rtl-end-layer, var(--tw-fade-identity-mask))') + assert.equal(declValue(rtl, '--tw-fade-mask-r'), 'var(--tw-fade-rtl-start-layer, var(--tw-fade-identity-mask))') + assert.equal(declValue(rtl, '--tw-fade-animation-l'), 'var(--tw-fade-rtl-end-animation, none)') + assert.equal(declValue(rtl, '--tw-fade-animation-r'), 'var(--tw-fade-rtl-start-animation, none)') + assert.equal(declValue(rtl, '--tw-fade-edge-size-l'), 'var(--tw-fade-size-end, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))))') + assert.equal(declValue(rtl, '--tw-fade-edge-size-r'), 'var(--tw-fade-size-start, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))))') }) test('the smoothstep ramp is the 13-stop sigmoid from the reference', () => { - for (const [cls, edge] of Object.entries(EDGES)) { - const grad = declValue(block(cls), `--tw-fade-mask-${edge}`) + const shared = block(SHARED_SELECTOR) + const edges = { t: '--tw-fade-gradient-t', b: '--tw-fade-gradient-b', l: '--tw-fade-gradient-l', r: '--tw-fade-gradient-r' } + for (const [edge, prop] of Object.entries(edges)) { + const grad = declValue(shared, prop) + assert.ok(grad, `missing ${prop}`) const stops = (grad.match(/rgb\(0 0 0 \//g) || []).length - assert.equal(stops, 13, `${cls} gradient should have 13 colour stops`) + assert.equal(stops, 13, `${prop} should have 13 colour stops`) const alphaMultipliers = [ ...grad.matchAll(new RegExp(`var\\(--tw-fade-${edge}\\)\\s*\\*\\s*([0-9.]+)`, 'g')), ].map((m) => m[1]) - assert.deepEqual(alphaMultipliers, ALPHA_MULTIPLIERS, `${cls} alpha ramp coefficients must stay exact`) + assert.deepEqual(alphaMultipliers, ALPHA_MULTIPLIERS, `${prop} alpha ramp coefficients must stay exact`) const positionMultipliers = [ ...grad.matchAll(new RegExp(`var\\(--tw-fade-edge-band-${edge}\\)\\s*\\*\\s*([0-9.]+)`, 'g')), ].map((m) => m[1]) - assert.deepEqual(positionMultipliers, POSITION_MULTIPLIERS, `${cls} position ramp coefficients must stay exact`) - } - // The curve endpoints: first stop fully transparent-capable (1 - amount), last - // stop fully opaque (rgb(0 0 0 / 1)) at the band's far edge, offset after any - // sticky-item clearance band. - const t = declValue(block('.fade-t'), '--tw-fade-mask-t') - assert.match(t, /rgb\(0 0 0 \/ calc\(1 - var\(--tw-fade-t\)\)\) var\(--tw-fade-edge-clear-t\),/) - assert.match( - t, - new RegExp(String.raw`rgb\(0 0 0 \/ 1\) calc\(var\(--tw-fade-edge-clear-t\) \+ var\(--tw-fade-edge-band-t\)\)\s*\)\s*$`), - ) -}) - -test('every public fade utility uses the shared four-edge reveal setup', () => { - // Public utilities need the same animation shorthand/longhands so arbitrary - // combinations like `fade-t fade-r` compose instead of last-declaration-wins. - // Physical axes (scroll(self y) / scroll(self x)) match the mask gradients. - const defaults = block(SHARED_DEFAULTS_SELECTOR) - assert.equal(declValue(defaults, '--tw-fade-range-active'), RANGE_ACTIVE_DECL) - assert.ok(!css.includes('--tw-fade-range-default'), 'range default should be folded into --tw-fade-range-active') - for (const cls of ALL) { - const u = block(cls) - const s = block('@supports (animation-timeline: scroll())', block(cls)) - assert.ok(s, `${cls} must include the scroll-timeline support block`) - assert.equal(declValue(s, 'animation').split(',').length, 4, `${cls} must animate all four amounts`) - assert.match(s, /animation-timeline:\s*scroll\(self y\), scroll\(self y\), scroll\(self x\), scroll\(self x\)/) - // Leading reveals over the first range; trailing hides over the last. Range - // is resolved once by the shared defaults rule (same fix as --tw-fade-size). - assert.equal(declValue(u, '--tw-fade-range-active'), undefined) - assert.match( - s, - new RegExp( - String.raw`animation-range:\s*0 var\(--tw-fade-range-active\), calc\(100% - var\(--tw-fade-range-active\)\) 100%, 0 var\(--tw-fade-range-active\), calc\(100% - var\(--tw-fade-range-active\)\) 100%`, - ), - ) - } -}) - -test('provides a static fallback when scroll-driven animation is unsupported', () => { - for (const cls of ALL) { - const fb = block('@supports not (animation-timeline: scroll())', block(cls)) - assert.ok(fb, `${cls} must include the static fallback block`) - for (const amt of ['--tw-fade-t', '--tw-fade-b', '--tw-fade-l', '--tw-fade-r']) { - assert.equal(declValue(fb, amt), '1', `${cls} fallback must pin ${amt}`) - } + assert.deepEqual(positionMultipliers, POSITION_MULTIPLIERS, `${prop} position ramp coefficients must stay exact`) } + assert.match(declValue(shared, '--tw-fade-gradient-t'), /^linear-gradient\(\s*to bottom/) + assert.match(declValue(shared, '--tw-fade-gradient-b'), /^linear-gradient\(\s*to top/) + assert.match(declValue(shared, '--tw-fade-gradient-l'), /^linear-gradient\(\s*to right/) + assert.match(declValue(shared, '--tw-fade-gradient-r'), /^linear-gradient\(\s*to left/) }) -test('fade-static forces the fade on, order-independently, and disables the animation', () => { - const s = block('.fade-static') - // The amounts are pinned with !important so fade-static wins regardless of where - // Tailwind sorts it relative to the reveal animation — an important author value - // outranks a running keyframe animation in the cascade. +test('static fallback pins selected fades on when scroll-driven animation is unsupported', () => { + const fallback = block('@supports not (animation-timeline: scroll())') + const shared = block(SHARED_SELECTOR, fallback) + assert.ok(shared, 'missing fallback block') for (const amt of ['--tw-fade-t', '--tw-fade-b', '--tw-fade-l', '--tw-fade-r']) { - assert.equal(declValue(s, amt), '1 !important', `${amt} must be pinned !important`) + assert.equal(declValue(shared, amt), '1') } - assert.equal(declValue(s, 'animation-name'), 'none') }) -test('exposes the size and range scales as theme-backed utilities', () => { +test('exposes the size and reveal scales as theme-backed utilities', () => { for (const [name] of SCALE) { assert.equal(declValue(block(`.fade-size-${name}`), '--tw-fade-size'), `var(--fade-size-${name})`) - assert.equal(declValue(block(`.fade-size-t-${name}`), '--tw-fade-size-t'), `var(--fade-size-${name})`) - assert.equal(declValue(block(`.fade-size-b-${name}`), '--tw-fade-size-b'), `var(--fade-size-${name})`) - assert.equal(declValue(block(`.fade-size-l-${name}`), '--tw-fade-size-l'), `var(--fade-size-${name})`) - assert.equal(declValue(block(`.fade-size-r-${name}`), '--tw-fade-size-r'), `var(--fade-size-${name})`) assert.equal(declValue(block(`.fade-size-y-${name}`), '--tw-fade-size-y'), `var(--fade-size-${name})`) assert.equal(declValue(block(`.fade-size-x-${name}`), '--tw-fade-size-x'), `var(--fade-size-${name})`) - assert.equal(declValue(block(`.fade-range-${name}`), '--tw-fade-range'), `var(--fade-range-${name})`) + assert.equal(declValue(block(`.fade-size-top-${name}`), '--tw-fade-size-top'), `var(--fade-size-${name})`) + assert.equal(declValue(block(`.fade-size-bottom-${name}`), '--tw-fade-size-bottom'), `var(--fade-size-${name})`) + assert.equal(declValue(block(`.fade-size-start-${name}`), '--tw-fade-size-start'), `var(--fade-size-${name})`) + assert.equal(declValue(block(`.fade-size-end-${name}`), '--tw-fade-size-end'), `var(--fade-size-${name})`) + assert.equal(declValue(block(`.fade-reveal-${name}`), '--tw-fade-reveal'), `var(--fade-reveal-${name})`) } - // The theme exposes the actual scale values for consumers to override/extend. - const root = block(':root, :host') + const rootBlock = block(':root, :host') for (const [name, units] of SCALE) { const expected = `calc(var(--spacing, 0.25rem) * ${units})` - assert.equal(declValue(root, `--fade-size-${name}`), expected) - assert.equal(declValue(root, `--fade-range-${name}`), expected) + assert.equal(declValue(rootBlock, `--fade-size-${name}`), expected) + assert.equal(declValue(rootBlock, `--fade-reveal-${name}`), expected) } - // No bare `.fade-size` / `.fade-range` no-op utility leaks out. - assert.equal(block('.fade-size'), null) - assert.equal(block('.fade-range'), null) + assert.equal(block('.fade-range-sm'), null) + assert.equal(block('.fade-size-left-sm'), null) }) -test('each edge resolves size as edge over axis over global over default', () => { - const defaults = block(SHARED_DEFAULTS_SELECTOR) - assert.equal(declValue(defaults, '--tw-fade-size-default'), DEFAULT_SIZE_DECL) - for (const [cls, edge] of Object.entries(EDGES)) { - const u = block(cls) - assert.equal(declValue(u, '--tw-fade-size-default'), undefined) - assert.equal(declValue(u, `--tw-fade-edge-size-${edge}`), EDGE_SIZE_DECLS[edge]) - assert.equal(declValue(u, `--tw-fade-edge-band-${edge}`), `calc(var(--tw-fade-edge-size-${edge}) * var(--tw-fade-${edge}))`) - } -}) - -test('exposes edge-clearance utilities for sticky content', () => { - for (const [name] of SCALE) { - assert.equal(declValue(block(`.fade-clear-t-${name}`), '--tw-fade-clear-t'), `var(--fade-clear-${name})`) - assert.equal(declValue(block(`.fade-clear-b-${name}`), '--tw-fade-clear-b'), `var(--fade-clear-${name})`) - assert.equal(declValue(block(`.fade-clear-l-${name}`), '--tw-fade-clear-l'), `var(--fade-clear-${name})`) - assert.equal(declValue(block(`.fade-clear-r-${name}`), '--tw-fade-clear-r'), `var(--fade-clear-${name})`) - - const y = block(`.fade-clear-y-${name}`) - assert.equal(declValue(y, '--tw-fade-clear-t'), `var(--fade-clear-${name})`) - assert.equal(declValue(y, '--tw-fade-clear-b'), `var(--fade-clear-${name})`) - assert.equal(declValue(y, '--tw-fade-clear-l'), undefined) - - const x = block(`.fade-clear-x-${name}`) - assert.equal(declValue(x, '--tw-fade-clear-l'), `var(--fade-clear-${name})`) - assert.equal(declValue(x, '--tw-fade-clear-r'), `var(--fade-clear-${name})`) - assert.equal(declValue(x, '--tw-fade-clear-t'), undefined) - - const xy = block(`.fade-clear-xy-${name}`) - for (const v of CLEAR_VARS) assert.equal(declValue(xy, v), `var(--fade-clear-${name})`, `xy must set ${v}`) - } - - const root = block(':root, :host') - for (const [name, units] of SCALE) { - assert.equal(declValue(root, `--fade-clear-${name}`), `calc(var(--spacing, 0.25rem) * ${units})`) - } - assert.equal(block('.fade-clear'), null) -}) - -test('supports integer spacing values for edge clearance in the Tailwind source path', () => { - const expected = 'calc(var(--spacing, 0.25rem) * 14)' - assert.equal(declValue(block('.fade-clear-t-14', integerClearCss), '--tw-fade-clear-t'), expected) - assert.equal(declValue(block('.fade-clear-b-14', integerClearCss), '--tw-fade-clear-b'), expected) - assert.equal(declValue(block('.fade-clear-l-14', integerClearCss), '--tw-fade-clear-l'), expected) - assert.equal(declValue(block('.fade-clear-r-14', integerClearCss), '--tw-fade-clear-r'), expected) - - const y = block('.fade-clear-y-14', integerClearCss) - assert.equal(declValue(y, '--tw-fade-clear-t'), expected) - assert.equal(declValue(y, '--tw-fade-clear-b'), expected) - assert.equal(declValue(y, '--tw-fade-clear-l'), undefined) - - const x = block('.fade-clear-x-14', integerClearCss) - assert.equal(declValue(x, '--tw-fade-clear-l'), expected) - assert.equal(declValue(x, '--tw-fade-clear-r'), expected) - assert.equal(declValue(x, '--tw-fade-clear-t'), undefined) - - const xy = block('.fade-clear-xy-14', integerClearCss) - for (const v of CLEAR_VARS) assert.equal(declValue(xy, v), expected, `integer xy must set ${v}`) -}) - -test('exposes public variable utilities for dynamic edge clearance', () => { +test('size resolves as edge over axis over global over capped default', () => { + const shared = block(SHARED_SELECTOR) + assert.equal(declValue(shared, '--tw-fade-size-default'), 'min(12%, var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12)))') assert.equal( - declValue(block('.fade-clear-t-var'), '--tw-fade-clear-t'), - 'var(--fade-clear-t, var(--fade-clear-y, var(--fade-clear-xy, 0px)))', + declValue(shared, '--tw-fade-edge-size-t'), + 'var(--tw-fade-size-top, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default))))', ) assert.equal( - declValue(block('.fade-clear-b-var'), '--tw-fade-clear-b'), - 'var(--fade-clear-b, var(--fade-clear-y, var(--fade-clear-xy, 0px)))', + declValue(shared, '--tw-fade-edge-size-b'), + 'var(--tw-fade-size-bottom, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default))))', ) assert.equal( - declValue(block('.fade-clear-l-var'), '--tw-fade-clear-l'), - 'var(--fade-clear-l, var(--fade-clear-x, var(--fade-clear-xy, 0px)))', + declValue(shared, '--tw-fade-edge-size-l'), + 'var(--tw-fade-size-start, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))))', ) assert.equal( - declValue(block('.fade-clear-r-var'), '--tw-fade-clear-r'), - 'var(--fade-clear-r, var(--fade-clear-x, var(--fade-clear-xy, 0px)))', + declValue(shared, '--tw-fade-edge-size-r'), + 'var(--tw-fade-size-end, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))))', ) +}) - const y = block('.fade-clear-y-var') - assert.equal( - declValue(y, '--tw-fade-clear-t'), - 'var(--fade-clear-t, var(--fade-clear-y, var(--fade-clear-xy, 0px)))', - ) - assert.equal( - declValue(y, '--tw-fade-clear-b'), - 'var(--fade-clear-b, var(--fade-clear-y, var(--fade-clear-xy, 0px)))', - ) - assert.equal(declValue(y, '--tw-fade-clear-l'), undefined) +test('arbitrary size and reveal utilities compile only through the source path', () => { + assert.equal(declValue(block('.fade-size-\\[15\\%\\]', customCss), '--tw-fade-size'), '15%') + assert.equal(declValue(block('.fade-size-top-\\[56px\\]', customCss), '--tw-fade-size-top'), '56px') + assert.equal(declValue(block('.fade-reveal-\\[80px\\]', customCss), '--tw-fade-reveal'), '80px') + assert.equal(block('.fade-size-\\[15\\%\\]'), null) + assert.equal(block('.fade-reveal-\\[80px\\]'), null) +}) - const x = block('.fade-clear-x-var') - assert.equal( - declValue(x, '--tw-fade-clear-l'), - 'var(--fade-clear-l, var(--fade-clear-x, var(--fade-clear-xy, 0px)))', - ) - assert.equal( - declValue(x, '--tw-fade-clear-r'), - 'var(--fade-clear-r, var(--fade-clear-x, var(--fade-clear-xy, 0px)))', - ) - assert.equal(declValue(x, '--tw-fade-clear-t'), undefined) +test('exposes directional clear utilities and source-path integer clear values', () => { + for (const [name] of SCALE) { + assert.equal(declValue(block(`.fade-clear-${name}`), '--tw-fade-clear'), `var(--fade-clear-${name})`) + assert.equal(declValue(block(`.fade-clear-y-${name}`), '--tw-fade-clear-y'), `var(--fade-clear-${name})`) + assert.equal(declValue(block(`.fade-clear-x-${name}`), '--tw-fade-clear-x'), `var(--fade-clear-${name})`) + assert.equal(declValue(block(`.fade-clear-top-${name}`), '--tw-fade-clear-top'), `var(--fade-clear-${name})`) + assert.equal(declValue(block(`.fade-clear-bottom-${name}`), '--tw-fade-clear-bottom'), `var(--fade-clear-${name})`) + assert.equal(declValue(block(`.fade-clear-start-${name}`), '--tw-fade-clear-start'), `var(--fade-clear-${name})`) + assert.equal(declValue(block(`.fade-clear-end-${name}`), '--tw-fade-clear-end'), `var(--fade-clear-${name})`) + } + const expected = 'calc(var(--spacing, 0.25rem) * 14)' + assert.equal(declValue(block('.fade-clear-14', customCss), '--tw-fade-clear'), expected) + assert.equal(declValue(block('.fade-clear-top-14', customCss), '--tw-fade-clear-top'), expected) + assert.equal(declValue(block('.fade-clear-start-14', customCss), '--tw-fade-clear-start'), expected) + assert.equal(block('.fade-clear-left-sm'), null) + assert.equal(block('.fade-clear-xy-sm'), null) +}) - const xy = block('.fade-clear-xy-var') +test('dynamic clear variables follow edge to axis to global precedence', () => { + assert.equal(declValue(block('.fade-clear-var'), '--tw-fade-clear'), 'var(--fade-clear, 0px)') + assert.equal(declValue(block('.fade-clear-y-var'), '--tw-fade-clear-y'), 'var(--fade-clear-y, var(--fade-clear, 0px))') + assert.equal(declValue(block('.fade-clear-x-var'), '--tw-fade-clear-x'), 'var(--fade-clear-x, var(--fade-clear, 0px))') assert.equal( - declValue(xy, '--tw-fade-clear-t'), - 'var(--fade-clear-t, var(--fade-clear-y, var(--fade-clear-xy, 0px)))', + declValue(block('.fade-clear-top-var'), '--tw-fade-clear-top'), + 'var(--fade-clear-top, var(--fade-clear-y, var(--fade-clear, 0px)))', ) assert.equal( - declValue(xy, '--tw-fade-clear-b'), - 'var(--fade-clear-b, var(--fade-clear-y, var(--fade-clear-xy, 0px)))', + declValue(block('.fade-clear-bottom-var'), '--tw-fade-clear-bottom'), + 'var(--fade-clear-bottom, var(--fade-clear-y, var(--fade-clear, 0px)))', ) assert.equal( - declValue(xy, '--tw-fade-clear-l'), - 'var(--fade-clear-l, var(--fade-clear-x, var(--fade-clear-xy, 0px)))', + declValue(block('.fade-clear-start-var'), '--tw-fade-clear-start'), + 'var(--fade-clear-start, var(--fade-clear-x, var(--fade-clear, 0px)))', ) assert.equal( - declValue(xy, '--tw-fade-clear-r'), - 'var(--fade-clear-r, var(--fade-clear-x, var(--fade-clear-xy, 0px)))', + declValue(block('.fade-clear-end-var'), '--tw-fade-clear-end'), + 'var(--fade-clear-end, var(--fade-clear-x, var(--fade-clear, 0px)))', ) }) +test('fade-none and fade-always pin amounts without disabling shared animations', () => { + const none = block('.fade-none') + for (const amt of ['--tw-fade-t', '--tw-fade-b', '--tw-fade-l', '--tw-fade-r']) { + assert.equal(declValue(none, amt), '0 !important') + } + const noneY = block('.fade-none-y') + assert.equal(declValue(noneY, '--tw-fade-t'), '0 !important') + assert.equal(declValue(noneY, '--tw-fade-b'), '0 !important') + assert.equal(declValue(noneY, '--tw-fade-l'), undefined) + + const noneX = block('.fade-none-x') + assert.equal(declValue(noneX, '--tw-fade-l'), '0 !important') + assert.equal(declValue(noneX, '--tw-fade-r'), '0 !important') + assert.equal(declValue(noneX, '--tw-fade-t'), undefined) + + const always = block('.fade-always') + for (const amt of ['--tw-fade-t', '--tw-fade-b', '--tw-fade-l', '--tw-fade-r']) { + assert.equal(declValue(always, amt), '1 !important') + } + assert.ok(!/animation-name:\s*none/.test(css), 'always utilities must not disable shared animations') + assert.ok(css.indexOf('.fade-none') < css.indexOf('.fade-always'), 'fade-always should be the emitted source-order winner') +}) + test('the framework-free build is real CSS, self-contained, with no Tailwind noise', () => { - // Source-only constructs must be fully compiled away. for (const token of ['@utility', '@theme', '@apply', '--value(']) { assert.ok(!css.includes(token), `compiled output must not contain ${token}`) } - // No Preflight / theme-dump leakage from the utilities-only, isolated build. - // Our own internal namespace is --tw-fade-*; any OTHER --tw-* would be Tailwind - // core noise, so exclude our prefix from the leak check. assert.ok(!/--tw-(?!fade-)/.test(css), 'no Tailwind core --tw-* variables should leak in') assert.ok(!/\.flex\s*\{|\.grid\s*\{|\.border\s*\{/.test(css), 'no built-in utilities should leak in') - assert.equal(block('.tw-fade-mask'), null, 'private tw-fade-mask utility should not be emitted as a public class') }) From e64b5e91fc25ea82961df6759afc1cc7ecec8b23 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Fri, 26 Jun 2026 19:32:39 -0700 Subject: [PATCH 08/17] Address PR #4 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine fix: - src/tw-fade.css: add neutral block-axis defaults to the shared base block so a nested vertical fade no longer inherits a parent's t/b animation/timeline/range hook and run a spurious (inert) second scroll animation. Mirrors the existing l/r indirection pattern. Rebuilt dist/tw-fade.css (30,079 B, well under the 55,000 cap). Tests: - Document REJECTED_CLASSES intent: both the old physical names and the fully-logical inline/block aliases are deliberately excluded, so re-adding either is a conscious choice, not a silent regression. Docs: - MIGRATING.md: rewrite as a concise guide for humans and coding agents alike — inclusive lead + explicit agent callout, ordered judgment -> mechanical -> verify steps, full rename tables, the physical->logical RTL caveat, and a token-boundary verification grep. Drop the maintainer-only demo resync note. - README.md: qualify the RTL guarantees with the :dir() floor, rewrite Browser Support as a per-feature table, and point the migration section at the agent-executable guide. Packaging / release: - package.json: add rtl/direction-aware/logical-properties/bidi keywords; ship CHANGELOG.md. - CHANGELOG.md: new Keep a Changelog 0.7.0 breaking-rename entry. - .github/workflows/ci.yml: run npm test + the Chromium/WebKit verify chain on PRs and pushes to main, closing the PR-time enforcement gap. Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- .github/workflows/ci.yml | 34 ++++++++ CHANGELOG.md | 38 +++++++++ MIGRATING.md | 170 ++++++++++++++++++++++----------------- README.md | 33 +++++--- dist/tw-fade.css | 6 ++ package.json | 7 +- src/tw-fade.css | 14 ++++ test/plugin.test.mjs | 6 ++ 8 files changed, 224 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fe790ae --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + # The verify chain launches both Chromium and WebKit. + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium webkit + + # Run unit tests against the committed dist first, so the + # "dist === fresh compile" freshness gate stays meaningful + # (a preceding build would mask a stale committed dist). + - name: Unit tests + run: npm test + + - name: Browser verify chain + run: npm run verify diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..308ca0d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres +to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.7.0] - 2026-06-26 + +Breaking rename to a plain direction API. [MIGRATING.md](./MIGRATING.md) is an +agent-ready upgrade procedure (ordered renames, RTL caveats, and a verification grep); +it also explains the [naming rationale](./MIGRATING.md#why-plain-directions). + +### Added + +- Direction-aware horizontal fades: `fade-start` / `fade-end` route to the correct + physical edge per the container's `:dir()`, so they flip automatically under RTL. +- `fade-none` / `fade-always` (and `-x` / `-y` axis variants) to force or disable + the active fade amount. +- Per-edge `fade-size-*` and `fade-clear-*` for `top` / `bottom` / `start` / `end`. + +### Changed + +- **Breaking:** the public API is now plain directions — `fade`, `fade-y`, `fade-top`, + `fade-bottom`, `fade-x`, `fade-start`, `fade-end` — replacing the old physical + `fade-t/b/l/r/x/y/xy` set. +- **Breaking:** `fade-range-*` renamed to `fade-reveal-*` (and `--fade-range-*` → + `--fade-reveal-*`). +- **Breaking:** `fade-static` renamed to `fade-always`. +- The prebuilt CDN example in the README is pinned to a fixed version; unversioned + URLs track latest and will receive breaking renames. + +### Removed + +- The old physical public class names (`fade-t/b/l/r/xy`, `fade-static`, and their + `fade-size-*` / `fade-range-*` / `fade-clear-*` families). No public physical + horizontal utility (`fade-left` / `fade-right`) is provided. + +[0.7.0]: https://github.com/petekp/tw-fade/releases/tag/v0.7.0 diff --git a/MIGRATING.md b/MIGRATING.md index 4e7da03..0866522 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -1,122 +1,144 @@ -# Migrating To The Plain Direction API +# Migrating to tw-fade 0.7.0 — Plain Direction API -This release is a breaking rename. The implementation still uses a physical four-edge mask internally, but the public API now uses readable directions: +This guide is for **humans and coding agents alike** — upgrade by hand, or point an agent at this file. Either way it takes a project from tw-fade `0.6.x` to `0.7.0`. + +> **Coding agents:** this document is written for you to execute. Run it top to bottom as a procedure — Step 1 (judgment calls) first, then the mechanical renames in Steps 2–3, then verify with Step 6. Don't reintroduce any removed name; the rejected shapes are deliberate and enforced by the test suite (see [Why Plain Directions](#why-plain-directions)). + +`0.7.0` is a breaking rename. Internally the engine still uses a physical four-edge mask; the public class names are now plain directions: ```txt -fade -fade-y -fade-top -fade-bottom -fade-x -fade-start -fade-end +fade fade-y fade-top fade-bottom fade-x fade-start fade-end ``` -`start` and `end` are horizontal and direction-aware. In LTR, start is left and end is right. In RTL, start is right and end is left. RTL routing follows the HTML `dir` attribute on the scroll container or an ancestor, not a CSS-only `direction: rtl` rule. +`start` / `end` are **horizontal and direction-aware**: in LTR, start = left and end = right; in RTL they swap. RTL is read from the `dir` attribute on the scroll container or an ancestor (the `:dir()` selector), not a CSS-only `direction: rtl`. -## Direction Renames +--- -| Before | After | -| --- | --- | -| `fade-xy` | `fade` | -| `fade-t` | `fade-top` | -| `fade-b` | `fade-bottom` | -| `fade-l` | `fade-start` for normal LTR horizontal rails | -| `fade-r` | `fade-end` for normal LTR horizontal rails | -| `fade-y` | unchanged | -| `fade-x` | unchanged name, now direction-aware | -| `fade-static` | `fade-always` | +## Step 0 — Search scope -There are no public `fade-left` or `fade-right` utilities in this API. Use `fade-start` and `fade-end` unless you intentionally need a physical-side escape hatch. That escape hatch is not part of v1. +Search the whole project, not just `.html`. Tokens appear in `class` / `className` strings (HTML, JSX/TSX, Vue, Svelte, Astro, templates), `@apply` rules, `@theme` overrides (`--fade-range-*`), and inline custom properties (`style="--fade-clear-l: 2"`). -## Size Renames +**Match whole tokens only.** A token is bounded by a quote, whitespace, backtick, or Tailwind variant colon (`:`). Variant prefixes stay attached: `sm:fade-t` → `sm:fade-top`, `hover:fade-l` → `hover:fade-start`. Never replace a *prefix* of a longer class — `fade-r` must not match inside `fade-range-*` (or the new `fade-reveal-*`). The Step 2 family rules are self-protecting because each prefix ends in `-`. -| Before | After | -| --- | --- | -| `fade-size-t-*` | `fade-size-top-*` | -| `fade-size-b-*` | `fade-size-bottom-*` | -| `fade-size-l-*` | `fade-size-start-*` | -| `fade-size-r-*` | `fade-size-end-*` | -| `fade-size-y-*` | unchanged | -| `fade-size-x-*` | unchanged name, now start + end | -| `fade-size-*` | unchanged | +--- -The source path still supports arbitrary values: +## Step 1 — Judgment calls (do first; never blind-replace) -```html -
...
-``` +### `fade-l` / `fade-r` were *physical*; `fade-start` / `fade-end` are *direction-aware* ⚠️ + +This is the one genuinely unsafe transform. In LTR the mapping is exact and automatable; inside `dir="rtl"` it **inverts** — `fade-start` resolves to the *right* edge — so RTL cases need human review, not a blind swap. + +| Context | `fade-l` → | `fade-r` → | +| --- | --- | --- | +| LTR container (the default) | `fade-start` ✅ exact | `fade-end` ✅ exact | +| RTL, author meant the physical **left** edge | `fade-end` ⚠️ review | — | +| RTL, author meant the physical **right** edge | — | `fade-start` ⚠️ review | + +**Flag every `fade-l` / `fade-r` inside an RTL subtree for human review.** There is no physical-side escape hatch by design (see [Why Plain Directions](#why-plain-directions)). -The prebuilt CSS includes named sizes only. +### `fade-x` — same name, new behavior -## Reveal Renames +`fade-x` is **not renamed**, but it now fades the inline start + end edges (direction-aware) rather than fixed physical left + right. No edit; just note the behavior change where RTL applies. -`range` was renamed to `reveal` because the class controls how quickly the fade appears and disappears during scroll. +--- -| Before | After | +## Step 2 — Mechanical renames (safe to automate) + +### Bare direction & state classes — match the whole token + +| Old | New | | --- | --- | -| `fade-range-*` | `fade-reveal-*` | -| `fade-range-[80px]` | `fade-reveal-[80px]` | -| `--fade-range-md` | `--fade-reveal-md` | +| `fade-xy` | `fade` | +| `fade-t` | `fade-top` | +| `fade-b` | `fade-bottom` | +| `fade-l` | `fade-start` _(Step 1 caveat)_ | +| `fade-r` | `fade-end` _(Step 1 caveat)_ | +| `fade-static` | `fade-always` | +| `fade-y` | _unchanged_ | +| `fade-x` | _unchanged name (Step 1)_ | -`fade-reveal-*` is global only. There are no per-edge reveal utilities. +### Family classes — replace the prefix; the suffix carries through -## Clear Zone Renames +The suffix may be a named scale (`-md`), a bare integer (`-2`), or an arbitrary value (`-[80px]`) — replacing only the prefix preserves all three. Each prefix ends in `-`, making the rule self-protecting (`fade-clear-t-md` → `fade-clear-top-md` no longer matches `fade-clear-t-`). -| Before | After | +| Old prefix | New prefix | | --- | --- | -| `fade-clear-t-*` | `fade-clear-top-*` | -| `fade-clear-b-*` | `fade-clear-bottom-*` | -| `fade-clear-l-*` | `fade-clear-start-*` | -| `fade-clear-r-*` | `fade-clear-end-*` | -| `fade-clear-xy-*` | `fade-clear-*` | -| `fade-clear-y-*` | unchanged | -| `fade-clear-x-*` | unchanged name, now start + end | +| `fade-size-t-` | `fade-size-top-` | +| `fade-size-b-` | `fade-size-bottom-` | +| `fade-size-l-` | `fade-size-start-` | +| `fade-size-r-` | `fade-size-end-` | +| `fade-range-` | `fade-reveal-` | +| `fade-clear-t-` | `fade-clear-top-` | +| `fade-clear-b-` | `fade-clear-bottom-` | +| `fade-clear-l-` | `fade-clear-start-` | +| `fade-clear-r-` | `fade-clear-end-` | +| `fade-clear-xy-` | `fade-clear-` | +| `fade-size-x-` · `fade-size-y-` · `fade-clear-x-` · `fade-clear-y-` | _unchanged_ | + +`range` → `reveal` controls how quickly the fade appears and disappears during scroll. `fade-reveal-*` is **global only** — no per-edge reveal utilities. + +--- -Dynamic clear variables use the new names too: +## Step 3 — Theme tokens & inline custom properties -| Before | After | +| Old | New | | --- | --- | +| `--fade-range-*` (e.g. `--fade-range-md`) | `--fade-reveal-*` | | `--fade-clear-t` | `--fade-clear-top` | | `--fade-clear-b` | `--fade-clear-bottom` | | `--fade-clear-l` | `--fade-clear-start` | | `--fade-clear-r` | `--fade-clear-end` | | `--fade-clear-xy` | `--fade-clear` | -## Force Or Disable +--- -| Before | After | -| --- | --- | -| `fade-static` | `fade-always` | -| none | `fade-always-y` | -| none | `fade-always-x` | -| none | `fade-none` | -| none | `fade-none-y` | -| none | `fade-none-x` | +## Step 4 — New utilities (no old equivalent) -Pair these with a direction utility. For example: +`fade-none`, `fade-none-x`, `fade-none-y`, `fade-always-x`, `fade-always-y` are new. They change only the fade *amount*, so pair them with a direction utility: ```html -
...
+
``` -## CDN Users +No migration action — listed so they aren't flagged as unrecognized. + +--- + +## Step 5 — Pin the CDN (prebuilt-CSS users only) -Pin CDN URLs to the version your HTML was written against. After migrating to this API, a pinned URL looks like this: +If the project links the prebuilt CSS, pin the version so this breaking rename can't arrive unannounced: ```html ``` -Unversioned URLs such as `https://unpkg.com/tw-fade/dist/tw-fade.css` track the latest package and will receive breaking API changes. +An unversioned URL (`https://unpkg.com/tw-fade/dist/tw-fade.css`) tracks latest and will receive future breaking renames. Projects that build from the Tailwind source path pick up the new names on the next build — no pin needed. -## Demo Release Note +--- -The demo CSS is generated. After changing the source API or demo markup, run: +## Step 6 — Verify + +Run from the project root. A complete migration prints **nothing** (the pattern matches every removed class/variable shape with token boundaries, so it won't false-positive on the new names): ```bash -npm run build:demo -npm run ship-demo +rg -nP '(? Date: Fri, 26 Jun 2026 22:34:20 -0700 Subject: [PATCH 09/17] =?UTF-8?q?Add=20LTR/RTL=20Direction=20control=20to?= =?UTF-8?q?=20demo;=20rename=20fade-reveal=20=E2=86=92=20fade-ramp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo: new "Direction" segmented control (LTR/RTL) on the advanced type-specimen, ordered Edges → Direction → Fade size → Ramp. Flipping direction swaps fade-start/fade-end to the opposite physical edge via :dir(rtl); a safeguard auto-enables a horizontal edge when none is set. Includes the a11y Label-in-Name fix and RTL padding-inline-end symmetry fix. API rename: fade-reveal-* → fade-ramp-* (theme scale --fade-ramp-*, internal --tw-fade-ramp / --tw-fade-ramp-active, keyframes tw-fade-ramp-*, data-fade-ramp-*). "ramp" describes the scroll-travel distance over which a fade eases in/out, rather than implying nearness to the edge. The English verb "reveal" is deliberately preserved in prose/comments. Docs (README/MIGRATING/CHANGELOG), tests, verifiers, and generated dist/demo CSS updated accordingly. npm test 16/16; npm run verify exit 0 (all 9 verifiers green). Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- CHANGELOG.md | 4 +- MIGRATING.md | 8 +- README.md | 22 ++--- build/verify-demo-runtime.mjs | 2 +- build/verify-dist.mjs | 16 ++-- build/verify-fade.mjs | 4 +- build/verify-page.mjs | 88 ++++++++++++++++- demo/demo-controls.js | 131 +++++++++++++++++++------ demo/demo.css | 56 ++++++++++- demo/index.html | 176 ++++++++++++++++++++-------------- demo/styles.css | 84 ++++++++-------- dist/tw-fade.css | 90 ++++++++--------- scripts/build-css.mjs | 4 +- src/tw-fade.css | 62 ++++++------ test/plugin.test.mjs | 48 +++++----- 15 files changed, 520 insertions(+), 275 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 308ca0d..6527877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,8 @@ it also explains the [naming rationale](./MIGRATING.md#why-plain-directions). - **Breaking:** the public API is now plain directions — `fade`, `fade-y`, `fade-top`, `fade-bottom`, `fade-x`, `fade-start`, `fade-end` — replacing the old physical `fade-t/b/l/r/x/y/xy` set. -- **Breaking:** `fade-range-*` renamed to `fade-reveal-*` (and `--fade-range-*` → - `--fade-reveal-*`). +- **Breaking:** `fade-range-*` renamed to `fade-ramp-*` (and `--fade-range-*` → + `--fade-ramp-*`). - **Breaking:** `fade-static` renamed to `fade-always`. - The prebuilt CDN example in the README is pinned to a fixed version; unversioned URLs track latest and will receive breaking renames. diff --git a/MIGRATING.md b/MIGRATING.md index 0866522..aee59df 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -18,7 +18,7 @@ fade fade-y fade-top fade-bottom fade-x fade-start fade-end Search the whole project, not just `.html`. Tokens appear in `class` / `className` strings (HTML, JSX/TSX, Vue, Svelte, Astro, templates), `@apply` rules, `@theme` overrides (`--fade-range-*`), and inline custom properties (`style="--fade-clear-l: 2"`). -**Match whole tokens only.** A token is bounded by a quote, whitespace, backtick, or Tailwind variant colon (`:`). Variant prefixes stay attached: `sm:fade-t` → `sm:fade-top`, `hover:fade-l` → `hover:fade-start`. Never replace a *prefix* of a longer class — `fade-r` must not match inside `fade-range-*` (or the new `fade-reveal-*`). The Step 2 family rules are self-protecting because each prefix ends in `-`. +**Match whole tokens only.** A token is bounded by a quote, whitespace, backtick, or Tailwind variant colon (`:`). Variant prefixes stay attached: `sm:fade-t` → `sm:fade-top`, `hover:fade-l` → `hover:fade-start`. Never replace a *prefix* of a longer class — `fade-r` must not match inside `fade-range-*` (or the new `fade-ramp-*`). The Step 2 family rules are self-protecting because each prefix ends in `-`. --- @@ -67,7 +67,7 @@ The suffix may be a named scale (`-md`), a bare integer (`-2`), or an arbitrary | `fade-size-b-` | `fade-size-bottom-` | | `fade-size-l-` | `fade-size-start-` | | `fade-size-r-` | `fade-size-end-` | -| `fade-range-` | `fade-reveal-` | +| `fade-range-` | `fade-ramp-` | | `fade-clear-t-` | `fade-clear-top-` | | `fade-clear-b-` | `fade-clear-bottom-` | | `fade-clear-l-` | `fade-clear-start-` | @@ -75,7 +75,7 @@ The suffix may be a named scale (`-md`), a bare integer (`-2`), or an arbitrary | `fade-clear-xy-` | `fade-clear-` | | `fade-size-x-` · `fade-size-y-` · `fade-clear-x-` · `fade-clear-y-` | _unchanged_ | -`range` → `reveal` controls how quickly the fade appears and disappears during scroll. `fade-reveal-*` is **global only** — no per-edge reveal utilities. +`range` → `ramp` controls how quickly the fade appears and disappears during scroll. `fade-ramp-*` is **global only** — no per-edge ramp utilities. --- @@ -83,7 +83,7 @@ The suffix may be a named scale (`-md`), a bare integer (`-2`), or an arbitrary | Old | New | | --- | --- | -| `--fade-range-*` (e.g. `--fade-range-md`) | `--fade-reveal-*` | +| `--fade-range-*` (e.g. `--fade-range-md`) | `--fade-ramp-*` | | `--fade-clear-t` | `--fade-clear-top` | | `--fade-clear-b` | `--fade-clear-bottom` | | `--fade-clear-l` | `--fade-clear-start` | diff --git a/README.md b/README.md index 8a9b371..7e257b0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A static gradient overlay is always on. It dims content even when nothing is hid - **Scroll-aware.** The fade is gated by the element's own scroll position. - **No extra DOM.** The mask lives on the scroll container. - **Surface-neutral.** The mask reveals whatever is behind the element instead of painting a fake background color. -- **Composable.** Direction, size, reveal distance, and clear zones are separate utilities. +- **Composable.** Direction, size, ramp distance, and clear zones are separate utilities. - **Graceful fallback.** Browsers without scroll-driven animations get a static fade instead of a broken mask. ## Install @@ -72,8 +72,8 @@ Put the fade utility on the element that actually scrolls.
...
- -
...
+ +
...
``` ## Direction Utilities @@ -137,22 +137,22 @@ On the Tailwind source path, size accepts named values, lengths, and percentages The prebuilt CSS includes the named sizes only. -## Reveal Distance +## Ramp Distance -Reveal distance controls how much scroll travel is used to bring a fade in or out. +Ramp distance controls how much scroll travel is used to bring a fade in or out. | Utility | Affects | | --- | --- | -| `fade-reveal-*` | all selected edges | +| `fade-ramp-*` | all selected edges | ```html -
...
-
...
+
...
+
...
``` -A smaller reveal distance snaps the fade in faster. A larger distance eases it over more scroll. +A smaller ramp distance snaps the fade in faster. A larger distance eases it over more scroll. -The named reveal scale is the same as the size scale and can be overridden with `--fade-reveal-{step}`. Arbitrary reveal values are source-path only. +The named ramp scale is the same as the size scale and can be overridden with `--fade-ramp-{step}`. Arbitrary ramp values are source-path only. ## Clear Zones @@ -250,7 +250,7 @@ Two details matter: | --- | --- | --- | | Needs Tailwind v4 | yes | no | | Direction utilities | yes | yes | -| Named size/reveal/clear utilities | yes | yes | +| Named size/ramp/clear utilities | yes | yes | | `fade-clear-*-var` | yes | yes | | Arbitrary values like `fade-size-[6rem]` | yes | no | | Integer clear values like `fade-clear-top-14` | yes | no | diff --git a/build/verify-demo-runtime.mjs b/build/verify-demo-runtime.mjs index d86b576..7fcabbc 100644 --- a/build/verify-demo-runtime.mjs +++ b/build/verify-demo-runtime.mjs @@ -249,7 +249,7 @@ async function smokePage(browser, options) { ) } assert(state.verticalRows >= 5, `${label}: vertical list rendered`, String(state.verticalRows)) - assert(state.advancedLabel === 'fade-y fade-size-2xl fade-reveal-2xl', `${label}: advanced controls initialize class readout`, state.advancedLabel) + assert(state.advancedLabel === 'fade-y fade-size-2xl fade-ramp-2xl', `${label}: advanced controls initialize class readout`, state.advancedLabel) assert(state.edgePressed === 'true,false,true,false', `${label}: top and bottom edge toggles initialize on`, state.edgePressed) assert(state.easedLine && state.scrollAwareThumb && state.composableTokens >= 3, `${label}: key animated graphics rendered`) assert(state.documentOverflowX <= 1, `${label}: no document-level horizontal overflow`, String(state.documentOverflowX)) diff --git a/build/verify-dist.mjs b/build/verify-dist.mjs index 64982f8..3141085 100644 --- a/build/verify-dist.mjs +++ b/build/verify-dist.mjs @@ -23,14 +23,14 @@ const html = `
-
+
@@ -54,8 +54,8 @@ async function probe(sel, top) { maxScroll: el.scrollHeight - el.clientHeight, t: num('--tw-fade-t'), b: num('--tw-fade-b'), - reveal: cs.getPropertyValue('--tw-fade-reveal').trim(), - revealActive: cs.getPropertyValue('--tw-fade-reveal-active').trim(), + ramp: cs.getPropertyValue('--tw-fade-ramp').trim(), + rampActive: cs.getPropertyValue('--tw-fade-ramp-active').trim(), animationRangeStart: cs.animationRangeStart, animationRangeEnd: cs.animationRangeEnd, maskComposite: cs.maskComposite, @@ -94,7 +94,7 @@ const r = { top: await probe('#tall', 0), mid: await probe('#tall', Math.round(tallMax / 2)), bottom: await probe('#tall', tallMax), - reveal: await probe('#reveal', 0), + ramp: await probe('#ramp', 0), short: await probe('#short', 0), combo: await probeBoth('#combo', 100, 100), } @@ -107,10 +107,10 @@ const checks = [ ['dist: mid → both fades (t≈1,b≈1)', approx(r.mid.t, 1) && approx(r.mid.b, 1)], ['dist: bottom → full top fade (t≈1)', approx(r.bottom.t, 1)], ['dist: bottom → no bottom fade (b≈0)', approx(r.bottom.b, 0)], - ['dist: fade-reveal-2xl resolves into the selected vertical animation range', /^96px, 100%, normal, normal$/.test(r.reveal.animationRangeEnd)], + ['dist: fade-ramp-2xl resolves into the selected vertical animation range', /^96px, 100%, normal, normal$/.test(r.ramp.animationRangeEnd)], ['dist: not scrollable → no fade (t≈0,b≈0)', approx(r.short.t, 0) && approx(r.short.b, 0)], ['dist: mixed fade-top fade-end composes (t≈1,r≈1)', approx(r.combo.t, 1) && approx(r.combo.r, 1)], - ['dist: mixed fade-top fade-end keeps four reveal animations', r.combo.animationNames.length === 4], + ['dist: mixed fade-top fade-end keeps four ramp animations', r.combo.animationNames.length === 4], ['dist: mask-composite intersect', /intersect/.test(r.top.maskComposite)], ['dist: mask-image has 4 layers', (r.top.maskImage.match(/gradient/g) || []).length >= 1], ] diff --git a/build/verify-fade.mjs b/build/verify-fade.mjs index 89a5717..e523552 100644 --- a/build/verify-fade.mjs +++ b/build/verify-fade.mjs @@ -204,12 +204,12 @@ const scopedBottomCross = crossing([...scopedLum].reverse()) const nestLum = await profileLine({ axis: 'y', innerSelector: '#inner', - body: `
${blocks('vb', 40)}
`, + body: `
${blocks('vb', 40)}
`, }) const nestCross = crossing(nestLum) // 6. Mixed individual edges. This catches the cascade failure where one utility's -// animation shorthand used to overwrite the other edge's reveal animation. +// animation shorthand used to overwrite the other edge's ramp animation. const combo = await samplePanel({ innerSelector: '#combo', body: `
`, diff --git a/build/verify-page.mjs b/build/verify-page.mjs index 481e657..7493118 100644 --- a/build/verify-page.mjs +++ b/build/verify-page.mjs @@ -9,6 +9,9 @@ * - the advanced specimen can switch to one edge without dragging in the * other mask layers * - the horizontal rail starts on Recede and still moves selection on click + * - the advanced Direction control swaps fade-start/fade-end to the opposite + * physical edge under RTL, and the safeguard activates a horizontal edge + * when none is set * * Usage: node build/verify-page.mjs */ @@ -46,7 +49,7 @@ const singleEdgeSpecimen = await page.evaluate(async () => { const specimen = document.querySelector('[data-demo="type-specimen"]') if (!specimen) return null - specimen.className = 'fade-top fade-size-2xl fade-reveal-md thin-scroll type-scale-sample h-64 overflow-auto p-5 sm:h-72' + specimen.className = 'fade-top fade-size-2xl fade-ramp-md thin-scroll type-scale-sample h-64 overflow-auto p-5 sm:h-72' specimen.scrollTop = 96 specimen.scrollLeft = 96 await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) @@ -89,9 +92,69 @@ const railSelection = await page.evaluate(() => { } }) +// The advanced Direction control drives the real radios + edge toggles, not a +// synthetic panel (verify-horizontal-rtl.mjs covers the CSS routing in +// isolation). This asserts the demo wiring: flipping LTR -> RTL migrates the +// `end` fade to the opposite physical edge, and the safeguard activates a +// horizontal edge when none is set so the toggle never looks dead. +const dirControl = await page.evaluate(async () => { + const specimen = document.querySelector('[data-demo="type-specimen"]') + const ltrRadio = document.querySelector('[data-fade-dir="ltr"]') + const rtlRadio = document.querySelector('[data-fade-dir="rtl"]') + const endToggle = document.querySelector('[data-fade-edge-toggle="end"]') + const startToggle = document.querySelector('[data-fade-edge-toggle="start"]') + if (!specimen || !ltrRadio || !rtlRadio || !endToggle || !startToggle) return null + + const settle = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) + const horizontalPressed = () => + endToggle.getAttribute('aria-pressed') === 'true' || + startToggle.getAttribute('aria-pressed') === 'true' + const masks = () => { + const cs = getComputedStyle(specimen) + return { + maskL: cs.getPropertyValue('--tw-fade-mask-l').trim(), + maskR: cs.getPropertyValue('--tw-fade-mask-r').trim(), + } + } + const scrollMid = () => { + const max = specimen.scrollWidth - specimen.clientWidth + specimen.scrollLeft = getComputedStyle(specimen).direction === 'rtl' ? -(max / 2) : max / 2 + } + + // LTR baseline: enable the `end` edge so a horizontal mask layer exists. + ltrRadio.click() + if (endToggle.getAttribute('aria-pressed') !== 'true') endToggle.click() + scrollMid() + await settle() + const ltr = { dir: specimen.getAttribute('dir'), ...masks() } + + // Flip to RTL: the same `end` edge must migrate to the opposite physical side. + rtlRadio.click() + scrollMid() + await settle() + const rtl = { dir: specimen.getAttribute('dir'), ...masks() } + + // Safeguard: from a clean no-horizontal-edge state, RTL auto-enables one. + ltrRadio.click() + if (endToggle.getAttribute('aria-pressed') === 'true') endToggle.click() + if (startToggle.getAttribute('aria-pressed') === 'true') startToggle.click() + await settle() + const horizontalBefore = horizontalPressed() + rtlRadio.click() + scrollMid() + await settle() + const safeguard = { horizontalBefore, horizontalAfter: horizontalPressed(), ...masks() } + + return { ltr, rtl, safeguard } +}) + await browser.close() const transparent = (c) => c === 'rgba(0, 0, 0, 0)' || c === 'transparent' +// A horizontal mask layer is "active" when its gradient points inward: +// physical-left fades "to right", physical-right fades "to left". +const hasLeftMask = (m) => !!m && /to right/.test(m.maskL) +const hasRightMask = (m) => !!m && /to left/.test(m.maskR) const checks = [ ['demo build does not emit private .tw-fade-mask utility', !/\.tw-fade-mask\s*\{/.test(demoCss), '.tw-fade-mask absent'], @@ -131,6 +194,29 @@ const checks = [ railSelection.after.filter(Boolean).length === 1, railSelection ? `${railSelection.count} cards, selected:${railSelection.selectedLabel}` : 'missing rail cards', ], + [ + 'advanced Direction control swaps the end fade to the opposite edge in RTL', + dirControl && + dirControl.ltr.dir === 'ltr' && + hasRightMask(dirControl.ltr) && + !hasLeftMask(dirControl.ltr) && + dirControl.rtl.dir === 'rtl' && + hasLeftMask(dirControl.rtl) && + !hasRightMask(dirControl.rtl), + dirControl + ? `ltr(R:${hasRightMask(dirControl.ltr)} L:${hasLeftMask(dirControl.ltr)}) -> rtl(L:${hasLeftMask(dirControl.rtl)} R:${hasRightMask(dirControl.rtl)})` + : 'missing direction control', + ], + [ + 'advanced Direction RTL safeguard activates a horizontal edge when none is set', + dirControl && + dirControl.safeguard.horizontalBefore === false && + dirControl.safeguard.horizontalAfter === true && + (hasLeftMask(dirControl.safeguard) || hasRightMask(dirControl.safeguard)), + dirControl + ? `before:${dirControl.safeguard.horizontalBefore} after:${dirControl.safeguard.horizontalAfter} mask(L:${hasLeftMask(dirControl.safeguard)} R:${hasRightMask(dirControl.safeguard)})` + : 'missing direction control', + ], ] console.log('Demo page: static file smoke') diff --git a/demo/demo-controls.js b/demo/demo-controls.js index ec7921a..fc732a0 100644 --- a/demo/demo-controls.js +++ b/demo/demo-controls.js @@ -22,10 +22,10 @@ const installCopyButton = document.querySelector("[data-copy-install]"); const installCopyIconStack = document.querySelector(".install-copy-icon-stack"); const installCopyStatus = document.querySelector("[data-copy-install-status]"); const fadeDepthSlider = document.querySelector("[data-fade-depth-slider]"); -const fadeRevealSlider = document.querySelector("[data-fade-reveal-slider]"); +const fadeRampSlider = document.querySelector("[data-fade-ramp-slider]"); const fadeOptionLabel = document.querySelector("[data-fade-option-label]"); const fadeDepthValue = document.querySelector("[data-fade-depth-value]"); -const fadeRevealValue = document.querySelector("[data-fade-reveal-value]"); +const fadeRampValue = document.querySelector("[data-fade-ramp-value]"); const fadeSpecimen = document.querySelector('[data-demo="type-specimen"]'); const fadeEdgeButtons = Array.from( document.querySelectorAll("[data-fade-edge-toggle]"), @@ -54,27 +54,27 @@ const fadeDepthSizes = [ aria: "quadruple extra large", }, ]; -const fadeReveals = [ - { className: "fade-reveal-sm", value: "sm", aria: "small" }, - { className: "fade-reveal-md", value: "md", aria: "medium" }, - { className: "fade-reveal-lg", value: "lg", aria: "large" }, +const fadeRamps = [ + { className: "fade-ramp-sm", value: "sm", aria: "small" }, + { className: "fade-ramp-md", value: "md", aria: "medium" }, + { className: "fade-ramp-lg", value: "lg", aria: "large" }, { - className: "fade-reveal-xl", + className: "fade-ramp-xl", value: "xl", aria: "extra large", }, { - className: "fade-reveal-2xl", + className: "fade-ramp-2xl", value: "2xl", aria: "double extra large", }, { - className: "fade-reveal-3xl", + className: "fade-ramp-3xl", value: "3xl", aria: "triple extra large", }, { - className: "fade-reveal-4xl", + className: "fade-ramp-4xl", value: "4xl", aria: "quadruple extra large", }, @@ -890,7 +890,7 @@ function syncEdgeGradientDepth(depthIndex) { function fadeUtilityParts(className) { const prefix = - ["fade-size-", "fade-reveal-", "fade-"].find((candidate) => + ["fade-size-", "fade-ramp-", "fade-"].find((candidate) => className.startsWith(candidate), ) ?? ""; return { @@ -948,16 +948,16 @@ function flashFadeOptionParts(parts) { } } -function setFadeOptionLabel(edgeClasses, sizeClass, revealClass) { +function setFadeOptionLabel(edgeClasses, sizeClass, rampClass) { const nextState = { edgeClasses: [...edgeClasses], sizeClass, - revealClass, + rampClass, }; const previous = fadeOptionReadoutState; const flashTargets = []; const fragment = document.createDocumentFragment(); - const fullClassName = [...edgeClasses, sizeClass, revealClass].join(" "); + const fullClassName = [...edgeClasses, sizeClass, rampClass].join(" "); const previousEdgeClasses = previous?.edgeClasses ?? []; const edgeStemChanged = previous && @@ -989,8 +989,8 @@ function setFadeOptionLabel(edgeClasses, sizeClass, revealClass) { appendToken(sizeClass, previous?.sizeClass === sizeClass ? "none" : "stem"); appendToken( - revealClass, - previous?.revealClass === revealClass ? "none" : "stem", + rampClass, + previous?.rampClass === rampClass ? "none" : "stem", ); fadeOptionLabel.replaceChildren(fragment); @@ -1002,10 +1002,10 @@ function setFadeOptionLabel(edgeClasses, sizeClass, revealClass) { function setFadeControls() { if ( !fadeDepthSlider || - !fadeRevealSlider || + !fadeRampSlider || !fadeOptionLabel || !fadeDepthValue || - !fadeRevealValue || + !fadeRampValue || !fadeSpecimen ) return; @@ -1014,12 +1014,12 @@ function setFadeControls() { fadeDepthSizes.length - 1, Math.max(0, Number(fadeDepthSlider.value)), ); - const revealIndex = Math.min( - fadeReveals.length - 1, - Math.max(0, Number(fadeRevealSlider.value)), + const rampIndex = Math.min( + fadeRamps.length - 1, + Math.max(0, Number(fadeRampSlider.value)), ); const nextSize = fadeDepthSizes[depthIndex]; - const nextReveal = fadeReveals[revealIndex]; + const nextRamp = fadeRamps[rampIndex]; const activeEdges = fadeEdgeOrder.filter((edge) => { const button = fadeEdgeButtons.find( (candidate) => candidate.dataset.fadeEdgeToggle === edge, @@ -1030,7 +1030,7 @@ function setFadeControls() { const utilityClasses = [ ...edgeClasses, nextSize.className, - nextReveal.className, + nextRamp.className, ]; fadeSpecimen.classList.remove( @@ -1039,16 +1039,16 @@ function setFadeControls() { "fade-y", "fade-x", ...fadeDepthSizes.map((size) => size.className), - ...fadeReveals.map((reveal) => reveal.className), + ...fadeRamps.map((ramp) => ramp.className), ); fadeSpecimen.classList.add(...utilityClasses); - setFadeOptionLabel(edgeClasses, nextSize.className, nextReveal.className); + setFadeOptionLabel(edgeClasses, nextSize.className, nextRamp.className); fadeDepthValue.textContent = nextSize.value; - fadeRevealValue.textContent = nextReveal.value; + fadeRampValue.textContent = nextRamp.value; fadeDepthSlider.setAttribute("aria-valuetext", nextSize.aria); - fadeRevealSlider.setAttribute("aria-valuetext", nextReveal.aria); + fadeRampSlider.setAttribute("aria-valuetext", nextRamp.aria); syncSliderKnob(fadeDepthSlider); - syncSliderKnob(fadeRevealSlider); + syncSliderKnob(fadeRampSlider); syncEdgeGradientDepth(depthIndex); } @@ -1357,7 +1357,7 @@ fadeDepthSlider?.addEventListener("input", (event) => { setFadeControls(); }); -fadeRevealSlider?.addEventListener("input", () => { +fadeRampSlider?.addEventListener("input", () => { setFadeControls(); }); @@ -1375,6 +1375,79 @@ for (const button of fadeEdgeButtons) { setFadeControls(); +// --- Direction (LTR / RTL) ------------------------------------------------- +// The plugin routes RTL entirely through the :dir(rtl) selector, so flipping +// the scroll container's `dir` attribute is all it takes to make fade-start / +// fade-end swap physical edges. This control sets nothing but `dir` and lets +// the CSS do the rest. +const dirGroup = document.querySelector("[data-fade-dir-group]"); +if (dirGroup && fadeSpecimen) { + const dirOptions = Array.from(dirGroup.querySelectorAll("[data-fade-dir]")); + + const hasHorizontalEdge = () => + fadeEdgeButtons.some( + (button) => + (button.dataset.fadeEdgeToggle === "start" || + button.dataset.fadeEdgeToggle === "end") && + button.getAttribute("aria-pressed") === "true", + ); + + // Block-axis edges (top / bottom) never flip with direction, so switching to + // RTL with no horizontal edge active would look like nothing happened. Enable + // `end` — it's the edge visible at the specimen's rest scroll position, so the + // left/right swap reads at once. The toggle springs on, so the user sees the + // cause rather than a hidden side effect. + const ensureHorizontalEdgeVisible = () => { + if (hasHorizontalEdge()) return; + const endButton = fadeEdgeButtons.find( + (button) => button.dataset.fadeEdgeToggle === "end", + ); + if (!endButton) return; + endButton.setAttribute("aria-pressed", "true"); + springEdgeToggleSelection(endButton); + setFadeControls(); + }; + + const setDir = (dir, focus = false) => { + fadeSpecimen.setAttribute("dir", dir); + for (const option of dirOptions) { + const active = option.dataset.fadeDir === dir; + option.setAttribute("aria-checked", active ? "true" : "false"); + // Roving tabindex: only the selected radio stays in the tab order. + option.tabIndex = active ? 0 : -1; + if (active && focus) option.focus(); + } + if (dir === "rtl") ensureHorizontalEdgeVisible(); + }; + + for (const option of dirOptions) { + option.tabIndex = option.getAttribute("aria-checked") === "true" ? 0 : -1; + option.addEventListener("click", () => setDir(option.dataset.fadeDir)); + } + + // Arrow keys move and select within the group, matching native radio semantics. + dirGroup.addEventListener("keydown", (event) => { + const current = dirOptions.indexOf(document.activeElement); + if (current === -1) return; + let next = null; + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + next = (current + 1) % dirOptions.length; + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + next = (current - 1 + dirOptions.length) % dirOptions.length; + } + if (next === null) return; + event.preventDefault(); + setDir(dirOptions[next].dataset.fadeDir, true); + }); + + // Establish the initial direction on the specimen (LTR by default) without + // tripping the RTL safeguard. + const initialDir = + dirOptions.find((option) => option.getAttribute("aria-checked") === "true") + ?.dataset.fadeDir ?? "ltr"; + fadeSpecimen.setAttribute("dir", initialDir); +} + function syncFloatingPalette() { setFloatingPaletteVisible(document.body.scrollTop > 24); } diff --git a/demo/demo.css b/demo/demo.css index be48509..36958a2 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -2142,6 +2142,56 @@ label.option-control { ); } +.dir-toggle-group { + display: inline-grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + width: min(100%, 10rem); + gap: 0.1875rem; + padding: 0.1875rem; + border: 1px solid + color-mix(in oklab, var(--demo-border-strong) 34%, transparent); + border-radius: 0.5rem; + background: color-mix(in oklab, var(--demo-page-bg) 62%, transparent); +} + +.dir-toggle-option { + border: 0; + border-radius: 0.375rem; + padding: 0.375rem 0; + background: transparent; + color: var(--demo-text-muted); + font: inherit; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + line-height: 1; + text-align: center; + cursor: pointer; + transition: + color 160ms ease, + background-color 160ms ease; + -webkit-tap-highlight-color: transparent; +} + +.dir-toggle-option[aria-checked="true"] { + color: var(--demo-accent-text); + background: color-mix(in oklab, var(--demo-accent-text) 16%, transparent); +} + +.dir-toggle-option:focus-visible { + outline: none; + box-shadow: + 0 0 0 2px var(--demo-page-bg), + 0 0 0 4px color-mix(in oklab, var(--demo-focus) 62%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + .dir-toggle-option { + transition: none; + } +} + .type-scale-sample { container-type: inline-size; padding: 2.5rem; @@ -2150,7 +2200,9 @@ label.option-control { .type-scale-content { width: max-content; min-width: 100%; - padding-right: 5rem; + /* Trailing breathing room follows the inline direction so the fade + dissolves over space, not glyphs, in both LTR and RTL. */ + padding-inline-end: 5rem; } .type-scale-row { @@ -2456,7 +2508,7 @@ class drawers up from below when composed / drops away when removed. */ } .type-scale-content { - padding-right: 3rem; + padding-inline-end: 3rem; } } diff --git a/demo/index.html b/demo/index.html index c3d0c95..4df35b7 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1180,7 +1180,7 @@

- set a larger reveal + set a larger ramp

@@ -1214,7 +1214,7 @@ fade-reveal-fade-ramp-
- -
+
+ Direction +
+ + +
+
+ +
@@ -2045,7 +2079,7 @@ x="91" y="118" > - reveal-2xl + ramp-2xl

- Axis, size, reveal, and clear-zone utilities compose + Axis, size, ramp, and clear-zone utilities compose directly on the scroll container.

@@ -2268,7 +2302,7 @@ />
Partial support in stable Firefox: the mask - renders, but the reveal falls back to a static + renders, but the ramp falls back to a static always-on fade. diff --git a/demo/styles.css b/demo/styles.css index 4ba38f6..b2b33a4 100644 --- a/demo/styles.css +++ b/demo/styles.css @@ -35,13 +35,13 @@ --fade-size-2xl: calc(var(--spacing, 0.25rem) * 24); --fade-size-3xl: calc(var(--spacing, 0.25rem) * 32); --fade-size-4xl: calc(var(--spacing, 0.25rem) * 40); - --fade-reveal-sm: calc(var(--spacing, 0.25rem) * 8); - --fade-reveal-md: calc(var(--spacing, 0.25rem) * 12); - --fade-reveal-lg: calc(var(--spacing, 0.25rem) * 16); - --fade-reveal-xl: calc(var(--spacing, 0.25rem) * 20); - --fade-reveal-2xl: calc(var(--spacing, 0.25rem) * 24); - --fade-reveal-3xl: calc(var(--spacing, 0.25rem) * 32); - --fade-reveal-4xl: calc(var(--spacing, 0.25rem) * 40); + --fade-ramp-sm: calc(var(--spacing, 0.25rem) * 8); + --fade-ramp-md: calc(var(--spacing, 0.25rem) * 12); + --fade-ramp-lg: calc(var(--spacing, 0.25rem) * 16); + --fade-ramp-xl: calc(var(--spacing, 0.25rem) * 20); + --fade-ramp-2xl: calc(var(--spacing, 0.25rem) * 24); + --fade-ramp-3xl: calc(var(--spacing, 0.25rem) * 32); + --fade-ramp-4xl: calc(var(--spacing, 0.25rem) * 40); } } @layer base { @@ -532,26 +532,26 @@ -webkit-user-select: none; user-select: none; } - .fade-reveal-2xl { - --tw-fade-reveal: var(--fade-reveal-2xl); + .fade-ramp-2xl { + --tw-fade-ramp: var(--fade-ramp-2xl); } - .fade-reveal-3xl { - --tw-fade-reveal: var(--fade-reveal-3xl); + .fade-ramp-3xl { + --tw-fade-ramp: var(--fade-ramp-3xl); } - .fade-reveal-4xl { - --tw-fade-reveal: var(--fade-reveal-4xl); + .fade-ramp-4xl { + --tw-fade-ramp: var(--fade-ramp-4xl); } - .fade-reveal-lg { - --tw-fade-reveal: var(--fade-reveal-lg); + .fade-ramp-lg { + --tw-fade-ramp: var(--fade-ramp-lg); } - .fade-reveal-md { - --tw-fade-reveal: var(--fade-reveal-md); + .fade-ramp-md { + --tw-fade-ramp: var(--fade-ramp-md); } - .fade-reveal-sm { - --tw-fade-reveal: var(--fade-reveal-sm); + .fade-ramp-sm { + --tw-fade-ramp: var(--fade-ramp-sm); } - .fade-reveal-xl { - --tw-fade-reveal: var(--fade-reveal-xl); + .fade-ramp-xl { + --tw-fade-ramp: var(--fade-ramp-xl); } .fade-size-2xl { --tw-fade-size: var(--fade-size-2xl); @@ -684,7 +684,7 @@ syntax: "*"; inherits: false; } -@property --tw-fade-reveal { +@property --tw-fade-ramp { syntax: "*"; inherits: false; } @@ -796,7 +796,7 @@ syntax: "*"; inherits: false; } -@keyframes tw-fade-reveal-t { +@keyframes tw-fade-ramp-t { from { --tw-fade-t: 0; } @@ -804,7 +804,7 @@ --tw-fade-t: 1; } } -@keyframes tw-fade-reveal-b { +@keyframes tw-fade-ramp-b { from { --tw-fade-b: 1; } @@ -812,7 +812,7 @@ --tw-fade-b: 0; } } -@keyframes tw-fade-reveal-l { +@keyframes tw-fade-ramp-l { from { --tw-fade-l: 0; } @@ -820,7 +820,7 @@ --tw-fade-l: 1; } } -@keyframes tw-fade-reveal-r { +@keyframes tw-fade-ramp-r { from { --tw-fade-r: 1; } @@ -828,7 +828,7 @@ --tw-fade-r: 0; } } -@keyframes tw-fade-reveal-r-leading { +@keyframes tw-fade-ramp-r-leading { from { --tw-fade-r: 0; } @@ -836,7 +836,7 @@ --tw-fade-r: 1; } } -@keyframes tw-fade-reveal-l-trailing { +@keyframes tw-fade-ramp-l-trailing { from { --tw-fade-l: 1; } @@ -847,7 +847,7 @@ @layer utilities { .fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end { --tw-fade-size-default: min(12%, var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12))); - --tw-fade-reveal-active: var(--tw-fade-reveal, var(--fade-reveal-md, calc(var(--spacing, 0.25rem) * 12))); + --tw-fade-ramp-active: var(--tw-fade-ramp, var(--fade-ramp-md, calc(var(--spacing, 0.25rem) * 12))); --tw-fade-identity-mask: linear-gradient(#000, #000); --tw-fade-edge-size-t: var(--tw-fade-size-top, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); --tw-fade-edge-size-b: var(--tw-fade-size-bottom, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); @@ -1008,35 +1008,35 @@ } .fade, .fade-y, .fade-top { --tw-fade-mask-t: var(--tw-fade-gradient-t); - --tw-fade-animation-t: tw-fade-reveal-t; + --tw-fade-animation-t: tw-fade-ramp-t; --tw-fade-timeline-t: scroll(self y); - --tw-fade-animation-range-t: 0 var(--tw-fade-reveal-active); + --tw-fade-animation-range-t: 0 var(--tw-fade-ramp-active); } .fade, .fade-y, .fade-bottom { --tw-fade-mask-b: var(--tw-fade-gradient-b); - --tw-fade-animation-b: tw-fade-reveal-b; + --tw-fade-animation-b: tw-fade-ramp-b; --tw-fade-timeline-b: scroll(self y); - --tw-fade-animation-range-b: calc(100% - var(--tw-fade-reveal-active)) 100%; + --tw-fade-animation-range-b: calc(100% - var(--tw-fade-ramp-active)) 100%; } .fade, .fade-x, .fade-start { --tw-fade-ltr-start-layer: var(--tw-fade-gradient-l); --tw-fade-rtl-start-layer: var(--tw-fade-gradient-r); - --tw-fade-ltr-start-animation: tw-fade-reveal-l; - --tw-fade-rtl-start-animation: tw-fade-reveal-r-leading; + --tw-fade-ltr-start-animation: tw-fade-ramp-l; + --tw-fade-rtl-start-animation: tw-fade-ramp-r-leading; --tw-fade-ltr-start-timeline: scroll(self inline); --tw-fade-rtl-start-timeline: scroll(self inline); - --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-reveal-active); - --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-reveal-active); + --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-ramp-active); + --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-ramp-active); } .fade, .fade-x, .fade-end { --tw-fade-ltr-end-layer: var(--tw-fade-gradient-r); --tw-fade-rtl-end-layer: var(--tw-fade-gradient-l); - --tw-fade-ltr-end-animation: tw-fade-reveal-r; - --tw-fade-rtl-end-animation: tw-fade-reveal-l-trailing; + --tw-fade-ltr-end-animation: tw-fade-ramp-r; + --tw-fade-rtl-end-animation: tw-fade-ramp-l-trailing; --tw-fade-ltr-end-timeline: scroll(self inline); --tw-fade-rtl-end-timeline: scroll(self inline); - --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-reveal-active)) 100%; - --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-reveal-active)) 100%; + --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; + --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; } @supports (animation-timeline: scroll()) { .fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end { @@ -1309,7 +1309,7 @@ --tw-fade-size-bottom: initial; --tw-fade-size-start: initial; --tw-fade-size-end: initial; - --tw-fade-reveal: initial; + --tw-fade-ramp: initial; --tw-fade-clear: initial; --tw-fade-clear-y: initial; --tw-fade-clear-x: initial; diff --git a/dist/tw-fade.css b/dist/tw-fade.css index 0e7cbc8..bab9f8e 100644 --- a/dist/tw-fade.css +++ b/dist/tw-fade.css @@ -190,29 +190,29 @@ .fade-clear-y-xs { --tw-fade-clear-y: var(--fade-clear-xs); } -.fade-reveal-2xl { - --tw-fade-reveal: var(--fade-reveal-2xl); +.fade-ramp-2xl { + --tw-fade-ramp: var(--fade-ramp-2xl); } -.fade-reveal-3xl { - --tw-fade-reveal: var(--fade-reveal-3xl); +.fade-ramp-3xl { + --tw-fade-ramp: var(--fade-ramp-3xl); } -.fade-reveal-4xl { - --tw-fade-reveal: var(--fade-reveal-4xl); +.fade-ramp-4xl { + --tw-fade-ramp: var(--fade-ramp-4xl); } -.fade-reveal-lg { - --tw-fade-reveal: var(--fade-reveal-lg); +.fade-ramp-lg { + --tw-fade-ramp: var(--fade-ramp-lg); } -.fade-reveal-md { - --tw-fade-reveal: var(--fade-reveal-md); +.fade-ramp-md { + --tw-fade-ramp: var(--fade-ramp-md); } -.fade-reveal-sm { - --tw-fade-reveal: var(--fade-reveal-sm); +.fade-ramp-sm { + --tw-fade-ramp: var(--fade-ramp-sm); } -.fade-reveal-xl { - --tw-fade-reveal: var(--fade-reveal-xl); +.fade-ramp-xl { + --tw-fade-ramp: var(--fade-ramp-xl); } -.fade-reveal-xs { - --tw-fade-reveal: var(--fade-reveal-xs); +.fade-ramp-xs { + --tw-fade-ramp: var(--fade-ramp-xs); } .fade-size-2xl { --tw-fade-size: var(--fade-size-2xl); @@ -430,7 +430,7 @@ syntax: "*"; inherits: false; } -@property --tw-fade-reveal { +@property --tw-fade-ramp { syntax: "*"; inherits: false; } @@ -551,14 +551,14 @@ --fade-size-2xl: calc(var(--spacing, 0.25rem) * 24); --fade-size-3xl: calc(var(--spacing, 0.25rem) * 32); --fade-size-4xl: calc(var(--spacing, 0.25rem) * 40); - --fade-reveal-xs: calc(var(--spacing, 0.25rem) * 6); - --fade-reveal-sm: calc(var(--spacing, 0.25rem) * 8); - --fade-reveal-md: calc(var(--spacing, 0.25rem) * 12); - --fade-reveal-lg: calc(var(--spacing, 0.25rem) * 16); - --fade-reveal-xl: calc(var(--spacing, 0.25rem) * 20); - --fade-reveal-2xl: calc(var(--spacing, 0.25rem) * 24); - --fade-reveal-3xl: calc(var(--spacing, 0.25rem) * 32); - --fade-reveal-4xl: calc(var(--spacing, 0.25rem) * 40); + --fade-ramp-xs: calc(var(--spacing, 0.25rem) * 6); + --fade-ramp-sm: calc(var(--spacing, 0.25rem) * 8); + --fade-ramp-md: calc(var(--spacing, 0.25rem) * 12); + --fade-ramp-lg: calc(var(--spacing, 0.25rem) * 16); + --fade-ramp-xl: calc(var(--spacing, 0.25rem) * 20); + --fade-ramp-2xl: calc(var(--spacing, 0.25rem) * 24); + --fade-ramp-3xl: calc(var(--spacing, 0.25rem) * 32); + --fade-ramp-4xl: calc(var(--spacing, 0.25rem) * 40); --fade-clear-xs: calc(var(--spacing, 0.25rem) * 6); --fade-clear-sm: calc(var(--spacing, 0.25rem) * 8); --fade-clear-md: calc(var(--spacing, 0.25rem) * 12); @@ -568,7 +568,7 @@ --fade-clear-3xl: calc(var(--spacing, 0.25rem) * 32); --fade-clear-4xl: calc(var(--spacing, 0.25rem) * 40); } -@keyframes tw-fade-reveal-t { +@keyframes tw-fade-ramp-t { from { --tw-fade-t: 0; } @@ -576,7 +576,7 @@ --tw-fade-t: 1; } } -@keyframes tw-fade-reveal-b { +@keyframes tw-fade-ramp-b { from { --tw-fade-b: 1; } @@ -584,7 +584,7 @@ --tw-fade-b: 0; } } -@keyframes tw-fade-reveal-l { +@keyframes tw-fade-ramp-l { from { --tw-fade-l: 0; } @@ -592,7 +592,7 @@ --tw-fade-l: 1; } } -@keyframes tw-fade-reveal-r { +@keyframes tw-fade-ramp-r { from { --tw-fade-r: 1; } @@ -600,7 +600,7 @@ --tw-fade-r: 0; } } -@keyframes tw-fade-reveal-r-leading { +@keyframes tw-fade-ramp-r-leading { from { --tw-fade-r: 0; } @@ -608,7 +608,7 @@ --tw-fade-r: 1; } } -@keyframes tw-fade-reveal-l-trailing { +@keyframes tw-fade-ramp-l-trailing { from { --tw-fade-l: 1; } @@ -619,7 +619,7 @@ @layer utilities { .fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end { --tw-fade-size-default: min(12%, var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12))); - --tw-fade-reveal-active: var(--tw-fade-reveal, var(--fade-reveal-md, calc(var(--spacing, 0.25rem) * 12))); + --tw-fade-ramp-active: var(--tw-fade-ramp, var(--fade-ramp-md, calc(var(--spacing, 0.25rem) * 12))); --tw-fade-identity-mask: linear-gradient(#000, #000); --tw-fade-edge-size-t: var(--tw-fade-size-top, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); --tw-fade-edge-size-b: var(--tw-fade-size-bottom, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); @@ -780,35 +780,35 @@ } .fade, .fade-y, .fade-top { --tw-fade-mask-t: var(--tw-fade-gradient-t); - --tw-fade-animation-t: tw-fade-reveal-t; + --tw-fade-animation-t: tw-fade-ramp-t; --tw-fade-timeline-t: scroll(self y); - --tw-fade-animation-range-t: 0 var(--tw-fade-reveal-active); + --tw-fade-animation-range-t: 0 var(--tw-fade-ramp-active); } .fade, .fade-y, .fade-bottom { --tw-fade-mask-b: var(--tw-fade-gradient-b); - --tw-fade-animation-b: tw-fade-reveal-b; + --tw-fade-animation-b: tw-fade-ramp-b; --tw-fade-timeline-b: scroll(self y); - --tw-fade-animation-range-b: calc(100% - var(--tw-fade-reveal-active)) 100%; + --tw-fade-animation-range-b: calc(100% - var(--tw-fade-ramp-active)) 100%; } .fade, .fade-x, .fade-start { --tw-fade-ltr-start-layer: var(--tw-fade-gradient-l); --tw-fade-rtl-start-layer: var(--tw-fade-gradient-r); - --tw-fade-ltr-start-animation: tw-fade-reveal-l; - --tw-fade-rtl-start-animation: tw-fade-reveal-r-leading; + --tw-fade-ltr-start-animation: tw-fade-ramp-l; + --tw-fade-rtl-start-animation: tw-fade-ramp-r-leading; --tw-fade-ltr-start-timeline: scroll(self inline); --tw-fade-rtl-start-timeline: scroll(self inline); - --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-reveal-active); - --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-reveal-active); + --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-ramp-active); + --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-ramp-active); } .fade, .fade-x, .fade-end { --tw-fade-ltr-end-layer: var(--tw-fade-gradient-r); --tw-fade-rtl-end-layer: var(--tw-fade-gradient-l); - --tw-fade-ltr-end-animation: tw-fade-reveal-r; - --tw-fade-rtl-end-animation: tw-fade-reveal-l-trailing; + --tw-fade-ltr-end-animation: tw-fade-ramp-r; + --tw-fade-rtl-end-animation: tw-fade-ramp-l-trailing; --tw-fade-ltr-end-timeline: scroll(self inline); --tw-fade-rtl-end-timeline: scroll(self inline); - --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-reveal-active)) 100%; - --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-reveal-active)) 100%; + --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; + --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; } @supports (animation-timeline: scroll()) { .fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end { @@ -868,7 +868,7 @@ --tw-fade-size-bottom: initial; --tw-fade-size-start: initial; --tw-fade-size-end: initial; - --tw-fade-reveal: initial; + --tw-fade-ramp: initial; --tw-fade-clear: initial; --tw-fade-clear-y: initial; --tw-fade-clear-x: initial; diff --git a/scripts/build-css.mjs b/scripts/build-css.mjs index e1dcbc7..29e8d64 100644 --- a/scripts/build-css.mjs +++ b/scripts/build-css.mjs @@ -37,12 +37,12 @@ const src = path.join(root, 'src', 'tw-fade.css') const outFile = path.join(root, 'dist', 'tw-fade.css') // Every class the framework-free build should contain. Arbitrary values -// (fade-size-[6rem], fade-reveal-[80px], fade-clear-top-[56px]) are intentionally +// (fade-size-[6rem], fade-ramp-[80px], fade-clear-top-[56px]) are intentionally // absent — those require Tailwind's JIT and belong to the v4 source path, not // the prebuilt drop-in. const SCALE = 'xs,sm,md,lg,xl,2xl,3xl,4xl' const CLASSES = - `fade fade-{x,y,top,bottom,start,end} fade-none fade-none-{x,y} fade-always fade-always-{x,y} fade-size-{${SCALE}} fade-size-{x,y,top,bottom,start,end}-{${SCALE}} fade-reveal-{${SCALE}} fade-clear-{${SCALE},var} fade-clear-{x,y,top,bottom,start,end}-{${SCALE},var}` + `fade fade-{x,y,top,bottom,start,end} fade-none fade-none-{x,y} fade-always fade-always-{x,y} fade-size-{${SCALE}} fade-size-{x,y,top,bottom,start,end}-{${SCALE}} fade-ramp-{${SCALE}} fade-clear-{${SCALE},var} fade-clear-{x,y,top,bottom,start,end}-{${SCALE},var}` const BANNER = '/*! tw-fade — framework-free build for plain HTML / CDN.\n' + diff --git a/src/tw-fade.css b/src/tw-fade.css index 98670cc..1447f94 100644 --- a/src/tw-fade.css +++ b/src/tw-fade.css @@ -25,7 +25,7 @@ /* Config: keep "*" with NO initial-value. A typed rejects the rem fallback and drops the registration in browsers; inherits:false stops nested -fades from leaking size, reveal, clearance, mask, or routing state. +fades from leaking size, ramp, clearance, mask, or routing state. */ @property --tw-fade-size { syntax: "*"; @@ -55,7 +55,7 @@ fades from leaking size, reveal, clearance, mask, or routing state. syntax: "*"; inherits: false; } -@property --tw-fade-reveal { +@property --tw-fade-ramp { syntax: "*"; inherits: false; } @@ -177,14 +177,14 @@ fades from leaking size, reveal, clearance, mask, or routing state. --fade-size-2xl: calc(var(--spacing, 0.25rem) * 24); --fade-size-3xl: calc(var(--spacing, 0.25rem) * 32); --fade-size-4xl: calc(var(--spacing, 0.25rem) * 40); - --fade-reveal-xs: calc(var(--spacing, 0.25rem) * 6); - --fade-reveal-sm: calc(var(--spacing, 0.25rem) * 8); - --fade-reveal-md: calc(var(--spacing, 0.25rem) * 12); - --fade-reveal-lg: calc(var(--spacing, 0.25rem) * 16); - --fade-reveal-xl: calc(var(--spacing, 0.25rem) * 20); - --fade-reveal-2xl: calc(var(--spacing, 0.25rem) * 24); - --fade-reveal-3xl: calc(var(--spacing, 0.25rem) * 32); - --fade-reveal-4xl: calc(var(--spacing, 0.25rem) * 40); + --fade-ramp-xs: calc(var(--spacing, 0.25rem) * 6); + --fade-ramp-sm: calc(var(--spacing, 0.25rem) * 8); + --fade-ramp-md: calc(var(--spacing, 0.25rem) * 12); + --fade-ramp-lg: calc(var(--spacing, 0.25rem) * 16); + --fade-ramp-xl: calc(var(--spacing, 0.25rem) * 20); + --fade-ramp-2xl: calc(var(--spacing, 0.25rem) * 24); + --fade-ramp-3xl: calc(var(--spacing, 0.25rem) * 32); + --fade-ramp-4xl: calc(var(--spacing, 0.25rem) * 40); --fade-clear-xs: calc(var(--spacing, 0.25rem) * 6); --fade-clear-sm: calc(var(--spacing, 0.25rem) * 8); --fade-clear-md: calc(var(--spacing, 0.25rem) * 12); @@ -195,7 +195,7 @@ fades from leaking size, reveal, clearance, mask, or routing state. --fade-clear-4xl: calc(var(--spacing, 0.25rem) * 40); } -@keyframes tw-fade-reveal-t { +@keyframes tw-fade-ramp-t { from { --tw-fade-t: 0; } @@ -203,7 +203,7 @@ fades from leaking size, reveal, clearance, mask, or routing state. --tw-fade-t: 1; } } -@keyframes tw-fade-reveal-b { +@keyframes tw-fade-ramp-b { from { --tw-fade-b: 1; } @@ -211,7 +211,7 @@ fades from leaking size, reveal, clearance, mask, or routing state. --tw-fade-b: 0; } } -@keyframes tw-fade-reveal-l { +@keyframes tw-fade-ramp-l { from { --tw-fade-l: 0; } @@ -219,7 +219,7 @@ fades from leaking size, reveal, clearance, mask, or routing state. --tw-fade-l: 1; } } -@keyframes tw-fade-reveal-r { +@keyframes tw-fade-ramp-r { from { --tw-fade-r: 1; } @@ -227,7 +227,7 @@ fades from leaking size, reveal, clearance, mask, or routing state. --tw-fade-r: 0; } } -@keyframes tw-fade-reveal-r-leading { +@keyframes tw-fade-ramp-r-leading { from { --tw-fade-r: 0; } @@ -235,7 +235,7 @@ fades from leaking size, reveal, clearance, mask, or routing state. --tw-fade-r: 1; } } -@keyframes tw-fade-reveal-l-trailing { +@keyframes tw-fade-ramp-l-trailing { from { --tw-fade-l: 1; } @@ -265,8 +265,8 @@ fades from leaking size, reveal, clearance, mask, or routing state. @utility fade-size-end-* { --tw-fade-size-end: --value(--fade-size-*, [length], [percentage]); } -@utility fade-reveal-* { - --tw-fade-reveal: --value(--fade-reveal-*, [length], [percentage]); +@utility fade-ramp-* { + --tw-fade-ramp: --value(--fade-ramp-*, [length], [percentage]); } @utility fade-clear-* { --tw-fade-clear: calc(var(--spacing, 0.25rem) * --value(integer)); @@ -327,7 +327,7 @@ fades from leaking size, reveal, clearance, mask, or routing state. .fade-start, .fade-end { --tw-fade-size-default: min(12%, var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12))); - --tw-fade-reveal-active: var(--tw-fade-reveal, var(--fade-reveal-md, calc(var(--spacing, 0.25rem) * 12))); + --tw-fade-ramp-active: var(--tw-fade-ramp, var(--fade-ramp-md, calc(var(--spacing, 0.25rem) * 12))); --tw-fade-identity-mask: linear-gradient(#000, #000); --tw-fade-edge-size-t: var(--tw-fade-size-top, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); --tw-fade-edge-size-b: var(--tw-fade-size-bottom, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); @@ -510,18 +510,18 @@ fades from leaking size, reveal, clearance, mask, or routing state. .fade-y, .fade-top { --tw-fade-mask-t: var(--tw-fade-gradient-t); - --tw-fade-animation-t: tw-fade-reveal-t; + --tw-fade-animation-t: tw-fade-ramp-t; --tw-fade-timeline-t: scroll(self y); - --tw-fade-animation-range-t: 0 var(--tw-fade-reveal-active); + --tw-fade-animation-range-t: 0 var(--tw-fade-ramp-active); } .fade, .fade-y, .fade-bottom { --tw-fade-mask-b: var(--tw-fade-gradient-b); - --tw-fade-animation-b: tw-fade-reveal-b; + --tw-fade-animation-b: tw-fade-ramp-b; --tw-fade-timeline-b: scroll(self y); - --tw-fade-animation-range-b: calc(100% - var(--tw-fade-reveal-active)) 100%; + --tw-fade-animation-range-b: calc(100% - var(--tw-fade-ramp-active)) 100%; } .fade, @@ -529,12 +529,12 @@ fades from leaking size, reveal, clearance, mask, or routing state. .fade-start { --tw-fade-ltr-start-layer: var(--tw-fade-gradient-l); --tw-fade-rtl-start-layer: var(--tw-fade-gradient-r); - --tw-fade-ltr-start-animation: tw-fade-reveal-l; - --tw-fade-rtl-start-animation: tw-fade-reveal-r-leading; + --tw-fade-ltr-start-animation: tw-fade-ramp-l; + --tw-fade-rtl-start-animation: tw-fade-ramp-r-leading; --tw-fade-ltr-start-timeline: scroll(self inline); --tw-fade-rtl-start-timeline: scroll(self inline); - --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-reveal-active); - --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-reveal-active); + --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-ramp-active); + --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-ramp-active); } .fade, @@ -542,12 +542,12 @@ fades from leaking size, reveal, clearance, mask, or routing state. .fade-end { --tw-fade-ltr-end-layer: var(--tw-fade-gradient-r); --tw-fade-rtl-end-layer: var(--tw-fade-gradient-l); - --tw-fade-ltr-end-animation: tw-fade-reveal-r; - --tw-fade-rtl-end-animation: tw-fade-reveal-l-trailing; + --tw-fade-ltr-end-animation: tw-fade-ramp-r; + --tw-fade-rtl-end-animation: tw-fade-ramp-l-trailing; --tw-fade-ltr-end-timeline: scroll(self inline); --tw-fade-rtl-end-timeline: scroll(self inline); - --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-reveal-active)) 100%; - --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-reveal-active)) 100%; + --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; + --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; } @supports (animation-timeline: scroll()) { diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 312a608..b1ac827 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -18,7 +18,7 @@ const root = path.resolve(__dirname, '..') const css = compileCss() const customCss = compileCss({ classes: - 'fade-size-[15%] fade-size-top-[56px] fade-reveal-[80px] fade-clear-14 fade-clear-y-14 fade-clear-x-14 fade-clear-top-14 fade-clear-bottom-14 fade-clear-start-14 fade-clear-end-14', + 'fade-size-[15%] fade-size-top-[56px] fade-ramp-[80px] fade-clear-14 fade-clear-y-14 fade-clear-x-14 fade-clear-top-14 fade-clear-bottom-14 fade-clear-start-14 fade-clear-end-14', }) function block(selector, source = css) { @@ -171,7 +171,7 @@ test('registers amounts as typed numbers and config/routing vars as universal no assert.equal(prop['initial-value'], '0') } - for (const v of [...SIZE_VARS, '--tw-fade-reveal', ...CLEAR_VARS, ...MASK_VARS, ...ROUTING_VARS]) { + for (const v of [...SIZE_VARS, '--tw-fade-ramp', ...CLEAR_VARS, ...MASK_VARS, ...ROUTING_VARS]) { const prop = property(v) assert.ok(prop, `missing @property ${v}`) assert.equal(prop.syntax, '"*"') @@ -214,16 +214,16 @@ test('prebuilt dist is generated from the current default compile output', () => test('leading and trailing keyframes include RTL horizontal variants', () => { const kf = (n) => block('@keyframes ' + n) - assert.match(kf('tw-fade-reveal-t'), /from\s*\{[^}]*--tw-fade-t:\s*0/) - assert.match(kf('tw-fade-reveal-t'), /to\s*\{[^}]*--tw-fade-t:\s*1/) - assert.match(kf('tw-fade-reveal-b'), /from\s*\{[^}]*--tw-fade-b:\s*1/) - assert.match(kf('tw-fade-reveal-b'), /to\s*\{[^}]*--tw-fade-b:\s*0/) - assert.match(kf('tw-fade-reveal-l'), /from\s*\{[^}]*--tw-fade-l:\s*0/) - assert.match(kf('tw-fade-reveal-r'), /from\s*\{[^}]*--tw-fade-r:\s*1/) - assert.match(kf('tw-fade-reveal-r-leading'), /from\s*\{[^}]*--tw-fade-r:\s*0/) - assert.match(kf('tw-fade-reveal-r-leading'), /to\s*\{[^}]*--tw-fade-r:\s*1/) - assert.match(kf('tw-fade-reveal-l-trailing'), /from\s*\{[^}]*--tw-fade-l:\s*1/) - assert.match(kf('tw-fade-reveal-l-trailing'), /to\s*\{[^}]*--tw-fade-l:\s*0/) + assert.match(kf('tw-fade-ramp-t'), /from\s*\{[^}]*--tw-fade-t:\s*0/) + assert.match(kf('tw-fade-ramp-t'), /to\s*\{[^}]*--tw-fade-t:\s*1/) + assert.match(kf('tw-fade-ramp-b'), /from\s*\{[^}]*--tw-fade-b:\s*1/) + assert.match(kf('tw-fade-ramp-b'), /to\s*\{[^}]*--tw-fade-b:\s*0/) + assert.match(kf('tw-fade-ramp-l'), /from\s*\{[^}]*--tw-fade-l:\s*0/) + assert.match(kf('tw-fade-ramp-r'), /from\s*\{[^}]*--tw-fade-r:\s*1/) + assert.match(kf('tw-fade-ramp-r-leading'), /from\s*\{[^}]*--tw-fade-r:\s*0/) + assert.match(kf('tw-fade-ramp-r-leading'), /to\s*\{[^}]*--tw-fade-r:\s*1/) + assert.match(kf('tw-fade-ramp-l-trailing'), /from\s*\{[^}]*--tw-fade-l:\s*1/) + assert.match(kf('tw-fade-ramp-l-trailing'), /to\s*\{[^}]*--tw-fade-l:\s*0/) }) test('the shared mask setup owns four physical layers and scroll animation wiring', () => { @@ -246,27 +246,27 @@ test('the shared mask setup owns four physical layers and scroll animation wirin test('direction classes select the expected layers and central RTL routing', () => { const top = block(TOP_SELECTOR) assert.equal(declValue(top, '--tw-fade-mask-t'), 'var(--tw-fade-gradient-t)') - assert.equal(declValue(top, '--tw-fade-animation-t'), 'tw-fade-reveal-t') + assert.equal(declValue(top, '--tw-fade-animation-t'), 'tw-fade-ramp-t') assert.equal(declValue(top, '--tw-fade-timeline-t'), 'scroll(self y)') const bottom = block(BOTTOM_SELECTOR) assert.equal(declValue(bottom, '--tw-fade-mask-b'), 'var(--tw-fade-gradient-b)') - assert.equal(declValue(bottom, '--tw-fade-animation-b'), 'tw-fade-reveal-b') + assert.equal(declValue(bottom, '--tw-fade-animation-b'), 'tw-fade-ramp-b') assert.equal(declValue(bottom, '--tw-fade-timeline-b'), 'scroll(self y)') const start = block(START_SELECTOR) assert.equal(declValue(start, '--tw-fade-ltr-start-layer'), 'var(--tw-fade-gradient-l)') assert.equal(declValue(start, '--tw-fade-rtl-start-layer'), 'var(--tw-fade-gradient-r)') - assert.equal(declValue(start, '--tw-fade-ltr-start-animation'), 'tw-fade-reveal-l') - assert.equal(declValue(start, '--tw-fade-rtl-start-animation'), 'tw-fade-reveal-r-leading') + assert.equal(declValue(start, '--tw-fade-ltr-start-animation'), 'tw-fade-ramp-l') + assert.equal(declValue(start, '--tw-fade-rtl-start-animation'), 'tw-fade-ramp-r-leading') assert.equal(declValue(start, '--tw-fade-ltr-start-timeline'), 'scroll(self inline)') assert.equal(declValue(start, '--tw-fade-rtl-start-timeline'), 'scroll(self inline)') const end = block(END_SELECTOR) assert.equal(declValue(end, '--tw-fade-ltr-end-layer'), 'var(--tw-fade-gradient-r)') assert.equal(declValue(end, '--tw-fade-rtl-end-layer'), 'var(--tw-fade-gradient-l)') - assert.equal(declValue(end, '--tw-fade-ltr-end-animation'), 'tw-fade-reveal-r') - assert.equal(declValue(end, '--tw-fade-rtl-end-animation'), 'tw-fade-reveal-l-trailing') + assert.equal(declValue(end, '--tw-fade-ltr-end-animation'), 'tw-fade-ramp-r') + assert.equal(declValue(end, '--tw-fade-rtl-end-animation'), 'tw-fade-ramp-l-trailing') const rtl = block(RTL_SELECTOR) assert.ok(rtl, 'missing central RTL routing block') @@ -310,7 +310,7 @@ test('static fallback pins selected fades on when scroll-driven animation is uns } }) -test('exposes the size and reveal scales as theme-backed utilities', () => { +test('exposes the size and ramp scales as theme-backed utilities', () => { for (const [name] of SCALE) { assert.equal(declValue(block(`.fade-size-${name}`), '--tw-fade-size'), `var(--fade-size-${name})`) assert.equal(declValue(block(`.fade-size-y-${name}`), '--tw-fade-size-y'), `var(--fade-size-${name})`) @@ -319,13 +319,13 @@ test('exposes the size and reveal scales as theme-backed utilities', () => { assert.equal(declValue(block(`.fade-size-bottom-${name}`), '--tw-fade-size-bottom'), `var(--fade-size-${name})`) assert.equal(declValue(block(`.fade-size-start-${name}`), '--tw-fade-size-start'), `var(--fade-size-${name})`) assert.equal(declValue(block(`.fade-size-end-${name}`), '--tw-fade-size-end'), `var(--fade-size-${name})`) - assert.equal(declValue(block(`.fade-reveal-${name}`), '--tw-fade-reveal'), `var(--fade-reveal-${name})`) + assert.equal(declValue(block(`.fade-ramp-${name}`), '--tw-fade-ramp'), `var(--fade-ramp-${name})`) } const rootBlock = block(':root, :host') for (const [name, units] of SCALE) { const expected = `calc(var(--spacing, 0.25rem) * ${units})` assert.equal(declValue(rootBlock, `--fade-size-${name}`), expected) - assert.equal(declValue(rootBlock, `--fade-reveal-${name}`), expected) + assert.equal(declValue(rootBlock, `--fade-ramp-${name}`), expected) } assert.equal(block('.fade-range-sm'), null) assert.equal(block('.fade-size-left-sm'), null) @@ -352,12 +352,12 @@ test('size resolves as edge over axis over global over capped default', () => { ) }) -test('arbitrary size and reveal utilities compile only through the source path', () => { +test('arbitrary size and ramp utilities compile only through the source path', () => { assert.equal(declValue(block('.fade-size-\\[15\\%\\]', customCss), '--tw-fade-size'), '15%') assert.equal(declValue(block('.fade-size-top-\\[56px\\]', customCss), '--tw-fade-size-top'), '56px') - assert.equal(declValue(block('.fade-reveal-\\[80px\\]', customCss), '--tw-fade-reveal'), '80px') + assert.equal(declValue(block('.fade-ramp-\\[80px\\]', customCss), '--tw-fade-ramp'), '80px') assert.equal(block('.fade-size-\\[15\\%\\]'), null) - assert.equal(block('.fade-reveal-\\[80px\\]'), null) + assert.equal(block('.fade-ramp-\\[80px\\]'), null) }) test('exposes directional clear utilities and source-path integer clear values', () => { From d2ed9983b8cb190404ef811e62c6c3b32590a971 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Fri, 26 Jun 2026 22:47:33 -0700 Subject: [PATCH 10/17] Fix flaky wave-scroll check under CI's headless Linux WebKit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo runtime smoke drove the scroll-linked wave field with `body.scrollTo({behavior:'instant'})` + a fixed 240ms wait, then asserted the SVG wave morphed. This passed on macOS WebKit but failed only in CI's headless Linux WebKit: that engine doesn't reliably emit a native `scroll` event for a *programmatic* position change on the overflow body, so the field's scroll listener never fired and the wave stayed frozen at its top geometry (1629 -> 1629). Set scrollTop directly, dispatch the `scroll` event the field listens for, and poll (rAF-driven) for the morph instead of betting on a single fixed delay. rAF itself works in CI WebKit (the mouse-spring check relies on it), so the poll converges; the manual dispatch covers the missing native event. This is a CI-environment artifact, not a real-user bug — genuine touch scrolls do fire the event. Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- build/verify-demo-runtime.mjs | 49 +++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/build/verify-demo-runtime.mjs b/build/verify-demo-runtime.mjs index 7fcabbc..01700c5 100644 --- a/build/verify-demo-runtime.mjs +++ b/build/verify-demo-runtime.mjs @@ -274,25 +274,46 @@ async function smokePage(browser, options) { assert(/transform/.test(state.movingWillChange.easedLine), `${label}: eased line is composited`, state.movingWillChange.easedLine) assert(/opacity/.test(state.movingWillChange.token), `${label}: composable tokens expose compositor hints`, state.movingWillChange.token) - await page.evaluate(() => { - document.body.scrollTo({ - top: Math.min(900, document.body.scrollHeight - document.body.clientHeight), - left: 0, - behavior: 'instant', - }) - }) - await page.waitForTimeout(240) - assert(await page.evaluate(() => Boolean(document.body)), `${label}: page survives scroll-linked animation probe`) + // Drive the scroll-linked wave field and confirm it morphs. Set scrollTop + // directly (the most portable scroll primitive) and dispatch the `scroll` + // event the field listens for: headless Linux WebKit does not always emit a + // native scroll event for a *programmatic* position change on an overflow + // body the way macOS WebKit does, which would otherwise freeze the wave and + // fail this check only in CI. Then poll for the morph (rAF-driven) instead + // of betting on a single fixed delay. + const waveAfterScroll = await page.evaluate( + async ({ initialD, initialHeight, reduced }) => { + const body = document.body + body.scrollTop = Math.min(900, body.scrollHeight - body.clientHeight) + body.dispatchEvent(new Event('scroll')) + const sample = () => ({ + d: document.querySelector('[data-demo-wave-path]')?.getAttribute('d') ?? '', + height: document.querySelector('[data-demo-wave-pattern]')?.getAttribute('height') ?? '', + bodyAlive: Boolean(document.body), + }) + if (reduced) return sample() + const deadline = performance.now() + 3000 + let current = sample() + while ( + performance.now() < deadline && + current.height === initialHeight && + current.d === initialD + ) { + await new Promise((resolve) => requestAnimationFrame(resolve)) + current = sample() + } + return current + }, + { initialD: state.wavePathD, initialHeight: state.wavePatternHeight, reduced: reducedMotion }, + ) + assert(waveAfterScroll.bodyAlive, `${label}: page survives scroll-linked animation probe`) if (!reducedMotion) { - const waveAfterScroll = await page.evaluate(() => ({ - d: document.querySelector('[data-demo-wave-path]')?.getAttribute('d') ?? '', - height: document.querySelector('[data-demo-wave-pattern]')?.getAttribute('height') ?? '', - })) assert(waveAfterScroll.d && waveAfterScroll.d !== state.wavePathD, `${label}: wave path geometry changes with scroll`, waveAfterScroll.d.slice(0, 32)) assert(waveAfterScroll.height !== state.wavePatternHeight, `${label}: wave wavelength changes with scroll`, `${state.wavePatternHeight} -> ${waveAfterScroll.height}`) } await page.evaluate(() => { - document.body.scrollTo({ top: 0, left: 0, behavior: 'instant' }) + document.body.scrollTop = 0 + document.body.dispatchEvent(new Event('scroll')) }) await page.waitForTimeout(120) From e45c668759a0932b5001c1d0658c980a7bba7b95 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Fri, 26 Jun 2026 23:02:12 -0700 Subject: [PATCH 11/17] Lighten RTL/LTR toggle bg tint to 35% opacity Drop the .dir-toggle-group background fill from 62% to 35% of --demo-page-bg so the dark tint behind the Direction toggle reads softer. Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- demo/demo.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/demo.css b/demo/demo.css index 36958a2..ce67f39 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -2152,7 +2152,7 @@ label.option-control { border: 1px solid color-mix(in oklab, var(--demo-border-strong) 34%, transparent); border-radius: 0.5rem; - background: color-mix(in oklab, var(--demo-page-bg) 62%, transparent); + background: color-mix(in oklab, var(--demo-page-bg) 35%, transparent); } .dir-toggle-option { From d33d5340690ffff65d57fe2f4f6c5b0376915676 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Sat, 27 Jun 2026 23:38:11 -0700 Subject: [PATCH 12/17] Fix demo container highlight to trace the superellipse corners The beveled inner-border highlight on the demo containers (install-snippet, tuning-card, feature-column, support-tooltip, etc.) drew a circular arc while the hosts use corner-shape: superellipse(1.35). corner-shape is not inherited and border-radius: inherit copies only the radius, so the highlight diverged from the host edge at every corner regardless of whether it was drawn with a mask, box-shadow, or border. - Add corner-shape: inherit to the highlight pseudo-elements so the hairline follows the host squircle exactly. Unsupported browsers drop it and both host and highlight stay round. - Rework the single per-side border (which met its top/side/bottom colors at a hard miter seam at each corner) into two uniform-color borders: ::before is a light top layer, ::after a dark bottom layer, each faded with a linear-gradient mask so the catch and shadow taper off smoothly as they wrap the corner. - Add --demo-inner-border-taper to control the falloff distance. Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- demo/demo.css | 76 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/demo/demo.css b/demo/demo.css index ce67f39..0330af7 100644 --- a/demo/demo.css +++ b/demo/demo.css @@ -200,6 +200,10 @@ html[data-surface] { --demo-inner-border-dark-alpha: 0.22; --demo-inner-border-opacity: 0.9; --demo-inner-border-blend-mode: soft-light; + /* Distance over which the top light / bottom shadow fade out as they wrap the + corners. ~2x the corner radius, so the highlight eases off just past the curve + instead of hard-stopping at the corner miter. */ + --demo-inner-border-taper: 1.75rem; /* Color for the stepped wave-strip fills. Matches the white wave strokes on every (dark) surface; overridable per theme for a tinted gradation. */ @@ -1003,6 +1007,16 @@ h3 { isolation: isolate; } +/* Inner bevel highlight, drawn as two uniform-color borders rather than one per-side + border. Why a border at all: a border is the only ring primitive that follows the box's + corner-shape — round OR superellipse(1.35) — so it tracks the squircle exactly (a mask / + box-shadow ring always corners as a circle and mismatches the host). corner-shape is NOT + inherited and border-radius: inherit copies only the radius, so corner-shape: inherit is + required here; unsupported browsers drop it and both host + highlight stay round. + Why two layers + masks: a per-side border meets its top/side/bottom colors at a hard + miter seam at each corner. Instead, ::before paints a uniform light border and ::after a + uniform dark one, each faded with a linear-gradient mask so the top catch and bottom + shadow taper off smoothly as they wrap the corner. */ :is( .install-snippet, .social-icon-link, @@ -1010,31 +1024,59 @@ h3 { .feature-column, .support-tooltip, .floating-surface-palette - )::before { + )::before, +:is( + .install-snippet, + .social-icon-link, + .tuning-card, + .feature-column, + .support-tooltip, + .floating-surface-palette + )::after { content: ""; position: absolute; inset: 0; z-index: 1; - padding: var(--demo-inner-border-size); border-radius: inherit; + corner-shape: inherit; pointer-events: none; - background: linear-gradient( - 180deg, - rgb(255 255 255 / var(--demo-inner-border-light-alpha)) 0%, - rgb(255 255 255 / var(--demo-inner-border-mid-alpha)) 38%, - rgb(0 0 0 / var(--demo-inner-border-mid-alpha)) 68%, - rgb(0 0 0 / var(--demo-inner-border-dark-alpha)) 100% - ); + border: var(--demo-inner-border-size) solid transparent; mix-blend-mode: var(--demo-inner-border-blend-mode); opacity: var(--demo-inner-border-opacity); - -webkit-mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - mask-composite: exclude; +} + +/* Light catch on the top edge, easing out as it rounds the top corners. */ +:is( + .install-snippet, + .social-icon-link, + .tuning-card, + .feature-column, + .support-tooltip, + .floating-surface-palette + )::before { + border-color: rgb(255 255 255 / var(--demo-inner-border-light-alpha)); + mask-image: linear-gradient( + to bottom, + #000, + transparent var(--demo-inner-border-taper) + ); +} + +/* Shadow on the bottom edge, easing out as it rounds the bottom corners. */ +:is( + .install-snippet, + .social-icon-link, + .tuning-card, + .feature-column, + .support-tooltip, + .floating-surface-palette + )::after { + border-color: rgb(0 0 0 / var(--demo-inner-border-dark-alpha)); + mask-image: linear-gradient( + to top, + #000, + transparent var(--demo-inner-border-taper) + ); } .tuning-card { From ae1de723023e63e465da93a2b85940fbf1e87360 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Sat, 27 Jun 2026 23:38:17 -0700 Subject: [PATCH 13/17] Tidy CHANGELOG, guard against retired token names, bump tw-shimmer stat - CHANGELOG.md: fix the Changed-section wording and note that fade-x is now direction-aware. - test/plugin.test.mjs: add a test asserting the shipped CSS carries none of the retired reveal/range token names. - demo/index.html: update the tw-shimmer credit from 475k+ to 500k+ weekly npm downloads. Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- CHANGELOG.md | 5 ++++- demo/index.html | 2 +- test/plugin.test.mjs | 27 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6527877..b8429ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,10 @@ it also explains the [naming rationale](./MIGRATING.md#why-plain-directions). - **Breaking:** the public API is now plain directions — `fade`, `fade-y`, `fade-top`, `fade-bottom`, `fade-x`, `fade-start`, `fade-end` — replacing the old physical - `fade-t/b/l/r/x/y/xy` set. + `fade-t` / `fade-b` / `fade-l` / `fade-r` / `fade-xy` set. +- **Breaking:** `fade-x` keeps its name but now fades the inline **start + end** edges + (direction-aware), not a fixed physical left + right — so it flips under RTL. + `fade-y` is unchanged (the block axis never flips with text direction). - **Breaking:** `fade-range-*` renamed to `fade-ramp-*` (and `--fade-range-*` → `--fade-ramp-*`). - **Breaking:** `fade-static` renamed to `fade-always`. diff --git a/demo/index.html b/demo/index.html index 4df35b7..ecbeb46 100644 --- a/demo/index.html +++ b/demo/index.html @@ -478,7 +478,7 @@ id="tw-shimmer-tooltip" class="creator-note-tooltip support-tooltip" role="tooltip" - >CSS-only shimmer effects, with 475k+ weekly npm + >CSS-only shimmer effects, with 500k+ weekly npm downloads. . diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index b1ac827..1f743b6 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -212,6 +212,33 @@ test('prebuilt dist is generated from the current default compile output', () => assert.equal(dist, css) }) +// Guards against a dormant retired utility name surviving in the shipped CSS. +// The REJECTED_CLASSES checks only prove the *default* compile omits a name; a +// stray @utility / @property / @keyframes / theme token for a retired name +// ("range" -> "ramp", and the never-shipped intermediate "reveal") could still +// ship undetected. Scan the source and prebuilt artifacts on disk. Scoped to +// the CSS files only — README/MIGRATING/CHANGELOG legitimately reference the +// old names when documenting the rename. Token-specific patterns, so the +// English verb "reveal" in prose/comments is never matched. +test('shipped CSS carries no retired reveal/range token names', () => { + const retired = [ + /fade-reveal/, + /fade-range/, + /--fade-reveal/, + /--fade-range/, + /--tw-fade-reveal/, + /--tw-fade-range/, + /tw-fade-reveal/, + /tw-fade-range/, + ] + for (const file of ['src/tw-fade.css', 'dist/tw-fade.css']) { + const source = fs.readFileSync(path.join(root, file), 'utf8') + for (const pattern of retired) { + assert.equal(pattern.test(source), false, `${file} still contains retired token ${pattern}`) + } + } +}) + test('leading and trailing keyframes include RTL horizontal variants', () => { const kf = (n) => block('@keyframes ' + n) assert.match(kf('tw-fade-ramp-t'), /from\s*\{[^}]*--tw-fade-t:\s*0/) From 69650f14a1d1d6539059dfb38829fec093bbfcca Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Sun, 28 Jun 2026 15:44:24 -0700 Subject: [PATCH 14/17] =?UTF-8?q?Decouple=20edge=20alpha=20from=20band=20w?= =?UTF-8?q?idth;=20rename=20fade-ramp=20=E2=86=92=20fade-travel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Edge transparency now rides --tw-fade-alpha-* (= min(1, t * onset), onset default 8), decoupled from band width. A leading edge's overflow clip is covered within travel ÷ onset of scroll, so content is never hard-clipped while the soft band is still easing open. - Default travel lowered to sm (was md): clip coverage no longer depends on it, so it is purely cosmetic band-open speed. - Rename the scroll-distance utility fade-ramp-* → fade-travel-* (theme --fade-travel-*, internal --tw-fade-travel[-active], keyframes tw-fade-travel-*). "ramp" read as a gradient/color ramp; "travel" names the scroll axis that distinguishes it from fade-size/fade-clear. MIGRATING maps fade-range-* → fade-travel-* directly (no ramp hop); the retired-token guard now bans the ramp token forms. - Demo: rename the Travel slider + marketing copy; fix the chat-demo answer for "make the fade wider?" → fade-size (was travel, now inaccurate post-decouple). Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- CHANGELOG.md | 10 +- MIGRATING.md | 8 +- README.md | 22 ++-- build/verify-demo-runtime.mjs | 2 +- build/verify-dist.mjs | 16 +-- build/verify-fade.mjs | 4 +- build/verify-page.mjs | 2 +- build/verify.mjs | 4 +- demo/demo-controls.js | 58 +++++----- demo/index.html | 32 +++--- demo/styles.css | 199 ++++++++++++++++---------------- dist/tw-fade.css | 207 +++++++++++++++++----------------- scripts/build-css.mjs | 4 +- src/tw-fade.css | 174 +++++++++++++++------------- test/plugin.test.mjs | 106 ++++++++++++----- 15 files changed, 462 insertions(+), 386 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8429ed..f0f382b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,14 @@ it also explains the [naming rationale](./MIGRATING.md#why-plain-directions). - **Breaking:** `fade-x` keeps its name but now fades the inline **start + end** edges (direction-aware), not a fixed physical left + right — so it flips under RTL. `fade-y` is unchanged (the block axis never flips with text direction). -- **Breaking:** `fade-range-*` renamed to `fade-ramp-*` (and `--fade-range-*` → - `--fade-ramp-*`). +- **Breaking:** `fade-range-*` renamed to `fade-travel-*` (and `--fade-range-*` → + `--fade-travel-*`). +- Edge transparency is now decoupled from the travel. The masked edge saturates to + fully transparent within `travel ÷ --tw-fade-onset` (default `8`) of scroll, so a + leading edge is no longer hard-clipped while the band is still widening over the + travel. `fade-travel-*` now controls only how fast the soft band eases open (cosmetic, + safe at any size). Tune edge speed with `--tw-fade-onset`. +- The default travel is now `sm` (was `md`), for a snappier band open. - **Breaking:** `fade-static` renamed to `fade-always`. - The prebuilt CDN example in the README is pinned to a fixed version; unversioned URLs track latest and will receive breaking renames. diff --git a/MIGRATING.md b/MIGRATING.md index aee59df..552c326 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -18,7 +18,7 @@ fade fade-y fade-top fade-bottom fade-x fade-start fade-end Search the whole project, not just `.html`. Tokens appear in `class` / `className` strings (HTML, JSX/TSX, Vue, Svelte, Astro, templates), `@apply` rules, `@theme` overrides (`--fade-range-*`), and inline custom properties (`style="--fade-clear-l: 2"`). -**Match whole tokens only.** A token is bounded by a quote, whitespace, backtick, or Tailwind variant colon (`:`). Variant prefixes stay attached: `sm:fade-t` → `sm:fade-top`, `hover:fade-l` → `hover:fade-start`. Never replace a *prefix* of a longer class — `fade-r` must not match inside `fade-range-*` (or the new `fade-ramp-*`). The Step 2 family rules are self-protecting because each prefix ends in `-`. +**Match whole tokens only.** A token is bounded by a quote, whitespace, backtick, or Tailwind variant colon (`:`). Variant prefixes stay attached: `sm:fade-t` → `sm:fade-top`, `hover:fade-l` → `hover:fade-start`. Never replace a *prefix* of a longer class — `fade-r` must not match inside `fade-range-*` (or the new `fade-travel-*`). The Step 2 family rules are self-protecting because each prefix ends in `-`. --- @@ -67,7 +67,7 @@ The suffix may be a named scale (`-md`), a bare integer (`-2`), or an arbitrary | `fade-size-b-` | `fade-size-bottom-` | | `fade-size-l-` | `fade-size-start-` | | `fade-size-r-` | `fade-size-end-` | -| `fade-range-` | `fade-ramp-` | +| `fade-range-` | `fade-travel-` | | `fade-clear-t-` | `fade-clear-top-` | | `fade-clear-b-` | `fade-clear-bottom-` | | `fade-clear-l-` | `fade-clear-start-` | @@ -75,7 +75,7 @@ The suffix may be a named scale (`-md`), a bare integer (`-2`), or an arbitrary | `fade-clear-xy-` | `fade-clear-` | | `fade-size-x-` · `fade-size-y-` · `fade-clear-x-` · `fade-clear-y-` | _unchanged_ | -`range` → `ramp` controls how quickly the fade appears and disappears during scroll. `fade-ramp-*` is **global only** — no per-edge ramp utilities. +`range` → `travel` controls how quickly the soft band eases open as you scroll (the masked edge itself is now covered almost immediately — see the [changelog](./CHANGELOG.md)). `fade-travel-*` is **global only** — no per-edge travel utilities. --- @@ -83,7 +83,7 @@ The suffix may be a named scale (`-md`), a bare integer (`-2`), or an arbitrary | Old | New | | --- | --- | -| `--fade-range-*` (e.g. `--fade-range-md`) | `--fade-ramp-*` | +| `--fade-range-*` (e.g. `--fade-range-md`) | `--fade-travel-*` | | `--fade-clear-t` | `--fade-clear-top` | | `--fade-clear-b` | `--fade-clear-bottom` | | `--fade-clear-l` | `--fade-clear-start` | diff --git a/README.md b/README.md index 7e257b0..6ac7d03 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A static gradient overlay is always on. It dims content even when nothing is hid - **Scroll-aware.** The fade is gated by the element's own scroll position. - **No extra DOM.** The mask lives on the scroll container. - **Surface-neutral.** The mask reveals whatever is behind the element instead of painting a fake background color. -- **Composable.** Direction, size, ramp distance, and clear zones are separate utilities. +- **Composable.** Direction, size, travel distance, and clear zones are separate utilities. - **Graceful fallback.** Browsers without scroll-driven animations get a static fade instead of a broken mask. ## Install @@ -72,8 +72,8 @@ Put the fade utility on the element that actually scrolls.
...
- -
...
+ +
...
``` ## Direction Utilities @@ -137,22 +137,22 @@ On the Tailwind source path, size accepts named values, lengths, and percentages The prebuilt CSS includes the named sizes only. -## Ramp Distance +## Travel Distance -Ramp distance controls how much scroll travel is used to bring a fade in or out. +Travel distance controls how far you scroll before the soft band eases open to its full width. | Utility | Affects | | --- | --- | -| `fade-ramp-*` | all selected edges | +| `fade-travel-*` | all selected edges | ```html -
...
-
...
+
...
+
...
``` -A smaller ramp distance snaps the fade in faster. A larger distance eases it over more scroll. +A smaller travel distance opens the band faster; a larger one eases it over more scroll. Either way the **edge is masked almost immediately** — the band's transparency saturates within `travel ÷ 8` of scroll, so content is never hard-clipped at the scroll edge while the band is still widening. The travel is purely the cosmetic open-speed; it is safe at any size. -The named ramp scale is the same as the size scale and can be overridden with `--fade-ramp-{step}`. Arbitrary ramp values are source-path only. +The named travel scale is the same as the size scale (default `sm`) and can be overridden with `--fade-travel-{step}`. Arbitrary travel values are source-path only. To change how fast the edge itself goes transparent, set `--tw-fade-onset` (default `8`; higher = snappier). ## Clear Zones @@ -250,7 +250,7 @@ Two details matter: | --- | --- | --- | | Needs Tailwind v4 | yes | no | | Direction utilities | yes | yes | -| Named size/ramp/clear utilities | yes | yes | +| Named size/travel/clear utilities | yes | yes | | `fade-clear-*-var` | yes | yes | | Arbitrary values like `fade-size-[6rem]` | yes | no | | Integer clear values like `fade-clear-top-14` | yes | no | diff --git a/build/verify-demo-runtime.mjs b/build/verify-demo-runtime.mjs index 01700c5..a7b71a6 100644 --- a/build/verify-demo-runtime.mjs +++ b/build/verify-demo-runtime.mjs @@ -249,7 +249,7 @@ async function smokePage(browser, options) { ) } assert(state.verticalRows >= 5, `${label}: vertical list rendered`, String(state.verticalRows)) - assert(state.advancedLabel === 'fade-y fade-size-2xl fade-ramp-2xl', `${label}: advanced controls initialize class readout`, state.advancedLabel) + assert(state.advancedLabel === 'fade-y fade-size-2xl fade-travel-2xl', `${label}: advanced controls initialize class readout`, state.advancedLabel) assert(state.edgePressed === 'true,false,true,false', `${label}: top and bottom edge toggles initialize on`, state.edgePressed) assert(state.easedLine && state.scrollAwareThumb && state.composableTokens >= 3, `${label}: key animated graphics rendered`) assert(state.documentOverflowX <= 1, `${label}: no document-level horizontal overflow`, String(state.documentOverflowX)) diff --git a/build/verify-dist.mjs b/build/verify-dist.mjs index 3141085..29a86f5 100644 --- a/build/verify-dist.mjs +++ b/build/verify-dist.mjs @@ -23,14 +23,14 @@ const html = `
-
+
@@ -54,8 +54,8 @@ async function probe(sel, top) { maxScroll: el.scrollHeight - el.clientHeight, t: num('--tw-fade-t'), b: num('--tw-fade-b'), - ramp: cs.getPropertyValue('--tw-fade-ramp').trim(), - rampActive: cs.getPropertyValue('--tw-fade-ramp-active').trim(), + travel: cs.getPropertyValue('--tw-fade-travel').trim(), + travelActive: cs.getPropertyValue('--tw-fade-travel-active').trim(), animationRangeStart: cs.animationRangeStart, animationRangeEnd: cs.animationRangeEnd, maskComposite: cs.maskComposite, @@ -94,7 +94,7 @@ const r = { top: await probe('#tall', 0), mid: await probe('#tall', Math.round(tallMax / 2)), bottom: await probe('#tall', tallMax), - ramp: await probe('#ramp', 0), + travel: await probe('#travel', 0), short: await probe('#short', 0), combo: await probeBoth('#combo', 100, 100), } @@ -107,10 +107,10 @@ const checks = [ ['dist: mid → both fades (t≈1,b≈1)', approx(r.mid.t, 1) && approx(r.mid.b, 1)], ['dist: bottom → full top fade (t≈1)', approx(r.bottom.t, 1)], ['dist: bottom → no bottom fade (b≈0)', approx(r.bottom.b, 0)], - ['dist: fade-ramp-2xl resolves into the selected vertical animation range', /^96px, 100%, normal, normal$/.test(r.ramp.animationRangeEnd)], + ['dist: fade-travel-2xl resolves into the selected vertical animation range', /^96px, 100%, normal, normal$/.test(r.travel.animationRangeEnd)], ['dist: not scrollable → no fade (t≈0,b≈0)', approx(r.short.t, 0) && approx(r.short.b, 0)], ['dist: mixed fade-top fade-end composes (t≈1,r≈1)', approx(r.combo.t, 1) && approx(r.combo.r, 1)], - ['dist: mixed fade-top fade-end keeps four ramp animations', r.combo.animationNames.length === 4], + ['dist: mixed fade-top fade-end keeps four travel animations', r.combo.animationNames.length === 4], ['dist: mask-composite intersect', /intersect/.test(r.top.maskComposite)], ['dist: mask-image has 4 layers', (r.top.maskImage.match(/gradient/g) || []).length >= 1], ] diff --git a/build/verify-fade.mjs b/build/verify-fade.mjs index e523552..53e4fa6 100644 --- a/build/verify-fade.mjs +++ b/build/verify-fade.mjs @@ -204,12 +204,12 @@ const scopedBottomCross = crossing([...scopedLum].reverse()) const nestLum = await profileLine({ axis: 'y', innerSelector: '#inner', - body: `
${blocks('vb', 40)}
`, + body: `
${blocks('vb', 40)}
`, }) const nestCross = crossing(nestLum) // 6. Mixed individual edges. This catches the cascade failure where one utility's -// animation shorthand used to overwrite the other edge's ramp animation. +// animation shorthand used to overwrite the other edge's travel animation. const combo = await samplePanel({ innerSelector: '#combo', body: `
`, diff --git a/build/verify-page.mjs b/build/verify-page.mjs index 7493118..bcb0ef8 100644 --- a/build/verify-page.mjs +++ b/build/verify-page.mjs @@ -49,7 +49,7 @@ const singleEdgeSpecimen = await page.evaluate(async () => { const specimen = document.querySelector('[data-demo="type-specimen"]') if (!specimen) return null - specimen.className = 'fade-top fade-size-2xl fade-ramp-md thin-scroll type-scale-sample h-64 overflow-auto p-5 sm:h-72' + specimen.className = 'fade-top fade-size-2xl fade-travel-md thin-scroll type-scale-sample h-64 overflow-auto p-5 sm:h-72' specimen.scrollTop = 96 specimen.scrollLeft = 96 await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) diff --git a/build/verify.mjs b/build/verify.mjs index f551e25..62e79d7 100644 --- a/build/verify.mjs +++ b/build/verify.mjs @@ -18,7 +18,7 @@ async function probe(selector, { top, left, noSnap } = {}) { // Some probes measure the fade as a pure function of scroll offset. Snap // would pull a small programmatic scroll to the nearest row center, so the // exact offset (and thus the partial fade) depends on row height rather - // than the plugin. Disable snap for those to test the ramp itself. + // than the plugin. Disable snap for those to test the travel itself. const prevSnap = el.style.scrollSnapType if (disableSnap) el.style.scrollSnapType = 'none' if (scrollTop != null) el.scrollTop = scrollTop @@ -58,7 +58,7 @@ const listMax = listTop.maxScrollTop results.list_top = listTop results.list_mid = await probe('[data-demo="list"]', { top: Math.round(listMax / 2) }) results.list_bottom = await probe('[data-demo="list"]', { top: listMax }) -// Measure the fade ramp at a small offset with snap off: this asserts the +// Measure the fade travel at a small offset with snap off: this asserts the // plugin reveals the top edge progressively, independent of where snap would // settle (which shifts with row height). results.list_quarter = await probe('[data-demo="list"]', { top: 25, noSnap: true }) diff --git a/demo/demo-controls.js b/demo/demo-controls.js index fc732a0..0bda881 100644 --- a/demo/demo-controls.js +++ b/demo/demo-controls.js @@ -22,10 +22,10 @@ const installCopyButton = document.querySelector("[data-copy-install]"); const installCopyIconStack = document.querySelector(".install-copy-icon-stack"); const installCopyStatus = document.querySelector("[data-copy-install-status]"); const fadeDepthSlider = document.querySelector("[data-fade-depth-slider]"); -const fadeRampSlider = document.querySelector("[data-fade-ramp-slider]"); +const fadeTravelSlider = document.querySelector("[data-fade-travel-slider]"); const fadeOptionLabel = document.querySelector("[data-fade-option-label]"); const fadeDepthValue = document.querySelector("[data-fade-depth-value]"); -const fadeRampValue = document.querySelector("[data-fade-ramp-value]"); +const fadeTravelValue = document.querySelector("[data-fade-travel-value]"); const fadeSpecimen = document.querySelector('[data-demo="type-specimen"]'); const fadeEdgeButtons = Array.from( document.querySelectorAll("[data-fade-edge-toggle]"), @@ -54,27 +54,27 @@ const fadeDepthSizes = [ aria: "quadruple extra large", }, ]; -const fadeRamps = [ - { className: "fade-ramp-sm", value: "sm", aria: "small" }, - { className: "fade-ramp-md", value: "md", aria: "medium" }, - { className: "fade-ramp-lg", value: "lg", aria: "large" }, +const fadeTravels = [ + { className: "fade-travel-sm", value: "sm", aria: "small" }, + { className: "fade-travel-md", value: "md", aria: "medium" }, + { className: "fade-travel-lg", value: "lg", aria: "large" }, { - className: "fade-ramp-xl", + className: "fade-travel-xl", value: "xl", aria: "extra large", }, { - className: "fade-ramp-2xl", + className: "fade-travel-2xl", value: "2xl", aria: "double extra large", }, { - className: "fade-ramp-3xl", + className: "fade-travel-3xl", value: "3xl", aria: "triple extra large", }, { - className: "fade-ramp-4xl", + className: "fade-travel-4xl", value: "4xl", aria: "quadruple extra large", }, @@ -890,7 +890,7 @@ function syncEdgeGradientDepth(depthIndex) { function fadeUtilityParts(className) { const prefix = - ["fade-size-", "fade-ramp-", "fade-"].find((candidate) => + ["fade-size-", "fade-travel-", "fade-"].find((candidate) => className.startsWith(candidate), ) ?? ""; return { @@ -948,16 +948,16 @@ function flashFadeOptionParts(parts) { } } -function setFadeOptionLabel(edgeClasses, sizeClass, rampClass) { +function setFadeOptionLabel(edgeClasses, sizeClass, travelClass) { const nextState = { edgeClasses: [...edgeClasses], sizeClass, - rampClass, + travelClass, }; const previous = fadeOptionReadoutState; const flashTargets = []; const fragment = document.createDocumentFragment(); - const fullClassName = [...edgeClasses, sizeClass, rampClass].join(" "); + const fullClassName = [...edgeClasses, sizeClass, travelClass].join(" "); const previousEdgeClasses = previous?.edgeClasses ?? []; const edgeStemChanged = previous && @@ -989,8 +989,8 @@ function setFadeOptionLabel(edgeClasses, sizeClass, rampClass) { appendToken(sizeClass, previous?.sizeClass === sizeClass ? "none" : "stem"); appendToken( - rampClass, - previous?.rampClass === rampClass ? "none" : "stem", + travelClass, + previous?.travelClass === travelClass ? "none" : "stem", ); fadeOptionLabel.replaceChildren(fragment); @@ -1002,10 +1002,10 @@ function setFadeOptionLabel(edgeClasses, sizeClass, rampClass) { function setFadeControls() { if ( !fadeDepthSlider || - !fadeRampSlider || + !fadeTravelSlider || !fadeOptionLabel || !fadeDepthValue || - !fadeRampValue || + !fadeTravelValue || !fadeSpecimen ) return; @@ -1014,12 +1014,12 @@ function setFadeControls() { fadeDepthSizes.length - 1, Math.max(0, Number(fadeDepthSlider.value)), ); - const rampIndex = Math.min( - fadeRamps.length - 1, - Math.max(0, Number(fadeRampSlider.value)), + const travelIndex = Math.min( + fadeTravels.length - 1, + Math.max(0, Number(fadeTravelSlider.value)), ); const nextSize = fadeDepthSizes[depthIndex]; - const nextRamp = fadeRamps[rampIndex]; + const nextTravel = fadeTravels[travelIndex]; const activeEdges = fadeEdgeOrder.filter((edge) => { const button = fadeEdgeButtons.find( (candidate) => candidate.dataset.fadeEdgeToggle === edge, @@ -1030,7 +1030,7 @@ function setFadeControls() { const utilityClasses = [ ...edgeClasses, nextSize.className, - nextRamp.className, + nextTravel.className, ]; fadeSpecimen.classList.remove( @@ -1039,16 +1039,16 @@ function setFadeControls() { "fade-y", "fade-x", ...fadeDepthSizes.map((size) => size.className), - ...fadeRamps.map((ramp) => ramp.className), + ...fadeTravels.map((travel) => travel.className), ); fadeSpecimen.classList.add(...utilityClasses); - setFadeOptionLabel(edgeClasses, nextSize.className, nextRamp.className); + setFadeOptionLabel(edgeClasses, nextSize.className, nextTravel.className); fadeDepthValue.textContent = nextSize.value; - fadeRampValue.textContent = nextRamp.value; + fadeTravelValue.textContent = nextTravel.value; fadeDepthSlider.setAttribute("aria-valuetext", nextSize.aria); - fadeRampSlider.setAttribute("aria-valuetext", nextRamp.aria); + fadeTravelSlider.setAttribute("aria-valuetext", nextTravel.aria); syncSliderKnob(fadeDepthSlider); - syncSliderKnob(fadeRampSlider); + syncSliderKnob(fadeTravelSlider); syncEdgeGradientDepth(depthIndex); } @@ -1357,7 +1357,7 @@ fadeDepthSlider?.addEventListener("input", (event) => { setFadeControls(); }); -fadeRampSlider?.addEventListener("input", () => { +fadeTravelSlider?.addEventListener("input", () => { setFadeControls(); }); diff --git a/demo/index.html b/demo/index.html index ecbeb46..e4db2ec 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1180,7 +1180,7 @@

- set a larger ramp + set a larger size

@@ -1214,7 +1214,7 @@ fade-ramp-fade-travel-
@@ -2079,7 +2079,7 @@ x="91" y="118" > - ramp-2xl + travel-2xl

- Axis, size, ramp, and clear-zone utilities compose + Axis, size, travel, and clear-zone utilities compose directly on the scroll container.

@@ -2302,7 +2302,7 @@ />
Partial support in stable Firefox: the mask - renders, but the ramp falls back to a static + renders, but the travel falls back to a static always-on fade. diff --git a/demo/styles.css b/demo/styles.css index b2b33a4..a8c3beb 100644 --- a/demo/styles.css +++ b/demo/styles.css @@ -35,13 +35,13 @@ --fade-size-2xl: calc(var(--spacing, 0.25rem) * 24); --fade-size-3xl: calc(var(--spacing, 0.25rem) * 32); --fade-size-4xl: calc(var(--spacing, 0.25rem) * 40); - --fade-ramp-sm: calc(var(--spacing, 0.25rem) * 8); - --fade-ramp-md: calc(var(--spacing, 0.25rem) * 12); - --fade-ramp-lg: calc(var(--spacing, 0.25rem) * 16); - --fade-ramp-xl: calc(var(--spacing, 0.25rem) * 20); - --fade-ramp-2xl: calc(var(--spacing, 0.25rem) * 24); - --fade-ramp-3xl: calc(var(--spacing, 0.25rem) * 32); - --fade-ramp-4xl: calc(var(--spacing, 0.25rem) * 40); + --fade-travel-sm: calc(var(--spacing, 0.25rem) * 8); + --fade-travel-md: calc(var(--spacing, 0.25rem) * 12); + --fade-travel-lg: calc(var(--spacing, 0.25rem) * 16); + --fade-travel-xl: calc(var(--spacing, 0.25rem) * 20); + --fade-travel-2xl: calc(var(--spacing, 0.25rem) * 24); + --fade-travel-3xl: calc(var(--spacing, 0.25rem) * 32); + --fade-travel-4xl: calc(var(--spacing, 0.25rem) * 40); } } @layer base { @@ -532,27 +532,6 @@ -webkit-user-select: none; user-select: none; } - .fade-ramp-2xl { - --tw-fade-ramp: var(--fade-ramp-2xl); - } - .fade-ramp-3xl { - --tw-fade-ramp: var(--fade-ramp-3xl); - } - .fade-ramp-4xl { - --tw-fade-ramp: var(--fade-ramp-4xl); - } - .fade-ramp-lg { - --tw-fade-ramp: var(--fade-ramp-lg); - } - .fade-ramp-md { - --tw-fade-ramp: var(--fade-ramp-md); - } - .fade-ramp-sm { - --tw-fade-ramp: var(--fade-ramp-sm); - } - .fade-ramp-xl { - --tw-fade-ramp: var(--fade-ramp-xl); - } .fade-size-2xl { --tw-fade-size: var(--fade-size-2xl); } @@ -574,6 +553,27 @@ .fade-size-xl { --tw-fade-size: var(--fade-size-xl); } + .fade-travel-2xl { + --tw-fade-travel: var(--fade-travel-2xl); + } + .fade-travel-3xl { + --tw-fade-travel: var(--fade-travel-3xl); + } + .fade-travel-4xl { + --tw-fade-travel: var(--fade-travel-4xl); + } + .fade-travel-lg { + --tw-fade-travel: var(--fade-travel-lg); + } + .fade-travel-md { + --tw-fade-travel: var(--fade-travel-md); + } + .fade-travel-sm { + --tw-fade-travel: var(--fade-travel-sm); + } + .fade-travel-xl { + --tw-fade-travel: var(--fade-travel-xl); + } .sm\:mr-4 { @media (width >= 40rem) { margin-right: calc(var(--spacing) * 4); @@ -684,7 +684,7 @@ syntax: "*"; inherits: false; } -@property --tw-fade-ramp { +@property --tw-fade-travel { syntax: "*"; inherits: false; } @@ -796,7 +796,7 @@ syntax: "*"; inherits: false; } -@keyframes tw-fade-ramp-t { +@keyframes tw-fade-travel-t { from { --tw-fade-t: 0; } @@ -804,7 +804,7 @@ --tw-fade-t: 1; } } -@keyframes tw-fade-ramp-b { +@keyframes tw-fade-travel-b { from { --tw-fade-b: 1; } @@ -812,7 +812,7 @@ --tw-fade-b: 0; } } -@keyframes tw-fade-ramp-l { +@keyframes tw-fade-travel-l { from { --tw-fade-l: 0; } @@ -820,7 +820,7 @@ --tw-fade-l: 1; } } -@keyframes tw-fade-ramp-r { +@keyframes tw-fade-travel-r { from { --tw-fade-r: 1; } @@ -828,7 +828,7 @@ --tw-fade-r: 0; } } -@keyframes tw-fade-ramp-r-leading { +@keyframes tw-fade-travel-r-leading { from { --tw-fade-r: 0; } @@ -836,7 +836,7 @@ --tw-fade-r: 1; } } -@keyframes tw-fade-ramp-l-trailing { +@keyframes tw-fade-travel-l-trailing { from { --tw-fade-l: 1; } @@ -847,7 +847,7 @@ @layer utilities { .fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end { --tw-fade-size-default: min(12%, var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12))); - --tw-fade-ramp-active: var(--tw-fade-ramp, var(--fade-ramp-md, calc(var(--spacing, 0.25rem) * 12))); + --tw-fade-travel-active: var(--tw-fade-travel, var(--fade-travel-sm, calc(var(--spacing, 0.25rem) * 8))); --tw-fade-identity-mask: linear-gradient(#000, #000); --tw-fade-edge-size-t: var(--tw-fade-size-top, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); --tw-fade-edge-size-b: var(--tw-fade-size-bottom, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); @@ -861,115 +861,120 @@ --tw-fade-edge-band-b: calc(var(--tw-fade-edge-size-b) * var(--tw-fade-b)); --tw-fade-edge-band-l: calc(var(--tw-fade-edge-size-l) * var(--tw-fade-l)); --tw-fade-edge-band-r: calc(var(--tw-fade-edge-size-r) * var(--tw-fade-r)); + --tw-fade-onset: 8; + --tw-fade-alpha-t: min(1, calc(var(--tw-fade-t) * var(--tw-fade-onset))); + --tw-fade-alpha-b: min(1, calc(var(--tw-fade-b) * var(--tw-fade-onset))); + --tw-fade-alpha-l: min(1, calc(var(--tw-fade-l) * var(--tw-fade-onset))); + --tw-fade-alpha-r: min(1, calc(var(--tw-fade-r) * var(--tw-fade-onset))); --tw-fade-gradient-t: linear-gradient( to bottom, #000 0 var(--tw-fade-edge-clear-t), - rgb(0 0 0 / calc(1 - var(--tw-fade-t))) var(--tw-fade-edge-clear-t), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t))) var(--tw-fade-edge-clear-t), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.983)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.933)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.854)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.75)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.629)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.5)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.371)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.25)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.146)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.067)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.017)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t)) ); --tw-fade-gradient-b: linear-gradient( to top, #000 0 var(--tw-fade-edge-clear-b), - rgb(0 0 0 / calc(1 - var(--tw-fade-b))) var(--tw-fade-edge-clear-b), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b))) var(--tw-fade-edge-clear-b), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.983)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.933)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.854)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.75)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.629)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.5)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.371)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.25)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.146)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.067)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.017)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b)) ); --tw-fade-gradient-l: linear-gradient( to right, #000 0 var(--tw-fade-edge-clear-l), - rgb(0 0 0 / calc(1 - var(--tw-fade-l))) var(--tw-fade-edge-clear-l), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l))) var(--tw-fade-edge-clear-l), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.983)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.933)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.854)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.75)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.629)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.5)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.371)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.25)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.146)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.067)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.017)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l)) ); --tw-fade-gradient-r: linear-gradient( to left, #000 0 var(--tw-fade-edge-clear-r), - rgb(0 0 0 / calc(1 - var(--tw-fade-r))) var(--tw-fade-edge-clear-r), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r))) var(--tw-fade-edge-clear-r), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.983)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.933)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.854)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.75)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.629)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.5)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.371)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.25)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.146)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.067)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.017)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r)) ); @@ -1008,35 +1013,35 @@ } .fade, .fade-y, .fade-top { --tw-fade-mask-t: var(--tw-fade-gradient-t); - --tw-fade-animation-t: tw-fade-ramp-t; + --tw-fade-animation-t: tw-fade-travel-t; --tw-fade-timeline-t: scroll(self y); - --tw-fade-animation-range-t: 0 var(--tw-fade-ramp-active); + --tw-fade-animation-range-t: 0 var(--tw-fade-travel-active); } .fade, .fade-y, .fade-bottom { --tw-fade-mask-b: var(--tw-fade-gradient-b); - --tw-fade-animation-b: tw-fade-ramp-b; + --tw-fade-animation-b: tw-fade-travel-b; --tw-fade-timeline-b: scroll(self y); - --tw-fade-animation-range-b: calc(100% - var(--tw-fade-ramp-active)) 100%; + --tw-fade-animation-range-b: calc(100% - var(--tw-fade-travel-active)) 100%; } .fade, .fade-x, .fade-start { --tw-fade-ltr-start-layer: var(--tw-fade-gradient-l); --tw-fade-rtl-start-layer: var(--tw-fade-gradient-r); - --tw-fade-ltr-start-animation: tw-fade-ramp-l; - --tw-fade-rtl-start-animation: tw-fade-ramp-r-leading; + --tw-fade-ltr-start-animation: tw-fade-travel-l; + --tw-fade-rtl-start-animation: tw-fade-travel-r-leading; --tw-fade-ltr-start-timeline: scroll(self inline); --tw-fade-rtl-start-timeline: scroll(self inline); - --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-ramp-active); - --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-ramp-active); + --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-travel-active); + --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-travel-active); } .fade, .fade-x, .fade-end { --tw-fade-ltr-end-layer: var(--tw-fade-gradient-r); --tw-fade-rtl-end-layer: var(--tw-fade-gradient-l); - --tw-fade-ltr-end-animation: tw-fade-ramp-r; - --tw-fade-rtl-end-animation: tw-fade-ramp-l-trailing; + --tw-fade-ltr-end-animation: tw-fade-travel-r; + --tw-fade-rtl-end-animation: tw-fade-travel-l-trailing; --tw-fade-ltr-end-timeline: scroll(self inline); --tw-fade-rtl-end-timeline: scroll(self inline); - --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; - --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; + --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-travel-active)) 100%; + --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-travel-active)) 100%; } @supports (animation-timeline: scroll()) { .fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end { @@ -1309,7 +1314,7 @@ --tw-fade-size-bottom: initial; --tw-fade-size-start: initial; --tw-fade-size-end: initial; - --tw-fade-ramp: initial; + --tw-fade-travel: initial; --tw-fade-clear: initial; --tw-fade-clear-y: initial; --tw-fade-clear-x: initial; diff --git a/dist/tw-fade.css b/dist/tw-fade.css index bab9f8e..4bf11f5 100644 --- a/dist/tw-fade.css +++ b/dist/tw-fade.css @@ -190,30 +190,6 @@ .fade-clear-y-xs { --tw-fade-clear-y: var(--fade-clear-xs); } -.fade-ramp-2xl { - --tw-fade-ramp: var(--fade-ramp-2xl); -} -.fade-ramp-3xl { - --tw-fade-ramp: var(--fade-ramp-3xl); -} -.fade-ramp-4xl { - --tw-fade-ramp: var(--fade-ramp-4xl); -} -.fade-ramp-lg { - --tw-fade-ramp: var(--fade-ramp-lg); -} -.fade-ramp-md { - --tw-fade-ramp: var(--fade-ramp-md); -} -.fade-ramp-sm { - --tw-fade-ramp: var(--fade-ramp-sm); -} -.fade-ramp-xl { - --tw-fade-ramp: var(--fade-ramp-xl); -} -.fade-ramp-xs { - --tw-fade-ramp: var(--fade-ramp-xs); -} .fade-size-2xl { --tw-fade-size: var(--fade-size-2xl); } @@ -382,6 +358,30 @@ .fade-size-y-xs { --tw-fade-size-y: var(--fade-size-xs); } +.fade-travel-2xl { + --tw-fade-travel: var(--fade-travel-2xl); +} +.fade-travel-3xl { + --tw-fade-travel: var(--fade-travel-3xl); +} +.fade-travel-4xl { + --tw-fade-travel: var(--fade-travel-4xl); +} +.fade-travel-lg { + --tw-fade-travel: var(--fade-travel-lg); +} +.fade-travel-md { + --tw-fade-travel: var(--fade-travel-md); +} +.fade-travel-sm { + --tw-fade-travel: var(--fade-travel-sm); +} +.fade-travel-xl { + --tw-fade-travel: var(--fade-travel-xl); +} +.fade-travel-xs { + --tw-fade-travel: var(--fade-travel-xs); +} @property --tw-fade-t { syntax: ""; inherits: false; @@ -430,7 +430,7 @@ syntax: "*"; inherits: false; } -@property --tw-fade-ramp { +@property --tw-fade-travel { syntax: "*"; inherits: false; } @@ -551,14 +551,14 @@ --fade-size-2xl: calc(var(--spacing, 0.25rem) * 24); --fade-size-3xl: calc(var(--spacing, 0.25rem) * 32); --fade-size-4xl: calc(var(--spacing, 0.25rem) * 40); - --fade-ramp-xs: calc(var(--spacing, 0.25rem) * 6); - --fade-ramp-sm: calc(var(--spacing, 0.25rem) * 8); - --fade-ramp-md: calc(var(--spacing, 0.25rem) * 12); - --fade-ramp-lg: calc(var(--spacing, 0.25rem) * 16); - --fade-ramp-xl: calc(var(--spacing, 0.25rem) * 20); - --fade-ramp-2xl: calc(var(--spacing, 0.25rem) * 24); - --fade-ramp-3xl: calc(var(--spacing, 0.25rem) * 32); - --fade-ramp-4xl: calc(var(--spacing, 0.25rem) * 40); + --fade-travel-xs: calc(var(--spacing, 0.25rem) * 6); + --fade-travel-sm: calc(var(--spacing, 0.25rem) * 8); + --fade-travel-md: calc(var(--spacing, 0.25rem) * 12); + --fade-travel-lg: calc(var(--spacing, 0.25rem) * 16); + --fade-travel-xl: calc(var(--spacing, 0.25rem) * 20); + --fade-travel-2xl: calc(var(--spacing, 0.25rem) * 24); + --fade-travel-3xl: calc(var(--spacing, 0.25rem) * 32); + --fade-travel-4xl: calc(var(--spacing, 0.25rem) * 40); --fade-clear-xs: calc(var(--spacing, 0.25rem) * 6); --fade-clear-sm: calc(var(--spacing, 0.25rem) * 8); --fade-clear-md: calc(var(--spacing, 0.25rem) * 12); @@ -568,7 +568,7 @@ --fade-clear-3xl: calc(var(--spacing, 0.25rem) * 32); --fade-clear-4xl: calc(var(--spacing, 0.25rem) * 40); } -@keyframes tw-fade-ramp-t { +@keyframes tw-fade-travel-t { from { --tw-fade-t: 0; } @@ -576,7 +576,7 @@ --tw-fade-t: 1; } } -@keyframes tw-fade-ramp-b { +@keyframes tw-fade-travel-b { from { --tw-fade-b: 1; } @@ -584,7 +584,7 @@ --tw-fade-b: 0; } } -@keyframes tw-fade-ramp-l { +@keyframes tw-fade-travel-l { from { --tw-fade-l: 0; } @@ -592,7 +592,7 @@ --tw-fade-l: 1; } } -@keyframes tw-fade-ramp-r { +@keyframes tw-fade-travel-r { from { --tw-fade-r: 1; } @@ -600,7 +600,7 @@ --tw-fade-r: 0; } } -@keyframes tw-fade-ramp-r-leading { +@keyframes tw-fade-travel-r-leading { from { --tw-fade-r: 0; } @@ -608,7 +608,7 @@ --tw-fade-r: 1; } } -@keyframes tw-fade-ramp-l-trailing { +@keyframes tw-fade-travel-l-trailing { from { --tw-fade-l: 1; } @@ -619,7 +619,7 @@ @layer utilities { .fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end { --tw-fade-size-default: min(12%, var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12))); - --tw-fade-ramp-active: var(--tw-fade-ramp, var(--fade-ramp-md, calc(var(--spacing, 0.25rem) * 12))); + --tw-fade-travel-active: var(--tw-fade-travel, var(--fade-travel-sm, calc(var(--spacing, 0.25rem) * 8))); --tw-fade-identity-mask: linear-gradient(#000, #000); --tw-fade-edge-size-t: var(--tw-fade-size-top, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); --tw-fade-edge-size-b: var(--tw-fade-size-bottom, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); @@ -633,115 +633,120 @@ --tw-fade-edge-band-b: calc(var(--tw-fade-edge-size-b) * var(--tw-fade-b)); --tw-fade-edge-band-l: calc(var(--tw-fade-edge-size-l) * var(--tw-fade-l)); --tw-fade-edge-band-r: calc(var(--tw-fade-edge-size-r) * var(--tw-fade-r)); + --tw-fade-onset: 8; + --tw-fade-alpha-t: min(1, calc(var(--tw-fade-t) * var(--tw-fade-onset))); + --tw-fade-alpha-b: min(1, calc(var(--tw-fade-b) * var(--tw-fade-onset))); + --tw-fade-alpha-l: min(1, calc(var(--tw-fade-l) * var(--tw-fade-onset))); + --tw-fade-alpha-r: min(1, calc(var(--tw-fade-r) * var(--tw-fade-onset))); --tw-fade-gradient-t: linear-gradient( to bottom, #000 0 var(--tw-fade-edge-clear-t), - rgb(0 0 0 / calc(1 - var(--tw-fade-t))) var(--tw-fade-edge-clear-t), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t))) var(--tw-fade-edge-clear-t), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.983)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.933)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.854)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.75)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.629)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.5)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.371)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.25)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.146)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.067)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.017)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t)) ); --tw-fade-gradient-b: linear-gradient( to top, #000 0 var(--tw-fade-edge-clear-b), - rgb(0 0 0 / calc(1 - var(--tw-fade-b))) var(--tw-fade-edge-clear-b), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b))) var(--tw-fade-edge-clear-b), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.983)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.933)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.854)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.75)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.629)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.5)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.371)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.25)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.146)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.067)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.017)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b)) ); --tw-fade-gradient-l: linear-gradient( to right, #000 0 var(--tw-fade-edge-clear-l), - rgb(0 0 0 / calc(1 - var(--tw-fade-l))) var(--tw-fade-edge-clear-l), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l))) var(--tw-fade-edge-clear-l), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.983)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.933)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.854)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.75)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.629)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.5)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.371)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.25)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.146)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.067)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.017)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l)) ); --tw-fade-gradient-r: linear-gradient( to left, #000 0 var(--tw-fade-edge-clear-r), - rgb(0 0 0 / calc(1 - var(--tw-fade-r))) var(--tw-fade-edge-clear-r), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r))) var(--tw-fade-edge-clear-r), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.983)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.933)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.854)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.75)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.629)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.5)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.371)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.25)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.146)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.067)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.017)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r)) ); @@ -780,35 +785,35 @@ } .fade, .fade-y, .fade-top { --tw-fade-mask-t: var(--tw-fade-gradient-t); - --tw-fade-animation-t: tw-fade-ramp-t; + --tw-fade-animation-t: tw-fade-travel-t; --tw-fade-timeline-t: scroll(self y); - --tw-fade-animation-range-t: 0 var(--tw-fade-ramp-active); + --tw-fade-animation-range-t: 0 var(--tw-fade-travel-active); } .fade, .fade-y, .fade-bottom { --tw-fade-mask-b: var(--tw-fade-gradient-b); - --tw-fade-animation-b: tw-fade-ramp-b; + --tw-fade-animation-b: tw-fade-travel-b; --tw-fade-timeline-b: scroll(self y); - --tw-fade-animation-range-b: calc(100% - var(--tw-fade-ramp-active)) 100%; + --tw-fade-animation-range-b: calc(100% - var(--tw-fade-travel-active)) 100%; } .fade, .fade-x, .fade-start { --tw-fade-ltr-start-layer: var(--tw-fade-gradient-l); --tw-fade-rtl-start-layer: var(--tw-fade-gradient-r); - --tw-fade-ltr-start-animation: tw-fade-ramp-l; - --tw-fade-rtl-start-animation: tw-fade-ramp-r-leading; + --tw-fade-ltr-start-animation: tw-fade-travel-l; + --tw-fade-rtl-start-animation: tw-fade-travel-r-leading; --tw-fade-ltr-start-timeline: scroll(self inline); --tw-fade-rtl-start-timeline: scroll(self inline); - --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-ramp-active); - --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-ramp-active); + --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-travel-active); + --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-travel-active); } .fade, .fade-x, .fade-end { --tw-fade-ltr-end-layer: var(--tw-fade-gradient-r); --tw-fade-rtl-end-layer: var(--tw-fade-gradient-l); - --tw-fade-ltr-end-animation: tw-fade-ramp-r; - --tw-fade-rtl-end-animation: tw-fade-ramp-l-trailing; + --tw-fade-ltr-end-animation: tw-fade-travel-r; + --tw-fade-rtl-end-animation: tw-fade-travel-l-trailing; --tw-fade-ltr-end-timeline: scroll(self inline); --tw-fade-rtl-end-timeline: scroll(self inline); - --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; - --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; + --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-travel-active)) 100%; + --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-travel-active)) 100%; } @supports (animation-timeline: scroll()) { .fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end { @@ -868,7 +873,7 @@ --tw-fade-size-bottom: initial; --tw-fade-size-start: initial; --tw-fade-size-end: initial; - --tw-fade-ramp: initial; + --tw-fade-travel: initial; --tw-fade-clear: initial; --tw-fade-clear-y: initial; --tw-fade-clear-x: initial; diff --git a/scripts/build-css.mjs b/scripts/build-css.mjs index 29e8d64..784c0a1 100644 --- a/scripts/build-css.mjs +++ b/scripts/build-css.mjs @@ -37,12 +37,12 @@ const src = path.join(root, 'src', 'tw-fade.css') const outFile = path.join(root, 'dist', 'tw-fade.css') // Every class the framework-free build should contain. Arbitrary values -// (fade-size-[6rem], fade-ramp-[80px], fade-clear-top-[56px]) are intentionally +// (fade-size-[6rem], fade-travel-[80px], fade-clear-top-[56px]) are intentionally // absent — those require Tailwind's JIT and belong to the v4 source path, not // the prebuilt drop-in. const SCALE = 'xs,sm,md,lg,xl,2xl,3xl,4xl' const CLASSES = - `fade fade-{x,y,top,bottom,start,end} fade-none fade-none-{x,y} fade-always fade-always-{x,y} fade-size-{${SCALE}} fade-size-{x,y,top,bottom,start,end}-{${SCALE}} fade-ramp-{${SCALE}} fade-clear-{${SCALE},var} fade-clear-{x,y,top,bottom,start,end}-{${SCALE},var}` + `fade fade-{x,y,top,bottom,start,end} fade-none fade-none-{x,y} fade-always fade-always-{x,y} fade-size-{${SCALE}} fade-size-{x,y,top,bottom,start,end}-{${SCALE}} fade-travel-{${SCALE}} fade-clear-{${SCALE},var} fade-clear-{x,y,top,bottom,start,end}-{${SCALE},var}` const BANNER = '/*! tw-fade — framework-free build for plain HTML / CDN.\n' + diff --git a/src/tw-fade.css b/src/tw-fade.css index 1447f94..6c77def 100644 --- a/src/tw-fade.css +++ b/src/tw-fade.css @@ -25,7 +25,7 @@ /* Config: keep "*" with NO initial-value. A typed rejects the rem fallback and drops the registration in browsers; inherits:false stops nested -fades from leaking size, ramp, clearance, mask, or routing state. +fades from leaking size, travel, clearance, mask, or routing state. */ @property --tw-fade-size { syntax: "*"; @@ -55,7 +55,7 @@ fades from leaking size, ramp, clearance, mask, or routing state. syntax: "*"; inherits: false; } -@property --tw-fade-ramp { +@property --tw-fade-travel { syntax: "*"; inherits: false; } @@ -177,14 +177,14 @@ fades from leaking size, ramp, clearance, mask, or routing state. --fade-size-2xl: calc(var(--spacing, 0.25rem) * 24); --fade-size-3xl: calc(var(--spacing, 0.25rem) * 32); --fade-size-4xl: calc(var(--spacing, 0.25rem) * 40); - --fade-ramp-xs: calc(var(--spacing, 0.25rem) * 6); - --fade-ramp-sm: calc(var(--spacing, 0.25rem) * 8); - --fade-ramp-md: calc(var(--spacing, 0.25rem) * 12); - --fade-ramp-lg: calc(var(--spacing, 0.25rem) * 16); - --fade-ramp-xl: calc(var(--spacing, 0.25rem) * 20); - --fade-ramp-2xl: calc(var(--spacing, 0.25rem) * 24); - --fade-ramp-3xl: calc(var(--spacing, 0.25rem) * 32); - --fade-ramp-4xl: calc(var(--spacing, 0.25rem) * 40); + --fade-travel-xs: calc(var(--spacing, 0.25rem) * 6); + --fade-travel-sm: calc(var(--spacing, 0.25rem) * 8); + --fade-travel-md: calc(var(--spacing, 0.25rem) * 12); + --fade-travel-lg: calc(var(--spacing, 0.25rem) * 16); + --fade-travel-xl: calc(var(--spacing, 0.25rem) * 20); + --fade-travel-2xl: calc(var(--spacing, 0.25rem) * 24); + --fade-travel-3xl: calc(var(--spacing, 0.25rem) * 32); + --fade-travel-4xl: calc(var(--spacing, 0.25rem) * 40); --fade-clear-xs: calc(var(--spacing, 0.25rem) * 6); --fade-clear-sm: calc(var(--spacing, 0.25rem) * 8); --fade-clear-md: calc(var(--spacing, 0.25rem) * 12); @@ -195,7 +195,7 @@ fades from leaking size, ramp, clearance, mask, or routing state. --fade-clear-4xl: calc(var(--spacing, 0.25rem) * 40); } -@keyframes tw-fade-ramp-t { +@keyframes tw-fade-travel-t { from { --tw-fade-t: 0; } @@ -203,7 +203,7 @@ fades from leaking size, ramp, clearance, mask, or routing state. --tw-fade-t: 1; } } -@keyframes tw-fade-ramp-b { +@keyframes tw-fade-travel-b { from { --tw-fade-b: 1; } @@ -211,7 +211,7 @@ fades from leaking size, ramp, clearance, mask, or routing state. --tw-fade-b: 0; } } -@keyframes tw-fade-ramp-l { +@keyframes tw-fade-travel-l { from { --tw-fade-l: 0; } @@ -219,7 +219,7 @@ fades from leaking size, ramp, clearance, mask, or routing state. --tw-fade-l: 1; } } -@keyframes tw-fade-ramp-r { +@keyframes tw-fade-travel-r { from { --tw-fade-r: 1; } @@ -227,7 +227,7 @@ fades from leaking size, ramp, clearance, mask, or routing state. --tw-fade-r: 0; } } -@keyframes tw-fade-ramp-r-leading { +@keyframes tw-fade-travel-r-leading { from { --tw-fade-r: 0; } @@ -235,7 +235,7 @@ fades from leaking size, ramp, clearance, mask, or routing state. --tw-fade-r: 1; } } -@keyframes tw-fade-ramp-l-trailing { +@keyframes tw-fade-travel-l-trailing { from { --tw-fade-l: 1; } @@ -265,8 +265,8 @@ fades from leaking size, ramp, clearance, mask, or routing state. @utility fade-size-end-* { --tw-fade-size-end: --value(--fade-size-*, [length], [percentage]); } -@utility fade-ramp-* { - --tw-fade-ramp: --value(--fade-ramp-*, [length], [percentage]); +@utility fade-travel-* { + --tw-fade-travel: --value(--fade-travel-*, [length], [percentage]); } @utility fade-clear-* { --tw-fade-clear: calc(var(--spacing, 0.25rem) * --value(integer)); @@ -327,7 +327,7 @@ fades from leaking size, ramp, clearance, mask, or routing state. .fade-start, .fade-end { --tw-fade-size-default: min(12%, var(--fade-size-md, calc(var(--spacing, 0.25rem) * 12))); - --tw-fade-ramp-active: var(--tw-fade-ramp, var(--fade-ramp-md, calc(var(--spacing, 0.25rem) * 12))); + --tw-fade-travel-active: var(--tw-fade-travel, var(--fade-travel-sm, calc(var(--spacing, 0.25rem) * 8))); --tw-fade-identity-mask: linear-gradient(#000, #000); --tw-fade-edge-size-t: var(--tw-fade-size-top, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); --tw-fade-edge-size-b: var(--tw-fade-size-bottom, var(--tw-fade-size-y, var(--tw-fade-size, var(--tw-fade-size-default)))); @@ -341,115 +341,131 @@ fades from leaking size, ramp, clearance, mask, or routing state. --tw-fade-edge-band-b: calc(var(--tw-fade-edge-size-b) * var(--tw-fade-b)); --tw-fade-edge-band-l: calc(var(--tw-fade-edge-size-l) * var(--tw-fade-l)); --tw-fade-edge-band-r: calc(var(--tw-fade-edge-size-r) * var(--tw-fade-r)); + /* + Decouple edge transparency from band growth. --tw-fade-t..r drive the + band WIDTH (above), so the band eases open over the full travel distance. + But the ALPHA at the very edge is driven by --tw-fade-alpha-*, a + saturated copy that reaches full transparency within travel/onset of + scroll. This covers the overflow clip almost immediately (no hard cut + during early scroll) while the band still widens cosmetically over the + whole travel. --tw-fade-onset = how fast the edge goes transparent + relative to the travel; higher = snappier clip coverage. At t=0 alpha is + also 0, so a flush (unscrolled) edge still shows no fade. + */ + --tw-fade-onset: 8; + --tw-fade-alpha-t: min(1, calc(var(--tw-fade-t) * var(--tw-fade-onset))); + --tw-fade-alpha-b: min(1, calc(var(--tw-fade-b) * var(--tw-fade-onset))); + --tw-fade-alpha-l: min(1, calc(var(--tw-fade-l) * var(--tw-fade-onset))); + --tw-fade-alpha-r: min(1, calc(var(--tw-fade-r) * var(--tw-fade-onset))); --tw-fade-gradient-t: linear-gradient( to bottom, #000 0 var(--tw-fade-edge-clear-t), - rgb(0 0 0 / calc(1 - var(--tw-fade-t))) var(--tw-fade-edge-clear-t), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t))) var(--tw-fade-edge-clear-t), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.983)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.933)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.854)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.75)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.629)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.5)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.371)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.25)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.146)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.067)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-t) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-t) * 0.017)) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-t) + var(--tw-fade-edge-band-t)) ); --tw-fade-gradient-b: linear-gradient( to top, #000 0 var(--tw-fade-edge-clear-b), - rgb(0 0 0 / calc(1 - var(--tw-fade-b))) var(--tw-fade-edge-clear-b), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b))) var(--tw-fade-edge-clear-b), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.983)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.933)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.854)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.75)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.629)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.5)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.371)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.25)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.146)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.067)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-b) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-b) * 0.017)) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-b) + var(--tw-fade-edge-band-b)) ); --tw-fade-gradient-l: linear-gradient( to right, #000 0 var(--tw-fade-edge-clear-l), - rgb(0 0 0 / calc(1 - var(--tw-fade-l))) var(--tw-fade-edge-clear-l), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l))) var(--tw-fade-edge-clear-l), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.983)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.933)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.854)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.75)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.629)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.5)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.371)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.25)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.146)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.067)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-l) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-l) * 0.017)) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-l) + var(--tw-fade-edge-band-l)) ); --tw-fade-gradient-r: linear-gradient( to left, #000 0 var(--tw-fade-edge-clear-r), - rgb(0 0 0 / calc(1 - var(--tw-fade-r))) var(--tw-fade-edge-clear-r), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.983)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r))) var(--tw-fade-edge-clear-r), + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.983)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.08333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.933)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.933)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.16667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.854)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.854)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.25), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.75)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.75)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.33333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.629)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.629)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.41667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.5)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.5)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.5), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.371)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.371)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.58333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.25)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.25)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.66667), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.146)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.146)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.75), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.067)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.067)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.83333), - rgb(0 0 0 / calc(1 - var(--tw-fade-r) * 0.017)) + rgb(0 0 0 / calc(1 - var(--tw-fade-alpha-r) * 0.017)) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r) * 0.91667), rgb(0 0 0 / 1) calc(var(--tw-fade-edge-clear-r) + var(--tw-fade-edge-band-r)) ); @@ -510,18 +526,18 @@ fades from leaking size, ramp, clearance, mask, or routing state. .fade-y, .fade-top { --tw-fade-mask-t: var(--tw-fade-gradient-t); - --tw-fade-animation-t: tw-fade-ramp-t; + --tw-fade-animation-t: tw-fade-travel-t; --tw-fade-timeline-t: scroll(self y); - --tw-fade-animation-range-t: 0 var(--tw-fade-ramp-active); + --tw-fade-animation-range-t: 0 var(--tw-fade-travel-active); } .fade, .fade-y, .fade-bottom { --tw-fade-mask-b: var(--tw-fade-gradient-b); - --tw-fade-animation-b: tw-fade-ramp-b; + --tw-fade-animation-b: tw-fade-travel-b; --tw-fade-timeline-b: scroll(self y); - --tw-fade-animation-range-b: calc(100% - var(--tw-fade-ramp-active)) 100%; + --tw-fade-animation-range-b: calc(100% - var(--tw-fade-travel-active)) 100%; } .fade, @@ -529,12 +545,12 @@ fades from leaking size, ramp, clearance, mask, or routing state. .fade-start { --tw-fade-ltr-start-layer: var(--tw-fade-gradient-l); --tw-fade-rtl-start-layer: var(--tw-fade-gradient-r); - --tw-fade-ltr-start-animation: tw-fade-ramp-l; - --tw-fade-rtl-start-animation: tw-fade-ramp-r-leading; + --tw-fade-ltr-start-animation: tw-fade-travel-l; + --tw-fade-rtl-start-animation: tw-fade-travel-r-leading; --tw-fade-ltr-start-timeline: scroll(self inline); --tw-fade-rtl-start-timeline: scroll(self inline); - --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-ramp-active); - --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-ramp-active); + --tw-fade-ltr-start-animation-range: 0 var(--tw-fade-travel-active); + --tw-fade-rtl-start-animation-range: 0 var(--tw-fade-travel-active); } .fade, @@ -542,12 +558,12 @@ fades from leaking size, ramp, clearance, mask, or routing state. .fade-end { --tw-fade-ltr-end-layer: var(--tw-fade-gradient-r); --tw-fade-rtl-end-layer: var(--tw-fade-gradient-l); - --tw-fade-ltr-end-animation: tw-fade-ramp-r; - --tw-fade-rtl-end-animation: tw-fade-ramp-l-trailing; + --tw-fade-ltr-end-animation: tw-fade-travel-r; + --tw-fade-rtl-end-animation: tw-fade-travel-l-trailing; --tw-fade-ltr-end-timeline: scroll(self inline); --tw-fade-rtl-end-timeline: scroll(self inline); - --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; - --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-ramp-active)) 100%; + --tw-fade-ltr-end-animation-range: calc(100% - var(--tw-fade-travel-active)) 100%; + --tw-fade-rtl-end-animation-range: calc(100% - var(--tw-fade-travel-active)) 100%; } @supports (animation-timeline: scroll()) { diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 1f743b6..e98459c 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -18,7 +18,7 @@ const root = path.resolve(__dirname, '..') const css = compileCss() const customCss = compileCss({ classes: - 'fade-size-[15%] fade-size-top-[56px] fade-ramp-[80px] fade-clear-14 fade-clear-y-14 fade-clear-x-14 fade-clear-top-14 fade-clear-bottom-14 fade-clear-start-14 fade-clear-end-14', + 'fade-size-[15%] fade-size-top-[56px] fade-travel-[80px] fade-clear-14 fade-clear-y-14 fade-clear-x-14 fade-clear-top-14 fade-clear-bottom-14 fade-clear-start-14 fade-clear-end-14', }) function block(selector, source = css) { @@ -89,6 +89,7 @@ const REJECTED_CLASSES = [ 'fade-inline-start', 'fade-inline-end', 'fade-range-sm', + 'fade-ramp-sm', 'fade-static', ] const SHARED_SELECTOR = '.fade, .fade-y, .fade-top, .fade-bottom, .fade-x, .fade-start, .fade-end' @@ -171,7 +172,7 @@ test('registers amounts as typed numbers and config/routing vars as universal no assert.equal(prop['initial-value'], '0') } - for (const v of [...SIZE_VARS, '--tw-fade-ramp', ...CLEAR_VARS, ...MASK_VARS, ...ROUTING_VARS]) { + for (const v of [...SIZE_VARS, '--tw-fade-travel', ...CLEAR_VARS, ...MASK_VARS, ...ROUTING_VARS]) { const prop = property(v) assert.ok(prop, `missing @property ${v}`) assert.equal(prop.syntax, '"*"') @@ -215,21 +216,25 @@ test('prebuilt dist is generated from the current default compile output', () => // Guards against a dormant retired utility name surviving in the shipped CSS. // The REJECTED_CLASSES checks only prove the *default* compile omits a name; a // stray @utility / @property / @keyframes / theme token for a retired name -// ("range" -> "ramp", and the never-shipped intermediate "reveal") could still -// ship undetected. Scan the source and prebuilt artifacts on disk. Scoped to -// the CSS files only — README/MIGRATING/CHANGELOG legitimately reference the -// old names when documenting the rename. Token-specific patterns, so the -// English verb "reveal" in prose/comments is never matched. -test('shipped CSS carries no retired reveal/range token names', () => { +// ("range" -> "ramp" -> "travel", and the never-shipped intermediate "reveal") +// could still ship undetected. Scan the source and prebuilt artifacts on disk. +// Scoped to the CSS files only — README/MIGRATING/CHANGELOG legitimately +// reference the old names when documenting the rename. Token-specific patterns, +// so the English nouns "reveal"/"ramp" in prose/comments are never matched. +test('shipped CSS carries no retired reveal/range/ramp token names', () => { const retired = [ /fade-reveal/, /fade-range/, + /fade-ramp/, /--fade-reveal/, /--fade-range/, + /--fade-ramp/, /--tw-fade-reveal/, /--tw-fade-range/, + /--tw-fade-ramp/, /tw-fade-reveal/, /tw-fade-range/, + /tw-fade-ramp/, ] for (const file of ['src/tw-fade.css', 'dist/tw-fade.css']) { const source = fs.readFileSync(path.join(root, file), 'utf8') @@ -241,16 +246,16 @@ test('shipped CSS carries no retired reveal/range token names', () => { test('leading and trailing keyframes include RTL horizontal variants', () => { const kf = (n) => block('@keyframes ' + n) - assert.match(kf('tw-fade-ramp-t'), /from\s*\{[^}]*--tw-fade-t:\s*0/) - assert.match(kf('tw-fade-ramp-t'), /to\s*\{[^}]*--tw-fade-t:\s*1/) - assert.match(kf('tw-fade-ramp-b'), /from\s*\{[^}]*--tw-fade-b:\s*1/) - assert.match(kf('tw-fade-ramp-b'), /to\s*\{[^}]*--tw-fade-b:\s*0/) - assert.match(kf('tw-fade-ramp-l'), /from\s*\{[^}]*--tw-fade-l:\s*0/) - assert.match(kf('tw-fade-ramp-r'), /from\s*\{[^}]*--tw-fade-r:\s*1/) - assert.match(kf('tw-fade-ramp-r-leading'), /from\s*\{[^}]*--tw-fade-r:\s*0/) - assert.match(kf('tw-fade-ramp-r-leading'), /to\s*\{[^}]*--tw-fade-r:\s*1/) - assert.match(kf('tw-fade-ramp-l-trailing'), /from\s*\{[^}]*--tw-fade-l:\s*1/) - assert.match(kf('tw-fade-ramp-l-trailing'), /to\s*\{[^}]*--tw-fade-l:\s*0/) + assert.match(kf('tw-fade-travel-t'), /from\s*\{[^}]*--tw-fade-t:\s*0/) + assert.match(kf('tw-fade-travel-t'), /to\s*\{[^}]*--tw-fade-t:\s*1/) + assert.match(kf('tw-fade-travel-b'), /from\s*\{[^}]*--tw-fade-b:\s*1/) + assert.match(kf('tw-fade-travel-b'), /to\s*\{[^}]*--tw-fade-b:\s*0/) + assert.match(kf('tw-fade-travel-l'), /from\s*\{[^}]*--tw-fade-l:\s*0/) + assert.match(kf('tw-fade-travel-r'), /from\s*\{[^}]*--tw-fade-r:\s*1/) + assert.match(kf('tw-fade-travel-r-leading'), /from\s*\{[^}]*--tw-fade-r:\s*0/) + assert.match(kf('tw-fade-travel-r-leading'), /to\s*\{[^}]*--tw-fade-r:\s*1/) + assert.match(kf('tw-fade-travel-l-trailing'), /from\s*\{[^}]*--tw-fade-l:\s*1/) + assert.match(kf('tw-fade-travel-l-trailing'), /to\s*\{[^}]*--tw-fade-l:\s*0/) }) test('the shared mask setup owns four physical layers and scroll animation wiring', () => { @@ -273,27 +278,27 @@ test('the shared mask setup owns four physical layers and scroll animation wirin test('direction classes select the expected layers and central RTL routing', () => { const top = block(TOP_SELECTOR) assert.equal(declValue(top, '--tw-fade-mask-t'), 'var(--tw-fade-gradient-t)') - assert.equal(declValue(top, '--tw-fade-animation-t'), 'tw-fade-ramp-t') + assert.equal(declValue(top, '--tw-fade-animation-t'), 'tw-fade-travel-t') assert.equal(declValue(top, '--tw-fade-timeline-t'), 'scroll(self y)') const bottom = block(BOTTOM_SELECTOR) assert.equal(declValue(bottom, '--tw-fade-mask-b'), 'var(--tw-fade-gradient-b)') - assert.equal(declValue(bottom, '--tw-fade-animation-b'), 'tw-fade-ramp-b') + assert.equal(declValue(bottom, '--tw-fade-animation-b'), 'tw-fade-travel-b') assert.equal(declValue(bottom, '--tw-fade-timeline-b'), 'scroll(self y)') const start = block(START_SELECTOR) assert.equal(declValue(start, '--tw-fade-ltr-start-layer'), 'var(--tw-fade-gradient-l)') assert.equal(declValue(start, '--tw-fade-rtl-start-layer'), 'var(--tw-fade-gradient-r)') - assert.equal(declValue(start, '--tw-fade-ltr-start-animation'), 'tw-fade-ramp-l') - assert.equal(declValue(start, '--tw-fade-rtl-start-animation'), 'tw-fade-ramp-r-leading') + assert.equal(declValue(start, '--tw-fade-ltr-start-animation'), 'tw-fade-travel-l') + assert.equal(declValue(start, '--tw-fade-rtl-start-animation'), 'tw-fade-travel-r-leading') assert.equal(declValue(start, '--tw-fade-ltr-start-timeline'), 'scroll(self inline)') assert.equal(declValue(start, '--tw-fade-rtl-start-timeline'), 'scroll(self inline)') const end = block(END_SELECTOR) assert.equal(declValue(end, '--tw-fade-ltr-end-layer'), 'var(--tw-fade-gradient-r)') assert.equal(declValue(end, '--tw-fade-rtl-end-layer'), 'var(--tw-fade-gradient-l)') - assert.equal(declValue(end, '--tw-fade-ltr-end-animation'), 'tw-fade-ramp-r') - assert.equal(declValue(end, '--tw-fade-rtl-end-animation'), 'tw-fade-ramp-l-trailing') + assert.equal(declValue(end, '--tw-fade-ltr-end-animation'), 'tw-fade-travel-r') + assert.equal(declValue(end, '--tw-fade-rtl-end-animation'), 'tw-fade-travel-l-trailing') const rtl = block(RTL_SELECTOR) assert.ok(rtl, 'missing central RTL routing block') @@ -314,7 +319,7 @@ test('the smoothstep ramp is the 13-stop sigmoid from the reference', () => { const stops = (grad.match(/rgb\(0 0 0 \//g) || []).length assert.equal(stops, 13, `${prop} should have 13 colour stops`) const alphaMultipliers = [ - ...grad.matchAll(new RegExp(`var\\(--tw-fade-${edge}\\)\\s*\\*\\s*([0-9.]+)`, 'g')), + ...grad.matchAll(new RegExp(`var\\(--tw-fade-alpha-${edge}\\)\\s*\\*\\s*([0-9.]+)`, 'g')), ].map((m) => m[1]) assert.deepEqual(alphaMultipliers, ALPHA_MULTIPLIERS, `${prop} alpha ramp coefficients must stay exact`) const positionMultipliers = [ @@ -328,6 +333,45 @@ test('the smoothstep ramp is the 13-stop sigmoid from the reference', () => { assert.match(declValue(shared, '--tw-fade-gradient-r'), /^linear-gradient\(\s*to left/) }) +test('edge alpha is decoupled from band growth so the clip is covered before the band finishes widening', () => { + const shared = block(SHARED_SELECTOR) + assert.ok(shared, 'missing shared block') + + // The onset constant sets how fast the edge reaches full transparency relative + // to the travel: full coverage by travel/onset of scroll, independent of band width. + assert.equal(declValue(shared, '--tw-fade-onset'), '8') + + for (const edge of ['t', 'b', 'l', 'r']) { + // alpha-* is a saturated copy of raw scroll progress (clamped at 1). This is + // what drives edge transparency, so the clip is covered almost immediately. + assert.equal( + declValue(shared, `--tw-fade-alpha-${edge}`), + `min(1, calc(var(--tw-fade-${edge}) * var(--tw-fade-onset)))`, + `--tw-fade-alpha-${edge} must saturate raw progress`, + ) + // Band WIDTH stays keyed to raw progress, so it still eases open over the + // full travel distance (cosmetic widening) while the edge is already covered. + assert.equal( + declValue(shared, `--tw-fade-edge-band-${edge}`), + `calc(var(--tw-fade-edge-size-${edge}) * var(--tw-fade-${edge}))`, + `band size for ${edge} must stay on raw --tw-fade-${edge}`, + ) + const grad = declValue(shared, `--tw-fade-gradient-${edge}`) + // Every alpha stop reads the saturated alpha var... + assert.match( + grad, + new RegExp(`calc\\(1 - var\\(--tw-fade-alpha-${edge}\\)`), + `${edge} gradient alpha must read the saturated alpha var`, + ) + // ...and never raw progress, or the clip would re-couple to the full travel. + assert.doesNotMatch( + grad, + new RegExp(`calc\\(1 - var\\(--tw-fade-${edge}\\)`), + `${edge} gradient alpha must not key off raw --tw-fade-${edge}`, + ) + } +}) + test('static fallback pins selected fades on when scroll-driven animation is unsupported', () => { const fallback = block('@supports not (animation-timeline: scroll())') const shared = block(SHARED_SELECTOR, fallback) @@ -337,7 +381,7 @@ test('static fallback pins selected fades on when scroll-driven animation is uns } }) -test('exposes the size and ramp scales as theme-backed utilities', () => { +test('exposes the size and travel scales as theme-backed utilities', () => { for (const [name] of SCALE) { assert.equal(declValue(block(`.fade-size-${name}`), '--tw-fade-size'), `var(--fade-size-${name})`) assert.equal(declValue(block(`.fade-size-y-${name}`), '--tw-fade-size-y'), `var(--fade-size-${name})`) @@ -346,13 +390,13 @@ test('exposes the size and ramp scales as theme-backed utilities', () => { assert.equal(declValue(block(`.fade-size-bottom-${name}`), '--tw-fade-size-bottom'), `var(--fade-size-${name})`) assert.equal(declValue(block(`.fade-size-start-${name}`), '--tw-fade-size-start'), `var(--fade-size-${name})`) assert.equal(declValue(block(`.fade-size-end-${name}`), '--tw-fade-size-end'), `var(--fade-size-${name})`) - assert.equal(declValue(block(`.fade-ramp-${name}`), '--tw-fade-ramp'), `var(--fade-ramp-${name})`) + assert.equal(declValue(block(`.fade-travel-${name}`), '--tw-fade-travel'), `var(--fade-travel-${name})`) } const rootBlock = block(':root, :host') for (const [name, units] of SCALE) { const expected = `calc(var(--spacing, 0.25rem) * ${units})` assert.equal(declValue(rootBlock, `--fade-size-${name}`), expected) - assert.equal(declValue(rootBlock, `--fade-ramp-${name}`), expected) + assert.equal(declValue(rootBlock, `--fade-travel-${name}`), expected) } assert.equal(block('.fade-range-sm'), null) assert.equal(block('.fade-size-left-sm'), null) @@ -379,12 +423,12 @@ test('size resolves as edge over axis over global over capped default', () => { ) }) -test('arbitrary size and ramp utilities compile only through the source path', () => { +test('arbitrary size and travel utilities compile only through the source path', () => { assert.equal(declValue(block('.fade-size-\\[15\\%\\]', customCss), '--tw-fade-size'), '15%') assert.equal(declValue(block('.fade-size-top-\\[56px\\]', customCss), '--tw-fade-size-top'), '56px') - assert.equal(declValue(block('.fade-ramp-\\[80px\\]', customCss), '--tw-fade-ramp'), '80px') + assert.equal(declValue(block('.fade-travel-\\[80px\\]', customCss), '--tw-fade-travel'), '80px') assert.equal(block('.fade-size-\\[15\\%\\]'), null) - assert.equal(block('.fade-ramp-\\[80px\\]'), null) + assert.equal(block('.fade-travel-\\[80px\\]'), null) }) test('exposes directional clear utilities and source-path integer clear values', () => { From 1184a07b5f281a949dced523ee690a3d8ecb05d8 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Sun, 28 Jun 2026 15:44:32 -0700 Subject: [PATCH 15/17] Add debug pressure-test harness (npm run debug) scripts/build-debug.mjs assembles a self-contained, gitignored debug/index.html from scripts/debug/ sources, inlining the compiled plugin CSS (same output dist/ ships) so the bench always reflects the real engine. Synchronized-scroll pressure test: every sample scroller is pinned to the same offset, so any regression is a side-by-side visual diff. Covers 7 directions + ~16 real-world/edge cases + travel/onset/size/clear sweeps, driving the engine via public custom props (no class explosion). onset=1 reproduces the old coupled hard-clip (the A/B); the static-fallback toggle mirrors the @supports-not path. Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- .gitignore | 4 + package.json | 1 + scripts/build-debug.mjs | 111 ++++++++++ scripts/debug/README.md | 77 +++++++ scripts/debug/harness.css | 354 +++++++++++++++++++++++++++++ scripts/debug/harness.js | 452 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 999 insertions(+) create mode 100644 scripts/build-debug.mjs create mode 100644 scripts/debug/README.md create mode 100644 scripts/debug/harness.css create mode 100644 scripts/debug/harness.js diff --git a/.gitignore b/.gitignore index a956bc4..ba65814 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ node_modules/ npm-debug.log* .DS_Store +# Generated debug harness (maintainer-only dev tool, not shipped). +# Sources live in scripts/debug/; regenerate with `npm run debug`. +/debug/ + # Note: dist/ and demo/styles.css ARE tracked on purpose — they're the # shipped (npm) and served (demo) artifacts. They're regenerated by # `npm run build` and `npm run build:demo`, and prepublishOnly rebuilds diff --git a/package.json b/package.json index 6260260..65c5c67 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "scripts": { "build": "node scripts/build-css.mjs", "build:demo": "tailwindcss -i demo/input.css -o demo/styles.css", + "debug": "node scripts/build-debug.mjs", "export:demo": "node scripts/export-demo-static.mjs", "ship-demo": "node scripts/ship-demo.mjs", "generate:seo": "node scripts/generate-seo-assets.mjs", diff --git a/scripts/build-debug.mjs b/scripts/build-debug.mjs new file mode 100644 index 0000000..1860a17 --- /dev/null +++ b/scripts/build-debug.mjs @@ -0,0 +1,111 @@ +/** + * build-debug.mjs — assemble debug/index.html, a self-contained pressure-test + * harness for the fade engine. + * + * node scripts/build-debug.mjs + * + * It inlines three sources into one standalone file (open via file:// or any + * static server, no build step at view time): + * 1. the compiled plugin CSS (compileCss() — the SAME output dist/ ships, so + * the harness always reflects the real engine, every named utility present); + * 2. scripts/debug/harness.css — UI chrome; + * 3. scripts/debug/harness.js — scenario config + controls + scroll sync. + * + * The harness drives the engine through its public custom properties, so this + * generator never needs to know the gradient internals — it just ships the + * current plugin CSS verbatim. + */ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { compileCss } from './build-css.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(__dirname, '..') +const debugDir = path.join(root, 'debug') +const outFile = path.join(debugDir, 'index.html') + +const pluginCss = compileCss() +const harnessCss = fs.readFileSync(path.join(__dirname, 'debug', 'harness.css'), 'utf8') +const harnessJs = fs.readFileSync(path.join(__dirname, 'debug', 'harness.js'), 'utf8') + +const ctrl = (label, valId, inner) => + `
${inner}
` + +const panel = ` +
+
+

tw-fade · pressure test

+ every sample is pinned to the same scroll offset — scrub to compare frame-for-frame + + +
+
+ ${ctrl('Fade size', 'sizeVal', '')} + ${ctrl('Travel (band open)', 'travelVal', '')} + ${ctrl('Onset (edge speed)', 'onsetVal', '')} + ${ctrl('Clear', 'clearVal', '')} +
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+ ${ctrl('Content', null, '')} + ${ctrl('Background', null, '')} + ${ctrl('Container shape', null, '')} +
+ + +
+
+

+ onset 1 reproduces the old coupled hard-clip (alpha = t); 8 is the decoupled default. + static fallback forces fade-always (the no-scroll-driven-animation path). Set scroll to 8px from start to inspect the onset edge-coverage window; switch to px from end to probe the trailing edge (fade-bottom / fade-end — a separate range). +

+
` + +const html = ` + + + + +tw-fade · pressure test + + + +${panel} +
+ + + +` + +fs.mkdirSync(debugDir, { recursive: true }) +fs.writeFileSync(outFile, html) +console.log(`wrote ${path.relative(root, outFile)} (${(html.length / 1024).toFixed(1)} KB)`) diff --git a/scripts/debug/README.md b/scripts/debug/README.md new file mode 100644 index 0000000..f03231c --- /dev/null +++ b/scripts/debug/README.md @@ -0,0 +1,77 @@ +# tw-fade debug harness + +An interactive pressure-test bench for the fade engine. It renders the plugin +across every direction utility, a battery of real-world / edge-case layouts, and +four parameter sweeps — all pinned to the **same scroll offset** so any bug is a +side-by-side visual diff. + +## Run + +```bash +npm run debug # → debug/index.html (self-contained, gitignored) +``` + +Open `debug/index.html` in a **Chromium-based browser** (needs scroll-driven +animations + `corner-shape`; Chrome/Edge ≥ 115-ish, full squircle support ≥ 149). +The file inlines the *compiled* plugin CSS — the same output `dist/` ships — so the +harness always reflects the real engine. Re-run `npm run debug` after editing +`src/tw-fade.css`. + +## What it exercises + +- **Directions** — all seven utilities (`fade`, `fade-y`, `fade-x`, `fade-top`, + `fade-bottom`, `fade-start`, `fade-end`) on identical content. +- **Real-world & edge cases** — short (non-overflowing) content, sticky-header + occlusion, padding lead-in, scroll-snap, nested fade scrollers, image/code/dense + content, tiny containers, thick borders, oversized (50%) fades, per-edge sizes, + `overflow: clip` trap, and the `fade-none` / `fade-none-y` / `fade-always-x` + override paths. +- **Sweeps** — travel, onset, size, clear each across their scale at the global + scroll offset. + +## Controls + +- **Synchronized scroll** — one slider drives every scroller. Fine chips + (`0/2/4/6/8/12/24px`) plus a **px-from-start / px-from-end** toggle let you probe + the onset edge-coverage window on the leading edge *and* the separate trailing + range. `auto` oscillates. +- **size / travel / onset / clear** sliders write the public custom properties + (`--tw-fade-size`, `--tw-fade-travel`, `--tw-fade-onset`, `--tw-fade-clear`) + directly on each scroller — no class explosion, continuous values. + - **onset 1 reproduces the old coupled hard-clip** (`alpha = t`); 8 is the + decoupled default. That is the A/B. +- **static fallback** forces `fade-always`, mirroring the `@supports not + (animation-timeline: scroll())` path that pins amounts to 1. +- **RTL**, **content type**, **background** (light/dark/checker/photo — reveals the + mask silhouette), and **container shape** (rounded/squircle/sharp). +- Each card shows a live readout: scroll offset, `%`, and the resolved + `--tw-fade-{t,b,l,r}` amounts. A non-overflowing card reads `no overflow → no + fade` (the key scroll-driven invariant). + +## Architecture + +Three sources, assembled into one standalone file by `scripts/build-debug.mjs`: + +| File | Role | +| --- | --- | +| `scripts/build-debug.mjs` | generator — inlines compiled plugin CSS + the two files below, writes `debug/index.html`, emits the control-panel markup | +| `scripts/debug/harness.css` | UI chrome only (never touches the fade engine) | +| `scripts/debug/harness.js` | scenario config, controls, scroll-sync engine | + +The config props are `@property … inherits:false`, so the harness sets them on +**each `.fade*` element directly** (a parent would not cascade). + +### Add a scenario + +Append an object to `SCENARIOS.directions` / `SCENARIOS.realworld` in +`harness.js`: + +```js +{ id: 'myCase', label: 'My case', cls: 'fade-y', special: 'simple', + content: 'text', lock: { travel: 80, vars: { '--tw-fade-size-top': '64px' } }, + note: 'what to look for' } +``` + +`special` selects a builder (`simple`, `plane`, `hscroll`, `sticky`, `leadin`, +`snap`, `short`, `clip`, `nested`, `bordered`); `lock` overrides global params for +that card (`size`/`travel`/`onset`/`clear`, or arbitrary inline `vars`). diff --git a/scripts/debug/harness.css b/scripts/debug/harness.css new file mode 100644 index 0000000..2f89792 --- /dev/null +++ b/scripts/debug/harness.css @@ -0,0 +1,354 @@ +/* tw-fade debug harness — UI chrome only. The plugin CSS is inlined separately + * by scripts/build-debug.mjs. Nothing here touches the fade engine; it only + * frames the sample scrollers and the control panel. */ + +:root { + --ui-bg: #0d0f14; + --ui-panel: #151922; + --ui-panel-2: #1c2230; + --ui-line: #2a3140; + --ui-text: #e6e9ef; + --ui-dim: #9aa4b5; + --ui-accent: #6ea8fe; + --ui-accent-2: #5be0c0; + --ui-warn: #ffb454; + --stage-bg: #f4f5f7; + --stage-card: #ffffff; + --stage-text: #1b2430; + --stage-dim: #5b6675; + --stage-line: #e2e5ea; + --scroller-h: 220px; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; } + +body { + background: var(--ui-bg); + color: var(--ui-text); + font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--ui-accent); } + +/* ---- Control panel ---------------------------------------------------- */ + +.panel { + position: sticky; + top: 0; + z-index: 50; + background: color-mix(in srgb, var(--ui-panel) 92%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--ui-line); + padding: 12px 16px; +} + +.panel-title { + display: flex; + align-items: baseline; + gap: 10px; + margin: 0 0 10px; +} +.panel-title h1 { font-size: 14px; margin: 0; letter-spacing: 0.2px; } +.panel-title .sub { color: var(--ui-dim); font-size: 12px; } +.panel-title .spacer { flex: 1; } + +.controls { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + align-items: flex-end; +} + +.ctrl { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.ctrl > label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--ui-dim); + display: flex; + justify-content: space-between; + gap: 8px; +} +.ctrl > label .val { color: var(--ui-accent-2); font-variant-numeric: tabular-nums; } + +.ctrl input[type="range"] { width: 168px; accent-color: var(--ui-accent); margin: 4px 0 0; } +.ctrl select, +.ctrl input[type="text"] { + background: var(--ui-panel-2); + color: var(--ui-text); + border: 1px solid var(--ui-line); + border-radius: 6px; + padding: 5px 8px; + font: inherit; +} + +.seg { display: inline-flex; gap: 0; border: 1px solid var(--ui-line); border-radius: 7px; overflow: hidden; } +.seg button { + background: var(--ui-panel-2); + color: var(--ui-dim); + border: 0; + border-right: 1px solid var(--ui-line); + padding: 5px 9px; + font: inherit; + cursor: pointer; +} +.seg button:last-child { border-right: 0; } +.seg button:hover { color: var(--ui-text); } +.seg button[aria-pressed="true"] { background: var(--ui-accent); color: #06121f; font-weight: 600; } + +.toggle { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--ui-panel-2); + border: 1px solid var(--ui-line); + border-radius: 7px; + padding: 5px 9px; + cursor: pointer; + user-select: none; +} +.toggle input { accent-color: var(--ui-accent); margin: 0; } +.toggle.warn[data-on="true"] { border-color: var(--ui-warn); color: var(--ui-warn); } + +.btn { + background: var(--ui-panel-2); + color: var(--ui-text); + border: 1px solid var(--ui-line); + border-radius: 7px; + padding: 6px 11px; + font: inherit; + cursor: pointer; +} +.btn:hover { border-color: var(--ui-accent); } +.btn.primary { background: var(--ui-accent); color: #06121f; border-color: transparent; font-weight: 600; } + +.scrubrow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.scrubrow .steps { display: inline-flex; gap: 4px; } +.scrubrow .steps button { + background: var(--ui-panel-2); + color: var(--ui-dim); + border: 1px solid var(--ui-line); + border-radius: 6px; + padding: 4px 7px; + font: 11px/1 ui-monospace, monospace; + cursor: pointer; +} +.scrubrow .steps button:hover { color: var(--ui-text); border-color: var(--ui-accent); } + +.hint { color: var(--ui-dim); font-size: 11px; margin: 8px 0 0; } +.hint code { color: var(--ui-accent-2); } + +/* ---- Stage (sample area) --------------------------------------------- */ + +.stage { padding: 18px 16px 64px; } + +.stage.bg-light { background: var(--stage-bg); } +.stage.bg-dark { background: #11151c; } +.stage.bg-check { + background: + repeating-conic-gradient(#dfe3ea 0 25%, #c5ccd8 0 50%) 0 0 / 22px 22px; +} +.stage.bg-photo { + background: linear-gradient(125deg, #ff9a9e, #fad0c4 30%, #a1c4fd 65%, #c2e9fb); +} + +.section-h { + display: flex; + align-items: baseline; + gap: 10px; + margin: 26px 4px 12px; + color: var(--stage-text); +} +.stage.bg-dark .section-h, .stage.bg-photo .section-h { color: #fff; } +.section-h h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 1px; margin: 0; } +.section-h .desc { font-size: 12px; color: var(--stage-dim); font-weight: 400; text-transform: none; letter-spacing: 0; } +.stage.bg-dark .section-h .desc, .stage.bg-photo .section-h .desc { color: rgba(255,255,255,0.8); } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; +} +.grid.sweep { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } + +.card { + background: var(--stage-card); + border: 1px solid var(--stage-line); + border-radius: 12px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.card-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--stage-line); + background: #fafbfc; +} +.card-label { font-weight: 600; font-size: 12px; color: var(--stage-text); } +.card-tag { + font: 10px/1 ui-monospace, monospace; + background: #eef1f6; + color: #46506a; + padding: 3px 6px; + border-radius: 5px; +} +.card-head .readout { + margin-left: auto; + font: 10px/1 ui-monospace, monospace; + color: var(--stage-dim); + font-variant-numeric: tabular-nums; +} + +.card-note { + padding: 7px 10px; + font-size: 11px; + color: var(--stage-dim); + border-top: 1px solid var(--stage-line); + background: #fafbfc; +} + +/* The scroll container that actually carries a fade-* class. Position + * relative so the optional band overlay can pin to it. */ +.scroller { + position: relative; + height: var(--scroller-h); + overflow: auto; + background: var(--stage-card); + color: var(--stage-text); + scrollbar-width: thin; +} +.scroller.h { overflow: auto; } +.scroller.short { height: 120px; } +.scroller.tiny { height: 84px; } + +.stage.bg-dark .card, .stage.bg-dark .scroller { background: #1b2230; color: #dfe5ee; border-color: #2a3346; } +.stage.bg-dark .card-head, .stage.bg-dark .card-note { background: #161d29; border-color: #2a3346; } +.stage.bg-dark .card-label { color: #eef2f8; } + +/* ---- Sample content -------------------------------------------------- */ + +.body-pad { padding: 14px 16px; } +.body-pad p { margin: 0 0 11px; } +.body-pad h3 { margin: 0 0 8px; font-size: 14px; } + +.lead-in { padding-top: 48px; padding-bottom: 48px; } + +.minilist { padding: 8px; display: flex; flex-direction: column; gap: 8px; } +.minicard { + border: 1px solid var(--stage-line); + border-radius: 9px; + padding: 10px 12px; + background: #fbfcfe; +} +.minicard b { display: block; font-size: 12px; } +.minicard span { color: var(--stage-dim); font-size: 11px; } +.stage.bg-dark .minicard { background: #222a3a; border-color: #313b50; } + +.tilegrid { padding: 10px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.tile { aspect-ratio: 4 / 3; border-radius: 9px; } + +.code { margin: 0; padding: 12px 14px; font: 11.5px/1.7 ui-monospace, SFMono-Regular, monospace; white-space: pre; color: #2b3850; } +.stage.bg-dark .code { color: #c7d2e6; } + +.dense { padding: 0; } +.dense .row { + display: flex; + justify-content: space-between; + padding: 7px 12px; + border-bottom: 1px solid var(--stage-line); + font-size: 12px; +} +.dense .row span:last-child { color: var(--stage-dim); font-variant-numeric: tabular-nums; } + +/* Horizontal content */ +.scroller.h .track { display: inline-flex; gap: 10px; padding: 14px; white-space: nowrap; height: 100%; align-items: center; } +.chip { + flex: 0 0 auto; + border: 1px solid var(--stage-line); + border-radius: 999px; + padding: 8px 16px; + background: #fbfcfe; + font-size: 12px; + font-weight: 500; +} +.stage.bg-dark .chip { background: #222a3a; border-color: #313b50; } +.hcard { + flex: 0 0 180px; + height: 150px; + border-radius: 11px; + padding: 12px; + color: #fff; + display: flex; + flex-direction: column; + justify-content: flex-end; + font-weight: 600; +} + +/* 2D content for fade (all edges) */ +.plane { width: 760px; padding: 14px; } +.plane .grid2d { display: grid; grid-template-columns: repeat(8, 84px); gap: 8px; } +.plane .cell { height: 84px; border-radius: 8px; display: grid; place-items: center; color: #fff; font-weight: 600; } + +/* Sticky header inside a scroller */ +.sticky-h { + position: sticky; + top: 0; + background: color-mix(in srgb, var(--stage-card) 86%, transparent); + backdrop-filter: blur(4px); + padding: 8px 14px; + font-weight: 600; + font-size: 12px; + border-bottom: 1px solid var(--stage-line); +} +.stage.bg-dark .sticky-h { background: color-mix(in srgb, #1b2230 86%, transparent); } + +/* Nested scroller card */ +.nested-outer { padding: 14px; } +.nested-inner { + height: 150px; + overflow: auto; + border: 1px dashed var(--stage-line); + border-radius: 9px; + margin: 10px 0; + background: #fbfcfe; +} +.stage.bg-dark .nested-inner { background: #222a3a; } + +/* Scroll-snap */ +.snap { scroll-snap-type: y mandatory; } +.snap .snapitem { + scroll-snap-align: start; + height: 110px; + margin: 8px 12px; + border-radius: 10px; + display: grid; + place-items: center; + color: #fff; + font-weight: 700; + font-size: 18px; +} + +/* Container shapes (applied to .scroller via JS shape class) */ +.shape-rounded .scroller { border-radius: 14px; } +.shape-squircle .scroller { border-radius: 26px; corner-shape: superellipse(1.8); } +.shape-sharp .scroller { border-radius: 0; } +.shape-rounded .card, .shape-squircle .card { border-radius: 14px; } + +.legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin: 6px 4px 0; + font-size: 11px; + color: var(--stage-dim); +} +.stage.bg-dark .legend, .stage.bg-photo .legend { color: rgba(255,255,255,0.85); } +.legend .sw { display: inline-block; width: 11px; height: 11px; border-radius: 3px; vertical-align: -1px; margin-right: 5px; } diff --git a/scripts/debug/harness.js b/scripts/debug/harness.js new file mode 100644 index 0000000..4bd85e0 --- /dev/null +++ b/scripts/debug/harness.js @@ -0,0 +1,452 @@ +/* tw-fade debug harness — runtime logic. + * + * Drives the inlined plugin via the public custom properties only: + * --tw-fade-size / --tw-fade-travel / --tw-fade-onset / --tw-fade-clear + * (all @property inherits:false, so they are set on each .fade* element + * directly, never a parent). Structural scenarios + parameter sweeps are + * generated from the SCENARIOS / SWEEPS config below. The single most useful + * affordance for a scroll-driven plugin is synchronized scroll: every sample + * scroller is pinned to the same offset so you can compare them frame-for-frame. + * + * Notes baked into the controls: + * - onset = 1 reproduces the OLD coupled behavior (alpha = t); 8 is the new + * decoupled default. That IS the A/B toggle. + * - "Static fallback" forces .fade-always, mirroring the @supports-not path + * that pins t/b/l/r = 1 where scroll-driven animation is unsupported. + */ +(() => { + 'use strict'; + + const COLORS = ['#6ea8fe', '#5be0c0', '#ffb454', '#ff7eb6', '#b388ff', '#7ee787', '#f0883e', '#79c0ff']; + const grad = (i) => `linear-gradient(135deg, ${COLORS[i % COLORS.length]}, ${COLORS[(i + 3) % COLORS.length]})`; + + const state = { + size: null, // null = plugin default; else number (px) + travel: null, // null = plugin default; else number (px) + onset: 8, + clear: 0, // px + scrollPct: 0, // 0..1 + scrollPx: null, // when set, absolute px offset (overrides pct) + scrollMode: 'start', // 'start' | 'end' — which side scrollPx is measured from + rtl: false, + content: 'auto', + bg: 'light', + shape: 'rounded', + fallback: false, + }; + + let autoTimer = null; + let autoDir = 1; + + /* ---- content generators ------------------------------------------- */ + const LOREM = [ + 'Scroll-driven masks ride the container’s own scroll position—no JavaScript, no observers.', + 'The leading edge is covered the instant you scroll, while the soft band keeps easing open over the travel.', + 'Each edge is an independent linear-gradient mask layer, composited with mask-composite: intersect.', + 'Because the fade is a mask, whatever sits behind the scroller shows through the faded pixels.', + 'Try a checkerboard or photo background to see the exact silhouette the mask carves.', + 'Vertical edges stay physical; horizontal start/end edges flip with text direction.', + 'A flush, unscrolled edge shows nothing—alpha is zero until the scroll timeline advances.', + 'Crank onset down to 1 and the old hard-clip coupling returns: the edge lags the scroll.', + ]; + + function cText(n) { + let h = '

Read me while scrolling

'; + for (let i = 0; i < n; i++) h += `

${LOREM[i % LOREM.length]}

`; + return h + '
'; + } + function cCards(n) { + let h = '
'; + for (let i = 0; i < n; i++) h += `
Item ${i + 1}secondary line of supporting metadata
`; + return h + '
'; + } + function cTiles(n) { + let h = '
'; + for (let i = 0; i < n; i++) h += `
`; + return h + '
'; + } + function cCode() { + const lines = [ + '
', + ' ', + '
', + '', + '.fade-y {', + ' --tw-fade-size: 3rem;', + ' --tw-fade-travel: 2rem; /* band open */', + ' --tw-fade-onset: 8; /* edge speed */', + '}', + '', + 'fade all four edges', + 'fade-y top + bottom', + 'fade-x start + end (RTL-aware)', + 'fade-top leading vertical only', + 'fade-bottom trailing vertical only', + ]; + let body = ''; + for (let k = 0; k < 3; k++) body += lines.join('\n') + '\n\n'; + return `
${body}
`; + } + function cDense(n) { + let h = '
'; + for (let i = 0; i < n; i++) h += `
row label ${i + 1}${(Math.sin(i) * 1000 | 0)}
`; + return h + '
'; + } + function cChips(n) { + const words = ['All', 'Design', 'Engineering', 'Marketing', 'Sales', 'Support', 'Research', 'Ops', 'Finance', 'Legal', 'People', 'Data', 'Brand', 'Growth']; + let h = '
'; + for (let i = 0; i < n; i++) h += `
${words[i % words.length]} ${i + 1}
`; + return h + '
'; + } + function cHCards(n) { + let h = '
'; + for (let i = 0; i < n; i++) h += `
Card ${i + 1}
`; + return h + '
'; + } + function cPlane(cols, rows) { + let h = `
`; + for (let i = 0; i < cols * rows; i++) h += `
${i + 1}
`; + return h + '
'; + } + + const CONTENT = { + text: () => cText(10), + cards: () => cCards(14), + images: () => cTiles(18), + code: () => cCode(), + dense: () => cDense(28), + }; + + /* ---- scenarios ----------------------------------------------------- */ + // kind: 'simple' (scroller + body html) or a named special builder. + // flex: body follows the global content-type selector when true. + const SCENARIOS = { + directions: [ + { id: 'fy', label: 'fade-y', cls: 'fade-y', body: () => cText(10), flex: true, note: 'Top + bottom. The canonical vertical scroller.' }, + { id: 'ftop', label: 'fade-top', cls: 'fade-top', body: () => cText(10), flex: true, note: 'Leading edge only; bottom is never masked.' }, + { id: 'fbot', label: 'fade-bottom', cls: 'fade-bottom', body: () => cText(10), flex: true, note: 'Trailing edge only; only dissolves near the very end.' }, + { id: 'fall', label: 'fade (all edges)', cls: 'fade', special: 'plane', note: '2-D scroll. Four mask layers intersect at the corners.' }, + { id: 'fx', label: 'fade-x', cls: 'fade-x', special: 'hscroll', content: 'chips', note: 'Inline start + end. Flips under RTL.' }, + { id: 'fstart', label: 'fade-start', cls: 'fade-start', special: 'hscroll', content: 'chips', note: 'Inline leading edge. Right edge under RTL.' }, + { id: 'fend', label: 'fade-end', cls: 'fade-end', special: 'hscroll', content: 'chips', note: 'Inline trailing edge. Left edge under RTL.' }, + ], + realworld: [ + { id: 'short', label: 'Short content', cls: 'fade-y short', special: 'short', note: 'Does NOT overflow → must show NO fade (scroll-driven path).' }, + { id: 'sticky', label: 'Sticky header', cls: 'fade-y', special: 'sticky', note: 'Sticky header sits over the faded top edge — watch for double-dim.' }, + { id: 'leadin', label: 'Padding lead-in', cls: 'fade-y', special: 'leadin', note: 'Top/bottom padding inside the scroller; fade rides the padded edge.' }, + { id: 'snap', label: 'Scroll-snap', cls: 'fade-y snap', special: 'snap', note: 'scroll-snap-type: y mandatory. Fade should track snapped offsets.' }, + { id: 'nested', label: 'Nested fade', cls: 'fade-y', special: 'nested', note: 'A fade-y scroller inside a fade-y scroller (block-axis isolation).' }, + { id: 'images', label: 'Image grid', cls: 'fade-y', special: 'simple', content: 'images', flex: true, note: 'Dense visual content; mask edge crosses tile boundaries.' }, + { id: 'code', label: 'Code block', cls: 'fade-y', special: 'simple', content: 'code', flex: true, note: 'Monospace, pre-wrapped; check baseline alignment at the edge.' }, + { id: 'tiny', label: 'Tiny container', cls: 'fade-y tiny', special: 'simple', content: 'dense', note: 'Very short viewport; size may exceed the box.' }, + { id: 'bordered', label: 'Thick border', cls: 'fade-y', special: 'bordered', note: 'Mask clips the element box — border + fade interaction.' }, + { id: 'hcards', label: 'Horizontal cards', cls: 'fade-x', special: 'hscroll', content: 'hcards', note: 'Fast flick row; both inline edges fade.' }, + { id: 'bigsize', label: 'Oversized fade (50%)', cls: 'fade-y', special: 'simple', content: 'text', lock: { vars: { '--tw-fade-size': '50%' } }, note: 'Degenerate: each band is half the viewport. Bands should not cross.' }, + { id: 'peredge', label: 'Per-edge sizes', cls: 'fade-y', special: 'simple', content: 'text', lock: { vars: { '--tw-fade-size-top': '72px', '--tw-fade-size-bottom': '16px' } }, note: 'top 72px / bottom 16px — asymmetric per-edge sizing.' }, + { id: 'clip', label: 'overflow: clip (trap)', cls: 'fade-y', special: 'clip', content: 'text', note: 'overflow:clip is not a scroll container → timeline inert → NO fade though content IS clipped.' }, + { id: 'fnone', label: 'fade-none (control)', cls: 'fade-y fade-none', special: 'simple', content: 'text', noFallback: true, note: 'Forced off — must never fade, even mid-scroll.' }, + { id: 'noney', label: 'fade-none-y on fade', cls: 'fade fade-none-y', special: 'plane', noFallback: true, note: 'All edges armed, vertical forced off → only the inline (l/r) edges fade.' }, + { id: 'alwaysx', label: 'fade-always-x on fade-y', cls: 'fade-y fade-always-x', special: 'simple', content: 'text', noFallback: true, note: 'l/r pinned to 1 but fade-y has no horizontal layer → identity mask, NO horizontal fade.' }, + ], + }; + + // One parameter swept across its scale, all cells at the global scroll offset. + const SWEEPS = [ + { + title: 'Travel sweep', key: 'travel', unit: 'px', + desc: 'Band-open speed. With the new decouple, the edge is covered immediately at every travel — only the soft band widens. Scrub to ~8px to compare.', + values: [12, 24, 48, 80, 120, 160], + }, + { + title: 'Onset sweep', key: 'onset', unit: '', + desc: 'Edge-coverage speed. onset 1 = the OLD coupled hard-clip; 8 = current default. Scrub to ~8px to see the difference.', + values: [1, 2, 4, 8, 12, 24], + }, + { + title: 'Size sweep', key: 'size', unit: 'px', + desc: 'Band width at full fade. Pure cosmetics — does not affect when the clip is covered.', + values: [24, 32, 48, 64, 96, 128], + }, + { + title: 'Clear sweep', key: 'clear', unit: 'px', + desc: 'Untouched gap before the band starts. Useful to keep a pinned first row crisp.', + values: [0, 8, 16, 24, 40], + }, + ]; + + /* ---- DOM building -------------------------------------------------- */ + const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + + function bodyHtmlFor(scn) { + const content = (state.content !== 'auto' && scn.flex) ? state.content : scn.content; + if (content && CONTENT[content]) return CONTENT[content](); + if (content === 'chips') return cChips(26); + if (content === 'hcards') return cHCards(12); + return cText(10); + } + + function buildScroller(scn) { + const lock = scn.lock ? JSON.stringify(scn.lock) : '{}'; + const extra = scn.noFallback ? ' data-nofallback="1"' : ''; + const sp = scn.special; + let cls = 'scroller ' + scn.cls; + let styleAttr = ''; + let inner; + + if (sp === 'plane') { cls += ' h'; inner = cPlane(8, 8); } + else if (sp === 'clip') { styleAttr = ' style="overflow:clip"'; inner = bodyHtmlFor(scn); } + else if (sp === 'hscroll') { cls += ' h'; inner = bodyHtmlFor(scn); } + else if (sp === 'sticky') { + inner = '
Sticky header · stays pinned
' + cCards(14); + } + else if (sp === 'leadin') { inner = '

' + LOREM.join('

') + '

'; } + else if (sp === 'snap') { + let s = '
'; + for (let i = 0; i < 10; i++) s += `
${i + 1}
`; + inner = s + '
'; + } + else if (sp === 'short') { inner = '

' + LOREM[0] + '

'; } + else { inner = bodyHtmlFor(scn); } + + let html = `
${inner}
`; + + if (sp === 'nested') { + html = `
` + + '

Outer scroller. The inner box is its own fade-y scroll context.

' + + '
' + cDense(18) + '
' + + '

After the nested region the outer keeps scrolling so its own bottom edge fades.

' + + cText(6).replace('
', '
') + + '
'; + } + if (sp === 'bordered') { + html = `
${cCards(14)}
`; + } + return html; + } + + function buildCard(scn) { + return `
` + + `
${esc(scn.label)}` + + `${esc(scn.cls.replace('scroller', '').trim())}` + + '
' + + buildScroller(scn) + + `
${esc(scn.note || '')}
` + + '
'; + } + + function buildSweepCard(sweep, value) { + const lock = { [sweep.key]: value }; + const label = `${sweep.key} ${value}${sweep.unit}` + (sweep.key === 'onset' && value === 1 ? ' (old)' : (sweep.key === 'onset' && value === 8 ? ' (default)' : '')); + return `
${esc(label)}
` + + `
${cText(9)}
` + + '
'; + } + + function render() { + const stage = document.getElementById('stage'); + let h = ''; + + h += '

Directions

all seven public direction utilities on identical content
'; + h += '
' + SCENARIOS.directions.map(buildCard).join('') + '
'; + + h += '

Real-world & edge cases

layouts and content that have historically broken edge-fade plugins
'; + h += '
' + SCENARIOS.realworld.map(buildCard).join('') + '
'; + + SWEEPS.forEach((sw) => { + h += `

${esc(sw.title)}

${esc(sw.desc)}
`; + h += '
' + sw.values.map((v) => buildSweepCard(sw, v)).join('') + '
'; + }); + + stage.innerHTML = h; + applyStageChrome(); + applyAll(); + syncScroll(); + } + + /* ---- applying parameters ------------------------------------------ */ + function setVarPx(el, prop, value) { + if (value == null || value === '') el.style.removeProperty(prop); + else el.style.setProperty(prop, typeof value === 'number' ? value + 'px' : value); + } + + function applyTo(el) { + const lock = JSON.parse(el.dataset.lock || '{}'); + const p = { + size: 'size' in lock ? lock.size : state.size, + travel: 'travel' in lock ? lock.travel : state.travel, + onset: 'onset' in lock ? lock.onset : state.onset, + clear: 'clear' in lock ? lock.clear : state.clear, + }; + setVarPx(el, '--tw-fade-size', p.size); + setVarPx(el, '--tw-fade-travel', p.travel); + el.style.setProperty('--tw-fade-onset', String(p.onset)); + setVarPx(el, '--tw-fade-clear', p.clear); + if (lock.vars) for (const k in lock.vars) el.style.setProperty(k, lock.vars[k]); + const allowFallback = state.fallback && !el.dataset.nofallback; + el.classList.toggle('fade-always', allowFallback); + } + + function applyAll() { + document.querySelectorAll('.scroller').forEach(applyTo); + } + + function applyStageChrome() { + const stage = document.getElementById('stage'); + stage.className = 'stage bg-' + state.bg + ' shape-' + state.shape; + const grid = stage; + grid.dir = state.rtl ? 'rtl' : 'ltr'; + } + + /* ---- scroll sync --------------------------------------------------- */ + function syncScroll() { + document.querySelectorAll('.scroller').forEach((el) => { + const maxY = el.scrollHeight - el.clientHeight; + const maxX = el.scrollWidth - el.clientWidth; + const sign = state.rtl ? -1 : 1; + if (state.scrollPx != null) { + const fromEnd = state.scrollMode === 'end'; + const posY = fromEnd ? maxY - state.scrollPx : state.scrollPx; + const posX = fromEnd ? maxX - state.scrollPx : state.scrollPx; + if (maxY > 0) el.scrollTop = Math.max(0, Math.min(posY, maxY)); + if (maxX > 0) el.scrollLeft = sign * Math.max(0, Math.min(posX, maxX)); + } else { + if (maxY > 0) el.scrollTop = Math.round(state.scrollPct * maxY); + if (maxX > 0) el.scrollLeft = sign * Math.round(state.scrollPct * maxX); + } + }); + requestAnimationFrame(() => requestAnimationFrame(updateReadouts)); + } + + function updateReadouts() { + document.querySelectorAll('.card').forEach((card) => { + const out = card.querySelector('.readout'); + const el = card.querySelector('.scroller'); + if (!out || !el) return; + const maxY = el.scrollHeight - el.clientHeight; + const maxX = el.scrollWidth - el.clientWidth; + const cs = getComputedStyle(el); + const amt = (p) => (Number(cs.getPropertyValue('--tw-fade-' + p)) || 0).toFixed(2).replace(/^0(?=\.)/, ''); + if (maxX > maxY && maxX > 0) { + const x = Math.abs(el.scrollLeft); + out.textContent = `x ${x|0}/${maxX|0} · ${Math.round(x / maxX * 100)}% · l${amt('l')} r${amt('r')}`; + } else if (maxY > 0) { + out.textContent = `y ${el.scrollTop|0}/${maxY|0} · ${Math.round(el.scrollTop / maxY * 100)}% · t${amt('t')} b${amt('b')}`; + } else { + out.textContent = 'no overflow → no fade'; + } + }); + } + + /* ---- auto-scroll --------------------------------------------------- */ + function toggleAuto(on) { + if (autoTimer) { cancelAnimationFrame(autoTimer); autoTimer = null; } + if (!on) return; + const tick = () => { + state.scrollPx = null; + state.scrollPct += autoDir * 0.004; + if (state.scrollPct >= 1) { state.scrollPct = 1; autoDir = -1; } + if (state.scrollPct <= 0) { state.scrollPct = 0; autoDir = 1; } + const slider = document.getElementById('scrollRange'); + if (slider) slider.value = String(Math.round(state.scrollPct * 100)); + syncScroll(); + autoTimer = requestAnimationFrame(tick); + }; + autoTimer = requestAnimationFrame(tick); + } + + /* ---- controls ------------------------------------------------------ */ + function fmtSize(v) { return v == null ? 'default' : v + 'px'; } + + function wire() { + const $ = (id) => document.getElementById(id); + + const bind = (id, fn) => { const e = $(id); if (e) e.addEventListener('input', fn); }; + + bind('sizeRange', (e) => { + state.size = e.target.value === '0' ? null : Number(e.target.value); + $('sizeVal').textContent = fmtSize(state.size); + applyAll(); + }); + bind('travelRange', (e) => { + state.travel = e.target.value === '0' ? null : Number(e.target.value); + $('travelVal').textContent = fmtSize(state.travel); + applyAll(); syncScroll(); + }); + bind('onsetRange', (e) => { + state.onset = Number(e.target.value); + $('onsetVal').textContent = String(state.onset) + (state.onset === 1 ? ' (old)' : ''); + applyAll(); + }); + bind('clearRange', (e) => { + state.clear = Number(e.target.value); + $('clearVal').textContent = state.clear + 'px'; + applyAll(); + }); + + bind('scrollRange', (e) => { + toggleAuto(false); $('autoChk').checked = false; + state.scrollPx = null; + state.scrollPct = Number(e.target.value) / 100; + syncScroll(); + }); + + document.querySelectorAll('[data-scrollmode]').forEach((b) => b.addEventListener('click', () => { + state.scrollMode = b.dataset.scrollmode; + document.querySelectorAll('[data-scrollmode]').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); + if (state.scrollPx != null) syncScroll(); + })); + + document.querySelectorAll('[data-step]').forEach((b) => b.addEventListener('click', () => { + toggleAuto(false); $('autoChk').checked = false; + const v = b.dataset.step; + if (v.endsWith('%')) { state.scrollPx = null; state.scrollPct = parseInt(v) / 100; $('scrollRange').value = parseInt(v); } + else { state.scrollPx = Number(v); } + syncScroll(); + })); + + $('autoChk').addEventListener('change', (e) => toggleAuto(e.target.checked)); + + const reRender = () => render(); + $('contentSel').addEventListener('change', (e) => { state.content = e.target.value; reRender(); }); + $('bgSel').addEventListener('change', (e) => { state.bg = e.target.value; applyStageChrome(); }); + $('shapeSel').addEventListener('change', (e) => { state.shape = e.target.value; applyStageChrome(); }); + + $('rtlChk').addEventListener('change', (e) => { state.rtl = e.target.checked; applyStageChrome(); syncScroll(); }); + $('fallbackChk').addEventListener('change', (e) => { + state.fallback = e.target.checked; + e.target.closest('.toggle').dataset.on = String(state.fallback); + applyAll(); + }); + $('resetBtn').addEventListener('click', () => { + Object.assign(state, { size: null, travel: null, onset: 8, clear: 0, scrollPct: 0, scrollPx: null, fallback: false }); + toggleAuto(false); + document.querySelectorAll('input,select').forEach((el) => { + if (el.type === 'checkbox') el.checked = false; + }); + $('sizeRange').value = 0; $('sizeVal').textContent = 'default'; + $('travelRange').value = 0; $('travelVal').textContent = 'default'; + $('onsetRange').value = 8; $('onsetVal').textContent = '8'; + $('clearRange').value = 0; $('clearVal').textContent = '0px'; + $('scrollRange').value = 0; + render(); + }); + + // Re-sync on real user scroll of any single card (so readouts stay honest) + document.getElementById('stage').addEventListener('scroll', (e) => { + if (e.target.classList && e.target.classList.contains('scroller')) { + requestAnimationFrame(updateReadouts); + } + }, true); + + window.addEventListener('resize', () => syncScroll()); + } + + /* ---- init ---------------------------------------------------------- */ + function init() { + render(); + wire(); + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); From 27e813af08c8a413c6ba6e189c5c0971136fa134 Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Sun, 28 Jun 2026 20:23:47 -0700 Subject: [PATCH 16/17] Fix three doc inaccuracies found in pre-publish review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: the prebuilt-CSS exclusion note listed "integer fade-size-* classes" — there is no integer fade-size form; fade-clear-* is the one with an integer scale. Corrected to fade-clear-*. - README: --tw-fade-onset is documented as a public edge-speed knob under Travel Distance, but the public-surface note declared the entire --tw-fade-* namespace internal — a contradiction. Carve onset out as the documented exception. - MIGRATING: note that the default travel changed md → sm (cosmetic, no action needed). It was documented only in the changelog. Found by the 6-dimension pre-publish review; all minor/nit, no behavior change. Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- MIGRATING.md | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MIGRATING.md b/MIGRATING.md index 552c326..f3a6e16 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -75,7 +75,7 @@ The suffix may be a named scale (`-md`), a bare integer (`-2`), or an arbitrary | `fade-clear-xy-` | `fade-clear-` | | `fade-size-x-` · `fade-size-y-` · `fade-clear-x-` · `fade-clear-y-` | _unchanged_ | -`range` → `travel` controls how quickly the soft band eases open as you scroll (the masked edge itself is now covered almost immediately — see the [changelog](./CHANGELOG.md)). `fade-travel-*` is **global only** — no per-edge travel utilities. +`range` → `travel` controls how quickly the soft band eases open as you scroll (the masked edge itself is now covered almost immediately — see the [changelog](./CHANGELOG.md)). `fade-travel-*` is **global only** — no per-edge travel utilities. The default travel is now `sm` (was `md`), so a surface that relied on the implicit default gets a slightly snappier band open — purely cosmetic, no action needed. --- diff --git a/README.md b/README.md index 6ac7d03..928e2aa 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ Two details matter: | Arbitrary values like `fade-size-[6rem]` | yes | no | | Integer clear values like `fade-clear-top-14` | yes | no | -The prebuilt file is generated from an explicit safelist. It does not include Tailwind Preflight, core Tailwind utilities, arbitrary values, or integer `fade-size-*` classes. +The prebuilt file is generated from an explicit safelist. It does not include Tailwind Preflight, core Tailwind utilities, arbitrary values, or integer `fade-clear-*` classes. ## Exports @@ -284,7 +284,7 @@ If an axis cannot scroll, that axis stays at `0`, so the fade does not show. Thi In browsers without scroll-driven animations, selected fades pin fully on as a static fallback. -The supported public surface is the `fade-*` utilities and the public `--fade-*` tokens described above. Treat `--tw-fade-*` as internal implementation detail. +The supported public surface is the `fade-*` utilities, the public `--fade-*` tokens described above, and `--tw-fade-onset` (the edge-speed knob documented under [Travel Distance](#travel-distance)). Treat the rest of the `--tw-fade-*` namespace as internal implementation detail. ## RTL From 182406a02a440d55aca38b28a5b523dfba18848e Mon Sep 17 00:00:00 2001 From: Pete Petrash Date: Sun, 28 Jun 2026 22:36:22 -0700 Subject: [PATCH 17/17] Release 0.7.0 Set the CHANGELOG 0.7.0 date to the actual publish date (the entry shipped with a 2026-06-26 placeholder). dist/demo/version were already current at the prior commit, so this carries only the date correction. Claude-Session: https://claude.ai/code/session_01B1Gn7DoGHbzGCLMbA72Stj --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f382b..62e205b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.7.0] - 2026-06-26 +## [0.7.0] - 2026-06-28 Breaking rename to a plain direction API. [MIGRATING.md](./MIGRATING.md) is an agent-ready upgrade procedure (ordered renames, RTL caveats, and a verification grep);