diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index fefd335..fbb4ac7 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -25,3 +25,4 @@ jobs: with: folder: demo branch: gh-pages +# touched to trigger rebuild Tue May 26 21:14:45 JST 2026 diff --git a/.gitignore b/.gitignore index e25795a..2f95953 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,16 @@ node_modules/ .DS_Store + +# Generated test pages (written at test runtime) +test/__generated__/ npm-debug.log* yarn-debug.log* yarn-error.log* package-lock.json pnpm-lock.yaml + +# Internal session handoff docs — do not publish. +HANDOFF.md +RENAME-HANDOFF.md +DEMO-HANDOFF.md +OSS-EXTRACTION-HANDOFF.md diff --git a/LICENSE b/LICENSE index 7ec9116..767ac1a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 bleed contributors +Copyright (c) 2026 bleedblend contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 9e7b620..8582e5b 100644 --- a/README.md +++ b/README.md @@ -1,674 +1,285 @@ -# bleed +# bleedblend -> Tiny CSS utilities for fixed headers and footers that seamlessly tint the iOS Safari status bar & home indicator — without hitting WebKit's `backdrop-filter` safe-area clipping bug. +> **Zero-config iOS Safari chrome tinting.** Paints the status bar and URL bar to match your page content at each viewport edge — gradients, sections, rubber-band overscroll, all handled automatically. One import. No theme-color juggling. No tint configuration. It just works. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![NPM Version](https://img.shields.io/npm/v/bleed.svg?color=blue)](https://www.npmjs.com/package/bleed) -[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-3178C6?logo=typescript&logoColor=white)](#) -[![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-38BDF8?logo=tailwind-css&logoColor=white)](https://tailwindcss.com) +[![NPM Version](https://img.shields.io/npm/v/bleedblend.svg?color=blue)](https://www.npmjs.com/package/bleedblend) +[![iOS Safari 26](https://img.shields.io/badge/iOS%20Safari-26+-blue?logo=safari&logoColor=white)](#) +[![Zero Config](https://img.shields.io/badge/Zero-Config-success)](#) -[**English**](#english) ・ [**Español**](#español) ・ [**日本語**](#日本語) ・ [**台灣華語**](#台灣華語) - -> 🎮 **[Live Interactive Demo →](https://cverinc.github.io/bleed/)** +> 🎮 **[Live Demo →](https://cverinc.github.io/bleedblend/)** --- -## English - -### 📌 Overview - -`bleed` is a lightweight utility designed to fix the iOS Safari status bar clipping bug when utilizing `backdrop-filter` on top-fixed (`position: fixed; top: 0`) elements with `viewport-fit=cover`. - -#### 🔴 The Problem -When combining `position: fixed`, `top: 0`, safe-area-inset padding, and `backdrop-filter` on iOS Safari, WebKit clips the filter sampling region at the safe-area boundary. This leaves the status bar/notch area transparent or filled with the page body background, rather than the banner's background. +## The Despair -#### 🟢 The Solution -`bleed` disables `backdrop-filter` on the outer fixed container and utilizes an opaque background. This ensures that the painted background surface extends fully into the status bar area. If a frosted glass effect is desired, the blur is applied to an inner child element instead of the main wrapper. +iOS Safari tints the chrome (status bar + URL bar) with **whatever happens to be at the viewport edge** — but the rules are quirky, undocumented, and shift between iOS versions. You ship a page with a gradient hero, and it looks great… until: -### ✨ Features -* 📱 **Top & Bottom Safe Area Tinting**: Seamlessly bleeds fixed header and footer backgrounds into the iOS status bar and home indicator. -* 🛠 **Multi-Framework Support**: Official integrations for Tailwind CSS, React, Vue, Svelte, and UnoCSS. -* 📐 **TypeScript Ready**: Full `.d.ts` declarations for all modules — enjoy autocomplete and type safety out of the box. -* ⚡ **JS Safe Area API**: `getSafeAreaInsets()` dynamically returns the device's safe area insets in pixels. -* ⚙️ **Clipping Prevention**: Overrides outer `backdrop-filter` declarations to avoid rendering glitches on iOS. +- The viewport bottom tints mint while your belt section is dark teal — a visible **seam** at the chrome boundary. +- Compact tab bar appears, the tinting "shifts" by 30px because `100lvh - 100svh` doesn't match the current chrome height. +- User pulls past the footer — rubber-band overscroll exposes the html background-color, which is the *wrong* color. +- `theme-color` meta is ignored on iOS 26. +- `position: fixed` elements tint chrome correctly… except when they don't, depending on `opacity`, `display`, viewport edge proximity, and dark-mode mood. +- You add `body::before { position: fixed; gradient }` to fake the bg — but it **stretches into the overscroll exposed area** and overrides whatever you set on `` and ``. -### 🏗 File Structure +This is a four-day rabbit hole. `bleedblend` walks it for you. -| File | Purpose | -|---|---| -| `src/index.css` | Vanilla CSS (`.bleed-top`, `.bleed-bottom`, `.bleed-inner-blur`) | -| `src/tailwind-plugin.js` | Tailwind CSS plugin | -| `src/unocss.js` | UnoCSS preset | -| `src/react.js` | React components (`BleedTop`, `BleedBottom`, `BleedInnerBlur`) | -| `src/vue.js` | Vue 3 components (`BleedTop`, `BleedBottom`, `BleedInnerBlur`) | -| `src/svelte.js` | Svelte actions (`bleedTop`, `bleedBottom`, `bleedInnerBlur`) | -| `src/utils.js` | JS safe area detector (`getSafeAreaInsets()`) | -| `src/*.d.ts` | TypeScript type declarations for every module | -| `demo/index.html` | Interactive visual showcase & iOS simulator | - -### 🚀 Installation & Usage +--- -#### 1. Install Package -```bash -npm install bleed -``` +## What you get -#### 2. Meta Tag Setup -Make sure your page's viewport metadata includes `viewport-fit=cover`: -```html - +```js +import 'bleedblend/auto'; ``` -#### 3. Choose Your Framework - -
-🎨 Option A: Vanilla CSS +That's it. After that one import, `bleedblend` watches scroll, resize, and `visualViewport` events, and: -Import the stylesheet at your application entry point: -```javascript -import 'bleed/style'; -``` - -Apply the classes: -```html -
- Notice: We are undergoing scheduled system maintenance. -
-``` -```css -.emergency-bar { - background: #b91c1c; - color: #fff; - padding-right: 16px; - padding-bottom: 8px; - padding-left: 16px; -} -``` -
+- **Top chrome tinting** stays light and unobtrusive by default (Safari's natural sampling of whatever's at viewport top). Want it to take your sticky nav's color instead? That's an opt-in — see [Make your own sticky header / footer tint the chrome](#make-your-own-sticky-header--footer-tint-the-chrome-bleedblend-top--bleedblend-bottom). +- **Bottom chrome tinting** mirrors the page content: gradient interp when you're in gradient territory, section color when an opaque section reaches the edge, footer color when you're at page-end. +- **Overscroll tinting** when the page-end section enters viewport — `bleedblend` overwrites ``, ``, AND `body::before` so the rubber-band exposed area tints the same color, not your fallback bg. +- **No flickering** between belt and footer sections — boundary probe and last-section check use the same Y so state transitions are clean. -
-⚡ Option B: Tailwind CSS +--- -Add the plugin to your `tailwind.config.js`: -```javascript -module.exports = { - content: ['./src/**/*.{html,js,ts,jsx,tsx,vue,svelte}'], - plugins: [require('bleed')], -}; -``` +## Install -Combine `.bleed-top` with an opaque background class: -```html -
-

Notice: We are undergoing scheduled system maintenance.

-
-``` -
- -
-⚛️ Option C: React / Next.js - -Import the components: -```jsx -import { BleedTop, BleedInnerBlur } from 'bleed/react'; -import 'bleed/style'; // (If not using Tailwind/UnoCSS) - -export default function Banner() { - return ( - -

Undergoing scheduled maintenance.

-
- ); -} +```bash +npm install bleedblend ``` -
-
-🟢 Option D: Vue 3 +Make sure your page has the cover viewport: -Import the components: ```html - - - + ``` -
- -
-🧡 Option E: Svelte -Use Svelte actions for ultimate flexibility: -```html - - -
-

Undergoing scheduled maintenance.

-
-``` -
+--- -
-📦 Option F: UnoCSS +## Use -Add the preset to your `uno.config.ts`: -```typescript -import { defineConfig } from 'unocss'; -import presetBleed from 'bleed/unocss'; +### Zero-config (recommended) -export default defineConfig({ - presets: [ - presetBleed(), - // other presets... - ], -}); +```js +import 'bleedblend/auto'; ``` -Then use `.bleed-top` directly in your markup. -
- -### ⚙️ API Reference - -* **`.bleed-top` / `` / `use:bleedTop`** - Positions an element at the top of the viewport with safe-area-aware padding (`calc(8px + env(safe-area-inset-top, 0px))`) and disables `backdrop-filter` on the wrapper to prevent clipping. -* **`.bleed-bottom` / `` / `use:bleedBottom`** - Same concept for the bottom edge — extends the footer background into the home indicator area with `env(safe-area-inset-bottom)`. -* **`.bleed-inner-blur` / `` / `use:bleedInnerBlur`** - Applies frosted glass styling (`blur(10px) saturate(140%)`) to an inner element. -* **`getSafeAreaInsets()`** _(from `bleed/utils`)_ - Returns `{ top, bottom, left, right }` in pixels. SSR-safe (returns zeros on server). - ```javascript - import { getSafeAreaInsets } from 'bleed/utils'; - const insets = getSafeAreaInsets(); - console.log(`Status bar: ${insets.top}px, Home indicator: ${insets.bottom}px`); - ``` ---- +Anywhere in your entry point. That's the whole API. -## Español +### With options -### 📌 Resumen +If you need to pass options (custom section selector, framework page-transition hook) or hold a reference to destroy later: -`bleed` es una utilidad ligera diseñada para solucionar el error de renderizado en la barra de estado de iOS Safari al usar `backdrop-filter` en elementos con posicionamiento fijo superior (`position: fixed; top: 0`) y `viewport-fit=cover`. +```js +import { createBleedblendAuto } from 'bleedblend/utils'; -#### 🔴 El Problema -Al combinar `position: fixed`, `top: 0`, relleno basado en safe-area-inset y `backdrop-filter` en iOS Safari, WebKit recorta la región de muestreo del filtro en el límite del área segura (Safe Area). Esto hace que la barra de estado o la zona de la pestaña (notch) quede transparente o pintada con el fondo del body de la página, en lugar de mostrar el fondo de la barra de anuncios. +const bleed = createBleedblendAuto({ + // CSS selector for "section"-like elements. Default: + // 'main section, main > *, footer' + sectionSelector: 'main > section, footer', -#### 🟢 La Solución -`bleed` desactiva el `backdrop-filter` en el contenedor externo fijo y utiliza un fondo opaco. Esto asegura que la superficie del fondo pintado se extienda completamente bajo la barra de estado. Si se desea un efecto de cristal esmerilado (frosted glass), el desenfoque (blur) se aplica a un elemento hijo interno en lugar del contenedor principal. + // Re-run bleedblend on framework page transitions: + onPageLoad: (update) => { + document.addEventListener('astro:page-load', update); + }, -### ✨ Características -* 📱 **Teñido Superior e Inferior del Área Segura**: Extiende de forma automática el fondo de barras fijas (header y footer) hacia la barra de estado y el indicador de inicio de iOS. -* 🛠 **Compatibilidad Multi-Framework**: Integraciones oficiales para Tailwind CSS, React, Vue 3, Svelte y UnoCSS. -* 📐 **Listo para TypeScript**: Declaraciones `.d.ts` completas para todos los módulos. -* ⚡ **API JS de Área Segura**: `getSafeAreaInsets()` devuelve dinámicamente los insets del área segura del dispositivo en píxeles. -* ⚙️ **Prevención de Recortes**: Anula las declaraciones externas de `backdrop-filter` para evitar glitches visuales en iOS. + // How far the page-end overwrite reaches. Default 'auto'. + // 'auto' — full overwrite on a designed end-zone, -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', +}); -### 🏗 Estructura de Archivos -(Consulte la tabla de la sección en inglés para más detalles) +// later, if you ever need to: +bleed.destroy(); +``` -### 🚀 Instalación y Uso +### Make your own sticky header / footer tint the chrome (`.bleedblend-top` / `.bleedblend-bottom`) -#### 1. Instalar Paquete -```bash -npm install bleed -``` +Have a sticky nav or footer bar and want **the status bar / URL bar to take its color** — a cream nav giving you a cream status bar? Mark it `.bleedblend-top` / `.bleedblend-bottom` and import the stylesheet: -#### 2. Configuración de Meta Tag -Asegúrese de que el viewport de su página web incluya `viewport-fit=cover`: ```html - +
+ +
``` -#### 3. Elige tu Framework - -
-🎨 Opción A: CSS Puro (Vanilla CSS) - -Importe la hoja de estilos en el punto de entrada de la aplicación: -```javascript -import 'bleed/style'; +```js +import 'bleedblend/style'; ``` -Aplique las clases: -```html -
- Aviso: Estamos realizando tareas de mantenimiento programadas. -
-``` -```css -.emergency-bar { - background: #b91c1c; - color: #fff; - padding-right: 16px; - padding-bottom: 8px; - padding-left: 16px; -} -``` -
+**This is the recipe that *makes* a sticky bar tint — not just a "defer" flag for bars that already work.** The class does two things that turn "doesn't tint" into "tints": -
-⚡ Opción B: Tailwind CSS +- **Pins it `position: fixed`, full-width, with `safe-area-inset` padding** — so it sits below the notch and Safari samples it as an edge element. +- **Strips `backdrop-filter` off the outer element.** This is the **#1 reason a sticky nav silently refuses to tint**: a frosted-glass blur on the safe-area layer triggers WebKit's safe-area *clipping* bug and Safari stops sampling the bar. **If your bar looks perfect on screen but the chrome stays plain white, this is almost always why.** -Agregue el plugin a su archivo `tailwind.config.js`: -```javascript -module.exports = { - content: ['./src/**/*.{html,js,ts,jsx,tsx,vue,svelte}'], - plugins: [require('bleed')], -}; -``` +Keep the frosted-glass look by moving the blur to an **inner** element with `.bleedblend-inner-blur` (the outer stays blur-free so sampling survives): -Combine `.bleed-top` con una clase de fondo opaco de Tailwind: ```html -
-

Aviso: Estamos realizando tareas de mantenimiento programadas.

-
+
+
+
``` -
- -
-⚛️ Opción C: React / Next.js - -Importe los componentes: -```jsx -import { BleedTop } from 'bleed/react'; -import 'bleed/style'; // (Si no usa Tailwind o UnoCSS) - -export default function Banner() { - return ( - -

Mantenimiento de sistema en curso.

-
- ); -} -``` -
-
-🟢 Opción D: Vue 3 +`bleedblend`'s controller also detects the marked bar (`STICKY_OWNED`, see below) and **steps back** on that edge so it never double-paints over you. -Importe los componentes: -```html - - - -``` -
+> **Prerequisite:** the cover viewport (`viewport-fit=cover`) must be in your **server-rendered ``** — injecting it via JS after load is unreliable on iOS, and without it there's no safe-area for the bar to fill. -
-🧡 Opción E: Svelte +### Tailwind CSS integration -Use las acciones de Svelte para máxima flexibilidad: -```html - - -
-

Mantenimiento de sistema en curso.

-
+```js +// tailwind.config.js +module.exports = { + plugins: [require('bleedblend')], +}; ``` -
- -
-📦 Opción F: UnoCSS -Agregue el preset a su archivo `uno.config.ts`: -```typescript -import { defineConfig } from 'unocss'; -import presetBleed from 'bleed/unocss'; +Then use `bleedblend-top`, `bleedblend-bottom`, and `bleedblend-inner-blur` utility classes: -export default defineConfig({ - presets: [ - presetBleed(), - ], -}); +```html +
+ +
``` -Luego, use la clase `.bleed-top` directamente en sus etiquetas. -
- -### ⚙️ Referencia de la API - -* **`.bleed-top` / `` / `use:bleedTop`** - Posiciona el elemento en la parte superior del viewport con relleno dinámico del área segura (`calc(8px + env(safe-area-inset-top, 0px))`) y deshabilita `backdrop-filter` en el contenedor para evitar el recorte visual. -* **`.bleed-bottom` / `` / `use:bleedBottom`** - Mismo concepto para el borde inferior — extiende el fondo del footer hacia el área del indicador de inicio con `env(safe-area-inset-bottom)`. -* **`.bleed-inner-blur` / `` / `use:bleedInnerBlur`** - Aplica un estilo de cristal esmerilado (`blur(10px) saturate(140%)`) a un elemento interno. -* **`getSafeAreaInsets()`** _(de `bleed/utils`)_ - Devuelve `{ top, bottom, left, right }` en píxeles. Compatible con SSR. --- -## 日本語 +## How it works (mental model) -### 📌 概要 +`bleedblend`'s state machine, per viewport edge: -`bleed` は、iOS Safari 上で固定配置(`position: fixed; top: 0`)されたアナウンスバー等のヘッダーが、`backdrop-filter` の使用時にステータスバー領域(ノッチ部分)でクリッピングされる WebKit のバグを解決するための軽量ユーティリティです。 +| State | When | What bleedblend does | +|---|---|---| +| `STICKY_OWNED` | User has a visible `.bleedblend-top` / `.bleedblend-bottom` | Steps back entirely. | +| `SAFE_NATURAL` | Page content at viewport edge already produces the right chrome tinting (e.g. top edge, mid-page section) | Hides the bleedblend tint (`display:none`) so Safari tints chrome with its native edge sampling. | +| `BLEED_OVERRIDE` | Page content at viewport edge would tint the wrong color (e.g. gradient terminal ≠ html bg, or page-end section needs explicit tinting) | Renders a 12px tint at the edge with the correct color. Safari samples it for chrome tinting. | -#### 🔴 課題 -iOS で `viewport-fit=cover` と `position: fixed` に加え `backdrop-filter: blur(...)` を併用すると、WebKit の仕様によりフィルタのサンプリング領域がセーフエリア境界でクリップされ、ステータスバー領域の背景が描画されずにページ背景が露出してしまいます。 +For the bottom edge specifically: -#### 🟢 解決策 -`bleed` は、外側の固定親要素で `backdrop-filter` を強制的に無効化し、不透明(Opaque)な背景色を指定することで、ステータスバー領域まで背景色を綺麗に「染める(bleed)」ことができます。すりガラス効果が必要な場合は、内側の別レイヤーにぼかしを適用します。 +- **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. 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 ``, ``, **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 **`` 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. -### ✨ 主な機能 -* 📱 **上下セーフエリアの自動染色**:ヘッダーとフッターの背景をステータスバー・ホームインジケーター領域まで一体化させます。 -* 🛠 **マルチフレームワーク対応**:Tailwind CSS、React、Vue 3、Svelte、UnoCSS に公式対応。 -* 📐 **TypeScript 対応**:全モジュールに完全な `.d.ts` 型定義を提供。 -* ⚡ **JS セーフエリア API**:`getSafeAreaInsets()` でデバイスのセーフエリアをピクセル単位で動的取得。 -* ⚙️ **クリッピング防止**:親要素の `backdrop-filter` を強制的に無効化し、表示崩れを防ぎます。 +For the top edge: always `SAFE_NATURAL` unless the user owns it via `.bleedblend-top`. Top chrome should feel light. -### 🏗 ファイル構成 -(Englishセクションの表と同様) +--- -### 🚀 導入手順 +## iOS quirks navigated -#### 1. インストール -```bash -npm install bleed -``` +Things `bleedblend` figured out (the hard way) so you don't have to: -#### 2. Viewport の設定 -HTML の `` に `viewport-fit=cover` が設定されていることを確認してください。 -```html - -``` +- **`theme-color` is ignored on iOS 26**. Don't rely on it. +- **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 `` background. To paint overscroll a section color, you have to override all three: `` bg, `` bg, and `body::before` bg via injected `${body} +${importLine}`; + +const GRAD = 'linear-gradient(180deg,#aceace 0%,#0a8c8e 100%)'; + +const CASES = [ + { + name: 'A solid-white (no gradient/sections)', ua: 'ios', + html: page('body{background:#ffffff}', '
'), + check: (t, b) => [ + ['top SAFE_NATURAL', t.topState === 'SAFE_NATURAL'], + ['bottom SAFE_NATURAL', b.botState === 'SAFE_NATURAL'], + ['bottom tint display:none', b.botDisplay === 'none'], + ['no html overwrite', b.htmlBg === ''], + ], + }, + { + name: 'B gradient terminal != html bg', ua: 'ios', + html: page(`html{background:#ffffff}body::before{content:"";position:fixed;inset:0;z-index:-1;background:${GRAD}}`, '
'), + check: (t) => [ + ['top SAFE_NATURAL', t.topState === 'SAFE_NATURAL'], + ['bottom BLEED_OVERRIDE', t.botState === 'BLEED_OVERRIDE'], + ['bottom tint display:block', t.botDisplay === 'block'], + ['bottom tint ~teal', near(t.botBg, 13, 142, 143, 16)], + ['bottom tint pad 12px', t.botPadBottom === '12px'], + ], + }, + { + name: 'C gradient terminal == html bg (smart no-op)', ua: 'ios', + html: page(`html{background:#0a8c8e}body::before{content:"";position:fixed;inset:0;z-index:-1;background:${GRAD}}`, '
'), + check: (t) => [ + ['bottom SAFE_NATURAL (already matches)', t.botState === 'SAFE_NATURAL'], + ['bottom tint display:none', t.botDisplay === 'none'], + ], + }, + { + name: 'D sections + opaque footer (no gradient)', ua: 'ios', + html: page('section{min-height:120vh;background:#ffffff}footer{min-height:70vh;background:#0b1d3a}', + '
1
2
foot
'), + check: (t, b) => [ + ['top: mid-section SAFE_NATURAL', t.botState === 'SAFE_NATURAL'], + ['top: no overscroll overwrite yet', t.htmlBg === ''], + ['bottom: footer BLEED_OVERRIDE', b.botState === 'BLEED_OVERRIDE'], + ['bottom tint ~navy', near(b.botBg, 11, 29, 58, 10)], + ['overscroll html bg ~navy', near(b.htmlBg, 11, 29, 58, 10)], + ['body::before overwritten', /background:\s*rgb\(11,\s*29,\s*58\)/.test(b.beforeOverride || '')], + ], + }, + { + name: 'E dark mode', ua: 'ios', dark: true, + html: page('body{background:#fff}@media (prefers-color-scheme:dark){body{background:#101418}}', '
'), + check: (t, b) => [['runs, bottom state is a string', typeof b.botState === 'string']], + }, + { + name: 'F sticky .bleedblend-top header', ua: 'ios', + html: page('.bleedblend-top{position:fixed;top:0;left:0;width:100%;background:#123456;color:#fff;padding:12px}', '
nav
'), + check: (t) => [['top STICKY_OWNED', t.topState === 'STICKY_OWNED']], + }, + { + name: 'G sticky .bleedblend-bottom bar', ua: 'ios', + html: page('.bleedblend-bottom{position:fixed;bottom:0;left:0;width:100%;background:#222;color:#fff;padding:12px}', '
bar
'), + check: (t) => [['bottom STICKY_OWNED', t.botState === 'STICKY_OWNED']], + }, + { + name: 'H translucent overlay (alpha compositing)', ua: 'ios', + html: page('body{background:#ffffff}.ov{min-height:220vh;background:rgba(0,0,0,0.5)}', '
'), + check: (t, b) => [['bottom SAFE_NATURAL (no last opaque section)', b.botState === 'SAFE_NATURAL']], + }, + { + name: 'I image bg, no detectable fill', ua: 'ios', + html: page('body{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==) repeat}', '
'), + check: (t, b) => [ + ['bottom SAFE_NATURAL (cannot determine color)', b.botState === 'SAFE_NATURAL'], + ['no overscroll overwrite', b.htmlBg === ''], + ], + }, + { + name: 'J non-semantic divs (zero-config limitation)', ua: 'ios', + html: page('.block{min-height:120vh;background:#fff}.end{min-height:80vh;background:#0b1d3a}', '
a
b
end
'), + check: (t, b) => [ + ['page-end NOT engaged (SAFE_NATURAL) — needs semantic markup', b.botState === 'SAFE_NATURAL'], + ['degrades safely: no overscroll paint', b.htmlBg === ''], + ], + }, + { + name: 'J2 same divs + custom sectionSelector (escape hatch)', ua: 'ios', + html: page('.block{min-height:120vh;background:#fff}.end{min-height:80vh;background:#0b1d3a}', + '
a
b
end
', + ''), + check: (t, b) => [ + ['page-end engaged via custom selector', b.botState === 'BLEED_OVERRIDE'], + ['bottom tint ~navy', near(b.botBg, 11, 29, 58, 10)], + ['overscroll html bg ~navy', near(b.htmlBg, 11, 29, 58, 10)], + ], + }, + { + name: 'L gradient stops in oklch() (modern color)', ua: 'ios', + html: page(`html{background:#ffffff}body::before{content:"";position:fixed;inset:0;z-index:-1;background:linear-gradient(180deg,oklch(0.95 0.03 160) 0%,oklch(0.55 0.13 195) 100%)}`, '
'), + check: (t) => [ + ['bottom BLEED_OVERRIDE (oklch resolved, was SAFE before fix)', t.botState === 'BLEED_OVERRIDE'], + ['bottom tint is a real color, not transparent', rgb(t.botBg) !== null && !/, 0\)$/.test(t.botBg)], + ['bottom tint is teal-ish (r
1
foot
'), + check: (t, b) => [ + ['footer BLEED_OVERRIDE', b.botState === 'BLEED_OVERRIDE'], + ['footer color read correctly (teal-ish, not white)', tealish(b.botBg)], + ['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 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}', + '
a
foot
'), + 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}', + '
a
foot
', + ''), + 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}', + '
a
foot
', + ''), + 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}', + '
a
foot
'), + 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}', + '
a
foot
'), + 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}}`, '
'), + check: (t, b) => [ + ['not iOS', t.isIOS === false], + ['top tint display:none', t.topDisplay === 'none'], + ['bottom tint display:none', t.botDisplay === 'none'], + ['no html overwrite on desktop', b.htmlBg === ''], + ['logic still resolves a state', typeof b.botState === 'string'], + ], + }, +]; + +const slug = (name) => name.replace(/[^a-z0-9]+/gi, '_'); +function rgb(s) { const m = /rgba?\(([^)]+)\)/.exec(s || ''); if (!m) return null; const p = m[1].split(/[ ,/]+/).map(parseFloat); return { r: p[0], g: p[1], b: p[2] }; } +function near(s, r, g, b, t = 12) { const c = rgb(s); return !!c && Math.abs(c.r - r) < t && Math.abs(c.g - g) < t && Math.abs(c.b - b) < t; } +function tealish(s) { const c = rgb(s); return !!c && c.r < c.g && c.r < c.b && c.b > 90; } + +const PROBE = `(async () => { + await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))); + await new Promise(r=>setTimeout(r,40)); + const top=document.getElementById('bleedblend-tint-top'); + const bot=document.getElementById('bleedblend-tint-bottom'); + const bo=document.getElementById('bleedblend-before-override'); + const g=el=>el?getComputedStyle(el):null; + return { + topState: window.__bleedblend_top_state ?? null, + botState: window.__bleedblend_bot_state ?? null, + topDisplay: top?g(top).display:null, + botDisplay: bot?g(bot).display:null, + botBg: bot?g(bot).backgroundColor:null, + botPadBottom: bot?g(bot).paddingBottom:null, + htmlBg: document.documentElement.style.backgroundColor, + bodyBg: document.body.style.backgroundColor, + beforeOverride: bo?bo.textContent:null, + isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), + innerH: window.innerHeight, scrollY: window.scrollY + }; +})()`; + +const SCROLL = `(async () => { window.scrollTo(0, document.documentElement.scrollHeight); + await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(()=>setTimeout(r,60)))); return window.scrollY; })()`; + +async function configure(cdp, sessionId, c) { + const ua = c.ua === 'desktop' ? UA_DESK : UA_IOS; + await cdp.send('Emulation.setUserAgentOverride', { userAgent: ua }, sessionId); + await cdp.send('Emulation.setDeviceMetricsOverride', + { width: c.ua === 'desktop' ? 1280 : 393, height: c.ua === 'desktop' ? 900 : 852, deviceScaleFactor: c.ua === 'desktop' ? 1 : 3, mobile: c.ua !== 'desktop' }, sessionId); + await cdp.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: c.dark ? 'dark' : 'light' }] }, sessionId); +} + +async function runCase(cdp, c) { + const { sessionId, errors } = await openPage(cdp); + await configure(cdp, sessionId, c); + const url = `http://localhost:${PORT}${GEN_URL}/${slug(c.name)}.html`; + const loaded = cdp.once('Page.loadEventFired', sessionId); + await cdp.send('Page.navigate', { url }, sessionId); + await loaded; + const top = await evaluate(cdp, sessionId, PROBE); + await evaluate(cdp, sessionId, SCROLL); + const bot = await evaluate(cdp, sessionId, PROBE); + return { results: c.check(top, bot), errors, top, bot }; +} + +async function colorSerializationProbe(cdp) { + const { sessionId } = await openPage(cdp); + await cdp.send('Emulation.setUserAgentOverride', { userAgent: UA_IOS }, sessionId); + const loaded = cdp.once('Page.loadEventFired', sessionId); + await cdp.send('Page.navigate', { url: `http://localhost:${PORT}${GEN_URL}/domunit.html` }, sessionId); + await loaded; await sleep(50); + const expr = `(() => { + const U = window.U; + const probe = a => { const d=document.createElement('div'); d.style.backgroundColor=a; document.body.appendChild(d); const s=getComputedStyle(d).backgroundColor; d.remove(); return s; }; + const authored = ['white','red','#0a8c8e','rgb(10 20 30)','rgb(10 20 30 / 0.5)','hsl(180 50% 40%)','hsla(180,50%,40%,0.5)','oklch(0.7 0.15 180)','color(display-p3 1 0 0)','rgba(0,0,0,0.5)','transparent']; + const ser = authored.map(a => { const s = probe(a); const p = U.parseColor(s); const ok = !!(p && Number.isFinite(p.r) && Number.isFinite(p.g) && Number.isFinite(p.b)); return { authored:a, serialized:s, parses:ok }; }); + const sample = U.sampleColorAt(window.innerWidth/2, window.innerHeight-13, []); + return { ser, sample }; + })()`; + return evaluate(cdp, sessionId, expr, false); +} + +async function lifecycleProbe(cdp) { + const { sessionId } = await openPage(cdp); + await cdp.send('Emulation.setUserAgentOverride', { userAgent: UA_IOS }, sessionId); + await cdp.send('Emulation.setDeviceMetricsOverride', { width: 393, height: 852, deviceScaleFactor: 3, mobile: true }, sessionId); + const loaded = cdp.once('Page.loadEventFired', sessionId); + await cdp.send('Page.navigate', { url: `http://localhost:${PORT}${GEN_URL}/domunit.html` }, sessionId); + await loaded; await sleep(50); + const expr = `(async () => { + const U = window.U; + const c1 = U.createBleedblendAuto(); + const c2 = U.createBleedblendAuto(); + await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))); + const tintNodes = document.querySelectorAll('#bleedblend-tint-top, #bleedblend-tint-bottom').length; + c1.destroy(); c2.destroy(); + const leftover = document.querySelectorAll('#bleedblend-tint-top,#bleedblend-tint-bottom,#bleedblend-before-override,#bleedblend-transition-style').length; + const bgCleared = document.documentElement.style.backgroundColor === '' && document.body.style.backgroundColor === ''; + return { tintNodes, leftover, bgCleared }; + })()`; + return evaluate(cdp, sessionId, expr); +} + +(async () => { + fs.rmSync(GEN, { recursive: true, force: true }); + fs.mkdirSync(GEN, { recursive: true }); + for (const c of CASES) fs.writeFileSync(path.join(GEN, `${slug(c.name)}.html`), c.html); + fs.writeFileSync(path.join(GEN, 'domunit.html'), + page('body{background:#ffffff}.ov{min-height:220vh;background:rgba(0,0,0,0.5)}', '
', + '')); + + const server = await startServer(PORT, REPO_ROOT); + const { proc, wsUrl } = await launchChrome(DBG); + const cdp = await CDP.connect(wsUrl); + + let pass = 0, fail = 0; const failLines = []; + console.log('\n================ INTEGRATION: state machine across site shapes ================\n'); + for (const c of CASES) { + try { + const { results, errors, top, bot } = await runCase(cdp, c); + console.log(`• ${c.name} [${c.ua}]`); + for (const [label, ok] of results) { console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}`); if (ok) pass++; else { fail++; failLines.push(`${c.name} :: ${label}`); } } + const realErrors = errors.filter((e) => !/favicon/.test(e)); + if (realErrors.length) { fail++; failLines.push(`${c.name} :: errors`); console.log(` FAIL no console errors -> ${realErrors.join(' | ')}`); } + else { pass++; console.log(' PASS no console errors'); } + } catch (e) { fail++; failLines.push(`${c.name} :: THREW ${e.message}`); console.log(`• ${c.name} THREW: ${e.message}`); } + } + + console.log('\n================ COLOR SERIALIZATION (what parseColor actually receives) ================\n'); + try { + const { ser, sample } = await colorSerializationProbe(cdp); + for (const row of ser) console.log(` ${row.parses ? 'parses OK ' : 'parses ✗ '} authored=${JSON.stringify(row.authored).padEnd(26)} -> "${row.serialized}"`); + console.log(`\n alpha compositing (50% black over white) = ${JSON.stringify(sample)} (expect ~127.5)`); + if (sample && Math.abs(sample.r - 127.5) < 2) pass++; else { fail++; failLines.push('compositing wrong'); } + const oklchRow = ser.find((r) => r.authored.startsWith('oklch(')); + const p3Row = ser.find((r) => r.authored.startsWith('color(display-p3')); + for (const [label, ok] of [ + ['oklch() now resolves via canvas readback', !!oklchRow && oklchRow.parses], + ['color(display-p3) now resolves via canvas readback', !!p3Row && p3Row.parses], + ]) { console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}`); if (ok) pass++; else { fail++; failLines.push('serialization :: ' + label); } } + } catch (e) { fail++; console.log(' serialization probe THREW: ' + e.message); } + + console.log('\n================ LIFECYCLE: double-init idempotency + destroy ================\n'); + try { + const lc = await lifecycleProbe(cdp); + for (const [label, ok] of [ + ['double-init => exactly 2 tint nodes (reused, not duplicated)', lc.tintNodes === 2], + ['destroy() removes all injected nodes', lc.leftover === 0], + ['destroy() clears html/body bg overrides', lc.bgCleared === true], + ]) { console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}`); if (ok) pass++; else { fail++; failLines.push('lifecycle :: ' + label); } } + } catch (e) { fail++; failLines.push('lifecycle :: THREW ' + e.message); console.log(' THREW ' + e.message); } + + console.log('\n================ SUMMARY ================'); + console.log(`pass=${pass} fail=${fail}`); + if (failLines.length) console.log('FAILED:\n - ' + failLines.join('\n - ')); + + cdp.close(); proc.kill(); server.close(); + process.exit(fail ? 1 : 0); +})().catch((e) => { console.error('FATAL', e); process.exit(1); }); diff --git a/test/pure.mjs b/test/pure.mjs new file mode 100644 index 0000000..406e5bf --- /dev/null +++ b/test/pure.mjs @@ -0,0 +1,73 @@ +// Pure-function unit tests for the color/gradient helpers (no browser needed). +// node test/pure.mjs +import { + parseColor, parseColorWithAlpha, colorToRgb, colorToHex, + isOpaque, colorsClose, parseGradient, gradientColorAt, +} from '../src/utils.mjs'; + +let pass = 0, fail = 0; +const fails = []; +function approx(c, r, g, b, a = 1, tol = 0.6) { + return c && Math.abs(c.r - r) < tol && Math.abs(c.g - g) < tol && + Math.abs(c.b - b) < tol && Math.abs((c.a ?? 1) - a) < 0.02; +} +function check(name, cond) { + if (cond) pass++; else { fail++; fails.push(name); } + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}`); +} + +console.log('=== parseColor: documented / common-path formats ==='); +check('#fff', approx(parseColor('#fff'), 255, 255, 255, 1)); +check('#ffffff', approx(parseColor('#ffffff'), 255, 255, 255, 1)); +check('#0a8c8e', approx(parseColor('#0a8c8e'), 10, 140, 142, 1)); +check('#80808080 (8-digit alpha)', approx(parseColor('#80808080'), 128, 128, 128, 128 / 255)); +check('rgb(10, 20, 30)', approx(parseColor('rgb(10, 20, 30)'), 10, 20, 30, 1)); +check('rgba(10, 20, 30, 0.5)', approx(parseColor('rgba(10, 20, 30, 0.5)'), 10, 20, 30, 0.5)); +check('rgb(10,20,30) no-space', approx(parseColor('rgb(10,20,30)'), 10, 20, 30, 1)); + +console.log('\n=== parseColor: off-DOM (Node) returns null for non-rgb/hex; in-browser these resolve via canvas ==='); +// In Node there is no document, so the canvas fallback is unavailable and these +// return null BY DESIGN (the pure path stays pure). In a browser the same calls +// resolve through normalizeViaCanvas — proven in integration.mjs. So these are +// NOT gaps in the live path; they are the documented Node boundary. +check('rgb() space-syntax -> null off-DOM', parseColor('rgb(255 0 0)') == null || Number.isNaN(parseColor('rgb(255 0 0)').g)); +check('named "red" -> null off-DOM', parseColor('red') === null); +check('hsl() -> null off-DOM', parseColor('hsl(0, 100%, 50%)') === null); +check('oklch() -> null off-DOM (resolves in browser)', parseColor('oklch(0.7 0.15 180)') === null); +check('color(display-p3) -> null off-DOM (resolves in browser)', parseColor('color(display-p3 1 0 0)') === null); +check('transparent keyword -> null off-DOM (live path gets rgba(0,0,0,0))', parseColor('transparent') === null); + +console.log('\n=== parseColorWithAlpha / isOpaque / colorsClose ==='); +check('alpha preserved', approx(parseColorWithAlpha('rgba(0,0,0,0)'), 0, 0, 0, 0)); +check('isOpaque rgb', isOpaque('rgb(1,2,3)') === true); +check('isOpaque rgba .95', isOpaque('rgba(1,2,3,0.95)') === true); +check('isOpaque rgba .5', isOpaque('rgba(1,2,3,0.5)') === false); +check('colorsClose within 8', colorsClose({ r: 10, g: 10, b: 10 }, { r: 12, g: 12, b: 12 }) === true); +check('colorsClose far', colorsClose({ r: 10, g: 10, b: 10 }, { r: 200, g: 10, b: 10 }) === false); + +console.log('\n=== colorToHex / colorToRgb ==='); +check('toHex', colorToHex({ r: 10, g: 140, b: 142 }) === '#0a8c8e'); +check('toRgb rounds', colorToRgb({ r: 10.4, g: 140.6, b: 142 }) === 'rgb(10, 141, 142)'); + +console.log('\n=== parseGradient (browsers serialize stops as explicit rgb) ==='); +const g1 = parseGradient('linear-gradient(180deg, rgb(172, 234, 206) 0%, rgb(10, 140, 142) 100%)'); +check('2-stop', g1 && g1.length === 2 && approx(g1[0].color, 172, 234, 206) && g1[0].pos === 0 && approx(g1[1].color, 10, 140, 142) && g1[1].pos === 1); +const g2 = parseGradient('linear-gradient(to bottom, rgb(0,0,0), rgb(255,255,255))'); +check('no-% endpoints -> 0/1', g2 && g2[0].pos === 0 && g2[1].pos === 1); +const g3 = parseGradient('linear-gradient(rgb(0,0,0) 0%, rgb(128,128,128) 50%, rgb(255,255,255) 100%)'); +check('explicit 3-stop midpoint', g3 && g3.length === 3 && g3[1].pos === 0.5); +const g4 = parseGradient('linear-gradient(90deg, rgb(0,0,0), rgb(100,100,100), rgb(200,200,200))'); +check('implied middle pos interpolated', g4 && Math.abs(g4[1].pos - 0.5) < 1e-9); +check('none -> null', parseGradient('none') === null); + +console.log('\n=== gradientColorAt interpolation + clamping ==='); +const stops = [{ color: { r: 0, g: 0, b: 0, a: 1 }, pos: 0 }, { color: { r: 100, g: 100, b: 100, a: 1 }, pos: 1 }]; +check('at 0', approx(gradientColorAt(stops, 0), 0, 0, 0)); +check('at .5', approx(gradientColorAt(stops, 0.5), 50, 50, 50)); +check('at 1', approx(gradientColorAt(stops, 1), 100, 100, 100)); +check('clamp < 0', approx(gradientColorAt(stops, -1), 0, 0, 0)); +check('clamp > 1', approx(gradientColorAt(stops, 2), 100, 100, 100)); + +console.log('\n================ PURE SUMMARY ================'); +console.log(`pass=${pass} fail=${fail}`); +if (fails.length) { console.log('FAILED:\n - ' + fails.join('\n - ')); process.exit(1); }