Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ const bleed = createBleedblendAuto({
onPageLoad: (update) => {
document.addEventListener('astro:page-load', update);
},

// How far the page-end overwrite reaches. Default 'auto'.
// 'auto' — full overwrite on a designed end-zone, <html>-only on an
// incidental short footer (no "footer flood" on flat pages)
// 'always' — legacy: always overwrite html + body + body::before
// 'never' — chrome-edge tint only, never touch html/body bg
overscrollFill: 'auto',
});

// later, if you ever need to:
Expand Down Expand Up @@ -151,7 +158,9 @@ For the bottom edge specifically:

- **Gradient territory**: extend the gradient interpolation into the chrome.
- **Mid-page section** (e.g. a belt between gradient and footer): step back — let Safari's edge sampling render the natural translucent chrome.
- **Last section** (footer at page-end): engage and tint the section color. Also overwrites `<html>`, `<body>`, and `body::before` so iOS rubber-band overscroll tints the same color and you don't see the html-bg mint leak through.
- **Last section** (footer at page-end): engage and tint the section color. How far that tint reaches into the background depends on what *kind* of ending it is (controlled by `overscrollFill`, default `'auto'`):
- **Designed end-zone** — a gradient ending, or a closing section ≥ 50% of the viewport, or a flat page whose background already matches the footer: overwrite `<html>`, `<body>`, **and** `body::before` so rubber-band overscroll tints the same color and you don't see the html-bg fallback leak through.
- **Incidental footer** — a short, high-contrast footer sitting over a flat light page: tint **`<html>` only**. The rubber-band-exposed strip gets the right color, but the *visible* body is left alone so it doesn't flood to the footer color.

For the top edge: always `SAFE_NATURAL` unless the user owns it via `.bleedblend-top`. Top chrome should feel light.

Expand All @@ -165,7 +174,7 @@ Things `bleedblend` figured out (the hard way) so you don't have to:
- **Safari samples non-fixed sections at the viewport edge**, not just fixed elements. The official docs and prior research suggested fixed-only.
- **`opacity: 0` is still sampled** — to truly "step back", you need `display: none`.
- **`100lvh - 100svh` is a static value** (the max dynamic chrome height), not the current chrome height. Don't use it for tint sizing — pick a small constant (e.g. 12px) that satisfies Safari's ≥3px sampling threshold.
- **`body::before { position: fixed; inset: 0 }` stretches into iOS rubber-band overscroll exposed area** and covers your `<html>` background. To paint overscroll a section color, you have to override all three: `<html>` bg, `<body>` bg, and `body::before` bg via injected `<style>`.
- **`body::before { position: fixed; inset: 0 }` stretches into iOS rubber-band overscroll exposed area** and covers your `<html>` background. To paint overscroll a section color, you have to override all three: `<html>` bg, `<body>` bg, and `body::before` bg via injected `<style>`. But that three-layer overwrite is a *fixed full-viewport* layer, so on a flat light page with a short high-contrast footer it floods the whole visible background to the footer color. bleedblend now does the full overwrite **only for designed end-zones**; an incidental footer tints `<html>` alone (see `overscrollFill`).
- **`safe-area-inset-*` reports `0` on iPhone Mirroring** — active probing is needed, with fallbacks for the 0 case.
- **`transparent` keyword has a dark band during alpha transitions** (WebKit treats it as `rgba(0,0,0,0)` = black with alpha 0). Use `rgba(R,G,B,0)` instead if you ever need alpha-0.
- **Boundary probe Y and "is this the last section" check must use the SAME Y** — otherwise you get a 12px flicker zone where one says "belt" and the other says "footer".
Expand All @@ -186,6 +195,14 @@ import { createBleedblendAuto } from 'bleedblend/utils';
interface BleedblendAutoOptions {
sectionSelector?: string;
onPageLoad?: (update: () => void) => void;
// Page-end overwrite reach (the "footer flood" control). Default 'auto'.
// 'auto' — full html+body+body::before overwrite on a designed end-zone
// (gradient ending / closing section ≥ 50vh / footer color ≈
// page bg); <html>-only tint on an incidental short footer over
// a flat bg, so the visible body isn't flooded.
// 'always' — legacy: always overwrite html + body + body::before.
// 'never' — chrome-edge tint only; never touch html/body background.
overscrollFill?: 'auto' | 'always' | 'never';
}

interface BleedblendController {
Expand All @@ -201,7 +218,7 @@ All the internals are exported in case you want to roll your own controller:
- Color: `parseColor`, `parseColorWithAlpha`, `colorToRgb`, `colorToHex`, `isOpaque`, `colorsClose`
- Gradient: `parseGradient`, `gradientColorAt`, `gradientColorAtY`
- Sampling: `measureInset`, `detectBackgroundFill`, `sampleColorAt`, `naturalSafariColor`
- Section: `findLastOpaqueSection`, `isInsideSection`
- Section: `findLastOpaqueSection`, `isInsideSection`, `isDesignedEndZone`
- Meta: `setMetaThemeColor`

See `src/utils.d.ts` for full signatures.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bleedblend",
"version": "2.1.0",
"version": "2.2.0-rc.0",
"description": "Zero-config iOS Safari chrome tinting — paints the status bar and URL bar to match your page content at each viewport edge. Handles gradients, sections, and rubber-band overscroll.",
"main": "src/tailwind-plugin.js",
"style": "src/index.css",
Expand Down
15 changes: 15 additions & 0 deletions src/utils.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ export interface BleedblendAutoOptions {
* document.addEventListener('astro:page-load', update)
*/
onPageLoad?: (update: () => void) => void;
/**
* How far the page-end overwrite reaches when the last opaque section is in
* view (controls the "footer flood" on flat content pages).
*
* - `'auto'` (default): heuristic — full `html` + `body` + `body::before`
* overwrite on a designed end-zone (gradient ending or a tall closing
* section), but only the rubber-band-exposed `<html>` is tinted on an
* incidental short footer over a flat background, so the visible body
* isn't flooded with the footer color.
* - `'always'`: legacy behavior — always overwrite `html` + `body` +
* `body::before`. Correct for pages designed to end in the footer color.
* - `'never'`: chrome-edge tint only — never touch `html`/`body` background.
*/
overscrollFill?: 'auto' | 'always' | 'never';
}

export interface BleedblendController {
Expand Down Expand Up @@ -63,6 +77,7 @@ export declare function sampleColorAt(x: number, y: number, ignoreIds?: string[]
export declare function naturalSafariColor(): Rgba | null;
export declare function findLastOpaqueSection(selector?: string): Element | null;
export declare function isInsideSection(y: number, lastSection: Element | null, ignoreIds?: string[]): boolean;
export declare function isDesignedEndZone(lastSection: Element | null, fill: BackgroundFill): boolean;

// ── theme-color meta ───────────────────────────────────────────────────────
export declare function setMetaThemeColor(hex: string | null | undefined): void;
Expand Down
64 changes: 59 additions & 5 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,36 @@ function isInsideSection(y, lastSection, ignoreIds) {
return false;
}

// Classify the page-end opaque section as a DESIGNED end-zone vs an INCIDENTAL
// footer — the distinction that decides how far the page-end overwrite reaches.
// See HANDOFF.md "footer flood".
// true → designed end-zone: overscroll is meant to match it, so the full
// html + body + body::before overwrite is correct.
// false → incidental footer (short footer on a flat content page): only the
// rubber-band exposed <html> should be tinted; overwriting the fixed
// full-viewport body::before would flood the visible background.
function isDesignedEndZone(lastSection, fill) {
if (!lastSection) return false;
const vh = (typeof window !== 'undefined' && window.innerHeight) || 1;
const r = lastSection.getBoundingClientRect();
// A closing section that dominates the viewport (≥ half) reads as a designed
// ending: it already covers most of the screen, so matching body/overscroll
// to it is seamless, not a flood.
if (r.height >= vh * 0.5) return true;
// A page-spanning gradient behind the content is a designed gradient ending;
// the overscroll is meant to continue it down to the footer.
if (fill && fill.kind === 'gradient') return true;
// Flat opaque page background: seamless only when the footer continues that
// same color. A short, high-contrast footer is incidental → don't flood.
if (fill && fill.kind === 'solid') {
const footer = parseColor(getComputedStyle(lastSection).backgroundColor);
return colorsClose(fill.color, footer, 24);
}
// No detectable fill + short footer → treat as incidental (safe default:
// tint <html> for overscroll, leave the visible body alone).
return false;
}

function setMetaThemeColor(hex) {
if (!hex) return;
const metas = document.querySelectorAll('meta[name="theme-color"]');
Expand Down Expand Up @@ -491,11 +521,34 @@ function createBleedblendAuto(options) {
}
if (lastSectionColor) {
const colorRgb = colorToRgb(lastSectionColor);
htmlEl.style.backgroundColor = colorRgb;
document.body.style.backgroundColor = colorRgb;
beforeOverrideEl.textContent = 'body::before { background: ' + colorRgb + ' !important; }';
const hex = colorToHex(lastSectionColor);
if (hex) setMetaThemeColor(hex);
// overscrollFill decides how far the page-end overwrite reaches:
// 'auto' (default) heuristic — full overwrite on a designed end-zone,
// <html>-only tint on an incidental flat-page footer.
// 'always' legacy behavior — always overwrite html+body+::before.
// 'never' chrome-edge tint only — never touch html/body bg.
const mode = opts.overscrollFill || 'auto';
const tintHtml = mode !== 'never';
const flood =
mode === 'always' ? true : mode === 'never' ? false : isDesignedEndZone(lastSection, fill);

// <html> bg is what iOS rubber-band overscroll exposes — tint it for the
// correct overscroll color in every non-'never' case.
htmlEl.style.backgroundColor = tintHtml ? colorRgb : '';
if (flood) {
// Designed end-zone: also overwrite body + the fixed full-viewport
// body::before so the whole ending matches.
document.body.style.backgroundColor = colorRgb;
beforeOverrideEl.textContent = 'body::before { background: ' + colorRgb + ' !important; }';
} else {
// Incidental footer: leave the visible body + body::before untouched so
// a flat content page's background isn't flooded with the footer color.
document.body.style.backgroundColor = '';
beforeOverrideEl.textContent = '';
}
if (tintHtml) {
const hex = colorToHex(lastSectionColor);
if (hex) setMetaThemeColor(hex);
}
} else {
htmlEl.style.backgroundColor = '';
document.body.style.backgroundColor = '';
Expand Down Expand Up @@ -570,6 +623,7 @@ module.exports = {
naturalSafariColor,
findLastOpaqueSection,
isInsideSection,
isDesignedEndZone,
setMetaThemeColor,
createBleedblendAuto,
default: createBleedblendAuto,
Expand Down
63 changes: 58 additions & 5 deletions src/utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,36 @@ export function isInsideSection(y, lastSection, ignoreIds = []) {
return false;
}

// Classify the page-end opaque section as a DESIGNED end-zone vs an INCIDENTAL
// footer — the distinction that decides how far the page-end overwrite reaches.
// See HANDOFF.md "footer flood".
// true → designed end-zone: overscroll is meant to match it, so the full
// html + body + body::before overwrite is correct.
// false → incidental footer (short footer on a flat content page): only the
// rubber-band exposed <html> should be tinted; overwriting the fixed
// full-viewport body::before would flood the visible background.
export function isDesignedEndZone(lastSection, fill) {
if (!lastSection) return false;
const vh = (typeof window !== 'undefined' && window.innerHeight) || 1;
const r = lastSection.getBoundingClientRect();
// A closing section that dominates the viewport (≥ half) reads as a designed
// ending: it already covers most of the screen, so matching body/overscroll
// to it is seamless, not a flood.
if (r.height >= vh * 0.5) return true;
// A page-spanning gradient behind the content is a designed gradient ending;
// the overscroll is meant to continue it down to the footer.
if (fill && fill.kind === 'gradient') return true;
// Flat opaque page background: seamless only when the footer continues that
// same color. A short, high-contrast footer is incidental → don't flood.
if (fill && fill.kind === 'solid') {
const footer = parseColor(getComputedStyle(lastSection).backgroundColor);
return colorsClose(fill.color, footer, 24);
}
// No detectable fill + short footer → treat as incidental (safe default:
// tint <html> for overscroll, leave the visible body alone).
return false;
}

// ─────────────────────────────────────────────────────────────────────────────
// theme-color <meta>
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -559,11 +589,34 @@ export function createBleedblendAuto(options = {}) {
}
if (lastSectionColor) {
const colorRgb = colorToRgb(lastSectionColor);
htmlEl.style.backgroundColor = colorRgb;
document.body.style.backgroundColor = colorRgb;
beforeOverrideEl.textContent = 'body::before { background: ' + colorRgb + ' !important; }';
const hex = colorToHex(lastSectionColor);
if (hex) setMetaThemeColor(hex);
// overscrollFill decides how far the page-end overwrite reaches:
// 'auto' (default) heuristic — full overwrite on a designed end-zone,
// <html>-only tint on an incidental flat-page footer.
// 'always' legacy behavior — always overwrite html+body+::before.
// 'never' chrome-edge tint only — never touch html/body bg.
const mode = options.overscrollFill || 'auto';
const tintHtml = mode !== 'never';
const flood =
mode === 'always' ? true : mode === 'never' ? false : isDesignedEndZone(lastSection, fill);

// <html> bg is what iOS rubber-band overscroll exposes — tint it for the
// correct overscroll color in every non-'never' case.
htmlEl.style.backgroundColor = tintHtml ? colorRgb : '';
if (flood) {
// Designed end-zone: also overwrite body + the fixed full-viewport
// body::before so the whole ending matches.
document.body.style.backgroundColor = colorRgb;
beforeOverrideEl.textContent = 'body::before { background: ' + colorRgb + ' !important; }';
} else {
// Incidental footer: leave the visible body + body::before untouched so
// a flat content page's background isn't flooded with the footer color.
document.body.style.backgroundColor = '';
beforeOverrideEl.textContent = '';
}
if (tintHtml) {
const hex = colorToHex(lastSectionColor);
if (hex) setMetaThemeColor(hex);
}
} else {
htmlEl.style.backgroundColor = '';
document.body.style.backgroundColor = '';
Expand Down
61 changes: 61 additions & 0 deletions test/integration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,67 @@ const CASES = [
['overscroll html bg teal-ish', tealish(b.htmlBg)],
],
},
{
// The "footer flood" regression (see HANDOFF.md): a flat light content page
// with a SHORT high-contrast footer must NOT dye the visible body — only the
// rubber-band <html> gets tinted; body + body::before stay untouched.
name: 'N flat page + short footer (incidental, no flood)', ua: 'ios',
html: page('body{background:#f7fafc}main{min-height:180vh}.card{background:#fff;margin:24px;height:200px}footer{height:250px;background:#4a3526}',
'<main><div class="card">a</div></main><footer>foot</footer>'),
check: (t, b) => [
['bottom footer BLEED_OVERRIDE (chrome edge still tints)', b.botState === 'BLEED_OVERRIDE'],
['bottom tint ~brown', near(b.botBg, 74, 53, 38, 10)],
['overscroll html tinted ~brown', near(b.htmlBg, 74, 53, 38, 10)],
['body NOT flooded', b.bodyBg === ''],
['body::before NOT overwritten', (b.beforeOverride || '') === ''],
],
},
{
// Same flat page, escape hatch overscrollFill:'always' → legacy full overwrite.
name: 'N2 flat page + overscrollFill always (legacy flood)', ua: 'ios',
html: page('body{background:#f7fafc}main{min-height:180vh}.card{background:#fff;margin:24px;height:200px}footer{height:250px;background:#4a3526}',
'<main><div class="card">a</div></main><footer>foot</footer>',
'<script type="module">import {createBleedblendAuto} from "/src/utils.mjs"; window.__c=createBleedblendAuto({overscrollFill:"always"});</script>'),
check: (t, b) => [
['body flooded (always)', near(b.bodyBg, 74, 53, 38, 10)],
['body::before overwritten', /background:\s*rgb\(74,\s*53,\s*38\)/.test(b.beforeOverride || '')],
['overscroll html ~brown', near(b.htmlBg, 74, 53, 38, 10)],
],
},
{
// Same flat page, escape hatch overscrollFill:'never' → chrome-edge tint only.
name: 'N3 flat page + overscrollFill never (chrome-edge only)', ua: 'ios',
html: page('body{background:#f7fafc}main{min-height:180vh}.card{background:#fff;margin:24px;height:200px}footer{height:250px;background:#4a3526}',
'<main><div class="card">a</div></main><footer>foot</footer>',
'<script type="module">import {createBleedblendAuto} from "/src/utils.mjs"; window.__c=createBleedblendAuto({overscrollFill:"never"});</script>'),
check: (t, b) => [
['bottom tint still ~brown (chrome edge survives)', near(b.botBg, 74, 53, 38, 10)],
['html NOT touched', b.htmlBg === ''],
['body NOT touched', b.bodyBg === ''],
['body::before NOT touched', (b.beforeOverride || '') === ''],
],
},
{
// A TALL closing footer (≥50% viewport) on a flat bg is a designed end-zone
// → it SHOULD still flood (it already covers most of the screen).
name: 'N4 flat page + tall footer (designed end-zone floods)', ua: 'ios',
html: page('body{background:#f7fafc}main{min-height:120vh}footer{min-height:70vh;background:#4a3526}',
'<main><div>a</div></main><footer>foot</footer>'),
check: (t, b) => [
['tall footer floods body', near(b.bodyBg, 74, 53, 38, 10)],
['body::before overwritten', /background:\s*rgb\(74,\s*53,\s*38\)/.test(b.beforeOverride || '')],
],
},
{
// Footer color continuous with a flat opaque body bg → seamless end-zone,
// overwrite is invisible so flooding is fine.
name: 'N5 footer color ≈ flat body bg (continuous, floods)', ua: 'ios',
html: page('body{background:#101418}main{min-height:120vh}footer{height:250px;background:#141a20}',
'<main><div>a</div></main><footer>foot</footer>'),
check: (t, b) => [
['continuous color → body flooded (seamless)', near(b.bodyBg, 20, 26, 32, 10)],
],
},
{
name: 'K desktop UA -> no-op (browser-support claim)', ua: 'desktop',
html: page(`html{background:#fff}body::before{content:"";position:fixed;inset:0;z-index:-1;background:${GRAD}}`, '<div style="height:300vh"></div>'),
Expand Down