From 92c8121763b0b09a6a9f010beb216de7736b3ac0 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Sun, 2 Aug 2026 19:15:00 -0300 Subject: [PATCH] Harden Cornerfill 0.0.1 --- .github/workflows/ci.yml | 16 +- .gitignore | 11 +- README.md | 58 +- bench/runtime-regression.mjs | 75 ++- notes/00-verdict-and-scope.md | 152 +++++ notes/01-spec-contract.md | 180 ++++++ notes/02-engine-implementations.md | 156 +++++ notes/03-live-css-image-backends.md | 202 ++++++ notes/04-architecture.md | 316 ++++++++++ notes/05-geometry-and-painting.md | 342 ++++++++++ notes/06-capture-and-invalidation.md | 250 ++++++++ notes/07-limits-and-rejected-routes.md | 129 ++++ notes/08-verification-plan.md | 301 +++++++++ notes/09-polycss-case-study.md | 225 +++++++ notes/README.md | 107 ++++ notes/evidence/README.md | 85 +++ notes/evidence/live-paint-surface-probe.html | 68 ++ notes/references.md | 110 ++++ oracle/README.md | 154 +++++ oracle/cases.mjs | 378 +++++++++++ oracle/fixture.html | 38 ++ oracle/fixture.mjs | 144 +++++ oracle/geometry.mjs | 13 + oracle/painter.mjs | 187 ++++++ oracle/qualification.json | 14 + oracle/tolerances.json | 19 + package-lock.json | 67 +- package.json | 26 +- scripts/compare.mjs | 194 ++++++ scripts/generate-qualification.mjs | 29 + scripts/oracle.mjs | 625 +++++++++++++++++++ scripts/png.mjs | 384 ++++++++++++ scripts/runtime-regressions.mjs | 63 +- src/auto-runtime.mts | 554 +++++++--------- src/auto.mts | 3 +- src/backends.mts | 100 ++- src/background.mts | 11 +- src/geometry.mts | 10 +- src/index.mts | 61 -- src/native.mts | 105 +++- src/paint.mts | 181 ++++-- src/runtime.mts | 245 +++++--- src/spec.mts | 74 +++ src/values.mts | 107 +++- test/auto.test.mjs | 25 +- test/backends.test.mjs | 46 +- test/contract.test.mjs | 4 +- test/geometry.test.mjs | 11 +- test/native.test.mjs | 38 +- test/paint.test.mjs | 20 +- test/png.test.mjs | 85 +++ test/values.test.mjs | 21 + 52 files changed, 6074 insertions(+), 745 deletions(-) create mode 100644 notes/00-verdict-and-scope.md create mode 100644 notes/01-spec-contract.md create mode 100644 notes/02-engine-implementations.md create mode 100644 notes/03-live-css-image-backends.md create mode 100644 notes/04-architecture.md create mode 100644 notes/05-geometry-and-painting.md create mode 100644 notes/06-capture-and-invalidation.md create mode 100644 notes/07-limits-and-rejected-routes.md create mode 100644 notes/08-verification-plan.md create mode 100644 notes/09-polycss-case-study.md create mode 100644 notes/README.md create mode 100644 notes/evidence/README.md create mode 100644 notes/evidence/live-paint-surface-probe.html create mode 100644 notes/references.md create mode 100644 oracle/README.md create mode 100644 oracle/cases.mjs create mode 100644 oracle/fixture.html create mode 100644 oracle/fixture.mjs create mode 100644 oracle/geometry.mjs create mode 100644 oracle/painter.mjs create mode 100644 oracle/qualification.json create mode 100644 oracle/tolerances.json create mode 100644 scripts/compare.mjs create mode 100644 scripts/generate-qualification.mjs create mode 100644 scripts/oracle.mjs create mode 100644 scripts/png.mjs delete mode 100644 src/index.mts create mode 100644 src/spec.mts create mode 100644 test/png.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 233fd9a..c8a7669 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,8 @@ name: CI +permissions: + contents: read + on: pull_request: push: @@ -12,7 +15,14 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - run: npm ci + - run: npm ci --ignore-scripts - run: npm test - - run: npx --yes --package @playwright/cli@0.1.17 playwright install --with-deps chromium webkit firefox - - run: npm run test:browser:runtime + - run: npx playwright install --with-deps chromium webkit firefox + - run: npm run test:browser:runtime:built + - run: node scripts/oracle.mjs run --cases=bevel,round + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cornerfill-oracle + path: oracle/results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index bdedca4..1ebdc53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,15 @@ -# Local research, oracle, and browser evidence -/notes/ -/oracle/ +# Local process notes and generated browser evidence +/notes/burnlists/ +/oracle/results/ /bench/mario-firefox-trace.mjs -/scripts/compare.mjs /scripts/mario-server.mjs -/scripts/oracle.mjs -/scripts/png.mjs /scripts/serve-firefox-mario.mjs /scripts/trace-firefox-mario.mjs /test/mario-server.test.mjs -/test/png.test.mjs # Generated and machine-local files /dist/ +/src/qualification.mts /.playwright-cli/ /output/ /node_modules/ diff --git a/README.md b/README.md index d0d07af..af25d8a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Cornerfill -A native-first paint polyfill for CSS `corner-shape` in Safari and Firefox. Write ordinary CSS, keep transforms on the original element, and let Cornerfill paint the shaped background and border into a transparent Canvas image. A semantically qualified native engine stays native and does not download the fallback renderer. +Cornerfill makes CSS `corner-shape` work in Safari and Firefox. You write ordinary CSS; Cornerfill paints the host background and border into a transparent Canvas-backed background image while leaving the original element, layout, and transform in place. A semantically qualified native engine stays native and never starts the fallback renderer. Cornerfill shapes host paint. It does not add descendant overflow clipping or shaped hit testing. It is built for retained DOM renderers such as [PolyCSS](https://github.com/LayoutitStudio/polycss), but the runtime and geometry are standalone. @@ -15,7 +15,9 @@ npm install cornerfill Import Cornerfill once: ```js -import "cornerfill"; +import cornerfill from "cornerfill"; + +await cornerfill?.ready; ``` Then write normal CSS: @@ -30,11 +32,11 @@ Then write normal CSS: } ``` -That is the plug-and-play document path. A qualified native engine renders the declaration itself. On supported Safari/WebKit and Firefox builds, Cornerfill finds accessible authored declarations and attaches the fallback automatically. You do not need custom properties, a build transform, or a second import. +That is the plug-and-play document path. A qualified native engine renders the declaration itself. On supported Safari/WebKit and Firefox builds, Cornerfill finds accessible authored declarations and attaches the fallback automatically. You do not need carrier properties, a build transform, or a second import. Startup installs discovery immediately; `ready` resolves after the first asynchronous stylesheet and attachment pass. The export is `null` only outside a DOM environment. ## How It Works -Safari and Firefox discard an unsupported `corner-shape` declaration when they build CSSOM. Cornerfill reads accessible stylesheet source instead, then writes private values into a companion stylesheet. Authors still write standard CSS; the browser still resolves selectors, variables, conditions, layers, importance, and the cascade. +Safari and Firefox may omit an unsupported `corner-shape` declaration from CSSOM. Cornerfill reads accessible authored stylesheet text and copies only those shape declarations into a private companion stylesheet. The browser then resolves selectors, variables, conditions, layers, importance, CSS-wide values, and declaration order. Backgrounds, borders, radii, and other supported paint inputs come from browser-computed style; Cornerfill does not recreate their cascade. Cornerfill parses `border-radius` and `corner-shape`, resolves the CSS radius constraints, builds the contour, and paints the host-owned pixels into a transparent Canvas surface. Safari/WebKit exposes that surface through `-webkit-canvas()`. Firefox registers it with `mozSetImageElement()` and displays it through `-moz-element()`. @@ -50,7 +52,7 @@ Cornerfill does not use `clip-path`, CSS masks, SVG or font stencils, or baked-a | Safari / WebKit | `-webkit-canvas()` when the live Canvas API is available | | Firefox | `-moz-element()` when Canvas registration is available | -Fallback backends are capability-probed. Test the exact stable browser versions in your support matrix. +Native selection requires syntax support, canonical computed corner-shape longhands, and a shaped hit-test probe; syntax support alone is not enough. The qualification report exposes those results separately and marks outer paint, inner borders, clipping, effects, and animation as `unobserved` rather than pretending one probe certified them. Fallback backends are capability-probed. Test the exact stable browser versions in your support matrix. ## Automatic Sources and Shadow Roots @@ -61,6 +63,7 @@ Open shadow roots are explicit because discovery does not cross a shadow boundar ```js import { cornerfill } from "cornerfill"; +if (!cornerfill) throw new Error("Cornerfill requires a document"); const scope = cornerfill.registerRoot(shadowRoot); await scope.ready; @@ -75,6 +78,7 @@ const sheet = new CSSStyleSheet(); sheet.replaceSync(css); shadowRoot.adoptedStyleSheets = [sheet]; +if (!cornerfill) throw new Error("Cornerfill requires a document"); const scope = cornerfill.registerRoot(shadowRoot, { adoptedStyleSheets: true }); await scope.ready; await scope.refreshAdoptedStyleSheet(sheet, css); @@ -82,10 +86,12 @@ await scope.refreshAdoptedStyleSheet(sheet, css); Call `refreshAdoptedStyleSheet()` again with the same standard source passed to a later `replace()` or `replaceSync()`. Cornerfill does not patch `attachShadow()`, `CSSStyleSheet`, or `CSS.supports()`. +Linked stylesheets and `@import` recovery use `fetch()`. A restrictive CSP must therefore allow those URLs through `connect-src` as well as normal stylesheet loading. Pass a `nonce` when the policy requires one for Cornerfill's generated companion style. + ## Supported - `round`, `squircle`, `square`, `bevel`, `scoop`, `notch`, and finite `superellipse()` corners. -- `border-radius` and `corner-shape` shorthands and longhands, physical and logical corners, elliptical percentages, the implemented `calc()` subset, overlap reduction, and opposite-concave constraints. +- `corner-shape` plus its physical and logical corner longhands. Standard `border-radius` declarations are read from browser-computed physical longhands, so relative units and browser-resolved `calc()`, `min()`, `max()`, and `clamp()` values are retained. The explicit value helpers also accept documented px/percentage expressions. - Solid colors, static same-origin or CORS raster layers and atlas crops, and non-repeating linear, radial, and conic gradients within the implemented grammar. - Admitted background stacks with sizing, positioning, repetition, origin, and clip. The explicit runtime also admits one opaque scroll-attached raster using `multiply` over one opaque `rgb()` or hex color. - One-color solid borders with unequal widths when the clipped inner edge remains one non-self-intersecting contour, one zero-offset zero-blur inset shadow with non-negative spread, and one fully contained solid outline on an empty paint-owned host. @@ -94,8 +100,40 @@ Call `refreshAdoptedStyleSheet()` again with the same standard source passed to Implemented support is not an oracle `PASS`. Current fallback comparisons remain `UNQUALIFIED`; `controller.capabilities.paint` reports available code paths, not pixel parity. Gradient color and repeated or resized raster sampling still need native qualification. +## Spec Surface + +Cornerfill is pinned to the 26 March 2026 Working Draft of [CSS Borders and Box Decorations Level 4](https://drafts.csswg.org/css-borders-4/). `cornerfill/spec` exports the exact CSSWG and WPT commits used by this release together with a machine-readable property matrix. + +| Surface | 0.0.1 status | +| --- | --- | +| `corner-shape` and four physical plus four logical corner-shape longhands | Automatic fallback | +| `border-radius` and four physical plus four logical radius longhands | Browser-computed input | +| Side shape shorthands, combined radius-and-shape properties, and side radius shorthands | Not implemented | +| Ordinary elements | Automatic fallback | +| `::before`, `::after`, and other pseudo-elements | Not implemented | + +The matrix is deliberately narrower than the current draft. Unknown or unsupported syntax is reported or left native; it is not approximated. + ## Runtime API +The package root is the zero-configuration entry. If an application needs scanner options, `cornerfill/auto` exports the installer without starting it as an import side effect: + +```js +import { installCornerfillAuto } from "cornerfill/auto"; + +const cornerfill = installCornerfillAuto({ + stylesheetTimeoutMs: 5_000, + nonce: document.currentScript?.nonce, + onError(error, context) { + console.error(`Cornerfill source error in ${context}`, error); + }, +}); + +await cornerfill.ready; +``` + +`autoObserve: false` switches off automatic source/state observation. `adoptedStyleSheets: true` opts a registered open shadow root into constructed-sheet handling. These options are for controlled integrations; normal document use should import `cornerfill`. + Use the scanner-free runtime when your application already owns element state: ```js @@ -173,7 +211,7 @@ cornerfill.destroy(); - Rare combinations of concave corners, radii, and unequal border widths can make the clipped inner border edge self-intersect and require multiple contours. Cornerfill refuses those elements before mutating their paint surface instead of approximating the border. - Animated CSS images, cross-origin images without CORS, general `image-set()` selection, repeating gradients, and gradient interpolation spaces or hints are outside the supported paint grammar. - General background blending is not supported. Automatic mode cannot prove raster opacity, so the bounded `multiply` path is explicit-runtime only. -- Automatic discovery supports one physical or logical declaration family at a time. Mixed families and keyframe-driven fallback paint are rejected. The explicit value API can resolve physical and logical declarations together. +- Automatic discovery supports one physical or logical declaration family at a time. Mixed families are rejected. Automatic CSS animations and transitions of shape or paint dependencies are not reproduced with native timing or interpolation; use the explicit update/interpolation API when that behavior matters. - Direct declaration tests such as `@supports (corner-shape: bevel)` are preserved. Complex conditions that cannot be transported without changing their meaning, anonymous layers, nested selector rules, and unknown at-rule contexts are refused before ownership. - Cross-origin stylesheets and imports require CORS. Closed or unregistered shadow roots are not discovered. Constructed/adopted sheets require explicit open-root registration and the exact-source refresh shown above. Generated styles require a CSP nonce when the page policy does. - After installation, automatic mode mirrors `insertRule()` and `deleteRule()` on directly discovered, non-import stylesheet instances and restores the original instance methods on teardown. Rules inserted before startup and unsupported values assigned through `CSSStyleDeclaration` cannot be recovered after the browser discards them. @@ -187,9 +225,13 @@ Cornerfill refuses unsupported cases instead of painting a result with different npm run build npm test npm run test:browser:runtime +npm run oracle:smoke +npm run oracle:cross ``` -TypeScript `.mts` modules are the source of truth. The build writes browser-ready `.mjs` files and matching declarations to `dist/`. +`test:browser:runtime` opens and closes Chrome, WebKit, and Firefox strictly one at a time. The oracle commands do the same and never use `kill-all`. The smoke and cross-engine fixtures include the real Mario texel crop and therefore require `CORNERFILL_MARIO_TEXELS=/absolute/path/to/texels.webp` when it is not at the development-machine default. See [the executable oracle contract](oracle/README.md). + +TypeScript `.mts` modules are the source of truth. The build writes browser-ready `.mjs` files and matching declarations to `dist/`. It also generates the exported qualification object from the tracked [oracle qualification record](oracle/qualification.json); candidate comparisons remain `UNQUALIFIED` until reviewed evidence supports explicit tolerances. The package root is asynchronous ESM; controlled integrations that cannot consume top-level await can call the installers from `cornerfill/auto` or `cornerfill/runtime` directly. ## License diff --git a/bench/runtime-regression.mjs b/bench/runtime-regression.mjs index b015233..796552b 100644 --- a/bench/runtime-regression.mjs +++ b/bench/runtime-regression.mjs @@ -203,7 +203,11 @@ await test("automatic install consumes standard corner-shape CSS and tears down" equal(auto.explain(dynamic).geometry.radii, [ { rx: 0, ry: 0 }, { rx: 0, ry: 0 }, { rx: 0, ry: 0 }, { rx: 0, ry: 0 }, ], "class radius change was not recaptured"); - assert(auto.explain(dynamic).paint.layer.color === "blue", "class paint change was not recaptured"); + const dynamicColor = auto.explain(dynamic).paint.layer.color; + assert( + /^(?:blue|rgb\(0,\s*0,\s*255\))$/u.test(dynamicColor), + `class paint change was not recaptured: ${dynamicColor}`, + ); equal(auto.explain(inline).geometry.shapeParameters, [0, 0, 0, 0], "raw inline corner-shape was not retained"); inline.setAttribute( "style", @@ -411,8 +415,8 @@ await test("automatic stylesheet refresh is serialized, stale-safe, and retryabl equal(auto.explain(element).geometry.shapeParameters, [-1, -1, -1, -1], "stale stylesheet won the refresh race"); assert(document.querySelectorAll('style[data-cornerfill-auto-styles=""]').length === 1, "refresh retained duplicate companion stylesheets"); assert( - [...auto.stylesheets.values()].some(({ companion }) => companion?.textContent.includes("https://assets.example/styles/sprite.png")), - "stylesheet response URL was not retained as the declaration base", + [...auto.stylesheets.values()].some(({ sources }) => sources.includes("https://assets.example/styles/main.css")), + "stylesheet response URL was not retained in source provenance", ); link.href = `data:text/css,${encodeURIComponent(".cornerfill-auto-remote{corner-shape:notch;border-radius:7px;background:green}")}`; @@ -487,7 +491,7 @@ await test("automatic stylesheet refresh is serialized, stale-safe, and retryabl globalThis.__CORNERFILL_TEST_STAGE__ = ""; }); -await test("automatic imports preserve cascade, URL bases, and idle selector state", async () => { +await test("automatic imports preserve cascade and idle selector state", async () => { const originalFetch = window.fetch; const fetched = []; window.fetch = (input, init) => { @@ -537,7 +541,6 @@ await test("automatic imports preserve cascade, URL bases, and idle selector sta ], "identical active imports were fetched more than once or out of order"); const [record] = [...auto.stylesheets.values()].filter(({ owner }) => owner === link); equal(record.sources.map((source) => new URL(source).pathname), fetched, "import provenance was incomplete"); - assert(record.companion.textContent.includes(`${location.origin}/bench/imports/sprite.png`), "imported paint URL did not resolve against its own source"); const fetchedBeforeState = [...fetched]; focused.focus(); await waitFor(() => auto.explain(focused)?.status === "active", "imported focus selector did not attach"); @@ -725,7 +728,7 @@ await test("automatic open-root scopes own local, inline, and opted-in adopted C await test("automatic diagnostics belong to the current source generation", async () => { const style = document.createElement("style"); - style.textContent = ".cornerfill-diagnostic{corner-shape:potato;border-radius:5px;background:red}"; + style.textContent = ".cornerfill-diagnostic{corner-shape:superellipse(calc(1 * 2));border-radius:5px;background:red}"; document.head.append(style); const element = host(document.body, "cornerfill-diagnostic-owner"); element.className = "cornerfill-diagnostic"; @@ -759,7 +762,7 @@ await test("automatic diagnostics belong to the current source generation", asyn assert(explanation.limitations.descendantOverflowClipping.supported === false, "fallback entry omitted descendant clipping limitation"); assert(explanation.limitations.shapedHitTesting.supported === false, "fallback entry omitted shaped hit-testing limitation"); - style.textContent = ".cornerfill-diagnostic{corner-shape:potato;border-radius:5px;background:blue}"; + style.textContent = ".cornerfill-diagnostic{corner-shape:superellipse(calc(1 * 2));border-radius:5px;background:blue}"; await auto.refresh(); assert(auto.explain().errors.length === 1, "new failed generation did not replace recovered state"); style.remove(); @@ -780,10 +783,12 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe @layer base { .cornerfill-layer-normal { corner-shape: bevel; background: red } .cornerfill-layer-important { corner-shape: bevel !important; background: red } + .cornerfill-all-layer { corner-shape: bevel } } @layer theme { .cornerfill-layer-normal { corner-shape: scoop; background: blue } .cornerfill-layer-important { corner-shape: scoop !important; background: blue } + .cornerfill-all-layer { all: unset; display: block } } .cornerfill-var-inherit { corner-shape: var(--cornerfill-test-shape, bevel) } .cornerfill-var-fallback { corner-shape: var(--cornerfill-missing-shape, scoop) } @@ -794,6 +799,19 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe @supports not (corner-shape: bevel) { .cornerfill-supports-negative { corner-shape: bevel } } @supports not (corner-shape: unknown-shape) { .cornerfill-supports-invalid-negative { corner-shape: bevel } } .cornerfill-mixed { corner-top-left-shape: bevel; corner-start-start-shape: scoop } + .cornerfill-all-base { corner-shape: bevel } + .cornerfill-all-base.cornerfill-all-reset { all: unset; display: block } + .cornerfill-all-before { all: unset; corner-shape: bevel; display: block } + .cornerfill-all-after { corner-shape: bevel; all: unset; display: block } + .cornerfill-all-important { corner-shape: bevel !important; all: unset !important; display: block !important } + .cornerfill-invalid-low { corner-shape: potato } + #cornerfill-valid-high { corner-shape: bevel } + #cornerfill-invalid-important { corner-shape: potato !important } + .cornerfill-valid-normal { corner-shape: scoop } + .cornerfill-radius-em { corner-shape: bevel; border-radius: 1em; font-size: 20px } + .cornerfill-radius-vw { corner-shape: bevel; border-radius: 5vw } + .cornerfill-radius-min { corner-shape: bevel; border-radius: min(20%, 2rem); font-size: 20px } + .cornerfill-radius-calc { corner-shape: bevel; border-radius: calc(1em + 5%); font-size: 20px } `; const anonymousLayer = document.createElement("style"); anonymousLayer.textContent = "@layer{.cornerfill-anonymous{corner-shape:bevel}}"; @@ -847,6 +865,32 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe inert.className = "cornerfill-inert-source"; const alternateElement = host(); alternateElement.className = "cornerfill-alternate-source"; + const allReset = host(); + allReset.className = "cornerfill-all-base cornerfill-all-reset"; + const allBefore = host(); + allBefore.className = "cornerfill-all-before"; + const allAfter = host(); + allAfter.className = "cornerfill-all-after"; + const allLayer = host(); + allLayer.className = "cornerfill-all-layer"; + const allImportant = host(); + allImportant.className = "cornerfill-all-important"; + const validHigh = host(document.body, "cornerfill-valid-high"); + validHigh.className = "cornerfill-invalid-low"; + const validBelowInvalidImportant = host(document.body, "cornerfill-invalid-important"); + validBelowInvalidImportant.className = "cornerfill-valid-normal"; + const relativeRadii = [ + ["cornerfill-radius-em", [{ rx: 20, ry: 20 }]], + ["cornerfill-radius-vw", [{ rx: 40, ry: 40 }]], + ["cornerfill-radius-min", [{ rx: 32, ry: 20 }]], + ["cornerfill-radius-calc", [{ rx: 30, ry: 25 }]], + ].map(([className, expected]) => { + const target = host(); + target.className = className; + target.style.width = "200px"; + target.style.height = "100px"; + return { target, expected }; + }); const auto = installCornerfillAuto(options({ autoObserve: false })); try { @@ -869,6 +913,21 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe assert(auto.explain(complex) === null, "complex support condition was partially owned"); assert(auto.explain(inert) === null, "non-CSS style source was activated"); assert(auto.explain(alternateElement) === null, "inactive alternate stylesheet was activated"); + assert(auto.explain(allReset) === null, "all: unset retained an earlier shape carrier"); + assert(auto.explain(allBefore), "shape after all: unset did not attach"); + equal(auto.explain(allBefore).geometry.shapeParameters, [0, 0, 0, 0], "shape after all: unset did not win"); + assert(auto.explain(allAfter) === null, "all: unset after shape did not reset the carrier"); + assert(auto.explain(allLayer) === null, "all: unset did not reset a shape from an earlier layer"); + assert(auto.explain(allImportant) === null, "important all: unset did not reset an important shape"); + equal(auto.explain(validHigh).geometry.shapeParameters, [0, 0, 0, 0], "losing invalid shape poisoned a valid winner"); + equal(auto.explain(validBelowInvalidImportant).geometry.shapeParameters, [-1, -1, -1, -1], "invalid important shape participated in the cascade"); + for (const { target: radiusTarget, expected } of relativeRadii) { + equal( + auto.explain(radiusTarget).geometry.radii, + Array.from({ length: 4 }, () => expected[0]), + `${radiusTarget.className} did not use the browser-resolved radius`, + ); + } const messages = auto.explain().errors.map(({ message }) => message).join("\n"); assert(/variable corner-shape shorthand combined with longhands/u.test(messages), "variable shorthand conflict was not reported"); assert(/mixed physical and logical/u.test(messages), "mixed declaration refusal was not reported"); @@ -895,6 +954,8 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe layerNormal, layerImportant, varParent, varFallback, varConflict, logical, media, supportsPositive, supportsNegative, supportsInvalidNegative, mixed, anonymous, nesting, complex, inert, alternateElement, + allReset, allBefore, allAfter, allLayer, allImportant, validHigh, validBelowInvalidImportant, + ...relativeRadii.map(({ target: radiusTarget }) => radiusTarget), ]) element.remove(); } }); diff --git a/notes/00-verdict-and-scope.md b/notes/00-verdict-and-scope.md new file mode 100644 index 0000000..b32e304 --- /dev/null +++ b/notes/00-verdict-and-scope.md @@ -0,0 +1,152 @@ +# Verdict and scope + +Status: current product-scope synthesis. “Feasible” means compatible with the +carrier in principle; “implemented” and “qualified” are stated separately. + +## Decision + +Build Cornerfill as a paint-equivalent polyfill, not as a false claim of complete browser geometry. + +The no-`clip-path` breakthrough is a live CSS image. A transparent canvas contains the correctly shaped background and border, while the original DOM element retains layout, `matrix3d()`, opacity, visibility, backface behavior, and compositor ownership. WebKit and Firefox both expose legacy live-image hooks capable of keeping a canvas connected to CSS. + +This is generic in the important sense: the shape is calculated at runtime from CSS box geometry and CSS shape values. It does not require authoring a special font or rewriting the application's source images. It remains subject to an explicit semantic ceiling because an image cannot alter descendant clipping or hit testing. + +## Three product modes + +Cornerfill should name its modes so users cannot confuse their guarantees. + +### Paint mode + +The element is an empty or paint-owned leaf. Cornerfill owns its admitted +background, border, and contained-effect paint. This is the primary target and +the PolyCSS mode. + +Target: native-equivalent visible pixels within a reviewed, declared raster +tolerance. No native-to-candidate parity guarantee exists while the oracle state +is `UNQUALIFIED`. CSS transforms and author filters remain native. + +### Decorate mode + +The element may contain descendants, but the caller only needs the admitted +host background and border subset to have the requested shape. A contained +outline is equivalent only when no host foreground or pseudo-element overlaps it. + +Contract: admitted host decoration only, with each paint subset retaining its +oracle qualification state. Descendants can still paint or receive pointer hits +in areas that native `corner-shape` would exclude. + +### Semantic mode + +Full native behavior, including descendant overflow clipping, replaced content, +multi-fragment boxes, shaped backdrop-filter interactions, external effects, +and hit testing. Ordinary author `filter` is not part of this unavailable mode: +it remains on the original element and browser-owned in paint/decorate modes. + +Guarantee: unavailable through a Paint Level 1/live-image backend alone. Cornerfill must not expose this mode unless a future platform primitive actually supplies those semantics. + +## Feasibility matrix + +| Behavior | Paint/live-image fallback | Notes | +| --- | --- | --- | +| Background color | Implemented, unqualified | Paint into the transparent surface and remove the outside region | +| One static same-origin/CORS raster URL | Implemented subset, unqualified | Cross-origin no-CORS and animated-image timing are outside the current decode/draw contract | +| PolyCSS atlas crop | Implemented, unqualified | A particularly small subset: URL, no-repeat, explicit size and position | +| Multiple URL layers | Implemented subset, unqualified | Every admitted layer is owned; general CSS image grammar is not implied | +| Background blending | One explicit-runtime `multiply` subset, unqualified | Exactly one explicitly opaque raster over one opaque RGB/hex color; broader modes, layers, gradients, and translucent inputs are refused | +| Linear/radial/conic gradients | Geometry implemented, color parity unqualified | Default CSS uses Oklab/premultiplied interpolation while Canvas does not; general exactness needs CSS Color-aware rasterization or a much narrower declared subset | +| Solid uniform border | Implemented, unqualified | Paint the region between outer and inner contours | +| Non-uniform border widths, one color | Implemented, unqualified | Correct inner contour is required; per-side partitioning is not | +| Dotted/dashed/double/groove borders | Unsupported; outside current lane | Native border painting is much more than a stroked path | +| Outer box shadow | No | The final background image cannot paint beyond the border box | +| Inset box shadow | One contained subset implemented, unqualified | Broader blur/offset grammar remains unsupported | +| Outline | One fully contained solid subset implemented, unqualified | External pixels are impossible; foreground/pseudo overlap prevents general stacking equivalence | +| `corner-shape` keywords | Implemented, unqualified | `round`, `squircle`, `square`, `bevel`, `scoop`, `notch` | +| Arbitrary `superellipse()` | Implemented, unqualified | Adaptive sampling is used; cubic fitting remains an optional optimization | +| Per-corner values | Implemented, unqualified | Resolve physical/logical longhands and 1–4 value shorthands | +| Radius animation | Implemented sampling, unqualified | Active computed radii are sampled while their declaration path is observable | +| Shape animation | Explicit-carrier/direct path only | Default auto transport does not preserve authored `corner-shape` keyframes | +| Transform animation | Implemented without repaint | The surface is attached before CSS compositing; transform is not a painter input | +| Opacity/visibility animation | Browser-owned without repaint | Native CSS applies these to the finished element | +| Descendant overflow clipping | No | An `` cannot install the element's browser clip chain | +| Pointer hit testing | No | The DOM box remains rectangular in fallback engines | +| Replaced-content clipping | No | Paint ownership does not change how the replaced content is clipped | +| Layout/content flow | Correct by doing nothing | Native `corner-shape` and `border-shape` do not reshape layout | +| `border-shape: polygon(...) circle(0)` | Future contained-paint candidate only | No current parser, runtime, capability, or oracle path; still no descendant clipping | +| Full `border-shape` grammar/output | No current parity claim | Parsing may be implementable, but valid stroke/fill paint can escape the carrier and full semantics include relevant-side ownership and native clipping | + +## Why transparent output requires paint ownership + +A transparent live canvas placed above an ordinary rectangular background does not remove that background. The original rectangle would remain visible through the canvas's transparent corner pixels. + +Therefore Cornerfill must do one of the following: + +1. own and repaint all affected background layers inside its surface; or +2. operate only on elements whose original background is already transparent and whose visible paint is supplied to Cornerfill through explicit carriers. + +Using the canvas merely as an overlay is not a general solution. Using it as a CSS mask would solve the knockout, but CSS masks are explicitly outside this project's contract. + +For PolyCSS the ownership transfer is narrow and deterministic: the painter receives the prepared atlas image plus `background-size` and `background-position`, draws the selected region, and clears everything outside the triangle contour. + +## Corner shape versus border shape + +They are related but not interchangeable. + +`corner-shape` modifies the corners inside the areas established by `border-radius`. A zero radius means no shaped corner. Its inner border contour is derived from the outer curve and border widths. + +`border-shape` replaces the rectangular border path with one or two arbitrary `` values. One shape is stroke mode. Two shapes are fill mode: the first is the outer boundary and the second the inner boundary. A non-`none` `border-shape` makes `border-radius` and `corner-shape` irrelevant. The current draft also makes the inner shape the overflow clip, which a paint-only fallback cannot reproduce. + +The completed/current `corner-shape` order is: + +1. `corner-shape` fill on paint-owned elements; +2. uniform and one-color unequal-width solid borders; +3. the prepared PolyCSS atlas path and dirty-only runtime; +4. only bounded background additions that preserve honest oracle status. + +`border-shape` is not the next free phase of this queue. Any later work must be +a separately authorized, border-box-contained paint subset with its own parser, +ownership, capability, and oracle contract. + +## What “polyfill” may honestly mean + +Cornerfill qualifies as a polyfill when it accepts the same author intent, computes the same paint geometry, and supplies it on browsers that lack the property. A polyfill does not have to be implemented inside the engine. + +The package description must nevertheless say “paint polyfill” or “paint-compatible fallback,” not “complete drop-in polyfill,” unless the usage is restricted to paint-owned leaves. The distinction is observable in tests: + +- native `corner-shape` changes hit testing in the corner region; +- Cornerfill's live image leaves the element's DOM hit box rectangular; +- native shaped overflow clips children; +- Cornerfill cannot remove child pixels it does not own. + +## Requirements that define success + +- No `clip-path` or CSS mask in any fallback backend. +- No font/glyph geometry. +- No application asset alpha preprocessing as the general mechanism. +- No required extra DOM child per PolyCSS face. +- The original face element keeps its transform. +- The original element keeps author `filter`, stacking, and pseudo-elements. +- A background-position change repaints only that face's surface. +- A transform-only change does not repaint. +- Hidden or culled faces do not repaint. +- Surfaces are explicitly disposed and unregistered. +- Native rendering is used only when the required semantics are known to work. +- Every fidelity claim is backed by a browser image comparison, not only DOM/CSS inspection. + +## Non-goals for the first release + +- Reimplement every CSS background grammar production. +- Reimplement all decorative border styles. +- Pretend to clip arbitrary descendants. +- Patch browser prototypes as broadly as the old CSS Paint polyfill did. +- Scan inaccessible cross-origin stylesheets and guess their discarded declarations. +- Use a static data URL path for high-frequency animation. + +## Go/no-go conclusion + +Go for the PolyCSS and paint-owned-box target. The core surface and rotation +premise is implemented beyond the original one-element probe, while visual +candidate parity remains `UNQUALIFIED`. Do not market arbitrary-DOM semantic +equivalence. Treat any `border-shape` work as a separate research and +implementation lane. It may reuse internal machinery, but full rendered parity +is not bridgeable when valid paint or native semantics extend beyond the +border-box image. diff --git a/notes/01-spec-contract.md b/notes/01-spec-contract.md new file mode 100644 index 0000000..4a22618 --- /dev/null +++ b/notes/01-spec-contract.md @@ -0,0 +1,180 @@ +# Spec contract + +Status: current semantic synthesis against the 26 March 2026 Working Draft. +Project/backend limitations are recorded separately from native semantics. + +Primary target: [CSS Borders and Box Decorations Level 4](https://drafts.csswg.org/css-borders-4/), Working Draft dated 26 March 2026. The editor's draft is live and can change; the pinned source revision used for this research is listed in [references](references.md). + +## `corner-shape` value model + +The shape lives inside each corner area established by `border-radius`. If either radius dimension is zero, that corner has no shaped area and `corner-shape` has no visible effect. + +The computed value of each shape longhand is a `superellipse()` parameter `s`. The mathematical superellipse exponent is: + +```text +n = 2^s +``` + +| Keyword | Parameter `s` | Exponent / limiting shape | +| --- | ---: | --- | +| `notch` | `-∞` | concave 90° notch | +| `scoop` | `-1` | concave quarter ellipse | +| `bevel` | `0` | straight diagonal | +| `round` | `1` | exponent 2, ordinary ellipse | +| `squircle` | `2` | exponent 4 | +| `square` | `+∞` | convex 90° square | + +`corner-shape` takes one to four values in top-left, top-right, bottom-right, bottom-left order, with the same missing-value expansion pattern as four-sided CSS shorthands. Physical and flow-relative longhands must be resolved using writing mode and direction before geometry is built. + +## Required contours + +Native painting does not use one path for everything. + +- The shape value defines the outer border edge. +- The inner border edge follows an offset-like contour that aims for nearly constant thickness; simply subtracting `border-width` from each radius is not generally correct. +- Outer shadows and overflow-clip outsets use axis-aligned expansion rules rather than blindly following the inner-border construction. +- Background clipping selects the appropriate border, padding, or content contour. +- Native overflow and hit testing use the shaped clip, not only the painted pixels. + +Cornerfill therefore needs an explicit contour request such as: + +```text +contour(box, radii, shapes, insets, purpose) +``` + +where `purpose` distinguishes outer edge, inner border, background clip, inset/outset shadow, and test-only hit geometry. + +## General corner construction + +The current draft describes each corner as a carve-out from an axis-aligned target rectangle. In broad terms: + +1. Compute the unshaped target rectangle from border-box insets. +2. Apply ordinary border-radius constraint scaling. +3. For concave diagonally opposite corners, compute the additional hull-based scale factor that prevents overlap. +4. Derive the adjusted corner start, outer, end, and center points from the requested insets. +5. Build the corner carve-out path. +6. Boolean-subtract each carve-out from the target rectangle. + +For a general finite parameter, the draft samples the curve with an implementation-chosen approximation. Expressed in the draft's corner coordinates, it uses a power curve based on `2^abs(s)` and reflects the convex result for negative parameters. Exact keyword cases can be emitted analytically: + +- `bevel`: one line; +- `round`: elliptical arc; +- `square`: axis-aligned outer corner; +- `notch`: axis-aligned concave corner; +- `scoop`: reflected ellipse; +- other finite values: adaptively sampled curve or fitted cubic Béziers. + +The spec intentionally leaves the clipping/boolean implementation to the engine. [CSSWG issue 14158](https://github.com/w3c/csswg-drafts/issues/14158) explains why this matters: the per-corner result is a pre-clip path that can overshoot the target rectangle, and the general case may require curve/line or curve/curve intersection if an implementation insists on producing one final vector path. + +Cornerfill can avoid a general-purpose vector boolean library for its painted output. Canvas compositing is itself a raster boolean operation: + +1. paint the target region; +2. set `globalCompositeOperation = 'destination-out'`; +3. fill each carve-out; +4. restore normal compositing. + +`PaintRenderingContext2D` includes the Canvas compositing, path, and image-drawing +mixins, so the operation is available to the current main-thread fallback +contexts and would also be available to a future Native Paint backend. This is +an internal painter operation, not a CSS mask. + +## Opposite-corner constraints + +Ordinary `border-radius` scaling prevents adjacent radii from exceeding the box edges. Concave shapes add another failure mode: diagonally opposite scoops/notches can overlap in the interior. + +The draft requires constructing a normalized hull for each concave corner, mapping the four hulls into the border box, and finding the largest common scale for each diagonal pair that prevents intersection. The final factor is the minimum of one and the two diagonal factors. + +This cannot be skipped in a spec-complete geometry engine. It should have dedicated tests because WebKit's current preview implementation exposes `oppositeCornerScaleFactor()` but still returns `1.0` with a `TODO` at the pinned revision. + +## Interpolation + +Linear interpolation of the raw superellipse parameter produces visibly uneven motion near concave and convex extremes. The CSSWG resolved that interpolation should be linear in the corner's diagonal intersection, then converted back to the superellipse parameter. See [CSSWG issue 11608](https://github.com/w3c/csswg-drafts/issues/11608). + +For finite `s`, let: + +```text +n = 2^abs(s) +h = 0.5^(1 / n) +v = s < 0 ? 1 - h : h +``` + +`v` is the signed diagonal interpolation coordinate: `0` at notch, `0.5` at bevel, and `1` at square. Interpolate `v`, then invert: + +```text +h = v < 0.5 ? 1 - v : v +n = ln(0.5) / ln(h) +s = log2(n) * (v < 0.5 ? -1 : 1) +``` + +### Current editor's-draft defect + +As of this snapshot, the draft's forward algorithm says to compute `k = 0.5^abs(s)` and then `convexHalfCorner = 0.5^(1/k)`. That is not the inverse of the following conversion algorithm, reverses the stated limiting behavior, and differs from Blink and WebKit. This is a local algebraic erratum. The open [CSSWG issue 14157](https://github.com/w3c/csswg-drafts/issues/14157) separately tracks signed-versus-convex half-corner selection and the concave hull-direction mismatch; it does not establish the distinct printed forward-expression defect. + +Cornerfill must not copy that expression verbatim. Use the CSSWG's closed interpolation resolution and differential tests against native Chromium. Keep the formula isolated behind tests so it can be updated when the draft is corrected. + +## Inner border contour + +The inner edge is not generally another superellipse with smaller radii. Border widths can differ on the two sides meeting at a corner, and concave shapes need their tip moved inward correctly. + +Practical implementation rules drawn from the draft and engine sources: + +- Special-case `round`, `scoop`, and `bevel` with stable closed-form geometry. +- Derive the general inset direction from the convex half-corner/hull direction, even when the visible shape is concave. +- Clip or composite the adjusted curve against the inner target rectangle. +- Preserve separate horizontal and vertical insets for non-uniform border widths. +- Generate the border as the outer region minus the inner region; do not use a centered Canvas stroke as the source of truth. + +This is where a visually plausible polyfill most easily diverges from native output. + +## Overflow, hit testing, and layout + +The draft says shaped corners retain the overflow behavior of `border-radius`, except with the new shape. The WPT suite includes a hit-test test that checks `elementsFromPoint()` in the removed bevel corners. Those semantics belong to the browser's clip and event systems. + +Paint Level 1 cannot supply them. Cornerfill records the same geometry for testing, but its live-image backends only change host paint. + +Layout is different: both `corner-shape` and `border-shape` are visual. They do not alter the box's layout geometry or content flow. Leaving layout untouched is correct. + +## `border-shape` contract + +The same CSS Borders 4 draft defines `border-shape` as `none` or one/two `` values with optional geometry boxes. + +### One shape: stroke mode + +The shape path is stroked. Width, style, and color come from the logical “relevant side,” which is the first non-`none` border side in block-start, inline-start, block-end, inline-end order, or block-start if all are `none`. The default reference box is `half-border-box`. + +### Two shapes: fill mode + +The first shape is the outer boundary and defaults to the border box. The second +is the inner boundary and defaults to the padding box. The border is the filled +region between them, using the relevant side's color. + +### Interactions + +- Non-`none` `border-shape` causes `border-radius` and `corner-shape` to be ignored. +- Outer shadow starts from the outer path and inset shadow from the inner path; + ordinary spread, blur, offset, paint-order, and clipping rules still apply. +- The inner path is the native overflow clip. +- Layout and content flow remain rectangular. + +The PolyCSS rule `polygon(50% 0, 100% 100%, 0 100%) circle(0)` is the simple two-shape fill case. A complete implementation is much larger because `` includes circles, ellipses, inset/rect/`xywh()` forms, polygons, `path()`, and `shape()` with geometry-box and percentage resolution. Cornerfill currently implements none of this `border-shape` lane, and valid paint outside the border box cannot be carried by its live background image. + +## Required conformance corpus + +Start from the [pinned WPT corner-shape directory](https://github.com/web-platform-tests/wpt/tree/4a5810a124fa0523dd2494996bf1542d4b67f394/css/css-borders/corner-shape), then classify tests: + +- parse/computed value; +- keyword and arbitrary parameter rendering; +- asymmetric/percentage radii; +- borders and images; +- inner/outer shadows; +- overflow and backdrop/filter composition; +- hit testing; +- interpolation and animation; +- zoom/extreme-value crash cases. + +Paint-mode conformance applies only to the admitted border-box-contained host +paint subset. Descendant overflow, hit testing, replaced-content clipping, +multi-fragment boxes, shaped backdrop-filter, outer shadows, external outlines, +and out-of-box `border-shape` paint must be marked “unimplementable by this +backend,” not silently counted as passes. Existing explicit runtime refusals are +sufficient; this classification does not require a new test per exclusion. diff --git a/notes/02-engine-implementations.md b/notes/02-engine-implementations.md new file mode 100644 index 0000000..419d4e8 --- /dev/null +++ b/notes/02-engine-implementations.md @@ -0,0 +1,156 @@ +# Engine implementations and current support + +Snapshot date: 2026-08-01. Source links are pinned in [references](references.md); support status can move quickly. + +Status: point-in-time native-engine evidence. This chapter does not describe +Cornerfill's implemented backend or qualified package capabilities. + +## Status table + +| Engine | `corner-shape` | `border-shape` | CSS Paint Worklet | Useful fallback bridge | +| --- | --- | --- | --- | --- | +| Chromium/Blink | Shipped in Chrome 139 | Shipped in Chrome 147 | Shipped | Native `paint()` | +| WebKit | Preview feature, defaults false in the pinned preference file | No equivalent implementation found in this audit | Testable/experimental, defaults false | `getCSSCanvasContext()` + `-webkit-canvas()` | +| Gecko/Firefox | Parser and initial rendering landed behind `layout.css.corner-shape.enabled`; incomplete follow-ups remain | Open implementation bug | Meta bug open and unassigned | `mozSetImageElement()` + `-moz-element()` | + +The table deliberately avoids turning a parse result into a completeness claim. In particular, Firefox's meta bug is still open and has separate assigned/new dependencies for borders, shadows, and inset-related display items. + +## Chromium/Blink + +Chrome's official release notes state that `corner-shape` shipped in [Chrome 139](https://developer.chrome.com/release-notes/139) and `border-shape` shipped in [Chrome 147](https://developer.chrome.com/release-notes/147). `background-clip: border-area`, used by the current PolyCSS `border-shape` leaf style, arrived in [Chrome 150](https://developer.chrome.com/release-notes/150). + +Blink's implementation is the best current native oracle because it is shipped and covers much more than a painted silhouette. + +### Representation + +`ContouredRect` extends a rounded rectangle with four corner curvatures. Blink stores the actual superellipse exponent `n = 2^s`, not the CSS parameter `s`. Its named constants are therefore: + +- round: `2`; +- bevel: `1`; +- scoop: `0.5`; +- practical straight/square clamp: `1000`; +- notch: reciprocal of the straight clamp. + +Concavity is represented by an exponent below one. Inverting a corner swaps its visual center/outer vertices and takes the reciprocal curvature, letting much of the curve math operate on the convex equivalent. + +### Path generation + +Blink emits exact line/conic cases where possible and uses two cubic Bézier halves for a general superellipse corner. Its `PathBuilder::AddContouredRect` uses Skia path operations for the difficult inset/constant-thickness intersections. The implementation article [The corner cases of implementing CSS corner-shape in Blink](https://developer.chrome.com/blog/implementing-corner-shape) documents the fitted cubic controls, non-uniform borders, shadows, and per-edge clipping challenges. + +The fitted coefficient set is useful evidence, not a requirement for Cornerfill's first implementation. A spec-sampled adaptive path is easier to audit and license cleanly; matching cubics can be introduced after differential tests establish their error. + +### Semantics beyond paint + +For non-round curvature, `ContouredRect::IntersectsQuad()` tests against the generated path. That is a reminder that native support propagates into geometry/hit systems. Cornerfill's CSS-image fallback cannot acquire that behavior simply by matching the pixels. + +## WebKit + +WebKit now has a substantial explicit-path implementation, but the pinned `UnifiedWebPreferences.yaml` marks `CSSCornerShapeEnabled` as `preview` and sets its defaults to false for WebKitLegacy, WebKit, and WebCore. + +`CSSPaintingAPIEnabled` is marked `testable`; it is true only under WebKit experimental builds in the pinned preferences and otherwise defaults false. Cornerfill therefore cannot rely on native `CSS.paintWorklet` for released Safari. + +### Geometry source + +`CornerShapeUtilities.cpp` works with the CSS parameter `s` directly and contains: + +- analytic bevel, scoop, and round inset construction; +- notch and square special cases; +- convex/concave inversion; +- fitted cubic Béziers for general superellipses; +- trimming of inset cubic segments to the target rectangle; +- outset miter construction and special interpolation for parameters between scoop, bevel, and round; +- an exported `borderContourPath()` used by rendering code. + +`BorderShape.cpp` resolves style radii and per-corner curvature, constructs outer and inner inputs, and connects the contour to border/background painting. + +### Important incompleteness + +At pinned WebKit revision `3108e0a68c0ea7f887716cdb73cbd3f9109ddc78`, the exported `oppositeCornerScaleFactor()` ends with: + +```cpp +// TODO: implement opposite-corner scale factor computation. +return 1.0; +``` + +The editor's draft requires this constraint for diagonally opposing concave corners. Cornerfill must implement and test it rather than copying WebKit's current result. + +### Legacy live CSS canvas + +WebKit's `Document` still exposes `getCSSCanvasContext()`. A WebKit layout test obtains a named context, assigns it through `-webkit-canvas(name)`, and checks incremental repaint after drawing. This legacy feature is the Safari fallback bridge: it is independent of the disabled Paint Worklet feature. + +## Gecko/Firefox + +Firefox's implementation strategy is materially different from Blink/WebKit's explicit CPU-side contour paths. + +### Current bugs + +As of this snapshot: + +- [Bug 1726232](https://bugzilla.mozilla.org/show_bug.cgi?id=1726232), the `corner-shape` meta bug, is `NEW` and still has open dependencies. +- [Bug 2035317](https://bugzilla.mozilla.org/show_bug.cgi?id=2035317), initial rendering support, is `RESOLVED FIXED` with target milestone `153 Branch`. +- [Bug 2047627](https://bugzilla.mozilla.org/show_bug.cgi?id=2047627), border rendering, is `ASSIGNED`. +- [Bug 2048908](https://bugzilla.mozilla.org/show_bug.cgi?id=2048908), box-shadow support, is `NEW`. +- [Bug 2058091](https://bugzilla.mozilla.org/show_bug.cgi?id=2058091), computed inset/display-item support, is `NEW`. +- [Bug 1982766](https://bugzilla.mozilla.org/show_bug.cgi?id=1982766), `border-shape`, is `NEW`. +- [Bug 1302328](https://bugzilla.mozilla.org/show_bug.cgi?id=1302328), the CSS Painting API meta bug, is `NEW` and unassigned. + +The initial-rendering bug's comments explicitly say important border and shadow behavior was not yet proper and should be handled in follow-up bugs. Source longhand/shorthand definitions are gated by `layout.css.corner-shape.enabled`, and Firefox's WPT metadata forces that preference true for the suite. This audit does not infer a complete stable-release feature from the milestone alone. + +### WebRender path + +The initial rendering commit passes per-corner `s` values through display items into WebRender. `ellipse.glsl` implements a signed-distance approximation for `superellipse(s)`, including square/notch thresholds, bevel, convex `2^s`, and reflected concave behavior. That is efficient for GPU clipping and anti-aliasing, but it is not a reusable JavaScript algorithm. + +Cornerfill should still use a deterministic Canvas `Path2D`/compositing geometry shared across fallback engines. Firefox's shader is a valuable visual oracle for its native path, not the architecture for the polyfill. + +### Legacy live element image + +Firefox's `Document.webidl` documents `mozSetImageElement(id, element)`, which gives the registered image element precedence for `-moz-element(#id)` and accepts `null` to unregister it. Gecko reftests verify that drawing into an out-of-document canvas invalidates and repaints the `-moz-element()` consumer. This is exactly the lifecycle Cornerfill needs. + +## Source licensing + +Use the standards algorithm as the primary design source and keep independently written geometry under Cornerfill's chosen license. + +- Chromium source carries the Chromium project's BSD-style terms. +- WebKit's `CornerShapeUtilities` files carry Apple's two-clause BSD-style notice. +- Firefox source is MPL-2.0. +- GoogleChromeLabs `css-paint-polyfill` is Apache-2.0. + +Do not paste engine constants or substantial control flow without recording the applicable attribution/redistribution obligations. The simplest clean first route is spec-derived adaptive sampling plus original Canvas composition. Native code remains a differential oracle. + +## Capability detection policy + +Detection must answer “does native support satisfy this caller's needs?”, not only “does the parser know the property?” + +Possible future requirement bits, not the current public capability schema: + +```ts +type NativeRequirements = { + fill: boolean; + border: boolean; + shadow: boolean; + overflowClip: boolean; + hitTest: boolean; + borderShape: boolean; +}; +``` + +The runtime can combine: + +1. `CSS.supports()` for syntax; +2. computed-value checks for the specific shorthand/longhand; +3. an offscreen `elementsFromPoint()` bevel probe for shaped hit testing, modeled after WPT; +4. a maintained engine/version qualification table for rendering features that JavaScript cannot inspect pixel-perfectly; +5. conservative fallback when a required native behavior is unknown. + +For paint-owned PolyCSS leaves the requirement set is much smaller: accurate fill geometry and image paint. An incomplete native border/shadow implementation does not matter if those features are unused. + +## Implementation lessons from the engines + +- Normalize the CSS parameter/exponent representation at one module boundary; do not mix Blink's `n` with WebKit/Gecko's `s`. +- Reflect concave curves from their convex counterparts where possible. +- Do not model borders as a centered stroke. +- Treat each contour purpose separately. +- Constrain concave opposite corners before generating paths. +- Use special cases for keyword shapes and an approximation for the general curve. +- Keep geometry independent of the rendering backend so all live surfaces receive identical paths. +- Test against native screenshots and WPT, because the editor's draft and preview implementations both contain known gaps. diff --git a/notes/03-live-css-image-backends.md b/notes/03-live-css-image-backends.md new file mode 100644 index 0000000..aa1d577 --- /dev/null +++ b/notes/03-live-css-image-backends.md @@ -0,0 +1,202 @@ +# Live CSS image backends + +Status: WebKit and Gecko live-image transport is implemented. The native Paint +backend remains an unimplemented design option; the static fallback currently +uses data URLs, not blob/object URLs. + +## The breakthrough + +Houdini's useful property here is not “running code in a worklet.” It is producing a transparent CSS `` whose pixels update when size or style inputs change. + +Safari and Firefox do not need to ship Paint Worklets for Cornerfill to emulate that output. Both engines already have a vendor-specific way to bind a live canvas to a CSS image: + +```text +WebKit: CanvasRenderingContext2D -> -webkit-canvas(name) +Firefox: canvas element -> -moz-element(#name) +``` + +Cornerfill paints the same geometry into either surface, then assigns that surface to the original element. CSS applies transforms after background painting, so the browser rotates the transparent result as one compositor input. + +## Why rotation does not glitch + +The painter never computes screen-space rotation and never makes a pre-rotated asset. It paints in the element's ordinary, untransformed border-box coordinate space. + +```text +CSS box coordinates + -> transparent shaped image + -> element background + -> browser-owned foreground and pseudo-elements + -> element opacity/filter/backface/visibility/stacking + -> matrix3d and compositor + -> screen +``` + +A shape/font workaround can be vulnerable to text rasterization and transform-specific engine bugs. A nested clipping construction can change the 3D subtree and flattening behavior. A live background image does neither: it stays on the existing face element. + +Transform-only animation is therefore not an invalidation input. Repaint is required only when the box size, shape/radius, or owned paint source changes. + +## Native CSS Paint backend — future, unimplemented + +Where `CSS.paintWorklet` is available, the ideal API is ordinary Custom Paint: + +```css +@property --cornerfill-image { + syntax: ""; + inherits: false; + initial-value: linear-gradient(transparent, transparent); +} + +.face { + --cornerfill-image: url("texels.webp"); + background-image: paint(cornerfill); +} +``` + +CSS Properties and Values Level 1 allows a registered `` custom property to reify as `CSSImageValue`. CSS Paint Level 1 extends `CanvasImageSource` with `CSSImageValue`, so the painter can pass it to `drawImage()`. The official WPT `paint2d-image.https.html` demonstrates the path by reading `border-image-source` and drawing it. + +The standards and pinned WPT describe this input route, but this repository has +no retained local Chrome 151 artifact with browser/source identity for the +earlier exploratory claim. That claim is therefore not package evidence. The +current backend selector does not expose a Paint Worklet path. + +The native worklet context also includes Canvas compositing, paths, and image drawing. It excludes pixel readback and text APIs, neither of which Cornerfill needs. + +## WebKit backend + +Runtime probe: + +```js +if (typeof document.getCSSCanvasContext === "function") { + const name = "cornerfill-42"; + const ctx = document.getCSSCanvasContext("2d", name, pixelWidth, pixelHeight); + element.style.backgroundImage = `-webkit-canvas(${name})`; +} +``` + +Properties: + +- the CSS image is named, not tied to a DOM canvas element; +- drawing into the returned context invalidates consumers; +- changing size requires obtaining the correctly sized named context; +- names must be unique per active surface unless two consumers intentionally share identical pixels; +- capability detection is mandatory because the API is non-standard; +- `CSSPaintingAPIEnabled` being false does not disable this legacy hook. + +The source proof is WebKit's `Document.getCSSCanvasContext` binding plus its incremental repaint layout test. Product qualification must still run in actual Safari Stable and Technology Preview, not only Playwright's WebKit build. + +## Firefox backend + +Preferred runtime path: + +```js +const canvas = document.createElement("canvas"); +canvas.width = pixelWidth; +canvas.height = pixelHeight; +const id = "cornerfill-42"; + +document.mozSetImageElement(id, canvas); +element.style.backgroundImage = `-moz-element(#${id})`; +``` + +Fallback if `mozSetImageElement` is unavailable but `-moz-element()` parses: give the hidden canvas that ID and append it to a Cornerfill-owned hidden root. + +Properties: + +- current Gecko source documents image-element IDs and their precedence; +- a reftest proves that repainting an out-of-document registered canvas invalidates the CSS consumer; +- teardown must call `document.mozSetImageElement(id, null)`; +- a hidden DOM canvas is still needed when the direct registration API is absent; +- IDs are document-scoped and must not collide across Cornerfill instances. + +## Static data-URL backend + +The shipped opt-in fallback converts the canvas to a data URL when neither live +bridge is available. It is not suitable for animated PolyCSS: + +- serialization and image decode can occur on every update; +- the CSS declaration changes every update; +- data size and garbage pressure are high; +- delivery can miss the intended frame. + +Static mode should be opt-in or limited to immutable decoration. + +## Local proof record + +The minimal probe is preserved as [live-paint-surface-probe.html](evidence/live-paint-surface-probe.html). It creates one 240×160 element with: + +```css +transform: rotateX(31deg) rotateY(47deg) rotateZ(13deg); +``` + +It paints a transparent triangle, exposes the canvas through the engine's live image hook, and repaints the gradient from orange/red to cyan/blue without replacing the CSS image or transform. + +Recorded results: + +| Run | Initial state | Repaint state | Result | +| --- | --- | --- | --- | +| Playwright `webkit` engine build | `backend=webkit-canvas`, `phase=0` | `phase=1` after `repaint(1)` | transparent rotated triangle updated in place | +| Playwright `firefox` engine build | `backend=moz-element`, `phase=0` | `phase=1` after `repaint(1)` | transparent rotated triangle updated in place | + +Evidence images remain in the source workspace and are linked from [evidence/README.md](evidence/README.md). The exact claim is engine-build proof of the live-surface mechanism. The production adapter, dirty-only scheduler, lifecycle, and complete 1,213-leaf/820-tick workload now have separate evidence in the [oracle](../oracle/README.md) and the [Firefox Mario ABBA record](../output/playwright/firefox-mario/hardening-full-abba-v2-2026-08-02/README.md). Those artifacts still do not approve native-to-candidate pixel tolerances or qualify released Safari. + +## Adapting the archived CSS Paint polyfill + +The Apache-2.0 [GoogleChromeLabs CSS Paint polyfill](https://github.com/GoogleChromeLabs/css-paint-polyfill) already contains the key backend selection: + +- detects `getCSSCanvasContext`; +- detects `-moz-element()`; +- creates one context/canvas per element and painter; +- applies `-webkit-canvas(...)` or `-moz-element(...)`; +- uses `ResizeObserver` and a queued update pass; +- falls back to `toDataURL()` elsewhere. + +That is the right archaeological base, not production code to import unchanged. It is archived, executes painter code on the main thread, monkey-patches broad DOM/CSS prototypes, and has only a minimal scalar Typed OM emulation. Cornerfill needs a narrower runtime and a real image-input/cache path. + +## Image transport + +A future Native Paint backend and the current main-thread fallback canvases would +need different adapters around one logical input: + +```ts +type PaintImage = + | { kind: "css-image"; value: CSSImageValue } // future Paint backend only + | { kind: "decoded"; value: CanvasImageSource; sourceUrl: string }; +``` + +- A future Native Paint backend could read a registered `` custom property as `CSSImageValue`. +- Main-thread fallbacks parse the serialized custom property, resolve the URL against the declaration's source URL, load/decode once, and draw the resulting image. +- The painter receives normalized source/destination rectangles, not backend-specific image-loading state. + +For multiple images and gradients, the logical paint graph should use explicit layer nodes rather than handing raw CSS strings to geometry code. + +## Security and correctness constraints + +- The current owned URL subset is static same-origin or CORS-enabled raster + input. Native CSS may display cross-origin no-CORS or animated images whose + fetch/timing semantics this decode-and-draw path cannot preserve. +- Resolve relative URLs against the stylesheet that authored them, not automatically against the document. +- Do not expose pixel-read APIs; they are unnecessary. +- Decode before switching the live surface into use to avoid a blank frame. +- Include device pixel ratio in backing dimensions, but keep painter coordinates in CSS pixels. +- Reinitialize context state after a resize because canvas resizing resets it. +- Disable or configure image smoothing according to the source's intended sampling; PolyCSS texel fields require an explicit choice. +- Bound surface dimensions and total decoded memory. + +## Lifecycle contract + +Every backend must implement the same small interface: + +```ts +interface LiveSurface { + readonly cssImage: string; + readonly context: CanvasRenderingContext2D; + resize(cssWidth: number, cssHeight: number, dpr: number): boolean; + commit(): void; + dispose(): void; +} +``` + +`commit()` is a no-op for automatically live contexts but remains in the +interface for the static data-URL surface. `dispose()` unregisters Firefox image +IDs, removes hidden canvases, and clears references. There is no current object +URL to revoke. diff --git a/notes/04-architecture.md b/notes/04-architecture.md new file mode 100644 index 0000000..dcc5a28 --- /dev/null +++ b/notes/04-architecture.md @@ -0,0 +1,316 @@ +# Architecture status and design + +Status: the ownership flow and WebKit/Gecko surface model are implemented. The +TypeScript module tree, build transform, Native Paint backend, `border-shape` +lane, and parts of the API below are historical or future sketches, not release +requirements. [`src/`](../src/) is the implementation authority. + +## End-to-end flow + +```text +authored CSS / direct prepared state + -> declaration transport + -> computed box snapshot + -> normalized corner shape + -> contour requests + -> owned paint graph + -> backend-neutral painter + -> WebKit live canvas | Firefox live element | opt-in static data URL + -> CSS image on the original element + -> browser applies foreground/pseudos, transform/opacity/filter/visibility/stacking +``` + +Geometry, paint ownership, scheduling, and backend plumbing must be separate. Mixing them is how a “small polyfill” becomes impossible to test. + +## Superseded proposed module boundaries + +```text +src/ + api/ + install.ts + controller.ts + requirements.ts + capture/ + build-transform.ts + stylesheet-scan.ts + computed-snapshot.ts + shadow-roots.ts + parse/ + corner-shape.ts + border-radius.ts + background.ts + border-shape.ts + css-values.ts + geometry/ + corner.ts + constraints.ts + contour.ts + interpolation.ts + basic-shape.ts + paint/ + graph.ts + background.ts + border.ts + shadow.ts + compositor.ts + backends/ + native-paint.ts + webkit-canvas.ts + moz-element.ts + static-image.ts + runtime/ + registry.ts + invalidation.ts + animation-loop.ts + image-cache.ts + visibility.ts + probes/ + native-corner.ts + live-surface.ts +``` + +The shipped source uses flat `.mjs` modules rather than this tree. The enduring +boundary is that numeric geometry remains DOM-independent and unit-testable; +this file layout must not be recreated merely to satisfy the old sketch. + +## Author declaration transport + +Unsupported properties can be discarded by the target engine's CSS parser, so a runtime CSSOM scanner cannot reliably recover the author's `corner-shape` or `border-shape` declaration. Cross-origin stylesheets add another barrier because their rules are not readable without CORS. + +A possible future general solution is a build transform that duplicates +supported declarations into durable custom-property carriers adjacent to the +original declarations. No such build transform ships today: + +```css +.card { + border-radius: 24px; + corner-shape: squircle; + --cornerfill-corner-shape: squircle; +} +``` + +For image ownership: + +```css +@property --cornerfill-background-image { + syntax: ""; + inherits: false; + initial-value: linear-gradient(transparent, transparent); +} + +.face { + background-image: url("texels.webp"); + --cornerfill-background-image: url("texels.webp"); +} +``` + +If implemented later, the transform must preserve cascade order, importance, +custom properties, media/supports/layer context, and URL base. It must not remove +the native declaration. Supporting browsers continue to use native CSS. + +The current default import instead reads accessible author CSS once and creates +a companion stylesheet containing shape carriers. It cannot recover inaccessible +cross-origin/imported rules, constructed/adopted sheets, closed roots, or +authored `corner-shape` declarations inside `@keyframes`; those cases require +explicit carriers or the direct API. + +Runtime-only adoption can be best-effort: + +- explicit `data-cornerfill`/custom properties; +- same-origin readable stylesheets; +- inline styles intercepted through a narrow Cornerfill API; +- direct prepared state for renderers such as PolyCSS. + +Do not promise recovery of an unknown declaration already dropped by a foreign parser. + +## Direct prepared-state path + +High-frequency renderers should not repeatedly serialize and reparse CSS. Expose an internal/direct controller: + +```ts +interface CornerfillHandle { + setGeometry(state: ResolvedGeometry): void; + setPaint(state: ResolvedPaint): void; + setVisibility(visible: boolean): void; + dispose(): void; +} +``` + +PolyCSS preparation can emit normalized radii, a fixed bevel shape, atlas identity, crop metadata, and canonical surface dimensions. Runtime lighting updates then change only the crop key. This stays a generic Cornerfill backend while avoiding CSSOM work in a known prepared pipeline. + +## Capability selection + +Backend selection is per document and requirement profile, not per frame. + +```text +if complete native property for requested features: + native property, no Cornerfill surface +else if WebKit named CSS canvas probe passes: + WebKit live canvas backend +else if Firefox element-image probe passes: + Gecko live element backend +else if static fallback explicitly allowed: + static image backend +else: + report unsupported +``` + +A Native Paint branch is a future option, not part of current selection. + +The live-surface probe should create one tiny surface, draw a known alpha/color pattern, attach it, mutate it, and verify the backend's state hooks. Automated release qualification adds screenshot comparison; normal runtime detection should remain cheap. + +## State model + +Each controlled element needs a compact record: + +```ts +interface Entry { + element: HTMLElement; + mode: "corner"; // border-shape would be a separate future state model + requirements: NativeRequirements; + geometryKey: string; + paintKey: string; + surfaceKey: string; + surface: LiveSurface | null; + animationActive: boolean; + visible: boolean; +} +``` + +Keys should be stable hashes/tuples over normalized data, not raw `cssText`. Separate geometry and paint keys allow an atlas crop change to reuse the path and a shape animation to reuse the decoded image. + +## Paint ownership protocol + +Before applying overrides: + +1. snapshot all CSS values Cornerfill will own; +2. resolve relative URLs against their declaration base; +3. build and decode the paint graph; +4. allocate/resize the surface; +5. paint a complete first frame; +6. atomically switch the element to the live CSS image; +7. make the original owned background/border paint transparent or otherwise inert without changing layout. + +The override must avoid recursive capture. Once `background-image` is the live surface, reading computed `background-image` would return Cornerfill's output rather than the source. Store the authored/resolved source separately and expose refresh hooks when author state changes. + +For a fully owned background, a typical override is conceptually: + +```css +background-color: transparent !important; +background-image: var(--cornerfill-live-image) !important; +background-position: 0 0 !important; +background-size: 100% 100% !important; +background-repeat: no-repeat !important; +border-color: transparent !important; /* only when border is painted by Cornerfill */ +``` + +Actual implementation should use one scoped generated stylesheet rather than a growing inline-style string, and it must restore the author's state on teardown. + +## Geometry-to-paint contract + +The painter should consume purpose-specific masks/paths: + +```ts +interface ShapeGeometry { + outer: RasterShape; + innerBorder?: RasterShape; + backgroundClip: RasterShape; +} +``` + +Outset contours may exist in a native-reference geometry tool, but they are not +renderable production output: the final live background image cannot carry +pixels beyond the border box. + +`RasterShape` can expose direct closed-path commands for simple contours and a `rect minus carveOuts` representation for general cases. The Canvas compositor decides how to realize the boolean operation. This avoids forcing geometry to solve path intersection when raster subtraction is sufficient. + +## Background paint graph + +Normalize CSS into explicit layers: + +```ts +type BackgroundLayer = + | { kind: "color"; color: string } + | { kind: "image"; imageId: string; size: SizeRule; position: PositionRule; repeat: RepeatRule } + | { kind: "linear-gradient"; /* normalized stops and line */ } + | { kind: "radial-gradient"; /* normalized center/radii/stops */ } + | { kind: "conic-gradient"; /* normalized center/angle/stops */ }; +``` + +The implementation supports a declared subset and must fail before ownership on +an unsupported layer. Gradient nodes currently map geometry into Canvas +gradients but remain color-semantics `UNQUALIFIED`; their presence in this graph +is not a CSS Color parity claim. Silently leaving a rectangular native layer +underneath would violate the core transparency guarantee. + +## `border-shape` lane — future and bounded + +There is no current `border-shape` parser, value transport, state model, +capability, painter path, or oracle fixture. It is not merely a replacement +geometry resolver: one/two-shape modes add geometry-box resolution, +relevant-side width/style/color selection, stroke/fill ownership, and different +overflow/effect semantics. + +A separately authorized future lane may reuse the border-box surface, scheduling, +and lifecycle only for a declared paint subset whose complete output stays +inside the carrier. Full rendered parity is unavailable when a valid stroke, +shadow, or outline extends outside it. Do not route arbitrary strings through +`clip-path`, and do not add general grammar to the `corner-shape` coverage queue. + +## Surface allocation + +Baseline: one surface per active element. It is easy to reason about and matches the old polyfill's proven live-image mapping. + +Possible later optimizations: + +- share immutable surfaces for identical size/geometry/paint keys; +- pool detached canvases by backing size; +- delay allocation for culled/offscreen entries; +- keep small canonical PolyCSS surfaces rather than transformed screen bounds; +- batch repaint scheduling while retaining per-element live image identity. + +Do not share surfaces whose pixels diverge per animation frame merely because their shape is identical. + +## Historical public API sketch + +The following illustrates intent but is not the shipped signature. Current +exports and behavior live in [`src/index.mts`](../src/index.mts) and +[`src/runtime.mts`](../src/runtime.mts). + +```ts +const controller = installCornerfill({ + selector: "[data-cornerfill]", + mode: "paint", + native: "qualified", + staticFallback: false, +}); + +controller.refresh(); +controller.attach(element, preparedState); +controller.detach(element); +controller.destroy(); +``` + +Useful diagnostics: + +```ts +controller.backend; +controller.entries; +controller.stats(); +controller.explain(element); +``` + +`explain()` should report the selected backend, captured source, unsupported paint features, last invalidation reason, surface dimensions, and whether the current mode lacks native overflow/hit semantics. + +## Failure policy + +- Invalid shape syntax: leave native/author fallback untouched and emit a development diagnostic. +- Unsupported background grammar: do not partially take ownership unless the caller explicitly accepts it. +- Plain URL decode failure: abort ownership/report failure before switching and + preserve the stack's `background-color`. A CSS + [invalid image](https://drafts.csswg.org/css-images-4/#invalid-image) is + otherwise transparent with no natural dimensions; use a grammar-defined + fallback only when that grammar is explicitly implemented. +- Surface bridge failure: fall through to the next backend once, not every frame. +- Oversized allocation: refuse. No current downscale policy is implemented. +- Native uncertainty: prefer Cornerfill for paint-only callers; report that semantic callers remain unsupported. diff --git a/notes/05-geometry-and-painting.md b/notes/05-geometry-and-painting.md new file mode 100644 index 0000000..b047570 --- /dev/null +++ b/notes/05-geometry-and-painting.md @@ -0,0 +1,342 @@ +# Geometry and painting design + +Status: contour/raster-boolean geometry and the documented shipped subsets are +implemented. General CSS backgrounds, borders, and effects remain bounded by +the qualification and carrier limits stated below. + +## Coordinate model + +All geometry is computed in the element's untransformed CSS-pixel border box. + +```ts +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +interface Corner { + rx: number; + ry: number; + s: number; // CSS superellipse parameter; may be +/-Infinity +} +``` + +The backing bitmap can be device-pixel sized, but path inputs remain CSS pixels and the context is scaled by DPR. `matrix3d()` and other transforms are never folded into these coordinates. + +## Resolve CSS values before drawing + +### Border radii + +Parse the complete `border-radius` model: + +- one to four horizontal radii; +- optional slash followed by one to four vertical radii; +- physical and logical longhands; +- lengths, percentages, and supported `calc()` values; +- percentages resolved against box width for `rx` and box height for `ry`. + +Apply the standard corner-radius reduction factor so sums on each edge do not exceed the box dimension. This is separate from the additional diagonal constraint for concave shapes. + +### Corner shapes + +Expand shorthands to four physical corners and normalize keywords to `s`: + +```text +round=1, squircle=2, square=+∞, +bevel=0, scoop=-1, notch=-∞ +``` + +Preserve `-0` only if native computed-value tests prove it observable; otherwise normalize it to zero. + +### Concave diagonal constraint + +Implement the draft's hull test, not WebKit's current placeholder: + +1. generate a convex hull polygon for each concave corner in normalized space; +2. rotate/map it into the element; +3. test each diagonal pair under a shared scalar; +4. solve for the largest non-intersecting scalar, using monotonic binary search if no simpler analytic solution is stable; +5. multiply all radii by the minimum pair scalar. + +Use a deterministic iteration count/tolerance and test exact keyword/extreme cases. Geometry must not depend on browser raster output. + +## Curve generation + +### Exact cases + +Prefer exact commands for the common keywords: + +- `bevel`: line between the two corner endpoints; +- `round`: quarter ellipse/Canvas `ellipse()` arc; +- `square`: two axis-aligned lines through the outer corner; +- `notch`: two axis-aligned lines through the inner corner; +- `scoop`: reflected quarter ellipse; +- zero radius: sharp box corner. + +Exact cases reduce segment count, numerical risk, and browser-to-browser anti-alias variation. + +### General superellipse + +For finite `s`, the normalized curve is a superellipse with exponent `n = 2^s`. A stable sampler can use the usual quarter-superellipse parameterization and map it into the corner's `rx × ry` rectangle. Concave values can be calculated directly or by reflecting the convex reciprocal equivalent, matching the native implementation strategy. + +Use adaptive subdivision: + +1. start with curve endpoints; +2. evaluate the analytic midpoint; +3. measure its distance from the chord or a fitted segment; +4. subdivide until device-space error is below a declared threshold; +5. cap depth/segments and special-case extreme parameters. + +Suggested starting error budget: at most 0.25 device pixel for ordinary UI and 0.125 device pixel for visual-oracle tests. This is a hypothesis to benchmark, not a frozen constant. + +Adaptive sampled output is the current correctness-first route. Two fitted cubic +halves, like Blink/WebKit, remain an optional optimization only after a +differential suite establishes maximum error across parameters, aspect ratios, +insets, and DPR. + +## Contour construction + +Represent the draft's operation instead of prematurely forcing one closed vector path: + +```ts +interface RasterShape { + targetRect: Rect; + carveOuts: PathCommand[][]; +} +``` + +The outer shape is `borderRect minus outerCornerCarveOuts`. Inner border, +background, and contained inset-effect contours repeat the construction with +purpose-specific insets and adjusted corners. Outset contours can be useful to +a native reference renderer, but cannot be emitted by the current border-box +carrier. + +This representation mirrors the spec and makes the omitted boolean operation explicit. + +## Raster boolean without `clip-path` + +The editor's draft leaves corner-path clipping to implementations. Blink delegates difficult intersections to Skia. Canvas gives Cornerfill a backend-neutral raster equivalent: + +```js +ctx.save(); +paintTargetRect(ctx); +ctx.globalCompositeOperation = "destination-out"; +for (const carveOut of carveOuts) { + ctx.fill(carveOut); +} +ctx.restore(); +``` + +Repeated `destination-out` is union subtraction: if two carve-outs overlap, the +overlap stays removed. That is safer than relying on even/odd parity. The +operation happens inside the generated image and uses the Canvas compositing API +available to the current main-thread surfaces (and to a possible future Paint +context). It is not a CSS mask and adds no DOM renderer. + +For a background fill, the simplest sequence is: + +1. clear the surface; +2. paint all owned background layers across their CSS painting areas; +3. subtract the regions outside the selected background-clip contour. + +This is the first production slice. + +## Borders + +An ordinary or `corner-shape` CSS border is a region between two contours, not +a centered Canvas stroke. One-shape `border-shape` is a separate explicit stroke +mode in CSS Borders 4. + +For a uniform solid border: + +1. paint the outer contour with the border color; +2. paint the background over it, restricted to the inner/background contour; or +3. construct the ring as outer shape minus inner shape on a scratch layer and composite it over the background. + +Main-thread live-surface backends can use a pooled `OffscreenCanvas`/hidden +scratch canvas if needed. A future Native Paint Worklet backend could not assume +a DOM canvas is constructible; it would need a correctly wound inner contour or +a direct compositing sequence on the supplied output context. + +The inner contour must come from border-aware inset geometry. A naive `rx -= borderWidth; ry -= borderWidth` fails on bevel/scoop/notch and non-uniform widths. + +### Unequal widths, per-side colors, and styles + +One-color unequal widths require the correct inner contour but no side-color +partition. That subset is implemented. Differing side colors or styles require +partitioning the ring into side regions without bleeding into neighboring +corners; Blink's implementation article shows why simple quadrant clips are +insufficient for extreme mixed curves. + +Conventional Cornerfill subset status: + +1. no border — implemented; +2. uniform solid border — implemented, oracle unqualified; +3. non-uniform widths with one color — implemented, oracle unqualified; +4. per-side solid colors — unsupported; +5. double/dashed/dotted — unsupported; +6. 3D styles (`groove`, `ridge`, `inset`, `outset`) — unsupported. + +This is not “full native borders”: `` colors, `hairline`, border +images, partial-border grammar, and other current/future CSS Borders 4 +productions remain outside the declared subset. Every admitted level must be +explicit in package metadata and diagnostics. + +## Background painting + +### Color + +Paint the background color at the bottom of the owned layer stack, subject to the resolved `background-clip` behavior. + +### URL image + +For admitted static same-origin or CORS-enabled raster URLs, implement the CSS +Images/Backgrounds sizing algorithm rather than treating every image as +`100% 100%`: + +- intrinsic dimensions/aspect ratio; +- explicit `` pairs; +- `cover` and `contain`; +- position area and percentage formula; +- repeat, round, space, no-repeat; +- origin and clip boxes; +- multiple layers in CSS paint order. + +The URL/repeat/origin geometry is implemented, but native raster sampling parity +is not qualified. In the focused `raster-repeat-origin` differential, the +content-box, six rounded tiles, restored aspect ratio, and bottom offset resolve +to the CSS algorithm. Its 1,865 changed interior pixels have zero interior alpha +error but nonzero premultiplied-RGB error, isolating the remaining difference to +native CSS versus Canvas image resampling. The case remains `UNQUALIFIED` and +`handle.explain()` reports that limit. +Native CSS cross-origin no-CORS images and animated-image timing are not +preserved by the current request/decode/draw path. + +General [`image-set()`](https://drafts.csswg.org/css-images-4/#image-set-notation) +remains unimplemented and UA-owned. Its candidate choice may use UA-specific +criteria and change over a page lifetime, while the selected density also +changes intrinsic CSS sizing. A deterministic URL-only DPR policy +could be a separately labelled Cornerfill/prepared subset, but it must not claim +general native `image-set()` parity. + +For the first PolyCSS adapter the normalized input is simpler and fully prepared: + +```ts +{ + imageId: "texels.webp", + repeat: "no-repeat", + size: { width: 4852, height: 3280 }, + position: { x: preparedX, y: preparedY } +} +``` + +The surface draws only the atlas crop intersecting the face box. An atlas-position update changes the paint key but not geometry. + +### Blend mode + +One bounded explicit-runtime subset is implemented: one scroll-attached raster +declared opaque with `rasterIsOpaque: true` can use `multiply` over one opaque +`rgb()`/hex background color. The painter fills that color, draws the raster with +Canvas `globalCompositeOperation = "multiply"`, and uses no scratch surface. +The ordinary and prepared atlas paths remain unchanged; prepared multiply is +refused. The focused Chrome differential is pixel-exact but remains +`UNQUALIFIED` under the oracle contract. Multiple images, gradients, translucent +inputs, other blend modes, and automatic opacity inference remain unsupported. + +### Gradients + +Canvas has linear/radial/conic gradient primitives, but CSS gradient fidelity also requires: + +- CSS angle/direction conventions; +- automatic and double-position color stops; +- interpolation hints; +- repeating periods; +- color interpolation spaces and hue methods; +- premultiplied-alpha behavior; +- radial sizing keywords and elliptical radii. + +The decisive mismatch exists even when the interpolation method is omitted: +[CSS Images 4](https://drafts.csswg.org/css-images-4/#coloring-gradient-line) +defaults gradients to Oklab with premultiplied-alpha interpolation, whereas +[Canvas](https://html.spec.whatwg.org/multipage/canvas.html#dom-canvasgradient-addcolorstop-dev) +interpolates stops in the context color space without premultiplying alpha. The +current parser rejects explicit interpolation spaces +but accepts the omitted/default form, then delegates color strings to Canvas. +That route is geometric/experimental, not default-CSS color parity. + +A deliberately narrow explicit-sRGB, fully opaque legacy-color subset could +avoid a general CSS Color engine because premultiplication is inert at alpha 1, +but it would still require raster qualification. Default gradients, differing +alpha, hints, missing components, wide-gamut colors, and synthesized boundary +colors require CSS Color-aware interpolation or refusal. Do not expand repeating +or absolute-stop grammar on top of the current unqualified premise. + +## Background clip choices + +The live surface occupies the border box. Resolve `background-origin` and `background-clip` independently for each layer. + +- `border-box`: subtract outside the outer contour. +- `padding-box`: use the inner border contour. +- `content-box`: use the content inset with a corresponding shaped contour where the spec requires it. +- `border-area`: relevant to a possible native/`border-shape` lane, but the current Cornerfill background parser rejects it. +- `text`: outside Cornerfill's first scope. + +## Contained effects and impossible outsets + +The final generated image is the host's border-box background. A padded scratch +surface does not change that destination bound. Outer box shadows and every +outline pixel outside the border box are therefore impossible through the +current backend and must remain unsupported. + +The implemented inset subset is one zero-offset, zero-blur inset ring with +non-negative spread. The implemented outline subset is one fully contained solid +ring. Both remain native-differential `UNQUALIFIED`; broader blur, offset, style, +or stacking behavior is not implied. + +Native [outline paint](https://drafts.csswg.org/css-ui-4/#outline-props) is above +the host box, while a live background image is below foreground and +pseudo-elements. Even a geometrically contained outline is +therefore equivalent only for empty/paint-owned leaves with no overlapping host +foreground or pseudos. Ordinary author `filter` remains browser-owned on the +original element; Cornerfill must not replace box-shadow with `drop-shadow()` or +rasterize the filter into its background. + +## Anti-aliasing and seams + +- Reuse identical curve commands for boundaries shared by background and border. +- Avoid independently rasterizing two coincident edges when one compositing operation can produce the ring. +- Clear the entire backing bitmap on every full repaint. +- Scale once for DPR; do not round geometry twice. +- Test transparent edges over light, dark, and saturated checkerboards. +- Test fractional CSS dimensions and transforms, not only integer boxes. +- Compare alpha separately from RGB so transparent-RGB noise does not obscure edge errors. + +## Cache strategy + +Cache by normalized inputs: + +- radii/shapes/box size/DPR -> geometry commands; +- image URL/request mode -> decode promise and decoded image; +- background layer normalization -> paint graph; +- complete geometry + paint -> optional shared immutable surface. + +Do not put transform matrices in any paint cache key. Do not cache a failed image +forever; the current cache removes the failed record so a later request can retry. + +## Numerical test points + +At minimum: + +- `s`: `-∞`, `-10`, `-2`, `-1`, `-0.5`, `0`, `0.5`, `1`, `2`, `10`, `+∞`; +- square, very wide, and very tall corner rectangles; +- zero/tiny radii; +- 50%/100% elliptical radii used by PolyCSS triangles; +- borders wider than one or both radii; +- all four corners mixed; +- diagonally opposing 80% concave corners; +- DPR 1, 1.25, 1.5, 2, 3; +- zoom and fractional box coordinates. + +The geometry library should serialize sampled points in deterministic fixtures so formula regressions are visible without starting a browser. diff --git a/notes/06-capture-and-invalidation.md b/notes/06-capture-and-invalidation.md new file mode 100644 index 0000000..cf7a2de --- /dev/null +++ b/notes/06-capture-and-invalidation.md @@ -0,0 +1,250 @@ +# CSS capture, invalidation, and lifecycle + +Status: the current runtime implements companion-style capture, direct prepared +state, dirty scheduling, shared image leases, and teardown. Build transforms, +complete DPR watching, and some observer/cache sketches below are future or +illustrative and are labelled accordingly. + +## Audit of the archived Paint polyfill + +The archived [GoogleChromeLabs CSS Paint polyfill](https://github.com/GoogleChromeLabs/css-paint-polyfill) proves the backend idea, but its runtime model is not sufficient for Cornerfill unchanged. + +At pinned revision `9dff83a8131fc7bb98490bfd2e05112c39842df8`, it: + +- searches image-valued properties with a broad property-name regex; +- rewrites `paint(name)` into a placeholder URL so unsupported parsers retain it; +- scans accessible stylesheets and refetches imports; +- records `Painter.inputProperties`; +- schedules element updates through `requestAnimationFrame`; +- observes border-box size with `ResizeObserver`; +- uses WebKit named canvas and Firefox element image when available; +- otherwise serializes Canvas to a URL; +- observes DOM/style mutations; +- patches `setAttribute`, `HTMLElement.style`, `cssText`, and `setProperty`; +- listens for animation/transition start/end/iteration and common interaction events. + +Its property container mostly returns trimmed strings or a small `CSSUnitValue` approximation. It does not provide Cornerfill's required registered ``/`CSSImageValue` model in fallback engines. It also alternates class instances on the main thread rather than reproducing worklet isolation. + +## Why event-only animation invalidation is wrong + +An `animationstart` or `transitionstart` event can enqueue one repaint, but computed values continue changing on every sampled frame without DOM mutations. The old polyfill listens to lifecycle events; it does not inherently poll all intermediate computed values. + +Cornerfill needs an active-animation loop: + +1. on transition/animation start, mark the affected entry active; +2. on every `requestAnimationFrame`, read only its observed computed signature; +3. repaint when the normalized geometry/paint signature changed; +4. remove it after end/cancel and one final sample; +5. pause work for hidden/culled entries while preserving final-state correctness. + +For prepared renderers, direct invalidation is better: the renderer already knows when a lighting crop or geometry value changes and calls the handle without a computed-style read. + +## Declaration survival + +### Build-time transform: future option, not implemented + +A future tool could insert Cornerfill carriers next to source declarations. This +could survive unsupported parser behavior and retain stylesheet-relative URL +bases, but no such transform is part of the current package. + +A future transform would have to cover: + +- `corner-shape` shorthand and physical/logical longhands; +- the combined `corner` shorthands if supported; +- `border-shape` only if a separately authorized bounded lane exists; +- background image/position/size/repeat/origin/clip when Cornerfill takes paint ownership; +- relevant admitted border and contained-effect inputs. + +Store source URL metadata for each transformed image declaration or rewrite relative URLs to absolute URLs during the build. + +### Runtime stylesheet scan: best effort + +Same-origin `document.styleSheets` rules can identify selectors and custom carriers. Handle nested `@media`, `@supports`, `@layer`, `@container`, and `@scope` without flattening their conditions. + +Limitations: + +- inaccessible cross-origin sheets cannot be read; +- unsupported native declarations may already be absent from CSSOM; +- constructed/adopted stylesheets may have no owner node; +- stylesheet mutations are not all represented by DOM mutations. + +The current auto entry performs one accessible-rule companion pass and exposes +author-controlled refresh/explicit attachment seams. It does not preserve +`corner-shape` declarations inside `@keyframes`, inaccessible imports, +constructed/adopted sheets, or closed roots. Document explicit carriers/direct +state—not a nonexistent build transform—as the reliable path for those cases. + +### Direct API: performance path + +PolyCSS should attach prepared entries explicitly and notify changed atlas fields directly. This removes selector queries and redundant style snapshots from the hot path. + +## Invalidation graph + +| Trigger | Geometry | Paint | Surface size | Action | +| --- | --- | --- | --- | --- | +| `corner-shape`/radius change | Yes | No | No | rebuild contour, repaint | +| border width change | Yes | Usually | Maybe | rebuild inner contour, repaint | +| background color/image/position/size change | No | Yes | No | repaint with cached geometry | +| box size change | Yes | Yes | Yes | resize surface, rebuild, repaint | +| DPR/zoom qualification change | Maybe | Yes | Yes | resize backing, repaint | +| transform change | No | No | No | do nothing | +| opacity/visibility change | No | No | No | normally do nothing | +| image decode completion | No | Yes | No | repaint dependents | +| stylesheet/class/style mutation | Maybe | Maybe | Maybe | recompute signature once | +| element removal | No | No | No | dispose | +| element reparent/document adoption | Maybe | Maybe | Maybe | rebind document backend and recompute | + +## Observer design + +Use narrowly scoped observers: + +- one `ResizeObserver` for controlled elements; +- one `MutationObserver` per registered root for child-list and relevant attribute changes; +- explicit registration for open ShadowRoots; +- a stylesheet registry API for adopted/constructed sheets; +- document-level animation/transition event listeners that only touch known entries; +- `matchMedia`/viewport hooks only for conditions that affect captured rules; +- a DPR watcher that detects actual resolution changes. + +The dedicated DPR watcher remains required but unimplemented. The current +generic runtime marks entries dirty on window `resize`; that is not proof of +every DPR/zoom transition and cannot reproduce UA-specific `image-set()` +reselection. Prepared entries remain caller-clocked and observer-free. + +Avoid recursively walking an entire changed subtree on every attribute mutation. Newly added subtrees can be scanned once against the registered selector set. + +## Computed signature + +Serialize normalized values, not the whole computed style declaration. + +Example geometry signature: + +```text +width,height,dpr; +tl(rx,ry,s),tr(...),br(...),bl(...); +border(top,right,bottom,left) +``` + +Example paint signature for the PolyCSS slice: + +```text +image-cache-id;background-size-x,y;background-position-x,y;smoothing +``` + +Compare numbers after one normalization/rounding policy to prevent repaint from harmless serialization differences such as whitespace or `0px` versus `0`. + +## Scheduling + +Maintain dirty sets by reason: + +```ts +geometryDirty: Set +paintDirty: Set +resizeDirty: Set +disposePending: Set +``` + +One scheduled animation-frame flush should: + +1. process removals; +2. resolve size and geometry; +3. advance ready image decodes; +4. repaint visible dirty entries; +5. retain dirtiness for temporarily hidden entries only when their next visible frame would otherwise be stale; +6. update counters/diagnostics. + +Do not schedule one promise/rAF per face. + +## Image cache + +Cache by absolute URL plus request semantics, not raw CSS token text. + +The following was an illustrative target record, not the current cache shape: + +```ts +interface ImageRecord { + key: string; + state: "loading" | "ready" | "error"; + promise: Promise; + image?: CanvasImageSource; + dependents: Set; + refCount: number; +} +``` + +The shipped cache shares a decode promise/request identity and hands callers +reference-counted leases; it does not keep this `dependents` set. Failed records +are removed so a later request can retry. Density selection, if ever added as a +non-general prepared policy, belongs in the paint descriptor/key rather than the +decoded URL cache. + +## Visibility and culling + +DOM visibility and application visibility are different. + +- A `display:none` entry has no useful size and should not allocate until measurable. +- `visibility:hidden`/opacity zero may still need a final state before becoming visible. +- IntersectionObserver is an optional UI optimization, not reliable for transformed 3D model culling. +- PolyCSS should pass its prepared visibility decision directly. + +When a hidden entry becomes visible, repaint once from current state; do not replay missed frames. + +## Shadow DOM + +Support is not automatic merely because Firefox's `-moz-element()` can be consumed inside a shadow root. + +Cornerfill needs: + +- a document-scoped surface registry shared by roots; +- root-scoped generated override styles; +- explicit registration of open roots; +- adopted stylesheet tracking; +- clear behavior for closed roots: explicit element attachment only. + +Surface IDs must remain unique across all roots in one document. + +## Teardown + +`destroy()` and element detach must: + +- unobserve resize/mutations where no longer needed; +- stop animation sampling; +- remove the entry's generated override rule; +- restore any inline declarations Cornerfill changed; +- unregister Firefox image IDs with `mozSetImageElement(id, null)`; +- remove hidden fallback canvases; +- release image cache references; +- clear strong element references. + +The old Paint polyfill unobserves some removed elements, but Cornerfill needs a complete, testable lifecycle because thousands of retained surfaces can otherwise leak substantial decoded memory. + +## Avoid broad prototype patching + +Patching `CSSStyleDeclaration.prototype` and `Element.prototype` made sense for a universal 2018 Paint API shim, but it increases compatibility and maintenance risk. + +Preferred order: + +1. current companion-style capture plus narrow observers; +2. direct controller/prepared API; +3. a future build transform when explicitly justified; +4. opt-in wrapper helpers for inline writes; +5. broad prototype interception only as a separately shipped compatibility mode. + +Frameworks should not lose behavior because Cornerfill replaced their style accessors. + +## Diagnostics and telemetry + +Expose development-only counters: + +- active/native/fallback entry counts; +- surfaces and total backing pixels; +- geometry rebuilds and paints per frame; +- paint time by backend; +- image cache bytes and misses; +- skipped hidden entries; +- unsupported CSS values; +- fallback reason for each element; +- live/static backend failures; +- current animation sampler size. + +These counters are necessary to distinguish an inherently expensive paint workload from accidental whole-scene invalidation. diff --git a/notes/07-limits-and-rejected-routes.md b/notes/07-limits-and-rejected-routes.md new file mode 100644 index 0000000..9782907 --- /dev/null +++ b/notes/07-limits-and-rejected-routes.md @@ -0,0 +1,129 @@ +# Hard limits and rejected routes + +Status: current backend limits. These are product boundaries, not deferred +implementation items unless the carrier itself changes. + +## The hard limit + +CSS Paint API Level 1 defines `paint()` as an `` generator. Its introduction explicitly says a future version could add ways to define a clip, global alpha, or filter on part of a box. That future-facing note is evidence that Level 1 does not currently install those box semantics. + +A live WebKit/Firefox canvas is also only a CSS image. It can make the host's corner pixels transparent. It cannot: + +- paint any final pixel outside the host's border box, including outer shadows, + external outlines, or out-of-box `border-shape` strokes/fills; +- alter the browser's overflow clip chain for descendants; +- change `elementsFromPoint()`/pointer hit testing; +- clip native text, replaced content, video, iframe, or child compositing layers it does not paint; +- represent multiple fragments of one element with one border-box image; +- install the shaped clip required by `backdrop-filter`; +- make layout flow follow the shape. + +This is an API boundary, not lack of effort. The honest solution is to target paint-owned leaves and label decoration-only behavior elsewhere. + +## Why no overlay can create transparency + +If a rectangular native background is already painted, drawing a transparent pixel above it reveals that background. Drawing an opaque “cover-up” pixel hides it with some replacement color, but fails over arbitrary content/backdrops and is not transparency. + +The fallback must either own the original paint or use a true clipping/masking primitive. Cornerfill chooses paint ownership because `clip-path` and CSS masks are excluded. + +## Route comparison + +| Route | Why it was considered | Why it is not Cornerfill's answer | +| --- | --- | --- | +| `clip-path: path(...)` | Directly clips paint, descendants, and hit region in many cases | Explicitly disallowed; it is the mechanism used by the existing Hyperellipse fallback | +| CSS `mask-image` / `-webkit-mask` | Alpha knockout with easy shape images | Explicitly disallowed and remains a second CSS renderer | +| Paint Worklet used as a mask | Houdini computes geometry | Still depends on CSS masking; does not unlock the requested route | +| SVG data URI/pseudo layers | Can draw complex rings, shadows, and outlines | Hyperellipse already uses this; extra layers and SVG ownership are wrong for the PolyCSS target and still do not give full host semantics | +| Font triangle + `background-clip:text` | Reusable vector stencil, no path parser | Font rasterization and transformed text introduce the rotation failure the user rejected; wrong abstraction for arbitrary corner values | +| Baked alpha in the sprite atlas | Keeps one face and rotates correctly | Application-specific asset rewrite, not a CSS property polyfill | +| Nested transformed boxes + `overflow` | Ordinary CSS boxes can intersect into a triangle | Adds DOM/paint layers per face, complicates 3D flattening/visibility, and performed poorly or failed in engine probes | +| Canvas overlay positioned above element | Canvas can draw exact contour | Requires duplicating/synchronizing transform, stacking, opacity, visibility, and hit behavior; no reason to detach it when a live CSS image can stay on the element | +| Pseudo-element canvas/image | Avoids replacing host background | Transparent pseudo pixels cannot erase a rectangular host background; ownership still has to move | +| `border-image` | Image-valued property with slicing | Changes border paint, not arbitrary background/descendant clipping; cannot remove the host's rectangular fill | +| Cover-up triangles in ancestor color | Cheap visual trick on flat UI | Cannot represent actual transparency or unknown content behind the element | +| Static data URL per frame | Works in almost any image property | Serialization/decode/style churn makes it a last-resort static mode, not an animation backend | +| Live CSS image | Repaints transparent pixels in place and stays on original element | Selected route; honest paint-only semantic ceiling | + +## Existing Hyperellipse polyfill + +[mikhailmogilnikov/hyperellipse](https://github.com/mikhailmogilnikov/hyperellipse) is useful prior art for parsing and geometry. Its own README says Safari/Firefox use `clip-path`/SVG layers. The renderer confirms: + +- simple mode applies `clip-path: path(...)` and `-webkit-clip-path`; +- visible fallback borders are generated as uniform solid SVG data-URI rings; +- dashed, dotted, `double`, and per-side border paint is flattened to that + uniform solid ring rather than preserved; +- shadow/outline mode creates SVG-backed pseudo layers. + +That is a rational general-UI design, but it violates the actual PolyCSS constraints. Cornerfill should study its tests and parsing decisions without adopting its renderer. + +The older [jsnkuhn/corner-shape](https://github.com/jsnkuhn/corner-shape) repository is historical prior art, not the live-surface solution. + +## Rejected alpha-atlas experiment + +An exploratory Mario-specific route put triangle alpha directly into each lighting tile. It was technically effective at keeping the existing transformed face element and eliminating live clipping. + +Recorded preparation result: + +- 994,660 polygon/frame fields; +- 150,985 deduplicated unique states; +- an exploratory 8×8 RGBA packing across 62 512×512 pages; +- about 7.9 MB encoded and 62 MB decoded in that prototype. + +It remained the wrong answer to the polyfill question. It rewrites source assets, encodes one application's triangle geometry, and cannot respond generically to `corner-shape`, radii, borders, or arbitrary author backgrounds. It belongs only in the rejected-experiment record. + +## Rejected overflow-box experiment + +Another experiment made a triangle from nested transformed boxes and rectangular overflow intersections. The standard `overflow: clip` value was promising because it avoids creating a scroll container and is friendlier to `transform-style` than `overflow:hidden`. + +Exploratory results, not release benchmarks: + +- the basic triangle survived compound rotation in Chromium, Firefox, and WebKit; +- a dense 1,213-face stress scene was roughly 60 fps in WebKit, around 25 fps in Chromium, and substantially slower in Firefox under that test setup; +- a later full-model Firefox prototype painted Mario; +- WebKit created the 1,213 fallback faces but they were invisible, exposing a 3D subtree/engine problem; +- the route required extra per-face boxes or prewarped assets. + +This line was stopped. It increases DOM, paint, and transform complexity and is inferior to the live-image route. The heavy probes also caused unacceptable machine pressure and must not be rerun casually. + +## Why Houdini was not a cop-out + +Two different claims were initially conflated: + +1. Safari/Firefox do not ship native Paint Worklets. +2. A Houdini-style painter cannot solve the rotating atlas problem. + +The first is true; the second is false. Native Paint in Chromium can receive a CSS image and draw it. Safari/Firefox can expose equivalent live output through their vendor canvas-image hooks. The transformed result stays attached to the original element. + +The fundamental spatial limitation is not rotation; it is the box semantics and +border-box output bound that the image carrier never exposes. Independent paint +fidelity limits—such as CSS-gradient color interpolation—still require their own +qualification or refusal. + +## `border-shape` limitation + +A border-box-contained one/two-shape paint experiment may be feasible, but no +`border-shape` path is currently implemented. Full native semantics and output +still include: + +- relevant-side border style selection; +- shadows following outer/inner paths; +- inner-path overflow clipping; +- all `` grammar and geometry boxes; +- stroke/fill pixels that validly extend outside the border-box carrier; +- unresolved draft questions such as clipping replaced elements. + +Any future Cornerfill subset must declare its parser, relevant-side ownership, +geometry boxes, paint modes, output bound, and oracle separately. It must not be +treated as a free follow-on to narrowing `corner-shape` background coverage. + +## Conditions that would change the boundary + +Any of the following could permit a more complete future mode: + +- interoperable native `corner-shape`/`border-shape` in all target engines; +- a future Paint API level that lets a worklet define the element clip; +- a standardized custom hit-test/overflow path API; +- relaxing the project's ban on `clip-path` or masks; +- restricting the component contract so all painted content is owned by Cornerfill. + +Until then, the package should make its paint-only contract impossible to miss. diff --git a/notes/08-verification-plan.md b/notes/08-verification-plan.md new file mode 100644 index 0000000..0e415d1 --- /dev/null +++ b/notes/08-verification-plan.md @@ -0,0 +1,301 @@ +# Verification plan + +Status: qualification framework plus current evidence ledger. The broad corpus +below describes possible general-release coverage; it is not a mandatory gate +for every narrow follow-on item. Tests must follow admitted shipped behavior and +must not turn permanent backend exclusions into implementation work. + +Cornerfill is finished only when the pixels, update behavior, and lifecycle are demonstrated in the target browsers. A parser test or a successful canvas draw is necessary evidence, but neither is visual parity. + +The executable first implementation of this plan lives in [the oracle harness](../oracle/README.md). + +## Evidence levels + +Keep these levels separate in reports: + +| Level | Question answered | Required artifact | +| --- | --- | --- | +| Geometry unit | Did the resolver produce the intended mathematical contour? | deterministic points/path commands and assertions | +| Raster unit | Did the painter fill, subtract, and layer the right regions? | small golden PNGs from the backend-neutral painter | +| Native differential | Does Cornerfill resemble a qualified native implementation? | native image, fallback image, absolute diff, source/browser identity | +| Browser integration | Did the live CSS image paint and update on the original transformed element? | screenshots plus observed backend/state | +| Semantic capability | Does the selected route provide the behavior the caller requested? | focused hit, overflow, border, and shadow probes | +| Performance/lifecycle | Can the target workload run without whole-scene repaint or leaks? | trace/counters, frame statistics, allocation and teardown results | + +Do not collapse these into a single `supported: true` result. + +The current `controller.capabilities.paint` booleans report that a code path +accepts/implements a grammar subset; they do not mean native-differential `PASS`. +Qualification remains the separate oracle state. Until the public schema carries +that distinction directly, documentation and `handle.explain()` must not cite a +paint boolean as parity evidence. + +## Reference identity + +Every native differential record must include: + +- browser product, version, engine revision when available, OS, and device-pixel ratio; +- whether the property was default-on, enabled by a flag/preference, or injected by test metadata; +- exact HTML/CSS input and viewport; +- exact native and fallback images; +- the diff algorithm, channel space, tolerance, and count/location of rejected pixels; +- the Cornerfill revision and backend; +- hashes of any raster source images. + +If the native feature is missing, pref-gated unexpectedly, or has known incomplete behavior for the case under test, label that capture `INVALID ORACLE` for the affected claim. Firefox's initial fill implementation must not be used as the border or shadow oracle while those follow-up bugs remain unresolved. + +## Browser matrix + +| Target | Native-oracle role | Fallback role | Qualification needed | +| --- | --- | --- | --- | +| Current Chrome | Primary shipped `corner-shape` oracle | Forced production static data-URL candidate for differential capture | exact version and screenshots | +| Older Chromium | No assumed native property | No current live package backend; opt-in static data URL only | no Paint Worklet claim until that backend exists | +| Safari Stable | No assumed native feature | `-webkit-canvas()` | real Safari run, not only Playwright WebKit | +| Safari Technology Preview | Candidate native/preview oracle and fallback host | native or `-webkit-canvas()` according to the probe | feature settings and build number | +| Firefox Stable | No assumed complete native feature | `-moz-element()` | real released Firefox run | +| Firefox Nightly | Candidate partial-native oracle | `-moz-element()` or native by requirement gate | preference state and open-feature exclusions | +| Playwright WebKit/Firefox | Fast integration regression | both live-image bridges | label as engine-build evidence only | + +Version tables document expectations; runtime selection must still probe capabilities. + +## Geometry corpus + +Derive fixtures from the CSS Borders 4 contract and the WPT directory. At minimum cover: + +### Value resolution + +- each keyword: `round`, `squircle`, `square`, `bevel`, `scoop`, and `notch`; +- representative `superellipse(s)` values on both sides of zero; +- zero, tiny, very large, and infinite parameter behavior; +- 1, 2, 3, and 4 corner-shape shorthand values; +- physical and logical longhands in horizontal and vertical writing modes; +- pixel and percentage radii, including slash syntax; +- zero radius, where the shape must have no visible corner region; +- computed interpolation between every adjacent keyword pair. + +### Box geometry + +- square and non-square boxes; +- symmetric and asymmetric elliptical radii; +- radii that require the ordinary overlap reduction factor; +- two diagonally opposed concave corners whose hulls overlap; +- mixed convex, bevel, and concave corners; +- widths and heights below one CSS pixel after scaling; +- fractional sizes, fractional radii, zoom, and several DPR values. + +### Border geometry + +- no border; +- uniform solid border; +- unequal side widths; +- a border wider than either resolved radius; +- mixed corner shapes around one border ring; +- inner contours at padding-box and content-box insets; +- later phases: per-side colors and every enabled border style. + +The interpolation fixtures must encode the practical half-corner mapping described in [01 — Spec contract](01-spec-contract.md), not the internally inconsistent equation currently printed in the editor's draft. Keep the local algebraic regression named independently. Track [CSSWG issue 14157](https://github.com/w3c/csswg-drafts/issues/14157) separately for signed-versus-convex half-corner and concave hull-direction changes. + +## Painter corpus + +Qualify paint features individually rather than using one broad “background supported” flag: + +- transparent and opaque background colors; +- one static same-origin or CORS-enabled raster URL with `no-repeat`; +- `cover`, `contain`, explicit lengths/percentages, and percentage positions; +- atlas crops with nearest-neighbor and smoothed sampling; +- multiple layers with independent origin/clip; +- one explicitly opaque raster using `multiply` over one opaque RGB/hex color; +- the current linear/radial/conic Canvas-gradient path as experimental evidence, + not qualified CSS color interpolation; +- `border-box`, `padding-box`, and `content-box`; `border-area` remains rejected by the current runtime; +- solid border rings; +- the existing contained inset ring and contained solid outline as separate + unqualified capabilities; outer shadows and external outlines are permanent + carrier exclusions. + +General `image-set()` selection is not in this corpus because candidate choice +is UA-specific. Do not add DPR/type-selection cases unless a separately labelled +non-general Cornerfill policy is authorized. + +Include overlapping carve-outs. The expected result for the raster-boolean algorithm is union subtraction: pixels removed by one corner must stay removed after another corner is processed. This specifically catches an incorrect even/odd implementation. + +## Native differential method + +Use a current shipped Chromium build as the first full-paint oracle because its implementation is already shipped and includes more of the required pipeline than the partial preview implementations. + +For each case: + +1. Render the native declaration on a transparent, fixed-size capture surface. +2. Render the same resolved input through Cornerfill with native `corner-shape` disabled or isolated in a second document. +3. Capture after fonts/images and two stable animation frames have completed. +4. Compare premultiplied alpha separately from RGB. Transparent RGB must not count as visible error. +5. Emit the native image, fallback image, heatmap, numerical summary, and worst-pixel coordinates. +6. Inspect every changed acceptance threshold visually before adopting it. + +When an automation backend cannot emit transparent screenshots, capture the painted result over both black and white and reconstruct alpha from the two composites. Preserve both inputs and record reconstruction diagnostics; never replace painted-browser evidence with the painter's source canvas. + +Recommended reporting metrics: + +- exact pixel count and percentage; +- alpha error percentiles and maximum; +- visible RGB error percentiles and maximum; +- a one-pixel boundary-band score, because anti-aliasing differences should not hide interior fill errors; +- connected regions of error, to distinguish a shifted contour from isolated raster noise. + +Do not choose a universal tolerance before seeing the first corpus. The accepted +boundary error may vary by backend rasterizer. Only solid, unfiltered opaque +interiors should normally be exact. Gradients, resampled images, blending, blur, +dithering, transformed edges, and color-managed output require behavior-specific +tolerances after native A/A calibration. + +## Live-backend tests + +Every implemented backend must pass the same observable contract: + +1. Allocate at a known CSS size and DPR. +2. Attach its CSS image to the original element. +3. Paint a pattern with transparent corners. +4. Mutate the pixels without changing the CSS image token. +5. Resize and verify that context state is reinitialized correctly. +6. Animate only `transform`; confirm zero painter commits. +7. Animate one registered paint input; confirm one coalesced commit per sampled frame. +8. Hide, detach, reattach, and dispose the element. +9. Confirm that disposed Firefox registrations, hidden canvases, observers, and strong references are gone. + +Backend-specific checks: + +- WebKit: document-global name collision, incremental repaint, DPR scaling, retained-surface behavior after element removal, and real Safari qualification. +- Firefox: detached-canvas invalidation, `mozSetImageElement(id, null)` cleanup, ID collision, and `-moz-element()` sizing. +- Future Paint Worklet backend: add its own checks only if that backend is implemented; it is not a current gate. +- Static data URL: CORS-tainted export failure and an explicit assertion that this backend is disabled for live animation by default. There is no object-URL revocation gate. + +## Semantic exclusion tests + +Tests should demonstrate the limits, not conceal them: + +- Put a bright child in a concave corner with `overflow: hidden`; the fallback is expected not to match native clipping. +- Probe `elementsFromPoint()` in a removed bevel corner; the fallback host is expected to retain its rectangular hit region. +- Place an `` as replaced content; the fallback must report it unsupported unless Cornerfill owns and repaints that image. +- Confirm that external box-shadow/outline outsets are refused; exercise only the + already implemented contained inset-ring and paint-owned-leaf contained-outline fixtures. + +These are passing tests when the runtime refuses an unsupported semantic +requirement or reports the limitation accurately. Existing focused refusals are +sufficient; do not add one new oracle image for every permanent exclusion. + +## Animation and invalidation tests + +Test all invalidation sources independently: + +- resize, including fractional ResizeObserver sizes; +- class, style, and relevant attribute changes; +- stylesheet insertion/removal where declarations are discoverable; +- transitions and CSS animations of radii, color, and background position; +- shape animation only through a declaration carrier/direct path that actually + preserves intermediate values; the default auto companion skips `@keyframes`; +- Web Animations API changes; +- image decode/load completion; +- DPR and zoom change; +- prepared direct-API update; +- transform, opacity, visibility, and ancestor-transform changes that must not repaint the surface; +- offscreen/hidden culling and the first repaint on return. + +For animation, record the computed signature and commit count for each frame. The archived CSS Paint polyfill's event-only invalidation pattern is not acceptable: `animationstart` and `transitionstart` do not expose intermediate computed values. + +## PolyCSS performance gate + +The real case is 1,213 retained Mario polygon leaves over 820 source frames. A currently inspected prepared lighting artifact records 150,985 retained lighting states and changed-only writes with mean `187.23`, p50 `131`, p95 `498`, and maximum `927` faces per source-frame transition. Those are evidence about expected dirtiness, not a promise that Canvas repaint is cheap. + +Measure at 30 Hz source playback and 60 Hz display presentation: + +- CPU time in style capture, geometry, image draw, and backend commit; +- number of candidate, dirty, visible, and repainted faces; +- long tasks and missed presentation frames; +- total live surface pixels and estimated RGBA backing bytes; +- JS heap before attachment, after a full 820-frame loop, and after disposal; +- paint/composite behavior under the existing `matrix3d()` workload. + +Initial acceptance gates: + +- transform-only playback causes zero Cornerfill repaints; +- no full scan or repaint of all 1,213 faces on a normal lighting update; +- repaint count is bounded by the prepared dirty-and-visible set; +- all work for one display frame is coalesced into one scheduler flush; +- no monotonically growing surface IDs, canvases, observers, image handles, or decoded atlas copies over repeated loops; +- fallback off produces the existing native-Chromium result unchanged; +- visual evidence passes before a frame-rate number is advertised. + +Do not run the full browser/performance matrix as an ordinary unit-test side effect. Keep deterministic geometry/raster tests cheap, and run qualified browser suites deliberately so local automation cannot monopolize the machine. + +## Optional general-release framework + +These gates describe a possible broad release campaign. A narrow Burnlist uses +only the gates touched by its changed behavior: normally one focused qualified +Chrome differential, existing source/runtime coverage, and one final +representative cross-engine live-backend capture. Do not require a fresh corpus +for unrelated permanent exclusions or unimplemented backends. + +### Gate A — geometry library + +- All value and contour units pass. +- Spec-defect regression cases are named and linked. +- No browser is required. + +### Gate B — one painted box + +- Color, one URL image, bevel, round, and one general superellipse match native screenshots. +- WebKit and Firefox live surfaces repaint under a compound 3D transform. +- Teardown passes. + +### Gate C — paint-owned API + +- Current companion-style declaration capture and direct API work for their + documented inputs. A build transform is not a current gate. +- Capability reporting refuses overflow and hit-test semantics. +- Active CSS animations sample intermediate frames when their values survive + through the documented carrier/direct path. + +### Gate D — PolyCSS slice + +- A real `texels.webp` crop paints on actual retained face elements. +- Dirty-only invalidation and visibility culling are connected. +- A representative multi-face scene passes visual and performance budgets. + +### Gate E — general release + +- Safari Stable and Firefox Stable are qualified directly. +- Documentation states the semantic ceiling prominently. +- Source licenses and notices are complete. +- Every capability advertised as qualified—not merely implemented—has reviewed + differential evidence. Experimental/unqualified paths remain labelled as such. + +## Existing evidence + +The preserved [live-surface probe](evidence/live-paint-surface-probe.html) passed +one dynamic repaint in Playwright's WebKit and Firefox engine builds under +`rotateX(31deg) rotateY(47deg) rotateZ(13deg)`. See the +[evidence record](evidence/README.md). That artifact remains transport evidence, +not released-Safari or pixel-parity qualification. + +The executable oracle now uses the production parser, painter, surfaces, +scheduler, prepared path, and teardown. Native Chrome A/A calibration is exact; +native-to-candidate comparisons remain `UNQUALIFIED` because no tolerance is +approved. Current focused evidence includes: + +- `gradient-layers`: 39.4639% changed pixels, demonstrating that the Canvas + gradient path is not qualified CSS-default color parity; +- `raster-repeat-origin`: 4.8750% changed pixels, so the advertised raster + repeat/origin geometry is implemented but native-versus-Canvas resampling + remains unqualified; its interior alpha error is zero; +- `background-blend-multiply`: exact zero changed pixels for one opaque raster + over one opaque color, retained as `UNQUALIFIED` because no candidate tolerance + has been approved; +- contained inset-shadow and outline fixtures, with external outsets explicitly + unsupported. + +The [complete Firefox Mario ABBA run](../output/playwright/firefox-mario/hardening-full-abba-v2-2026-08-02/README.md) +executes 1,213 retained leaves over all 820 source ticks in eight fresh sessions, +with identical workload streams, measured Cornerfill paints, and teardown +records. It proves workload integration/lifecycle equivalence only. It does not +approve native visual tolerance, and its roughly 25 source FPS / 50 ms display +p95 is not a 30/60 performance claim. diff --git a/notes/09-polycss-case-study.md b/notes/09-polycss-case-study.md new file mode 100644 index 0000000..b9f88cd --- /dev/null +++ b/notes/09-polycss-case-study.md @@ -0,0 +1,225 @@ +# PolyCSS Mario case study + +Status: implemented workload integration with completed Firefox ABBA lifecycle +and timing evidence. Native-to-candidate visual parity remains `UNQUALIFIED`. + +This is the first high-value consumer for Cornerfill. It is also an unusually clean fit for the honest paint-only boundary: Mario is a retained DOM scene made from empty polygon leaves whose visible content is an atlas image. The browser still owns every face's layout and 3D transform; Cornerfill needs to own only the leaf's local pixels. + +## Current source facts + +The current Super Mario 64 adapter in `/Users/ekrof/fed/cssGraphics` emits 1,213 retained face leaves and an 820-frame loop. + +The common leaf style is: + +```css +[data-shape] > :is(s, u, i) { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0; + opacity: 1; + background-color: transparent; + background-image: url("./assets/texels.webp"); + background-repeat: no-repeat; + backface-visibility: visible; + transform-style: preserve-3d; +} +``` + +The actual current Mario player/audit creates `u` leaves. Their triangular silhouette is expressed as two shaped top corners: + +```css +[data-shape] > u { + border-top-left-radius: 50% 100%; + border-top-right-radius: 50% 100%; + corner-top-left-shape: bevel; + corner-top-right-shape: bevel; +} +``` + +There is also an adjacent/general `i` rule: + +```css +[data-shape] > i { + border-shape: polygon(50% 0, 100% 100%, 0 100%) circle(0); + background-clip: border-area; +} +``` + +That `border-shape` rule is not the current Mario rendering route and is not part +of the `corner-shape` follow-on. It may inform separately authorized future +research, but Cornerfill has no current parser/runtime/oracle support for it. + +Source anchors: + +- package CSS: `/Users/ekrof/fed/cssGraphics/src/adapters/super-mario-64/package.mjs`; +- player leaf creation: `/Users/ekrof/fed/cssGraphics/src/adapters/super-mario-64/player/scene.ts`; +- audit leaf creation: `/Users/ekrof/fed/cssGraphics/src/adapters/super-mario-64/audit/scene.ts`; +- prepared playback contract: `/Users/ekrof/fed/cssGraphics/src/adapters/super-mario-64/stages/playbackPacket.mjs`; +- prepared lighting contract: `/Users/ekrof/fed/cssGraphics/src/adapters/super-mario-64/stages/lighting.mjs`. + +## What Cornerfill changes + +For a fallback engine, one face becomes: + +```text +prepared face dimensions + fixed bevel geometry +prepared atlas image + crop/size/position state + | + v + local transparent surface + | + v + original retained leaf + | + v + existing CSS matrix3d / visibility / opacity +``` + +The leaf remains in the same DOM position and keeps the same transform. Cornerfill suppresses the leaf's original rectangular `background-image`, draws the selected atlas region into the local surface, and subtracts the two outside top-corner regions. Because the two bevel lines meet at the top center, the retained pixels form the same triangle. + +Canvas is a backing store for a CSS image here, not a replacement scene renderer. It never receives world coordinates, a camera, a depth sort, mesh topology, or a frame-wide draw list. The scene remains retained DOM/CSS; the browser composites each original face. + +## Exact first painter + +The Mario route needs only a deliberately small paint grammar: + +- one already-decoded `texels.webp` image; +- `background-repeat: no-repeat`; +- prepared `background-size`; +- prepared `background-position`; +- transparent background color; +- zero border; +- fixed top-left and top-right `bevel` with `50% 100%` radii; +- the face's canonical untransformed width and height; +- the existing image-sampling choice. + +This avoids pretending that the first slice needs a complete parser for CSS gradients, repeating images, border styles, or arbitrary descendants. + +Pseudo-paint sequence: + +```js +function paintMarioFace(ctx, face, atlas) { + ctx.clearRect(0, 0, face.width, face.height); + ctx.save(); + ctx.beginPath(); + ctx.moveTo(face.width / 2, 0); + ctx.lineTo(face.width, face.height); + ctx.lineTo(0, face.height); + ctx.closePath(); + ctx.clip(); + drawPreparedAtlasBackground(ctx, atlas, face.background); + ctx.restore(); +} +``` + +The production painter should obtain the triangle from the same normalized corner resolver as every other caller, even if this closed-form path is retained as a unit oracle and fast path. + +## Prepared-state integration + +Do not discover 1,213 faces and reparse their computed CSS on every source frame. The Mario runtime already knows exactly which leaves change. + +Attach once: + +```ts +const handle = cornerfill.attachPrepared(leaf, { + mode: "paint", + geometry: preparedBevelTriangle, + size: preparedCanonicalSize, + paint: preparedOpaqueAtlasPaint, + visibility: preparedInitialVisibility, +}); +``` + +Update only prepared state: + +```ts +cornerfill.updatePreparedBatch(changedFaces.map((face) => ({ + element: face.leaf, + backgroundPosition: face.nextPreparedCrop, + visible: face.nextVisibility, +}))); +``` + +The generic stylesheet-capture path is still needed for public use, but it does not sit in this hot loop. The direct API is not a Mario-specific renderer; it is a normalized input seam for any prepared retained-DOM system. The current local evidence adapter brackets the original runtime's source tick and submits at most one batch. It does not patch `CSSStyleDeclaration`, install a Cornerfill observer, or schedule a follow-up microtask. + +The opaque atlas fast path retains the contour alpha already painted into each live surface. A real crop change updates a changed visible face with one `drawImage()` under preconfigured `source-in` compositing. That draw is irreducible under the no-mask, no-`clip-path`, live-image constraints; browser repaints and transform-only source changes do not call Cornerfill at all. + +## Workload evidence + +The checked prepared artifact `build/generated/lean-mario-runtime-oracle-20260729/lighting-atlases.json` records: + +| Field | Value | +| --- | ---: | +| retained faces | 1,213 | +| retained lighting states | 150,985 | +| source frames | 820 | +| changed lighting faces, mean | 187.23 | +| changed lighting faces, p50 | 131 | +| changed lighting faces, p95 | 498 | +| changed lighting faces, maximum | 927 | + +The exact values belong to that prepared artifact and should be refreshed if preparation changes. Their architectural meaning is stable: a normal frame changes a subset, and Cornerfill must preserve that sparsity. Repainting all 1,213 leaves because one root variable or stylesheet rule changed would erase the preparation work. + +The current runtime connects prepared visibility and changed-face decisions. A +face that changes while hidden retains only its latest logical crop and repaints +once when it becomes visible again. + +The completed [Firefox Mario ABBA trace](../output/playwright/firefox-mario/hardening-full-abba-v2-2026-08-02/README.md) +runs eight fresh OFF/ON lanes across all 820 source ticks with one identical +ordered workload stream. Each ON lane records 132,424 Cornerfill paints and zero +style checks. This proves the prepared dirty-only integration and retained +lifecycle under that fixture; it is not native visual parity. + +## Transform rule + +Face transforms and atlas crops are independent invalidation domains. + +- A `matrix3d()` change: no surface repaint. +- Parent/model transform: no surface repaint. +- Opacity, visibility, or backface behavior: no surface repaint, although visibility can suppress future pixel work. +- Canonical face size or DPR change: resize and repaint. +- Atlas crop/lighting field change: repaint that visible face. +- Fixed bevel geometry: resolve once per canonical size. + +This distinction is mandatory. Treating every style mutation as a paint mutation would make the fallback look correct while destroying the reason the retained/prepared architecture is fast. + +## Memory model + +A simple implementation allocates one small surface per active face: + +```text +backing bytes ~= sum(width * height * DPR^2 * 4) +``` + +Use the canonical local face dimensions, never the transformed screen-space bounding box. Track the total backing pixels, not merely the number of canvas objects. The implementation may later share immutable surfaces for faces with identical size, geometry, and crop, but it must not merge faces that can diverge on the next lighting frame. + +The atlas image must decode once per document/runtime and be shared by every face painter. A per-face decoded copy would be an implementation bug. + +WebKit's document-global CSS-canvas names use a bounded pool and still need +released-Safari qualification. Firefox registrations use explicit +`mozSetImageElement(id, null)` teardown; the complete ABBA trace records fresh +sessions and post-dispose resources without growth for the tested workload. + +## Completed and open criteria + +- Completed: real `texels.webp` crops paint through the production prepared path. +- Completed: the original leaf owns its CSS transform; transform-only changes + produce zero Cornerfill commits. +- Completed: lighting work follows the prepared changed/visible stream, shares + the atlas decode, and tears down registrations in the tested Firefox workload. +- Completed: eight 820-tick OFF/ON lanes have identical workload identity. +- Open: native-to-candidate triangle/texel pixels need an approved edge and + sampling tolerance; current oracle results remain `UNQUALIFIED`. +- Open: released Safari qualification and its long-lived named-canvas behavior. +- Not achieved/claimed: 30 Hz source playback on 60 Hz presentation. The current + trace is roughly 25 source FPS with about 50 ms display p95. + +## What this case does not prove + +Mario proves the most valuable paint-owned workload and lifecycle use case. It +does not prove native visual parity, descendant overflow clipping, shaped pointer +hit testing, replaced-content clipping, multi-fragment boxes, shaped +`backdrop-filter`, full CSS background grammar, arbitrary borders, external +effects, or general `border-shape`. Permanent carrier exclusions are not future +Mario gates. diff --git a/notes/README.md b/notes/README.md new file mode 100644 index 0000000..e8e3216 --- /dev/null +++ b/notes/README.md @@ -0,0 +1,107 @@ +# Cornerfill polyfill bible + +Status: hardened research and design synthesis, 2026-08-02. It is not blanket +implementation or parity authority. Current specifications define semantics, +the root [`README.md`](../README.md) and [`src/`](../src/) define shipped behavior, +and the executable [oracle contract](../oracle/README.md) defines qualification. +This bible records the reasoning, limits, implemented subset, and explicitly +labelled future work behind those contracts. + +Cornerfill is the no-`clip-path`, no-CSS-mask paint polyfill for CSS +`corner-shape`. `border-shape` is specification context and possible separately +authorized research, not an implied follow-on phase. The target is not a baked +Mario asset trick: it is a reusable painter that computes shape from CSS values +and places a live transparent CSS image on the original element. + +## Verdict + +The central route is feasible: + +1. Resolve `border-radius`, `corner-shape`, the element's paint inputs, and its untransformed border-box size. +2. Build the CSS Borders 4 contour in JavaScript. +3. Paint the background and border through that contour into a transparent canvas-backed image. +4. Expose the canvas as the element's live CSS image: + - `-webkit-canvas(name)` on WebKit; + - `-moz-element(#name)` on Firefox; + - an opt-in static data URL only when a live bridge is unavailable. +5. Leave `transform`, including `matrix3d()`, on the original element. The browser compositor rotates the finished image with the element. + +A native `paint()` backend remains an unimplemented future option. It is not a +current Cornerfill package path or release gate. + +That route was proven locally with a dynamically repainted transparent triangle under compound 3D rotation in Playwright's WebKit and Firefox engine builds. It used no `clip-path`, CSS mask, font, SVG layer, extra clipping element, or baked sprite alpha. See [the evidence record](evidence/README.md). + +There is one non-negotiable carrier boundary: the generated CSS image occupies +the host's border box and changes only pixels Cornerfill owns inside that box. +It cannot paint external shadow/outline outsets, install the browser's descendant +overflow clip or hit-test geometry, clip replaced content, represent multiple +box fragments, or supply shaped `backdrop-filter` clipping. Cornerfill is +therefore a strong fit for empty paint-owned leaves such as PolyCSS faces, but +it cannot honestly claim complete `corner-shape` semantics for arbitrary DOM. +Ordinary author `filter`, transforms, opacity, stacking, and pseudo-elements stay +on the original element and remain browser-owned. + +## What this is not + +- Not Hyperellipse's `clip-path: path(...)` fallback. +- Not a font glyph used as a stencil. +- Not triangle alpha baked into an application sprite sheet. +- Not nested transformed boxes with `overflow`. +- Not an SVG or CSS-mask renderer hidden behind a polyfill API. +- Not a claim that Houdini Paint Worklets ship in Safari or Firefox. They do not; the live canvas bridges emulate the useful image-producing part. + +## Compatibility layers + +| Layer | Selection | Output | Intended engines | +| --- | --- | --- | --- | +| Native property | A requirement-aware native capability gate passes | Browser-native `corner-shape` or `border-shape` | Complete native implementations | +| Native Paint (future, unimplemented) | No current package selection | `paint(cornerfill)` | Possible later Chromium/test path | +| WebKit live surface | `document.getCSSCanvasContext` exists | `-webkit-canvas(cornerfill-…)` | Safari/WebKit fallback | +| Gecko live surface | `-moz-element()` plus `mozSetImageElement` works | `-moz-element(#cornerfill-…)` | Firefox fallback | +| Static image | Explicitly enabled and no live bridge exists | Data URL | Last-resort, non-animation-grade mode only | + +`CSS.supports('corner-shape: bevel')` is not a sufficient native gate. Firefox's first rendering landing is pref-gated and still has separate open border and shadow work. The gate must be tied to the semantics the caller needs. + +## Scope in one sentence + +Cornerfill should promise only explicitly implemented, oracle-labelled, +border-box-contained host paint and must refuse external outsets, descendant +overflow clipping, replaced-element clipping, multi-fragment boxes, shaped +`backdrop-filter`, and pointer hit testing. + +## Reading order + +| Note | Status | Purpose | +| --- | --- | --- | +| [00 — Verdict and scope](00-verdict-and-scope.md) | Current contract synthesis | Exact product promise, feasibility matrix, and hard boundary | +| [01 — Spec contract](01-spec-contract.md) | Current semantic synthesis | Normative geometry, property semantics, and recorded spec defects | +| [02 — Engine implementations](02-engine-implementations.md) | Point-in-time support snapshot | Chromium, WebKit, Firefox, support state, and source-level clues | +| [03 — Live CSS image breakthrough](03-live-css-image-backends.md) | Implemented WebKit/Gecko transport plus historical research | Why the live output rotates safely; future paths are labelled | +| [04 — Architecture](04-architecture.md) | Implemented flow plus superseded/future sketches | Current ownership and the status of earlier module/backend proposals | +| [05 — Geometry and painting](05-geometry-and-painting.md) | Implemented core plus qualified limits | Contours, raster boolean operations, borders, images, and contained effects | +| [06 — Capture and invalidation](06-capture-and-invalidation.md) | Implemented lifecycle plus labelled proposals | CSS capture, observers, animation sampling, caching, and teardown | +| [07 — Limits and rejected routes](07-limits-and-rejected-routes.md) | Current hard limits | Why the alternatives and out-of-box semantics do not fit this backend | +| [08 — Verification plan](08-verification-plan.md) | Qualification framework and evidence ledger | Current proof status and optional future release matrix | +| [09 — PolyCSS case study](09-polycss-case-study.md) | Implemented workload case | The Mario retained-face workload, completed runtime proof, and open visual qualification | +| [References](references.md) | Source index | Primary sources, pinned revisions, bugs, tests, and prior art | + +Executable evidence is maintained separately in the [Cornerfill oracle harness](../oracle/README.md). + +## Research rules + +- Treat the [CSS Borders 4 editor's draft](https://drafts.csswg.org/css-borders-4/) as the semantic target, but do not transcribe it blindly. The local algebraic audit found that its printed forward half-corner interpolation expression is not the inverse of the following conversion. [CSSWG issue 14157](https://github.com/w3c/csswg-drafts/issues/14157) separately tracks the signed-versus-convex half-corner and concave hull-direction problem; it is not the authority for the distinct printed-expression defect. +- Treat native engine source as implementation evidence and a differential oracle, not as license-free code to paste. +- Keep `corner-shape` and `border-shape` separate. The former shapes the corner regions established by `border-radius`; the latter accepts arbitrary basic shapes and has stroke and fill modes. +- Distinguish engine proof from product proof. The local WebKit run is not a shipped-Safari certification. +- A visual fallback is not semantic equivalence unless overflow and hit testing are also demonstrated. + +## The shortest implementation thesis + +Study and, where attribution permits, narrowly adapt useful scheduling and +surface-selection ideas from the archived Apache-2.0 +[GoogleChromeLabs CSS Paint polyfill](https://github.com/GoogleChromeLabs/css-paint-polyfill). +The shipped implementation instead uses an independently written contour and +paint pipeline over WebKit/Gecko live surfaces. It owns only admitted background, +border, and contained-effect subsets inside the border box. Transform-only +changes and author-filter changes remain browser/compositor work and must never +trigger Cornerfill repaint. diff --git a/notes/evidence/README.md b/notes/evidence/README.md new file mode 100644 index 0000000..e475f44 --- /dev/null +++ b/notes/evidence/README.md @@ -0,0 +1,85 @@ +# Live CSS image evidence + +Status: mechanism probe completed 2026-08-01. This is evidence for the live-image transport and transform premise only. + +## Question + +Can Safari/WebKit and Firefox display a dynamically changing transparent Canvas surface as the background image of the original element, while that element remains under an ordinary compound CSS 3D transform? + +If yes, Cornerfill does not need a font, SVG overlay, extra clipping face, per-frame data URL, baked alpha atlas, or `clip-path` to keep a generated silhouette attached to a rotating PolyCSS face. + +## Probe + +The preserved [live-paint-surface-probe.html](live-paint-surface-probe.html) creates one `240px` by `160px` face and applies: + +```css +transform: rotateX(31deg) rotateY(47deg) rotateZ(13deg); +``` + +It selects one of two engines' legacy image bridges: + +- WebKit: `document.getCSSCanvasContext('2d', name, width, height)` with `background-image: -webkit-canvas(name)`; +- Firefox: a canvas registered with `document.mozSetImageElement(id, canvas)` and `background-image: -moz-element(#id)`. + +The painter clips a gradient to a triangle, leaving transparent pixels outside it. Calling `repaint(1)` clears and redraws the same backing surface with different colors. The CSS image token and transform are not replaced. + +The document exposes simple observation state: + +```text +document.documentElement.dataset.ready = "true" +document.documentElement.dataset.backend = "webkit-canvas" | "moz-element" +document.documentElement.dataset.phase = "0" | "1" +``` + +## Recorded result + +| Browser runner | Initial | Mutation | Observed result | +| --- | --- | --- | --- | +| Playwright WebKit engine build | ready, `webkit-canvas`, phase 0 | `repaint(1)` | phase 1 cyan/blue triangle repainted in place under the same transform | +| Playwright Firefox engine build | ready, `moz-element`, phase 0 | `repaint(1)` | phase 1 cyan/blue triangle repainted in place under the same transform | + +Source-workspace screenshots: + +- [WebKit initial](/Users/ekrof/fed/cssGraphics/.playwright-cli/page-2026-08-01T17-57-55-255Z.png) +- [WebKit repaint](/Users/ekrof/fed/cssGraphics/.playwright-cli/page-2026-08-01T17-58-24-908Z.png) +- [Firefox initial](/Users/ekrof/fed/cssGraphics/.playwright-cli/page-2026-08-01T17-59-07-135Z.png) +- [Firefox repaint](/Users/ekrof/fed/cssGraphics/.playwright-cli/page-2026-08-01T17-59-25-017Z.png) + +The original scratch probe remains at `/Users/ekrof/fed/cssGraphics/output/playwright/live-paint-surface-probe.html`; the copy beside this record is the durable research artifact. + +## What this establishes + +- Transparent generated pixels can replace the rectangular background pixels. +- Updating the backing canvas invalidates the CSS image consumer in both tested engine builds. +- The generated local image remains attached while the original element is transformed in 3D. +- No independent overlay needs to mirror the element's transform. +- The two nonstandard live-image APIs are real implementation routes worth productizing and testing in released browsers. + +## What this does not establish + +- This was not a Safari Stable or Safari Technology Preview run. Playwright WebKit is engine evidence, not shipped-product certification. +- It did not implement the CSS Borders 4 superellipse contour. +- It did not compare against native `corner-shape` pixels. +- It did not test borders, shadows, descendant overflow, hit testing, replaced content, CORS images, DPR changes, or teardown. +- It did not measure 1,213 surfaces or an 820-frame animation. +- It did not prove that WebKit named-canvas resources are reclaimed under long-lived name churn. + +Those statements describe this historical probe, not the repository's later +evidence. The production adapter and completed Firefox 1,213-leaf/820-tick ABBA +workload are indexed in [08 — Verification plan](../08-verification-plan.md); +native-to-candidate pixels remain `UNQUALIFIED` and released Safari remains open. + +## Reproduction discipline + +The probe is intentionally static and dependency-free. When rerunning it: + +1. Run one browser engine at a time. +2. Wait for `dataset.ready === "true"`. +3. Record the backend and initial phase. +4. Capture the initial painted frame. +5. call `window.repaint(1)`; +6. wait for `dataset.phase === "1"` and two presentation frames; +7. capture the repaint; +8. close the runner and verify its child processes have exited. + +Do not turn this tiny evidence page into a full-scene stress harness. The full workload belongs in a separately invoked performance test with counters and cleanup checks. diff --git a/notes/evidence/live-paint-surface-probe.html b/notes/evidence/live-paint-surface-probe.html new file mode 100644 index 0000000..95e6cb8 --- /dev/null +++ b/notes/evidence/live-paint-surface-probe.html @@ -0,0 +1,68 @@ + + +Live paint surface rotation probe + +
+ diff --git a/notes/references.md b/notes/references.md new file mode 100644 index 0000000..189fbc5 --- /dev/null +++ b/notes/references.md @@ -0,0 +1,110 @@ +# References + +Snapshot date: 2026-08-01. Immutable source links are pinned where the host permits it. Release notes and bug trackers are live documents and must be rechecked before making a current-support claim. + +Status: source index. A citation supports only the claim its linked text actually +makes; local algebraic findings and project/backend limits are labelled as such. + +## Standards and specification history + +- [CSS Borders and Box Decorations Level 4 — editor's draft](https://drafts.csswg.org/css-borders-4/): current `corner-shape`, `superellipse()`, `border-shape`, contour, interpolation, overflow, border, and shadow contract. +- [CSS Borders and Box Decorations Level 4 — W3C publication](https://www.w3.org/TR/css-borders-4/): dated published snapshot; use the editor's draft for the newest text and record which one a test targets. +- [CSS Painting API Level 1](https://drafts.css-houdini.org/css-paint-api-1/): defines `paint()`, worklet inputs, paint invalidation, the restricted 2D context, and output as a CSS ``; its introduction leaves custom clipping to a possible future level. +- [CSS Properties and Values API Level 1](https://drafts.css-houdini.org/css-properties-values-api-1/): registration of typed custom properties, including `` carriers for Paint input. +- [CSS Typed OM Level 1](https://drafts.css-houdini.org/css-typed-om-1/): typed computed values used by native worklets. +- [CSS Shapes Level 1](https://drafts.csswg.org/css-shapes-1/): underlying `` concepts used by `border-shape`. +- [CSS Images Level 4](https://drafts.csswg.org/css-images-4/): gradient color interpolation, image fetching/invalid-image behavior, and UA-specific `image-set()` selection. +- [HTML Canvas 2D](https://html.spec.whatwg.org/multipage/canvas.html): Canvas gradient interpolation and `drawImage()` source behavior. +- [CSS Basic User Interface Level 4 — outlines](https://drafts.csswg.org/css-ui-4/#outline-props): outline paint position/order and its distinction from background paint. +- [Pinned CSSWG Borders 4 source](https://github.com/w3c/csswg-drafts/blob/13b14ec48af0219c893713d670cf80d8c014a648/css-borders-4/Overview.bs): source snapshot audited for this notebook. +- [CSSWG issue 11608 — interpolate across the corner diagonal](https://github.com/w3c/csswg-drafts/issues/11608): resolved discussion behind the current interpolation direction. +- [CSSWG issue 14157 — signed/convex half-corner and concave hull direction](https://github.com/w3c/csswg-drafts/issues/14157): open issue distinct from this bible's local finding that the printed forward interpolation expression is not the inverse of the following conversion. +- [CSSWG issue 14158 — corner paths and overlapping shape components](https://github.com/w3c/csswg-drafts/issues/14158): editorial clarification around pre-clip paths and boolean combination, not evidence that Cornerfill can carry external paint. + +## Web-platform tests + +WPT source snapshot: [`4a5810a124fa0523dd2494996bf1542d4b67f394`](https://github.com/web-platform-tests/wpt/tree/4a5810a124fa0523dd2494996bf1542d4b67f394). + +- [Corner-shape test directory](https://github.com/web-platform-tests/wpt/tree/4a5810a124fa0523dd2494996bf1542d4b67f394/css/css-borders/corner-shape): fill, border, image/video, overflow, hit-test, shadow, writing-mode, extreme-value, and animation coverage. +- [Corner-shape hit-test test](https://github.com/web-platform-tests/wpt/blob/4a5810a124fa0523dd2494996bf1542d4b67f394/css/css-borders/corner-shape/corner-shape-hittest.html): evidence that native geometry participates beyond background pixels. +- [Paint 2D CSS image test](https://github.com/web-platform-tests/wpt/blob/4a5810a124fa0523dd2494996bf1542d4b67f394/css/css-paint-api/paint2d-image.https.html): exercises a style-map `CSSImageValue` as a `drawImage()` input. +- [Current WPT results dashboard for corner-shape](https://wpt.fyi/results/css/css-borders/corner-shape): useful for triage, but not a substitute for pinned browser evidence. + +## Chromium/Blink + +Pinned Chromium source snapshot: [`68daa42e384169237794b95b703647edd70c3b6b`](https://chromium.googlesource.com/chromium/src/+/68daa42e384169237794b95b703647edd70c3b6b/). + +- [Chrome 139 release notes](https://developer.chrome.com/release-notes/139): shipped `corner-shape`, `superellipse()`, and `squircle`. +- [Chrome 147 release notes](https://developer.chrome.com/release-notes/147): shipped `border-shape`. +- [Chrome 150 release notes](https://developer.chrome.com/release-notes/150): shipped `background-clip: border-area`. +- [The corner cases of implementing CSS corner-shape in Blink](https://developer.chrome.com/blog/implementing-corner-shape): primary implementation account covering curve fitting, non-uniform borders, shadows, and clipping. +- [`ContouredRect` declaration](https://chromium.googlesource.com/chromium/src/+/68daa42e384169237794b95b703647edd70c3b6b/third_party/blink/renderer/platform/geometry/contoured_rect.h): corner curvature representation and geometry interface. +- [`ContouredRect` implementation](https://chromium.googlesource.com/chromium/src/+/68daa42e384169237794b95b703647edd70c3b6b/third_party/blink/renderer/platform/geometry/contoured_rect.cc): inversion, containment, intersection, and contour behavior. +- [`PathBuilder` implementation](https://chromium.googlesource.com/chromium/src/+/68daa42e384169237794b95b703647edd70c3b6b/third_party/blink/renderer/platform/geometry/path_builder.cc): line/conic/cubic generation and Skia path operations for contoured rectangles. + +Chromium is the first differential oracle because the feature is shipped and participates in a broader native paint/geometry pipeline. Its source is implementation evidence, not the polyfill's specification. + +## WebKit + +Pinned WebKit source snapshot: [`3108e0a68c0ea7f887716cdb73cbd3f9109ddc78`](https://github.com/WebKit/WebKit/tree/3108e0a68c0ea7f887716cdb73cbd3f9109ddc78). + +- [Unified preferences — `CSSCornerShapeEnabled`](https://github.com/WebKit/WebKit/blob/3108e0a68c0ea7f887716cdb73cbd3f9109ddc78/Source/WTF/Scripts/Preferences/UnifiedWebPreferences.yaml#L1175-L1187): preview category and default-off state in the audited revision. +- [Unified preferences — `CSSPaintingAPIEnabled`](https://github.com/WebKit/WebKit/blob/3108e0a68c0ea7f887716cdb73cbd3f9109ddc78/Source/WTF/Scripts/Preferences/UnifiedWebPreferences.yaml#L1442-L1455): testable/experimental Paint API state. +- [`CornerShapeUtilities.h`](https://github.com/WebKit/WebKit/blob/3108e0a68c0ea7f887716cdb73cbd3f9109ddc78/Source/WebCore/platform/graphics/CornerShapeUtilities.h): native contour API and types. +- [`CornerShapeUtilities.cpp`](https://github.com/WebKit/WebKit/blob/3108e0a68c0ea7f887716cdb73cbd3f9109ddc78/Source/WebCore/platform/graphics/CornerShapeUtilities.cpp): explicit bevel/scoop/round cases, convex/concave conversion, general cubic construction, inset/outset logic, and the current opposite-corner TODO. +- [`BorderShape.cpp`](https://github.com/WebKit/WebKit/blob/3108e0a68c0ea7f887716cdb73cbd3f9109ddc78/Source/WebCore/rendering/BorderShape.cpp): resolves style geometry into outer and inner paths. +- [`Document.idl`](https://github.com/WebKit/WebKit/blob/3108e0a68c0ea7f887716cdb73cbd3f9109ddc78/Source/WebCore/dom/Document.idl#L91-L96): nonstandard `getCSSCanvasContext()` API. +- [Named canvas incremental repaint layout test](https://github.com/WebKit/WebKit/blob/3108e0a68c0ea7f887716cdb73cbd3f9109ddc78/LayoutTests/fast/canvas/canvas-as-image-incremental-repaint.html): verifies that drawing updates a `-webkit-canvas()` CSS consumer. +- [Animated canvas-as-background manual test](https://github.com/WebKit/WebKit/blob/3108e0a68c0ea7f887716cdb73cbd3f9109ddc78/ManualTests/animated-canvas-as-background.html): additional live-image evidence. + +## Gecko/Firefox + +Pinned Firefox source snapshot: [`56ad29049a11ced909e25d7e1fabcc6155e1a516`](https://github.com/mozilla-firefox/firefox/tree/56ad29049a11ced909e25d7e1fabcc6155e1a516). + +- [Bug 1726232 — `corner-shape` meta](https://bugzilla.mozilla.org/show_bug.cgi?id=1726232): current root tracker. +- [Bug 2035317 — initial corner-shape rendering](https://bugzilla.mozilla.org/show_bug.cgi?id=2035317): fixed initial rendering landing, explicitly separated from incomplete border/shadow follow-ups. +- [Bug 2047627 — border rendering](https://bugzilla.mozilla.org/show_bug.cgi?id=2047627): follow-up tracked as assigned at this snapshot. +- [Bug 2048908 — box shadows](https://bugzilla.mozilla.org/show_bug.cgi?id=2048908): open follow-up. +- [Bug 2058091 — inset/display-item behavior](https://bugzilla.mozilla.org/show_bug.cgi?id=2058091): open follow-up. +- [Bug 1982766 — `border-shape`](https://bugzilla.mozilla.org/show_bug.cgi?id=1982766): open implementation bug. +- [Bug 1302328 — CSS Painting API](https://bugzilla.mozilla.org/show_bug.cgi?id=1302328): open, unassigned meta bug. +- [Style longhand definitions](https://github.com/mozilla-firefox/firefox/blob/56ad29049a11ced909e25d7e1fabcc6155e1a516/servo/components/style/properties/longhands.toml#L3175-L3273): pref-gated corner-shape properties. +- [Default preference](https://github.com/mozilla-firefox/firefox/blob/56ad29049a11ced909e25d7e1fabcc6155e1a516/modules/libpref/init/StaticPrefList.yaml#L10922-L10927): `layout.css.corner-shape.enabled` state at the pinned revision. +- [WPT metadata](https://github.com/mozilla-firefox/firefox/blob/56ad29049a11ced909e25d7e1fabcc6155e1a516/testing/web-platform/meta/css/css-borders/corner-shape/__dir__.ini): forces the preference for the test directory. +- [Initial implementation commit](https://github.com/mozilla-firefox/firefox/commit/8880cba9faec): landing that transports corner parameters into WebRender. +- [`ellipse.glsl`](https://github.com/mozilla-firefox/firefox/blob/56ad29049a11ced909e25d7e1fabcc6155e1a516/gfx/wr/webrender/res/ellipse.glsl): signed-distance superellipse rendering. +- [`Document.webidl`](https://github.com/mozilla-firefox/firefox/blob/56ad29049a11ced909e25d7e1fabcc6155e1a516/dom/webidl/Document.webidl#L210-L241): documents `mozSetImageElement()` and unregistering with `null`. +- [Detached-canvas invalidation reftest](https://github.com/mozilla-firefox/firefox/blob/56ad29049a11ced909e25d7e1fabcc6155e1a516/layout/reftests/image-element/canvas-outside-document-invalidate-01.html): verifies live repaint through `-moz-element()`. + +## Polyfills and geometry prior art + +- [GoogleChromeLabs `css-paint-polyfill`](https://github.com/GoogleChromeLabs/css-paint-polyfill/tree/9dff83a8131fc7bb98490bfd2e05112c39842df8): archived Apache-2.0 project that discovered and implemented the same engine-specific live CSS image bridges. +- [`css-paint-polyfill` main source](https://github.com/GoogleChromeLabs/css-paint-polyfill/blob/9dff83a8131fc7bb98490bfd2e05112c39842df8/src/index.js): backend detection, canvases, observers, style interception, update queue, and static fallback to audit before adaptation. +- [`css-paint-polyfill` license](https://github.com/GoogleChromeLabs/css-paint-polyfill/blob/9dff83a8131fc7bb98490bfd2e05112c39842df8/LICENSE): Apache License 2.0. +- [Hyperellipse package README at pinned revision](https://github.com/mikhailmogilnikov/hyperellipse/blob/d530a4ec47f31c146b0af80a37c151d4f9f8cc5b/packages/hyperellipse/README.md#L107-L119): documents its `clip-path`/SVG fallback and uniform-solid fallback-border ceiling; dashed/dotted/per-side forms flatten. +- [Hyperellipse renderer at pinned revision](https://github.com/mikhailmogilnikov/hyperellipse/blob/d530a4ec47f31c146b0af80a37c151d4f9f8cc5b/packages/hyperellipse/src/internal/render.ts#L436-L548): applies `clip-path: path(...)` and constructs SVG/pseudo-element border layers. +- [jsnkuhn `corner-shape` at pinned revision](https://github.com/jsnkuhn/corner-shape/tree/39523ad8bdf3148d7341ec553419f004cb639ca6): earlier JavaScript geometry/polyfill prior art; useful for comparison, not a substitute for current spec and native differentials. + +## Local project and evidence + +- [Cornerfill evidence record](evidence/README.md): exact local live-surface probe, result scope, and screenshot paths. +- [Preserved live-surface probe](evidence/live-paint-surface-probe.html): self-contained WebKit/Firefox transport experiment. +- [Executable oracle contract](../oracle/README.md): production-adapter qualification states, exact native A/A requirement, and current contained-effect/external-outset boundary. +- [Complete Firefox Mario ABBA trace](../output/playwright/firefox-mario/hardening-full-abba-v2-2026-08-02/README.md): eight fresh 820-tick workload-equivalent lanes with timing, paint, and teardown evidence; not native visual parity. +- `/Users/ekrof/fed/cssGraphics/src/adapters/super-mario-64/package.mjs`: current paint declarations and atlas source. +- `/Users/ekrof/fed/cssGraphics/src/adapters/super-mario-64/player/scene.ts`: current retained `u` face creation. +- `/Users/ekrof/fed/cssGraphics/src/adapters/super-mario-64/stages/playbackPacket.mjs`: 820-frame, 1,213-leaf prepared playback contract. +- `/Users/ekrof/fed/cssGraphics/src/adapters/super-mario-64/stages/lighting.mjs`: changed-only prepared lighting state and runtime contract. +- `/Users/ekrof/fed/cssGraphics/build/generated/lean-mario-runtime-oracle-20260729/lighting-atlases.json`: inspected workload snapshot used in the case study; generated evidence, not a permanent API. + +## Source and license policy + +The intended clean-room hierarchy is: + +1. standards text for behavior; +2. WPT for the conformance surface; +3. native engines as differential oracles and algorithmic clues; +4. independently written geometry and painter code; +5. narrowly adapted Apache-2.0 live-surface scheduling/backend code only if its attribution and license are preserved. + +Relevant upstream licenses differ: Chromium is BSD-style, the audited WebKit corner utility files carry an Apple two-clause BSD-style notice, Firefox is MPL-2.0, and the archived Google polyfill is Apache-2.0. Review notices before copying code; source links alone do not satisfy redistribution obligations. diff --git a/oracle/README.md b/oracle/README.md new file mode 100644 index 0000000..b0ae311 --- /dev/null +++ b/oracle/README.md @@ -0,0 +1,154 @@ +# Cornerfill executable oracle + +This harness captures the same deterministic fixtures through: + +1. native Chromium `corner-shape`; +2. the forced production Cornerfill runtime in Chromium; +3. optionally, the same candidate pixels through WebKit `-webkit-canvas()` and Firefox `-moz-element()`. + +It is the executable companion to [the verification plan](../notes/08-verification-plan.md). It is not itself the Cornerfill runtime. + +## Evidence contract + +Every run writes an immutable directory containing: + +```text +oracle/results// + manifest.json + README.md + frames/ + native-chrome-a/frame_0000.png + native-chrome-b/frame_0000.png + candidate-chrome/frame_0000.png + candidate-webkit/frame_0000.png # when requested + candidate-firefox/frame_0000.png # when requested + composites/candidate-firefox/ + frame_0000.black.png # retained alpha-reconstruction inputs + frame_0000.white.png + reports// + report.json + report.csv + summary.md + diffs/frame_0000.png + driver/.playwright-cli/ # raw driver diagnostics +``` + +Raw numbered PNGs are the source of truth. The manifest binds the fixture/painter source hashes, `texels.webp` hash when used, host identity, browser user agent, backend, DPR, computed styles, frame-to-case mapping, and capture order. + +The comparator preserves alpha. It reports alpha independently, compares premultiplied RGB so invisible transparent RGB is ignored, separates boundary and fully opaque interior error, and reports connected changed regions. + +## Safety rule + +Browsers are launched strictly one at a time and closed in `finally` before another engine starts. The harness never calls `playwright-cli kill-all`, never launches a full scene, and never starts concurrent capture workers. + +The driver uses direct Playwright page code because it needs clipped raster evidence and no interactive element references. Every CLI command has a 30-second wall timeout so a wedged driver cannot run indefinitely. + +The current Playwright Firefox/BiDi backend cannot request a transparent page background. Firefox frames are therefore captured twice against opaque black and white, then reconstructed as RGBA from the two composites. Both opaque inputs are retained, the manifest records the method and reconstruction diagnostics, and the reconstructor rejects transparent inputs or mismatched dimensions. This preserves painted `-moz-element()` evidence instead of substituting the source canvas. + +The default is Chromium only: + +```bash +npm run build +node scripts/oracle.mjs run +``` + +Run the build before invoking `scripts/oracle.mjs` directly; captures load the generated production modules from `dist/`. + +Explicit cross-engine capture remains serial: + +```bash +node scripts/oracle.mjs run --browsers=chrome,webkit,firefox +``` + +If a requested browser binary is missing, install only that browser through Playwright CLI, then rerun. Do not work around a missing engine by silently relabeling another browser. + +## Commands + +List the fixed corpus: + +```bash +node scripts/oracle.mjs list +``` + +Run the small integration proof: + +```bash +node scripts/oracle.mjs run --cases=bevel,round,mario-texel-face +``` + +Run the representative Chrome/WebKit/Firefox proof, sequentially. It includes a compound 3D transform and the Mario crop: + +```bash +node scripts/oracle.mjs run --browsers=chrome,webkit,firefox --cases=bevel,bevel-rotated,mario-texel-face +``` + +Run selected cases: + +```bash +node scripts/oracle.mjs run --cases=bevel,squircle,mario-texel-face +``` + +Choose an explicit output directory: + +```bash +node scripts/oracle.mjs run --out=/absolute/path/to/new-run +``` + +The harness refuses to overwrite an existing output directory. + +## Qualification states + +- `PASS`: an approved tolerance exists and every structural and pixel gate passes. +- `FAIL`: an approved tolerance exists and at least one gate fails. +- `UNQUALIFIED`: metrics and artifacts exist, but no candidate tolerance has been approved. +- `INVALID`: missing/mismatched frames or dimensions make comparison meaningless. +- `INVALID ORACLE`: the capture driver could not prove that Chromium computed the requested native property. + +Native A/A calibration is approved at exact zero and must pass. Native-vs-candidate tolerances begin deliberately unapproved in [tolerances.json](tolerances.json). A candidate run therefore produces measurements and heatmaps without being mislabeled as parity. + +`node scripts/oracle.mjs run --enforce-candidate` enables enforcement. It should remain red until reviewed evidence justifies explicit tolerances and the candidate implementation passes them. + +## Production candidate adapter + +[painter.mjs](painter.mjs) is now only the adapter from fixed oracle cases into the production build in [`dist/`](../dist/), generated from [`src/`](../src/). The fixture uses the production parser, geometry/cache, painter, live-surface backend, ownership overrides, invalidation scheduler, and teardown. There is no separate reference-candidate renderer. + +`controller.capabilities.paint` booleans mean that a production code path is +implemented for the admitted grammar. They do not override this chapter's +qualification states and must not be read as native-differential `PASS`. + +For the `bevel` case, every requested browser also executes a post-capture lifecycle proof: a literal `matrix3d()` change must cause zero paints, a carrier style change and a resize must each cause one paint, and disposal must remove the active entry. Failure makes the oracle run fail structurally. + +For `mario-texel-face`, a post-capture prepared crop update must repaint the same surface, reuse the already-decoded atlas, resolve the next exact 4×4 source field, and unregister cleanly. This proof also fails the run structurally if any invariant is lost. + +The `opposite-concave-overlap` fixture now exercises the CSSWG hull scale. Current Chromium's path-intersection result is retained as implementation evidence, so that frame is still deliberately unqualified rather than treated as a tolerance pass. The border fixtures exercise uniform and unequal shaped rings. `inset-shadow-shaped` and `outline-contained-shaped` exercise the two effects that fit entirely inside the live image; the outline fixture is an empty paint-owned host, because a background image cannot reproduce native outline stacking over arbitrary foreground/pseudos. External outsets remain explicitly unsupported. + +`raster-repeat-origin` retains spec-resolved repeat/origin geometry while exposing +the remaining native-versus-Canvas raster sampling difference. +`background-blend-multiply` is the only blend fixture: one explicitly opaque +static raster over one opaque color, composited by the production painter with +Canvas `multiply` and no scratch surface. It does not imply support for general +blend modes, multiple layers, gradients, translucent inputs, or prepared atlas +updates. Both candidate comparisons remain `UNQUALIFIED` regardless of their +measured pixel counts. + +## Mario evidence + +The `mario-texel-face` case reads, but does not copy or modify, the existing source at: + +```text +/Users/ekrof/fed/cssGraphics/dist/cssface/models/mario/assets/texels.webp +``` + +Override it portably with `CORNERFILL_MARIO_TEXELS` or `--mario-texels=`. The run manifest hashes the exact file. + +The fixture uses prepared face index 7: + +```text +element: 64 x 44 CSS pixels +atlas: 4852 x 3280 pixels +background-size: 77632px 36080px +background-position: -448px 0 +resolved source crop: x=28, y=0, width=4, height=4 +``` + +That is the actual 4×4 lighting field stretched across one retained triangular face. diff --git a/oracle/cases.mjs b/oracle/cases.mjs new file mode 100644 index 0000000..3ba558a --- /dev/null +++ b/oracle/cases.mjs @@ -0,0 +1,378 @@ +const corners = (rx, ry = rx) => Object.freeze([ + Object.freeze({ rx, ry }), + Object.freeze({ rx, ry }), + Object.freeze({ rx, ry }), + Object.freeze({ rx, ry }), +]); + +const shapes = (...values) => Object.freeze(values.length === 1 + ? [values[0], values[0], values[0], values[0]] + : values); + +const solid = (color) => Object.freeze({ kind: "solid", color }); + +const blendRaster = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAABkCAYAAAABtjuPAAABe0lEQVR42u3SQQ2AMBAAwfrj2x9BCd4w0QQHNVADxcNxyX3msQY201Y/d2b9fVLb40rtuGdq/v2rGQgggAACaCCAAAIIoIEAAggggAYCCCCAABoIIIAAAmgggAACCKCBAAIIIIAGAggggAAC6B+AAAIIoH8hgMAAU/kPQAABBBBAAwEEEEAADQQQQAABNBBAAAEE0EAAAQQQQAMBBBBAAA0EEEAAATQQQAABBBBA/wAEEEAA/YsBBAaYyn8AAggggAAaCCCAAAJoIIAAAgiggQACCCCABgIIIIAAGggggAACaCCAAAIIoIEAAggggAD6ByCAAALoXwwgMMBU/gMQQAABBNBAAAEEEEADAQQQQAANBBBAAAE0EEAAAQTQQAABBBBAAwEEEEAADQQQQAABBNA/AAEEEED/YgCBAabyH4AAAggggAYCCCCAABoIIIAAAmgggAACCKCBAAIIIIAGAggggAAaCCCAAAJoIIAAAggggP4BCCCAAPoX6gNlH7bAXGC7VAAAAABJRU5ErkJggg=="; + +const gradient = Object.freeze({ + kind: "linear-gradient", + css: "linear-gradient(135deg, #75f6ff 0%, #2757d7 52%, #172a73 100%)", + from: Object.freeze([0, 0]), + to: Object.freeze([1, 1]), + stops: Object.freeze([ + Object.freeze([0, "#75f6ff"]), + Object.freeze([0.52, "#2757d7"]), + Object.freeze([1, "#172a73"]), + ]), +}); + +export const ORACLE_CASE_SCHEMA = "cornerfill-oracle-case@1"; + +export const oracleCases = Object.freeze([ + Object.freeze({ + id: "bevel", + description: "Symmetric bevel keyword on an opaque solid fill", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "48px", + radii: corners(48), + shapeCss: "bevel", + shapeParameters: shapes(0), + paint: solid("#f05a47"), + }), + Object.freeze({ + id: "round", + description: "Ordinary round corner baseline", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "48px", + radii: corners(48), + shapeCss: "round", + shapeParameters: shapes(1), + paint: gradient, + }), + Object.freeze({ + id: "squircle", + description: "Convex exponent-four squircle", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "58px", + radii: corners(58), + shapeCss: "squircle", + shapeParameters: shapes(2), + paint: solid("#6f5cff"), + }), + Object.freeze({ + id: "scoop", + description: "Concave scoop keyword", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "48px", + radii: corners(48), + shapeCss: "scoop", + shapeParameters: shapes(-1), + paint: solid("#20bb86"), + }), + Object.freeze({ + id: "notch", + description: "Concave limiting notch keyword", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "42px", + radii: corners(42), + shapeCss: "notch", + shapeParameters: shapes(Number.NEGATIVE_INFINITY), + paint: solid("#f5bf38"), + }), + Object.freeze({ + id: "superellipse-positive", + description: "Arbitrary finite convex superellipse parameter", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "58px 42px / 44px 62px", + radii: Object.freeze([ + Object.freeze({ rx: 58, ry: 44 }), + Object.freeze({ rx: 42, ry: 62 }), + Object.freeze({ rx: 58, ry: 44 }), + Object.freeze({ rx: 42, ry: 62 }), + ]), + shapeCss: "superellipse(3)", + shapeParameters: shapes(3), + paint: gradient, + }), + Object.freeze({ + id: "superellipse-negative", + description: "Arbitrary finite concave superellipse parameter", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "52px", + radii: corners(52), + shapeCss: "superellipse(-1.5)", + shapeParameters: shapes(-1.5), + paint: solid("#e75ca8"), + }), + Object.freeze({ + id: "mixed-asymmetric", + description: "Four shape values and asymmetric elliptical radii", + size: Object.freeze([230, 170]), + captureSize: Object.freeze([310, 250]), + radiusCss: "64px 34px 52px 24px / 34px 58px 28px 46px", + radii: Object.freeze([ + Object.freeze({ rx: 64, ry: 34 }), + Object.freeze({ rx: 34, ry: 58 }), + Object.freeze({ rx: 52, ry: 28 }), + Object.freeze({ rx: 24, ry: 46 }), + ]), + shapeCss: "squircle bevel scoop round", + shapeParameters: shapes(2, 0, -1, 1), + paint: gradient, + }), + Object.freeze({ + id: "zero-radius", + description: "Zero radius makes corner-shape visually inert", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "0", + radii: corners(0), + shapeCss: "notch", + shapeParameters: shapes(Number.NEGATIVE_INFINITY), + paint: solid("#db6644"), + }), + Object.freeze({ + id: "border-round", + description: "Uniform solid border and round inner contour", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "48px", + radii: corners(48), + shapeCss: "round", + shapeParameters: shapes(1), + border: Object.freeze({ width: 10, color: "#f4efdf" }), + paint: solid("#473bbf"), + }), + Object.freeze({ + id: "border-shaped-unequal", + description: "One-color solid border with unequal widths and four corner shapes", + size: Object.freeze([230, 170]), + captureSize: Object.freeze([310, 250]), + radiusCss: "64px 34px 52px 24px / 34px 58px 28px 46px", + radii: Object.freeze([ + Object.freeze({ rx: 64, ry: 34 }), + Object.freeze({ rx: 34, ry: 58 }), + Object.freeze({ rx: 52, ry: 28 }), + Object.freeze({ rx: 24, ry: 46 }), + ]), + shapeCss: "squircle bevel scoop notch", + shapeParameters: shapes(2, 0, -1, Number.NEGATIVE_INFINITY), + border: Object.freeze({ + widths: Object.freeze([8, 16, 22, 5]), + color: "#f4efdf", + }), + paint: solid("#473bbf"), + }), + Object.freeze({ + id: "inset-shadow-shaped", + description: "Contained zero-blur inset shadow follows a shaped inner contour", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "56px", + radii: corners(56), + shapeCss: "squircle", + shapeParameters: shapes(2), + boxShadow: "inset 0 0 0 14px rgba(244, 239, 223, 0.82)", + paint: solid("#473bbf"), + }), + Object.freeze({ + id: "outline-contained-shaped", + description: "Negative-offset solid outline stays inside a shaped border box", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "52px", + radii: corners(52), + shapeCss: "bevel", + shapeParameters: shapes(0), + outline: Object.freeze({ width: 10, style: "solid", color: "#f4efdf", offset: -10 }), + paint: solid("#473bbf"), + }), + Object.freeze({ + id: "opposite-concave-overlap", + description: "Diagonal concave hull constraint regression", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "118px 18px 118px 18px / 92px 18px 92px 18px", + radii: Object.freeze([ + Object.freeze({ rx: 118, ry: 92 }), + Object.freeze({ rx: 18, ry: 18 }), + Object.freeze({ rx: 118, ry: 92 }), + Object.freeze({ rx: 18, ry: 18 }), + ]), + shapeCss: "scoop round scoop round", + shapeParameters: shapes(-1, 1, -1, 1), + paint: solid("#1db6c9"), + nativeOracleLimitation: "Chromium path intersection differs from the CSSWG opposite-corner hull scale", + }), + Object.freeze({ + id: "interpolation-midpoint", + description: "Caller-clocked scoop-to-squircle midpoint in diagonal-intersection space", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "52px", + radii: corners(52), + shapeCss: "superellipse(0.28833415474651186)", + shapeParameters: shapes(0.28833415474651186), + interpolation: Object.freeze({ from: "scoop", to: "squircle", progress: 0.5 }), + paint: solid("#ed6f32"), + }), + Object.freeze({ + id: "bevel-rotated", + description: "Bevel surface under compound 3D rotation", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([340, 300]), + radiusCss: "50% 50% 0 0 / 100% 100% 0 0", + radii: Object.freeze([ + Object.freeze({ rx: 110, ry: 160 }), + Object.freeze({ rx: 110, ry: 160 }), + Object.freeze({ rx: 0, ry: 0 }), + Object.freeze({ rx: 0, ry: 0 }), + ]), + shapeCss: "bevel bevel round round", + shapeParameters: shapes(0, 0, 1, 1), + transform: "rotateX(31deg) rotateY(47deg) rotateZ(13deg)", + perspective: 600, + paint: gradient, + }), + Object.freeze({ + id: "mario-texel-face", + description: "Real 4 by 4 Mario texel field stretched across one retained face", + size: Object.freeze([64, 44]), + captureSize: Object.freeze([160, 140]), + radiusCss: "50% 50% 0 0 / 100% 100% 0 0", + radii: Object.freeze([ + Object.freeze({ rx: 32, ry: 44 }), + Object.freeze({ rx: 32, ry: 44 }), + Object.freeze({ rx: 0, ry: 0 }), + Object.freeze({ rx: 0, ry: 0 }), + ]), + shapeCss: "bevel bevel round round", + shapeParameters: shapes(0, 0, 1, 1), + paint: Object.freeze({ + kind: "image", + url: "/__mario/texels.webp", + sourceSize: Object.freeze([4852, 3280]), + backgroundSize: Object.freeze([77632, 36080]), + backgroundPosition: Object.freeze([-448, 0]), + repeat: "no-repeat", + }), + sourceEvidence: Object.freeze({ + faceIndex: 7, + field: Object.freeze({ x: 28, y: 0, width: 4, height: 4 }), + css: "width:64px;height:44px;background-size:77632px 36080px;background-position:-448px 0", + }), + }), + Object.freeze({ + id: "raster-repeat-origin", + description: "Atlas raster with round repeat and content-box origin and clip", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + padding: "14px 20px 18px 12px", + radiusCss: "48px", + radii: corners(48), + shapeCss: "bevel", + shapeParameters: shapes(0), + paint: Object.freeze({ + kind: "image", + url: "/__mario/texels.webp", + sourceSize: Object.freeze([4852, 3280]), + backgroundSize: "34px auto", + backgroundPosition: "center bottom 8px", + repeat: "round no-repeat", + origin: "content-box", + clip: "content-box", + }), + }), + Object.freeze({ + id: "background-blend-multiply", + description: "One static opaque raster multiplies over one opaque background color", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + radiusCss: "48px", + radii: corners(48), + shapeCss: "bevel", + shapeParameters: shapes(0), + paint: Object.freeze({ + kind: "image", + url: blendRaster, + sourceSize: Object.freeze([160, 100]), + backgroundSize: Object.freeze([160, 100]), + backgroundPosition: "center", + repeat: "no-repeat", + origin: "border-box", + clip: "border-box", + blendMode: "multiply", + color: "#734cc4", + opaque: true, + }), + }), + Object.freeze({ + id: "gradient-layers", + description: "Linear, radial, conic, and atlas layers in CSS paint order", + size: Object.freeze([220, 160]), + captureSize: Object.freeze([300, 240]), + padding: "10px 14px 16px 8px", + radiusCss: "58px 34px 52px 24px / 44px 58px 28px 46px", + radii: Object.freeze([ + Object.freeze({ rx: 58, ry: 44 }), + Object.freeze({ rx: 34, ry: 58 }), + Object.freeze({ rx: 52, ry: 28 }), + Object.freeze({ rx: 24, ry: 46 }), + ]), + shapeCss: "squircle bevel scoop round", + shapeParameters: shapes(2, 0, -1, 1), + paint: Object.freeze({ + kind: "layers", + color: "#14213d", + layers: Object.freeze([ + Object.freeze({ + kind: "linear-gradient", + css: "linear-gradient(to bottom right, rgba(117,246,255,.72) 0%, rgba(39,87,215,.08) 52%, rgba(23,42,115,.62) 100%)", + backgroundSize: "55% 100%", + backgroundPosition: "left top", + repeat: "repeat-x", + origin: "padding-box", + clip: "content-box", + }), + Object.freeze({ + kind: "radial-gradient", + css: "radial-gradient(ellipse farthest-corner at 32% 68%, rgba(255,244,170,.9) 0%, rgba(232,72,132,.45) 45%, transparent 100%)", + backgroundSize: "96px 72px", + backgroundPosition: "right 8px top 10px", + repeat: "no-repeat", + origin: "border-box", + clip: "padding-box", + }), + Object.freeze({ + kind: "conic-gradient", + css: "conic-gradient(from 30deg at 55% 45%, rgba(255,80,60,.55) 0deg, rgba(70,230,130,.45) 120deg, rgba(60,100,255,.55) 240deg, rgba(255,80,60,.55) 1turn)", + backgroundSize: "60px 60px", + backgroundPosition: "center", + repeat: "round space", + origin: "content-box", + clip: "border-box", + }), + Object.freeze({ + kind: "image", + url: "/__mario/texels.webp", + sourceSize: Object.freeze([4852, 3280]), + backgroundSize: "80px 54px", + backgroundPosition: "center bottom", + repeat: "space no-repeat", + origin: "content-box", + clip: "border-box", + }), + ]), + }), + }), +]); + +export function getOracleCase(id) { + return oracleCases.find((entry) => entry.id === id) ?? null; +} diff --git a/oracle/fixture.html b/oracle/fixture.html new file mode 100644 index 0000000..a3eade9 --- /dev/null +++ b/oracle/fixture.html @@ -0,0 +1,38 @@ + + + + +Cornerfill oracle fixture + +
+ + diff --git a/oracle/fixture.mjs b/oracle/fixture.mjs new file mode 100644 index 0000000..0833f76 --- /dev/null +++ b/oracle/fixture.mjs @@ -0,0 +1,144 @@ +import { getOracleCase, ORACLE_CASE_SCHEMA } from "./cases.mjs"; +import { + attachProductionCandidate, + CANDIDATE_PAINTER_SCHEMA, + createLifecycleProof, + createRasterUpdateProof, + nativeBackgroundCss, +} from "./painter.mjs"; + +const query = new URLSearchParams(location.search); +const caseId = query.get("case") ?? "bevel"; +const mode = query.get("mode") ?? "native"; +const oracleCase = getOracleCase(caseId); +const capture = document.querySelector("#capture"); +const face = document.querySelector("#face"); + +function applyStyles(element, declarations) { + for (const [property, value] of Object.entries(declarations)) { + if (value !== undefined && value !== null) element.style[property] = String(value); + } +} + +function nextPaint() { + return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); +} + +function applyBorder(element, border) { + if (!border) return; + const widths = Array.isArray(border.widths) + ? border.widths + : [border.width, border.width, border.width, border.width]; + element.style.borderStyle = "solid"; + element.style.borderWidth = widths.map((width) => `${width}px`).join(" "); + element.style.borderColor = border.color; +} + +function applyEffects(element, oracleCase) { + if (oracleCase.boxShadow) element.style.boxShadow = oracleCase.boxShadow; + if (oracleCase.outline) { + element.style.outlineWidth = `${oracleCase.outline.width}px`; + element.style.outlineStyle = oracleCase.outline.style; + element.style.outlineColor = oracleCase.outline.color; + element.style.outlineOffset = `${oracleCase.outline.offset}px`; + } +} + +async function render() { + if (!oracleCase) throw new Error(`unknown oracle case: ${caseId}`); + if (!new Set(["native", "candidate"]).has(mode)) throw new Error(`unknown oracle mode: ${mode}`); + + const [width, height] = oracleCase.size; + const [captureWidth, captureHeight] = oracleCase.captureSize; + applyStyles(capture, { + width: `${captureWidth}px`, + height: `${captureHeight}px`, + perspective: oracleCase.perspective ? `${oracleCase.perspective}px` : "none", + }); + applyStyles(face, { + width: `${width}px`, + height: `${height}px`, + padding: oracleCase.padding ?? "0", + borderRadius: oracleCase.radiusCss, + transform: oracleCase.transform ?? "none", + }); + applyEffects(face, oracleCase); + + let backend; + let candidate; + if (mode === "native") { + backend = "native-corner-shape"; + face.style.setProperty("corner-shape", oracleCase.shapeCss); + applyStyles(face, nativeBackgroundCss(oracleCase.paint)); + applyBorder(face, oracleCase.border); + } else { + applyStyles(face, nativeBackgroundCss(oracleCase.paint)); + applyBorder(face, oracleCase.border); + const production = await attachProductionCandidate(face, oracleCase); + backend = production.handle.backend; + candidate = production.metadata; + globalThis.__cornerfillOracleController = production.controller; + globalThis.__cornerfillOracleHandle = production.handle; + if (caseId === "bevel") { + globalThis.__cornerfillOracleRunLifecycle = createLifecycleProof({ + ...production, + element: face, + oracleCase, + }); + } else if (caseId === "mario-texel-face") { + globalThis.__cornerfillOracleRunLifecycle = createRasterUpdateProof({ + ...production, + element: face, + oracleCase, + }); + } + } + + await document.fonts.ready; + await nextPaint(); + const computed = getComputedStyle(face); + const nativeSupported = CSS.supports("corner-shape", oracleCase.shapeCss); + globalThis.__cornerfillOracle = Object.freeze({ + ready: true, + schema: "cornerfill-browser-fixture@1", + caseSchema: ORACLE_CASE_SCHEMA, + candidateSchema: CANDIDATE_PAINTER_SCHEMA, + caseId, + description: oracleCase.description, + mode, + backend, + nativeSupported, + expectedCandidateLimitation: oracleCase.expectedCandidateLimitation ?? null, + nativeOracleLimitation: oracleCase.nativeOracleLimitation ?? null, + candidate: candidate ?? null, + userAgent: navigator.userAgent, + devicePixelRatio, + captureSize: Object.freeze([captureWidth, captureHeight]), + faceSize: Object.freeze([width, height]), + computed: Object.freeze({ + backgroundImage: computed.backgroundImage, + backgroundPosition: computed.backgroundPosition, + backgroundSize: computed.backgroundSize, + borderRadius: computed.borderRadius, + borderTopWidth: computed.borderTopWidth, + boxShadow: computed.boxShadow, + outline: `${computed.outlineWidth} ${computed.outlineStyle} ${computed.outlineColor} / ${computed.outlineOffset}`, + cornerShape: computed.getPropertyValue("corner-shape"), + transform: computed.transform, + }), + sourceEvidence: oracleCase.sourceEvidence ?? null, + }); + document.documentElement.dataset.ready = "true"; + document.documentElement.dataset.case = caseId; + document.documentElement.dataset.mode = mode; + document.documentElement.dataset.backend = backend; +} + +render().catch((error) => { + globalThis.__cornerfillOracle = Object.freeze({ + ready: false, + error: error instanceof Error ? `${error.name}: ${error.message}` : String(error), + }); + document.documentElement.dataset.error = globalThis.__cornerfillOracle.error; + throw error; +}); diff --git a/oracle/geometry.mjs b/oracle/geometry.mjs new file mode 100644 index 0000000..b48ee85 --- /dev/null +++ b/oracle/geometry.mjs @@ -0,0 +1,13 @@ +// Compatibility seam for historical oracle imports. The executable candidate and +// unit tests now use the production geometry implementation. +export { + buildCornerGeometry, + contourPoints, + convexPolygonsOverlap, + cornerCarveOuts, + insetGeometry, + oppositeCornerScaleFactor, + resolveCornerRadii, + resolveRadii, + sampleCanonicalCorner, +} from "../dist/geometry.mjs"; diff --git a/oracle/painter.mjs b/oracle/painter.mjs new file mode 100644 index 0000000..10e5842 --- /dev/null +++ b/oracle/painter.mjs @@ -0,0 +1,187 @@ +import { + CORNERFILL_RUNTIME_SCHEMA, + installCornerfill, +} from "../dist/runtime.mjs"; + +export const CANDIDATE_PAINTER_SCHEMA = CORNERFILL_RUNTIME_SCHEMA; + +function nextPaint() { + return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); +} + +export async function attachProductionCandidate(element, oracleCase) { + element.style.setProperty("--cornerfill-border-radius", oracleCase.radiusCss); + element.style.setProperty( + "--cornerfill-corner-shape", + oracleCase.interpolation?.from ?? oracleCase.shapeCss, + ); + const controller = installCornerfill({ + document, + forceFallback: true, + staticFallback: true, + idPrefix: `cornerfill-oracle-${oracleCase.id}`, + }); + const handle = oracleCase.id === "mario-texel-face" + ? controller.attachPrepared(element, { + mode: "paint", + size: oracleCase.size, + borderRadius: oracleCase.radiusCss, + cornerShape: oracleCase.shapeCss, + paint: Object.freeze({ ...oracleCase.paint, opaque: true }), + border: oracleCase.border ?? null, + visibility: true, + }) + : controller.attach(element, { + mode: "paint", + paint: oracleCase.paint, + border: oracleCase.border ?? null, + }); + await handle.ready; + if (oracleCase.interpolation) { + await handle.interpolateCornerShape( + oracleCase.interpolation.from, + oracleCase.interpolation.to, + oracleCase.interpolation.progress, + ); + } + return Object.freeze({ + controller, + handle, + metadata: handle.explain(), + }); +} + +export function createLifecycleProof({ controller, handle, element, oracleCase }) { + return async () => { + const initial = controller.stats(); + const initialPaints = initial.counters.paints; + const originalTransform = element.style.transform; + const originalWidth = element.style.width; + const originalShape = element.style.getPropertyValue("--cornerfill-corner-shape"); + + element.style.transform = "matrix3d(1,0,0,0,0,0.8660254,0.5,0,0,-0.5,0.8660254,0,0,0,0,1)"; + await handle.refresh(); + await nextPaint(); + const afterTransform = controller.stats(); + + element.style.setProperty("--cornerfill-corner-shape", "notch"); + await handle.refresh(); + const afterStyle = controller.stats(); + + element.style.width = `${oracleCase.size[0] + 7}px`; + await handle.refresh(); + const afterResize = controller.stats(); + + element.style.width = originalWidth; + element.style.transform = originalTransform; + element.style.setProperty("--cornerfill-corner-shape", originalShape); + await handle.refresh(); + const beforeDispose = handle.explain(); + handle.dispose(); + const afterDispose = controller.stats(); + const disposed = handle.explain(); + controller.destroy(); + + const proof = Object.freeze({ + schema: "cornerfill-lifecycle-proof@1", + transformPaintDelta: afterTransform.counters.paints - initialPaints, + stylePaintDelta: afterStyle.counters.paints - afterTransform.counters.paints, + resizePaintDelta: afterResize.counters.paints - afterStyle.counters.paints, + surfaceResizeDelta: afterResize.counters.surfaceResizes - afterStyle.counters.surfaceResizes, + entriesAfterDispose: afterDispose.entries, + disposedStatus: disposed.status, + backendBeforeDispose: beforeDispose.backend, + originalElementKeptTransform: beforeDispose.transformOwnedByCornerfill === false, + }); + return Object.freeze({ + ...proof, + passed: proof.transformPaintDelta === 0 + && proof.stylePaintDelta === 1 + && proof.resizePaintDelta === 1 + && proof.surfaceResizeDelta === 1 + && proof.entriesAfterDispose === 0 + && proof.disposedStatus === "disposed" + && proof.originalElementKeptTransform, + }); + }; +} + +export function createRasterUpdateProof({ controller, handle, element, oracleCase }) { + return async () => { + const initial = controller.stats(); + const initialEntry = handle.explain(); + const nextPosition = [oracleCase.paint.backgroundPosition[0] - 64, oracleCase.paint.backgroundPosition[1]]; + controller.updatePreparedBatch([{ + element, + backgroundPosition: nextPosition, + }]); + const updated = controller.stats(); + const updatedEntry = handle.explain(); + handle.dispose(); + const disposed = controller.stats(); + controller.destroy(); + const sourceRect = updatedEntry.paint?.layer?.sourceRect ?? null; + const proof = Object.freeze({ + schema: "cornerfill-raster-update-proof@1", + paintDelta: updated.counters.paints - initial.counters.paints, + imageDecodeDelta: updated.counters.imageDecodes - initial.counters.imageDecodes, + sameSurface: initialEntry.surface?.id === updatedEntry.surface?.id, + updatedSourceRect: sourceRect, + entriesAfterDispose: disposed.entries, + }); + return Object.freeze({ + ...proof, + passed: proof.paintDelta === 1 + && proof.imageDecodeDelta === 0 + && proof.sameSurface + && sourceRect?.[0] === 32 + && proof.entriesAfterDispose === 0, + }); + }; +} + +export function nativeBackgroundCss(paint) { + if (paint.kind === "solid") return Object.freeze({ backgroundColor: paint.color }); + if (paint.kind === "linear-gradient") return Object.freeze({ backgroundImage: paint.css }); + if (paint.kind === "radial-gradient" || paint.kind === "conic-gradient") { + return Object.freeze({ backgroundImage: paint.css }); + } + if (paint.kind === "layers") { + const value = (layer, property, fallback) => { + const candidate = layer[property]; + if (Array.isArray(candidate)) return `${candidate[0]}px ${candidate[1]}px`; + return candidate ?? fallback; + }; + const image = (layer) => layer.kind === "image" + ? `url(${JSON.stringify(layer.url)})` + : layer.kind === "none" ? "none" : layer.css; + return Object.freeze({ + backgroundColor: paint.color, + backgroundImage: paint.layers.map(image).join(", "), + backgroundSize: paint.layers.map((layer) => value(layer, "backgroundSize", "auto")).join(", "), + backgroundPosition: paint.layers.map((layer) => value(layer, "backgroundPosition", "0% 0%")).join(", "), + backgroundRepeat: paint.layers.map((layer) => value(layer, "repeat", "repeat")).join(", "), + backgroundOrigin: paint.layers.map((layer) => value(layer, "origin", "padding-box")).join(", "), + backgroundClip: paint.layers.map((layer) => value(layer, "clip", "border-box")).join(", "), + }); + } + if (paint.kind === "image") { + const size = Array.isArray(paint.backgroundSize) + ? `${paint.backgroundSize[0]}px ${paint.backgroundSize[1]}px` + : paint.backgroundSize; + const position = Array.isArray(paint.backgroundPosition) + ? `${paint.backgroundPosition[0]}px ${paint.backgroundPosition[1]}px` + : paint.backgroundPosition; + return Object.freeze({ + backgroundColor: paint.color, + backgroundImage: `url(${JSON.stringify(paint.url)})`, + backgroundSize: size, + backgroundPosition: position, + backgroundRepeat: paint.repeat, + backgroundOrigin: paint.origin, + backgroundClip: paint.clip, + backgroundBlendMode: paint.blendMode, + }); + } + throw new TypeError(`unsupported paint kind: ${paint.kind}`); +} diff --git a/oracle/qualification.json b/oracle/qualification.json new file mode 100644 index 0000000..de7b85a --- /dev/null +++ b/oracle/qualification.json @@ -0,0 +1,14 @@ +{ + "schema": "cornerfill-oracle-qualification@1", + "nativeCalibration": { + "status": "PASS", + "scope": "same-fixture native A/A capture", + "approvedTolerance": true, + "exactZeroTolerance": true + }, + "candidate": { + "status": "UNQUALIFIED", + "approvedTolerance": false, + "reason": "No native-versus-candidate pixel tolerance has been approved." + } +} diff --git a/oracle/tolerances.json b/oracle/tolerances.json new file mode 100644 index 0000000..ebbc0c9 --- /dev/null +++ b/oracle/tolerances.json @@ -0,0 +1,19 @@ +{ + "schema": "cornerfill-oracle-tolerances@1", + "calibration": { + "approved": true, + "maxMeanAlpha": 0, + "maxMeanPremultipliedRgb": 0, + "maxChangedPixelRatio": 0, + "channelThreshold": 0, + "note": "Repeated screenshots of the same static native fixture must be byte-pixel exact." + }, + "candidate": { + "approved": false, + "maxMeanAlpha": 0, + "maxMeanPremultipliedRgb": 0, + "maxChangedPixelRatio": 0, + "channelThreshold": 0, + "note": "No native-vs-candidate tolerance is approved yet. Calibrate from evidence before qualification." + } +} diff --git a/package-lock.json b/package-lock.json index c2e4d1c..3c1a0b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,28 @@ "version": "0.0.1", "license": "MIT", "devDependencies": { + "@playwright/cli": "0.1.17", "typescript": "7.0.2" }, "engines": { - "node": ">=22.12.0" + "node": ">=18.17.0" + } + }, + "node_modules/@playwright/cli": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@playwright/cli/-/cli-0.1.17.tgz", + "integrity": "sha512-VBw6y3p8eqOqmjKg07IkWSPGKJkpIhMRNDFI6DOYsDD6fAfcI1XYEWMLWyhSZQ0B/Oc2KN49eq4XqE64PUPHBg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0-alpha-1783623505000", + "playwright-core": "1.62.0-alpha-1783623505000" + }, + "bin": { + "playwright-cli": "playwright-cli.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/@typescript/typescript-aix-ppc64": { @@ -355,6 +373,53 @@ "node": ">=16.20.0" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.0-alpha-1783623505000", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0-alpha-1783623505000.tgz", + "integrity": "sha512-6KV9h4PP3hqu4NaGdxxcijWfYh9LJcFI/R2sP4TTC4I5cFo3oRawN0ETlW5MkE3cQEgKhhoj0KUNz4sfpCT0Tg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0-alpha-1783623505000" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0-alpha-1783623505000", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0-alpha-1783623505000.tgz", + "integrity": "sha512-CPJZdsA/KGT2QQlekiV6Wt+QlQrZHVSZ6oiNtOI/bYYOIVLM8jfKGWTM4zQiyd4UN+40Cq4cA6lxmZHZbtPvJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/package.json b/package.json index 574725d..ed4224e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cornerfill", "version": "0.0.1", - "description": "Native-first paint polyfill for CSS corner-shape in Safari and Firefox", + "description": "CSS corner-shape polyfill for Safari and Firefox", "license": "MIT", "type": "module", "sideEffects": [ @@ -23,6 +23,11 @@ "import": "./dist/runtime.mjs", "default": "./dist/runtime.mjs" }, + "./auto": { + "types": "./dist/auto-runtime.d.mts", + "import": "./dist/auto-runtime.mjs", + "default": "./dist/auto-runtime.mjs" + }, "./geometry": { "types": "./dist/geometry.d.mts", "import": "./dist/geometry.mjs", @@ -32,6 +37,11 @@ "types": "./dist/values.d.mts", "import": "./dist/values.mjs", "default": "./dist/values.mjs" + }, + "./spec": { + "types": "./dist/spec.d.mts", + "import": "./dist/spec.mjs", + "default": "./dist/spec.mjs" } }, "repository": { @@ -53,16 +63,22 @@ "access": "public" }, "engines": { - "node": ">=22.12.0" + "node": ">=18.17.0" }, "scripts": { - "clean": "node --input-type=module --eval \"import { rmSync } from 'node:fs'; rmSync('dist', { force: true, recursive: true });\"", - "build": "npm run clean && tsc -p tsconfig.json && tsc -p tsconfig.types.json", + "clean": "node --input-type=module --eval \"import { rmSync } from 'node:fs'; rmSync('dist', { force: true, recursive: true }); rmSync('src/qualification.mts', { force: true });\"", + "generate": "node scripts/generate-qualification.mjs", + "build": "npm run clean && npm run generate && tsc -p tsconfig.json && tsc -p tsconfig.types.json", "prepare": "npm run build", "test": "npm run build && node --test", - "test:browser:runtime": "npm run build && node scripts/runtime-regressions.mjs" + "test:browser:runtime": "npm run build && node scripts/runtime-regressions.mjs", + "test:browser:runtime:built": "node scripts/runtime-regressions.mjs", + "oracle:list": "node scripts/oracle.mjs list", + "oracle:smoke": "npm run build && node scripts/oracle.mjs run --cases=bevel,round,mario-texel-face", + "oracle:cross": "npm run build && node scripts/oracle.mjs run --browsers=chrome,webkit,firefox --cases=bevel,bevel-rotated,mario-texel-face" }, "devDependencies": { + "@playwright/cli": "0.1.17", "typescript": "7.0.2" } } diff --git a/scripts/compare.mjs b/scripts/compare.mjs new file mode 100644 index 0000000..ed43371 --- /dev/null +++ b/scripts/compare.mjs @@ -0,0 +1,194 @@ +import { + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { basename, join } from "node:path"; +import { comparePngImages, readPng, writePng } from "./png.mjs"; + +export const COMPARE_REPORT_SCHEMA = "cornerfill-oracle-compare@1"; + +function listFrames(directory) { + return readdirSync(directory) + .filter((file) => /^frame_\d{4}\.png$/u.test(file)) + .sort((a, b) => a.localeCompare(b)); +} + +function fixed(value) { + return Number(value.toFixed(8)); +} + +function compactMetrics(metrics) { + return Object.freeze({ + ...metrics, + changedPixelRatio: fixed(metrics.changedPixelRatio), + meanAlpha: fixed(metrics.meanAlpha), + meanPremultipliedRgb: fixed(metrics.meanPremultipliedRgb), + maxPremultipliedRgb: fixed(metrics.maxPremultipliedRgb), + boundaryChangedPixelRatio: fixed(metrics.boundaryChangedPixelRatio), + interiorMeanAlpha: fixed(metrics.interiorMeanAlpha), + interiorMeanPremultipliedRgb: fixed(metrics.interiorMeanPremultipliedRgb), + connectedRegions: metrics.connectedRegions.slice(0, 32), + omittedConnectedRegions: Math.max(0, metrics.connectedRegions.length - 32), + }); +} + +function withinTolerance(metrics, tolerance) { + return metrics.meanAlpha <= tolerance.maxMeanAlpha + && metrics.meanPremultipliedRgb <= tolerance.maxMeanPremultipliedRgb + && metrics.changedPixelRatio <= tolerance.maxChangedPixelRatio; +} + +function csvCell(value) { + const text = String(value ?? ""); + return /[",\n]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +function reportCsv(frames) { + const columns = [ + "frame", + "caseId", + "status", + "meanAlpha", + "meanPremultipliedRgb", + "changedPixelRatio", + "boundaryChangedPixelRatio", + "interiorMeanAlpha", + "interiorMeanPremultipliedRgb", + "connectedRegionCount", + "largestRegionPixels", + ]; + const rows = frames.map((frame) => [ + frame.frame, + frame.caseId, + frame.status, + frame.metrics.meanAlpha, + frame.metrics.meanPremultipliedRgb, + frame.metrics.changedPixelRatio, + frame.metrics.boundaryChangedPixelRatio, + frame.metrics.interiorMeanAlpha, + frame.metrics.interiorMeanPremultipliedRgb, + frame.metrics.connectedRegions.length + frame.metrics.omittedConnectedRegions, + frame.metrics.connectedRegions[0]?.pixels ?? 0, + ]); + return `${[columns, ...rows].map((row) => row.map(csvCell).join(",")).join("\n")}\n`; +} + +function reportMarkdown(report) { + const lines = [ + `# ${report.label}`, + "", + `Status: **${report.status}**`, + "", + `Expected: \`${report.expectedDirectory}\``, + "", + `Actual: \`${report.actualDirectory}\``, + "", + `Tolerance: ${report.tolerance.approved ? "approved" : "not approved"} — ${report.tolerance.note}`, + "", + "| Frame | Case | Status | Mean alpha | Mean premultiplied RGB | Changed pixels |", + "| --- | --- | --- | ---: | ---: | ---: |", + ]; + for (const frame of report.frames) { + lines.push( + `| ${frame.frame} | ${frame.caseId} | ${frame.status} | ` + + `${frame.metrics.meanAlpha} | ${frame.metrics.meanPremultipliedRgb} | ` + + `${(frame.metrics.changedPixelRatio * 100).toFixed(4)}% |`, + ); + } + lines.push( + "", + `Worst frame by mean alpha: ${report.summary.worstMeanAlpha?.frame ?? "none"}`, + "", + `Worst frame by changed ratio: ${report.summary.worstChangedPixelRatio?.frame ?? "none"}`, + "", + ); + return lines.join("\n"); +} + +export function compareFrameDirectories({ + expectedDirectory, + actualDirectory, + outputDirectory, + label, + tolerance, + caseByFrame = new Map(), +}) { + const expectedFiles = listFrames(expectedDirectory); + const actualFiles = listFrames(actualDirectory); + const missingExpected = actualFiles.filter((file) => !expectedFiles.includes(file)); + const missingActual = expectedFiles.filter((file) => !actualFiles.includes(file)); + mkdirSync(outputDirectory, { recursive: true }); + const diffDirectory = join(outputDirectory, "diffs"); + mkdirSync(diffDirectory, { recursive: true }); + const paired = expectedFiles.filter((file) => actualFiles.includes(file)); + const frames = []; + for (const file of paired) { + const expected = readPng(join(expectedDirectory, file)); + const actual = readPng(join(actualDirectory, file)); + const comparison = comparePngImages(expected, actual, { + channelThreshold: tolerance.channelThreshold, + }); + const metrics = compactMetrics(comparison.metrics); + const qualifiedPass = withinTolerance(metrics, tolerance); + const status = tolerance.approved ? (qualifiedPass ? "PASS" : "FAIL") : "UNQUALIFIED"; + const caseId = caseByFrame.get(file) ?? basename(file, ".png"); + writePng(join(diffDirectory, file), comparison.heatmap); + frames.push(Object.freeze({ frame: file, caseId, status, metrics })); + } + const worst = (key) => frames.length === 0 ? null : frames.reduce( + (current, frame) => frame.metrics[key] > current.metrics[key] ? frame : current, + ); + const structurallyValid = missingExpected.length === 0 && missingActual.length === 0 + && expectedFiles.length > 0 && actualFiles.length > 0; + const status = !structurallyValid + ? "INVALID" + : !tolerance.approved + ? "UNQUALIFIED" + : frames.every((frame) => frame.status === "PASS") + ? "PASS" + : "FAIL"; + const report = Object.freeze({ + schema: COMPARE_REPORT_SCHEMA, + label, + status, + expectedDirectory, + actualDirectory, + tolerance, + structure: Object.freeze({ + expectedFrames: expectedFiles.length, + actualFrames: actualFiles.length, + comparedFrames: paired.length, + missingExpected, + missingActual, + }), + summary: Object.freeze({ + worstMeanAlpha: worst("meanAlpha"), + worstMeanPremultipliedRgb: worst("meanPremultipliedRgb"), + worstChangedPixelRatio: worst("changedPixelRatio"), + }), + frames: Object.freeze(frames), + }); + writeFileSync(join(outputDirectory, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + writeFileSync(join(outputDirectory, "report.csv"), reportCsv(frames)); + writeFileSync(join(outputDirectory, "summary.md"), reportMarkdown(report)); + return report; +} + +export function readTolerances(path) { + const value = JSON.parse(readFileSync(path, "utf8")); + if (value?.schema !== "cornerfill-oracle-tolerances@1") { + throw new Error(`unexpected tolerance schema: ${value?.schema ?? "missing"}`); + } + for (const key of ["calibration", "candidate"]) { + const entry = value[key]; + if (typeof entry?.approved !== "boolean" || !Number.isFinite(entry.maxMeanAlpha) + || !Number.isFinite(entry.maxMeanPremultipliedRgb) + || !Number.isFinite(entry.maxChangedPixelRatio) + || !Number.isInteger(entry.channelThreshold)) { + throw new Error(`invalid ${key} tolerance`); + } + } + return Object.freeze(value); +} diff --git a/scripts/generate-qualification.mjs b/scripts/generate-qualification.mjs new file mode 100644 index 0000000..ceff252 --- /dev/null +++ b/scripts/generate-qualification.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; + +const input = new URL("../oracle/qualification.json", import.meta.url); +const output = new URL("../src/qualification.mts", import.meta.url); +const qualification = JSON.parse(readFileSync(input, "utf8")); + +if (qualification?.schema !== "cornerfill-oracle-qualification@1" + || qualification.nativeCalibration?.status !== "PASS" + || qualification.nativeCalibration?.approvedTolerance !== true + || qualification.candidate?.status !== "UNQUALIFIED" + || qualification.candidate?.approvedTolerance !== false) { + throw new TypeError("oracle/qualification.json does not satisfy the release qualification contract"); +} + +function frozen(value) { + if (Array.isArray(value)) return `Object.freeze([${value.map(frozen).join(",")}])`; + if (value && typeof value === "object") { + return `Object.freeze({${Object.entries(value).map(([key, entry]) => ( + `${JSON.stringify(key)}:${frozen(entry)}` + )).join(",")}})`; + } + return JSON.stringify(value); +} + +writeFileSync( + output, + `// Generated from oracle/qualification.json. Do not edit.\nexport const CORNERFILL_ORACLE_QUALIFICATION = ${frozen(qualification)};\n`, +); diff --git a/scripts/oracle.mjs b/scripts/oracle.mjs new file mode 100644 index 0000000..223c29d --- /dev/null +++ b/scripts/oracle.mjs @@ -0,0 +1,625 @@ +#!/usr/bin/env node +import { + createReadStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { platform, release } from "node:os"; +import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { getOracleCase, oracleCases } from "../oracle/cases.mjs"; +import { compareFrameDirectories, readTolerances } from "./compare.mjs"; +import { + readPng, + reconstructTransparencyFromBlackAndWhite, + writePng, +} from "./png.mjs"; + +const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = resolve(SCRIPT_DIRECTORY, ".."); +const DEFAULT_MARIO_TEXELS = "/Users/ekrof/fed/cssGraphics/dist/cssface/models/mario/assets/texels.webp"; +const VALID_BROWSERS = new Set(["chrome", "webkit", "firefox"]); +const MANIFEST_SCHEMA = "cornerfill-oracle-run@1"; + +function usage() { + console.log(`Usage: + node scripts/oracle.mjs list + node scripts/oracle.mjs run [options] + +Options: + --browsers= Sequential browser list. Default: chrome + --cases= Fixture ids. Default: all + --out= Run output. Default: oracle/results/ + --mario-texels= Existing texels.webp source path + --enforce-candidate Exit nonzero unless approved candidate tolerances pass + +Environment: + CORNERFILL_PLAYWRIGHT_CLI playwright-cli binary/wrapper path + CORNERFILL_MARIO_TEXELS existing texels.webp source path + +Safety: + Browsers are always opened and closed serially. This command never calls + playwright-cli kill-all and never launches multiple engines concurrently. +`); +} + +function parseArguments(argv) { + const command = argv.shift(); + if (!command || command === "--help" || command === "-h") return { command: "help" }; + if (!new Set(["list", "run"]).has(command)) throw new Error(`unknown command: ${command}`); + const values = { + command, + browsers: ["chrome"], + cases: oracleCases.map(({ id }) => id), + out: null, + marioTexels: process.env.CORNERFILL_MARIO_TEXELS || DEFAULT_MARIO_TEXELS, + enforceCandidate: false, + }; + for (const argument of argv) { + if (argument.startsWith("--browsers=")) { + values.browsers = argument.slice("--browsers=".length).split(",").filter(Boolean); + } else if (argument.startsWith("--cases=")) { + values.cases = argument.slice("--cases=".length).split(",").filter(Boolean); + } else if (argument.startsWith("--out=")) { + values.out = resolve(argument.slice("--out=".length)); + } else if (argument.startsWith("--mario-texels=")) { + values.marioTexels = resolve(argument.slice("--mario-texels=".length)); + } else if (argument === "--enforce-candidate") values.enforceCandidate = true; + else if (argument === "--help" || argument === "-h") return { command: "help" }; + else throw new Error(`unknown option: ${argument}`); + } + if (values.browsers.length === 0 || new Set(values.browsers).size !== values.browsers.length) { + throw new Error("browser list must be non-empty and unique"); + } + for (const browser of values.browsers) { + if (!VALID_BROWSERS.has(browser)) throw new Error(`unsupported browser: ${browser}`); + } + if (values.cases.length === 0 || new Set(values.cases).size !== values.cases.length) { + throw new Error("case list must be non-empty and unique"); + } + for (const id of values.cases) if (!getOracleCase(id)) throw new Error(`unknown case: ${id}`); + values.cases = oracleCases.map(({ id }) => id).filter((id) => values.cases.includes(id)); + return values; +} + +function utcRunId() { + return new Date().toISOString().replaceAll(":", "-").replace(/\.\d{3}Z$/u, "Z"); +} + +function hashFile(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function sourceIdentity(path) { + const stats = statSync(path); + return Object.freeze({ + path: realpathSync(path), + bytes: stats.size, + sha256: hashFile(path), + }); +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function locatePlaywrightCli() { + const explicit = process.env.CORNERFILL_PLAYWRIGHT_CLI; + if (explicit) { + const path = resolve(explicit); + if (!existsSync(path)) throw new Error(`CORNERFILL_PLAYWRIGHT_CLI does not exist: ${path}`); + return path; + } + const local = join(PROJECT_ROOT, "node_modules", ".bin", "playwright-cli"); + if (existsSync(local)) return local; + throw new Error( + "playwright-cli is unavailable; run npm install or set CORNERFILL_PLAYWRIGHT_CLI", + ); +} + +function topLevelFiles(directory, predicate) { + return readdirSync(join(PROJECT_ROOT, directory), { withFileTypes: true }) + .filter((entry) => entry.isFile() && predicate(entry.name)) + .map((entry) => `${directory}/${entry.name}`); +} + +const MIME_TYPES = Object.freeze({ + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".webp": "image/webp", +}); + +function sendFile(response, path) { + response.writeHead(200, { + "cache-control": "no-store", + "content-type": MIME_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream", + }); + createReadStream(path).pipe(response); +} + +async function startFixtureServer(marioTexels) { + const rootWithSeparator = `${PROJECT_ROOT}${sep}`; + const server = createServer((request, response) => { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.pathname === "/__mario/texels.webp") { + if (!existsSync(marioTexels)) { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Mario texels source is unavailable\n"); + return; + } + sendFile(response, marioTexels); + return; + } + const requested = url.pathname === "/" ? "/oracle/fixture.html" : decodeURIComponent(url.pathname); + const path = resolve(PROJECT_ROOT, `.${requested}`); + if (path !== PROJECT_ROOT && !path.startsWith(rootWithSeparator)) { + response.writeHead(403); + response.end(); + return; + } + if (!existsSync(path) || !statSync(path).isFile()) { + response.writeHead(404); + response.end(); + return; + } + sendFile(response, path); + } catch (error) { + response.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); + response.end(`${error instanceof Error ? error.message : String(error)}\n`); + } + }); + await new Promise((resolvePromise, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolvePromise); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("fixture server did not bind a TCP port"); + return Object.freeze({ + origin: `http://127.0.0.1:${address.port}`, + close: () => new Promise((resolvePromise, reject) => { + server.close((error) => error ? reject(error) : resolvePromise()); + }), + }); +} + +function runCli(cli, session, driverDirectory, args, { raw = false, allowFailure = false } = {}) { + const commandArgs = raw ? ["--raw", ...args] : args; + console.log(` playwright ${args[0]}`); + return new Promise((resolvePromise, reject) => { + const child = spawn(cli, commandArgs, { + cwd: driverDirectory, + env: { ...process.env, PLAYWRIGHT_CLI_SESSION: session }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, 30000); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once("close", (status, signal) => { + clearTimeout(timeout); + if (timedOut) { + reject(new Error(`playwright-cli ${args[0]} timed out after 30s\n${stdout}${stderr}`)); + return; + } + if (status !== 0 && !allowFailure) { + reject(new Error( + `playwright-cli ${args[0]} failed (${status ?? signal ?? "unknown"})\n${stdout}${stderr}`, + )); + return; + } + resolvePromise(Object.freeze({ status, stdout, stderr })); + }); + }); +} + +function parseReturnedJson(output, label) { + const first = output.indexOf("{"); + const last = output.lastIndexOf("}"); + if (first < 0 || last < first) throw new Error(`${label} returned no JSON object: ${output}`); + return JSON.parse(output.slice(first, last + 1)); +} + +const NEXT_PAINT_CODE = "await page.evaluate(() => new Promise(resolve => " + + "requestAnimationFrame(() => requestAnimationFrame(resolve))));"; + +function screenshotCode(paths, { opaquePairs = null } = {}) { + const captureOptions = "animations:\"disabled\",scale:\"css\""; + let calls; + let captureMethod; + if (opaquePairs) { + captureMethod = "dual-opaque-alpha-reconstruction"; + calls = opaquePairs.map(({ black, white }) => [ + "await page.evaluate(color => {", + "document.documentElement.style.setProperty(\"background\",color,\"important\");", + "document.body.style.setProperty(\"background\",color,\"important\");", + "}, \"#000\");", + NEXT_PAINT_CODE, + `await page.locator(\"#capture\").screenshot({path:${JSON.stringify(black)},${captureOptions},omitBackground:false});`, + "await page.evaluate(color => {", + "document.documentElement.style.setProperty(\"background\",color,\"important\");", + "document.body.style.setProperty(\"background\",color,\"important\");", + "}, \"#fff\");", + NEXT_PAINT_CODE, + `await page.locator(\"#capture\").screenshot({path:${JSON.stringify(white)},${captureOptions},omitBackground:false});`, + ].join("")).join(NEXT_PAINT_CODE); + } else { + captureMethod = "transparent-browser-screenshot"; + calls = paths.map((path) => ( + `await page.locator(\"#capture\").screenshot({path:${JSON.stringify(path)},${captureOptions},omitBackground:true});` + )).join(NEXT_PAINT_CODE); + } + const code = [ + "async (page) => {", + "await page.waitForFunction(() => globalThis.__cornerfillOracle?.ready === true ", + "|| Boolean(globalThis.__cornerfillOracle?.error), null, {timeout:15000});", + "const metadata = await page.evaluate(() => globalThis.__cornerfillOracle);", + "if (!metadata.ready) throw new Error(metadata.error || \"fixture failed\");", + calls, + "const lifecycle = await page.evaluate(() => typeof globalThis.__cornerfillOracleRunLifecycle === \"function\"", + "? globalThis.__cornerfillOracleRunLifecycle() : null);", + `return {...metadata,driverCaptureMethod:${JSON.stringify(captureMethod)},lifecycle};`, + "}", + ].join(""); + if (process.env.CORNERFILL_DEBUG) console.log(` run-code source: ${code}`); + return code; +} + +function opaquePairPaths(outputPath, compositeDirectory) { + const stem = basename(outputPath, extname(outputPath)); + return Object.freeze({ + output: outputPath, + black: join(compositeDirectory, `${stem}.black.png`), + white: join(compositeDirectory, `${stem}.white.png`), + }); +} + +function reconstructOpaquePairs(pairs) { + return pairs.map(({ output, black, white }) => { + const reconstructed = reconstructTransparencyFromBlackAndWhite(readPng(black), readPng(white)); + writePng(output, reconstructed); + return Object.freeze({ output, black, white, diagnostics: reconstructed.diagnostics }); + }); +} + +function fixtureUrl(origin, caseId, mode) { + const url = new URL("/oracle/fixture.html", origin); + url.searchParams.set("case", caseId); + url.searchParams.set("mode", mode); + return url.href; +} + +function captureMetadata({ + browser, + mode, + frame, + caseId, + metadata, + files, + composites = [], + reconstruction = null, +}) { + return Object.freeze({ + browser, + mode, + frame, + caseId, + files: Object.freeze(files.map((path) => relative(PROJECT_ROOT, path))), + composites: Object.freeze(composites.map((path) => relative(PROJECT_ROOT, path))), + reconstruction, + metadata, + }); +} + +async function captureBrowser({ + browser, + cli, + driverDirectory, + compositesRoot, + framesRoot, + origin, + selectedCases, +}) { + const session = `cornerfill-oracle-${process.pid}-${browser}`; + const candidateDirectory = join(framesRoot, `candidate-${browser}`); + mkdirSync(candidateDirectory, { recursive: true }); + const compositeDirectory = join(compositesRoot, `candidate-${browser}`); + if (browser === "firefox") mkdirSync(compositeDirectory, { recursive: true }); + const nativeADirectory = join(framesRoot, "native-chrome-a"); + const nativeBDirectory = join(framesRoot, "native-chrome-b"); + if (browser === "chrome") { + mkdirSync(nativeADirectory, { recursive: true }); + mkdirSync(nativeBDirectory, { recursive: true }); + } + const records = []; + const firstMode = browser === "chrome" ? "native" : "candidate"; + const firstUrl = fixtureUrl(origin, selectedCases[0].id, firstMode); + try { + await runCli(cli, session, driverDirectory, ["open", firstUrl, "--browser", browser]); + await runCli(cli, session, driverDirectory, ["resize", "420", "360"]); + for (let index = 0; index < selectedCases.length; index += 1) { + const oracleCase = selectedCases[index]; + const frame = `frame_${String(index).padStart(4, "0")}.png`; + if (browser === "chrome") { + await runCli(cli, session, driverDirectory, ["goto", fixtureUrl(origin, oracleCase.id, "native")]); + const nativeAPath = join(nativeADirectory, frame); + const nativeBPath = join(nativeBDirectory, frame); + const result = await runCli( + cli, + session, + driverDirectory, + ["run-code", screenshotCode([nativeAPath, nativeBPath])], + { raw: true }, + ); + const metadata = parseReturnedJson(result.stdout, `${oracleCase.id} native capture`); + if (!metadata.nativeSupported || !metadata.computed?.cornerShape) { + throw new Error( + `INVALID ORACLE: Chrome did not compute corner-shape for ${oracleCase.id}; ` + + `supported=${metadata.nativeSupported} computed=${metadata.computed?.cornerShape ?? "missing"}`, + ); + } + records.push(captureMetadata({ + browser, + mode: "native-a-b", + frame, + caseId: oracleCase.id, + metadata, + files: [nativeAPath, nativeBPath], + })); + } + + await runCli(cli, session, driverDirectory, ["goto", fixtureUrl(origin, oracleCase.id, "candidate")]); + const candidatePath = join(candidateDirectory, frame); + const opaquePairs = browser === "firefox" + ? [opaquePairPaths(candidatePath, compositeDirectory)] + : null; + const result = await runCli( + cli, + session, + driverDirectory, + ["run-code", screenshotCode([candidatePath], { opaquePairs })], + { raw: true }, + ); + const metadata = parseReturnedJson(result.stdout, `${oracleCase.id} candidate capture`); + const expectedBackend = browser === "chrome" + ? "static-data-url" + : browser === "webkit" + ? "webkit-canvas" + : "moz-element"; + if (metadata.candidate?.runtime !== "cornerfill-runtime@2" + || metadata.backend !== expectedBackend) { + throw new Error( + `production candidate did not use the required ${browser} adapter for ${oracleCase.id}; ` + + `runtime=${metadata.candidate?.runtime ?? "missing"} backend=${metadata.backend ?? "missing"}`, + ); + } + if (oracleCase.id === "bevel" && metadata.lifecycle?.passed !== true) { + throw new Error(`production lifecycle proof failed in ${browser}: ${JSON.stringify(metadata.lifecycle)}`); + } + if (oracleCase.id === "mario-texel-face" && metadata.lifecycle?.passed !== true) { + throw new Error(`production raster-update proof failed in ${browser}: ${JSON.stringify(metadata.lifecycle)}`); + } + const reconstruction = opaquePairs ? reconstructOpaquePairs(opaquePairs) : null; + records.push(captureMetadata({ + browser, + mode: "candidate", + frame, + caseId: oracleCase.id, + metadata, + files: [candidatePath], + composites: opaquePairs ? opaquePairs.flatMap(({ black, white }) => [black, white]) : [], + reconstruction: reconstruction?.map(({ diagnostics }) => diagnostics) ?? null, + })); + } + } finally { + try { + const close = await runCli(cli, session, driverDirectory, ["close"], { allowFailure: true }); + if (close.status !== 0) { + console.error(`warning: failed to close Playwright session ${session}\n${close.stdout}${close.stderr}`); + } + } catch (error) { + console.error(`warning: failed to close Playwright session ${session}: ${error.message}`); + } + } + return Object.freeze(records); +} + +function runSummary({ runDirectory, manifest, reports }) { + const lines = [ + "# Cornerfill oracle run", + "", + `Run: \`${manifest.runId}\``, + "", + `Browsers (serial): ${manifest.configuration.browsers.join(", ")}`, + "", + `Cases: ${manifest.configuration.cases.join(", ")}`, + "", + "| Comparison | Status | Report |", + "| --- | --- | --- |", + ]; + for (const report of reports) { + const path = relative(runDirectory, report.outputDirectory); + lines.push(`| ${report.label} | ${report.status} | [summary](${path}/summary.md) |`); + } + lines.push( + "", + "Raw numbered PNGs are the source of truth. Diff PNGs are diagnostic heatmaps.", + "", + ); + return lines.join("\n"); +} + +async function runOracle(options) { + const runId = utcRunId(); + const runDirectory = options.out ?? join(PROJECT_ROOT, "oracle", "results", runId); + if (existsSync(runDirectory)) throw new Error(`output already exists: ${runDirectory}`); + const selectedCases = options.cases.map(getOracleCase); + const usesMario = (paint) => paint?.url === "/__mario/texels.webp" + || paint?.layers?.some(usesMario) === true; + const needsMario = selectedCases.some(({ paint }) => usesMario(paint)); + if (needsMario && !existsSync(options.marioTexels)) { + throw new Error(`Mario case requires texels.webp: ${options.marioTexels}`); + } + const cli = locatePlaywrightCli(); + const driverDirectory = join(runDirectory, "driver"); + const framesRoot = join(runDirectory, "frames"); + const compositesRoot = join(runDirectory, "composites"); + const reportsRoot = join(runDirectory, "reports"); + mkdirSync(driverDirectory, { recursive: true }); + mkdirSync(framesRoot, { recursive: true }); + mkdirSync(reportsRoot, { recursive: true }); + + const sourceFiles = [ + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.types.json", + "README.md", + "scripts/compare.mjs", + "scripts/generate-qualification.mjs", + "scripts/oracle.mjs", + "scripts/png.mjs", + ...topLevelFiles("oracle", (name) => /\.(?:html|json|mjs)$/u.test(name)), + ...topLevelFiles("src", (name) => name.endsWith(".mts")), + ...topLevelFiles("dist", (name) => name.endsWith(".mjs")), + ].sort(); + const sources = Object.fromEntries(sourceFiles.map((path) => [path, sourceIdentity(join(PROJECT_ROOT, path))])); + const marioSource = needsMario + ? sourceIdentity(options.marioTexels) + : null; + const configuration = Object.freeze({ + browsers: Object.freeze([...options.browsers]), + cases: Object.freeze(selectedCases.map(({ id }) => id)), + captureOrder: "strictly sequential, one browser session at a time", + enforceCandidate: options.enforceCandidate, + }); + const manifest = { + schema: MANIFEST_SCHEMA, + runId, + status: "CAPTURING", + createdAt: new Date().toISOString(), + projectRoot: PROJECT_ROOT, + runDirectory, + host: Object.freeze({ platform: platform(), release: release(), node: process.version }), + configuration, + playwrightCli: sourceIdentity(cli), + sources, + assets: Object.freeze({ marioTexels: marioSource }), + cases: Object.freeze(selectedCases.map((entry, index) => Object.freeze({ + frame: `frame_${String(index).padStart(4, "0")}.png`, + id: entry.id, + description: entry.description, + expectedCandidateLimitation: entry.expectedCandidateLimitation ?? null, + nativeOracleLimitation: entry.nativeOracleLimitation ?? null, + }))), + captures: [], + reports: [], + }; + writeJson(join(runDirectory, "manifest.partial.json"), manifest); + + const server = await startFixtureServer(options.marioTexels); + try { + for (const browser of options.browsers) { + console.log(`capture ${browser}: ${selectedCases.length} case(s), one session`); + const records = await captureBrowser({ + browser, + cli, + driverDirectory, + compositesRoot, + framesRoot, + origin: server.origin, + selectedCases, + }); + manifest.captures.push(...records); + writeJson(join(runDirectory, "manifest.partial.json"), manifest); + } + } finally { + await server.close(); + } + + const tolerances = readTolerances(join(PROJECT_ROOT, "oracle", "tolerances.json")); + const caseByFrame = new Map(manifest.cases.map(({ frame, id }) => [frame, id])); + const reports = []; + const nativeA = join(framesRoot, "native-chrome-a"); + const nativeB = join(framesRoot, "native-chrome-b"); + if (options.browsers.includes("chrome")) { + const outputDirectory = join(reportsRoot, "native-chrome-a-vs-native-chrome-b"); + const report = compareFrameDirectories({ + expectedDirectory: nativeA, + actualDirectory: nativeB, + outputDirectory, + label: "Native Chrome A/A calibration", + tolerance: tolerances.calibration, + caseByFrame, + }); + reports.push(Object.freeze({ ...report, outputDirectory })); + } + if (options.browsers.includes("chrome")) { + for (const browser of options.browsers) { + const outputDirectory = join(reportsRoot, `native-chrome-vs-candidate-${browser}`); + const report = compareFrameDirectories({ + expectedDirectory: nativeA, + actualDirectory: join(framesRoot, `candidate-${browser}`), + outputDirectory, + label: `Native Chrome vs candidate ${browser}`, + tolerance: tolerances.candidate, + caseByFrame, + }); + reports.push(Object.freeze({ ...report, outputDirectory })); + } + } + + manifest.status = reports.some((report) => report.label.includes("A/A") && report.status !== "PASS") + ? "INVALID_CALIBRATION" + : options.enforceCandidate && reports.some((report) => report.label.includes("candidate") && report.status !== "PASS") + ? "CANDIDATE_FAILED" + : "COMPLETE"; + manifest.completedAt = new Date().toISOString(); + manifest.reports = reports.map((report) => Object.freeze({ + label: report.label, + status: report.status, + path: relative(runDirectory, report.outputDirectory), + })); + writeJson(join(runDirectory, "manifest.json"), manifest); + writeFileSync(join(runDirectory, "README.md"), runSummary({ runDirectory, manifest, reports })); + console.log(`oracle run: ${manifest.status}`); + console.log(`evidence: ${runDirectory}`); + if (manifest.status !== "COMPLETE") process.exitCode = 1; +} + +function listCases() { + for (const [index, entry] of oracleCases.entries()) { + console.log(`${String(index).padStart(2, "0")} ${entry.id.padEnd(28)} ${entry.description}`); + } +} + +try { + const options = parseArguments(process.argv.slice(2)); + if (options.command === "help") usage(); + else if (options.command === "list") listCases(); + else await runOracle(options); +} catch (error) { + console.error(error instanceof Error ? `${error.name}: ${error.message}` : String(error)); + process.exitCode = 1; +} diff --git a/scripts/png.mjs b/scripts/png.mjs new file mode 100644 index 0000000..c2c1813 --- /dev/null +++ b/scripts/png.mjs @@ -0,0 +1,384 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { deflateSync, inflateSync } from "node:zlib"; + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +function paeth(a, b, c) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + if (pa <= pb && pa <= pc) return a; + return pb <= pc ? b : c; +} + +function channelsForColorType(colorType) { + if (colorType === 0) return 1; + if (colorType === 2) return 3; + if (colorType === 4) return 2; + if (colorType === 6) return 4; + throw new Error(`unsupported PNG color type ${colorType}`); +} + +export function decodePngBuffer(buffer, label = "PNG") { + if (!Buffer.isBuffer(buffer) || buffer.length < PNG_SIGNATURE.length + || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { + throw new Error(`invalid PNG signature: ${label}`); + } + let offset = PNG_SIGNATURE.length; + let width; + let height; + let bitDepth; + let colorType; + let interlace; + const idat = []; + while (offset + 12 <= buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.subarray(offset + 4, offset + 8).toString("ascii"); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > buffer.length) throw new Error(`truncated ${type} chunk: ${label}`); + const data = buffer.subarray(dataStart, dataEnd); + offset = dataEnd + 4; + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + const compression = data[10]; + const filter = data[11]; + interlace = data[12]; + if (bitDepth !== 8 || compression !== 0 || filter !== 0 || interlace !== 0) { + throw new Error( + `unsupported PNG encoding bitDepth=${bitDepth} compression=${compression} ` + + `filter=${filter} interlace=${interlace}: ${label}`, + ); + } + } else if (type === "IDAT") idat.push(data); + else if (type === "IEND") break; + } + if (!Number.isInteger(width) || !Number.isInteger(height) || idat.length === 0) { + throw new Error(`PNG is missing IHDR or IDAT: ${label}`); + } + const channels = channelsForColorType(colorType); + const stride = width * channels; + const inflated = inflateSync(Buffer.concat(idat)); + if (inflated.length !== (stride + 1) * height) { + throw new Error(`unexpected PNG payload length: ${label}`); + } + const rgba = Buffer.alloc(width * height * 4); + let sourceOffset = 0; + let previous = Buffer.alloc(stride); + for (let y = 0; y < height; y += 1) { + const filter = inflated[sourceOffset]; + sourceOffset += 1; + const row = Buffer.from(inflated.subarray(sourceOffset, sourceOffset + stride)); + sourceOffset += stride; + for (let index = 0; index < stride; index += 1) { + const left = index >= channels ? row[index - channels] : 0; + const up = previous[index]; + const upLeft = index >= channels ? previous[index - channels] : 0; + if (filter === 1) row[index] = (row[index] + left) & 255; + else if (filter === 2) row[index] = (row[index] + up) & 255; + else if (filter === 3) row[index] = (row[index] + Math.floor((left + up) / 2)) & 255; + else if (filter === 4) row[index] = (row[index] + paeth(left, up, upLeft)) & 255; + else if (filter !== 0) throw new Error(`unsupported PNG row filter ${filter}: ${label}`); + } + for (let x = 0; x < width; x += 1) { + const source = x * channels; + const target = (y * width + x) * 4; + if (colorType === 0) { + rgba[target] = row[source]; + rgba[target + 1] = row[source]; + rgba[target + 2] = row[source]; + rgba[target + 3] = 255; + } else if (colorType === 2) { + rgba[target] = row[source]; + rgba[target + 1] = row[source + 1]; + rgba[target + 2] = row[source + 2]; + rgba[target + 3] = 255; + } else if (colorType === 4) { + rgba[target] = row[source]; + rgba[target + 1] = row[source]; + rgba[target + 2] = row[source]; + rgba[target + 3] = row[source + 1]; + } else { + rgba[target] = row[source]; + rgba[target + 1] = row[source + 1]; + rgba[target + 2] = row[source + 2]; + rgba[target + 3] = row[source + 3]; + } + } + previous = row; + } + return Object.freeze({ width, height, pixels: rgba }); +} + +export function readPng(path) { + return decodePngBuffer(readFileSync(path), path); +} + +let crcTable; + +function makeCrcTable() { + return Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + return value >>> 0; + }); +} + +function crc32(buffer) { + crcTable ??= makeCrcTable(); + let crc = 0xffffffff; + for (const byte of buffer) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const typeBuffer = Buffer.from(type, "ascii"); + const output = Buffer.alloc(data.length + 12); + output.writeUInt32BE(data.length, 0); + typeBuffer.copy(output, 4); + data.copy(output, 8); + output.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), data.length + 8); + return output; +} + +export function encodePng({ width, height, pixels }) { + if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) { + throw new TypeError("PNG dimensions must be positive integers"); + } + if (!Buffer.isBuffer(pixels) && !(pixels instanceof Uint8Array)) { + throw new TypeError("PNG pixels must be an RGBA byte buffer"); + } + if (pixels.length !== width * height * 4) throw new RangeError("PNG RGBA buffer has the wrong length"); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + const raw = Buffer.alloc((width * 4 + 1) * height); + for (let y = 0; y < height; y += 1) { + const rowOffset = y * (width * 4 + 1); + raw[rowOffset] = 0; + Buffer.from(pixels.buffer, pixels.byteOffset + y * width * 4, width * 4) + .copy(raw, rowOffset + 1); + } + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +export function writePng(path, image) { + writeFileSync(path, encodePng(image)); +} + +function median3(a, b, c) { + return a + b + c - Math.min(a, b, c) - Math.max(a, b, c); +} + +export function reconstructTransparencyFromBlackAndWhite(black, white) { + if (black.width !== white.width || black.height !== white.height) { + throw new Error( + `opaque-pair dimensions differ: ${black.width}x${black.height} vs ${white.width}x${white.height}`, + ); + } + const pixels = Buffer.alloc(black.pixels.length); + let maxChannelSpread = 0; + let pixelsWithChannelSpreadAboveOne = 0; + for (let offset = 0; offset < pixels.length; offset += 4) { + if (black.pixels[offset + 3] !== 255 || white.pixels[offset + 3] !== 255) { + throw new Error(`opaque-pair input contains transparency at pixel ${offset / 4}`); + } + const deltas = [0, 1, 2].map((channel) => ( + Math.max(0, Math.min(255, white.pixels[offset + channel] - black.pixels[offset + channel])) + )); + const spread = Math.max(...deltas) - Math.min(...deltas); + maxChannelSpread = Math.max(maxChannelSpread, spread); + if (spread > 1) pixelsWithChannelSpreadAboveOne += 1; + const alpha = 255 - median3(...deltas); + pixels[offset + 3] = alpha; + for (let channel = 0; channel < 3; channel += 1) { + pixels[offset + channel] = alpha === 0 + ? 0 + : Math.max(0, Math.min(255, Math.round(black.pixels[offset + channel] * 255 / alpha))); + } + } + return Object.freeze({ + width: black.width, + height: black.height, + pixels, + diagnostics: Object.freeze({ + maxChannelSpread, + pixelsWithChannelSpreadAboveOne, + pixelCount: black.width * black.height, + }), + }); +} + +function alphaBoundaryMask(image) { + const { width, height, pixels } = image; + const raw = new Uint8Array(width * height); + const alphaAt = (x, y) => pixels[(y * width + x) * 4 + 3]; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const alpha = alphaAt(x, y); + let boundary = alpha > 0 && alpha < 255; + if (!boundary && x > 0) boundary = alphaAt(x - 1, y) !== alpha; + if (!boundary && x + 1 < width) boundary = alphaAt(x + 1, y) !== alpha; + if (!boundary && y > 0) boundary = alphaAt(x, y - 1) !== alpha; + if (!boundary && y + 1 < height) boundary = alphaAt(x, y + 1) !== alpha; + if (boundary) raw[y * width + x] = 1; + } + } + const dilated = new Uint8Array(raw); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (!raw[y * width + x]) continue; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const nextX = x + dx; + const nextY = y + dy; + if (nextX >= 0 && nextX < width && nextY >= 0 && nextY < height) { + dilated[nextY * width + nextX] = 1; + } + } + } + } + } + return dilated; +} + +function connectedRegions(mask, width, height) { + const visited = new Uint8Array(mask.length); + const regions = []; + const queue = new Int32Array(mask.length); + for (let start = 0; start < mask.length; start += 1) { + if (!mask[start] || visited[start]) continue; + let head = 0; + let tail = 0; + queue[tail++] = start; + visited[start] = 1; + let pixels = 0; + let minX = width; + let minY = height; + let maxX = 0; + let maxY = 0; + while (head < tail) { + const index = queue[head++]; + const x = index % width; + const y = Math.floor(index / width); + pixels += 1; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + const neighbors = [index - 1, index + 1, index - width, index + width]; + for (let direction = 0; direction < neighbors.length; direction += 1) { + const next = neighbors[direction]; + if (next < 0 || next >= mask.length || visited[next] || !mask[next]) continue; + if (direction === 0 && x === 0) continue; + if (direction === 1 && x + 1 === width) continue; + visited[next] = 1; + queue[tail++] = next; + } + } + regions.push(Object.freeze({ pixels, bounds: Object.freeze([minX, minY, maxX + 1, maxY + 1]) })); + } + return Object.freeze(regions.sort((a, b) => b.pixels - a.pixels)); +} + +export function comparePngImages(expected, actual, { channelThreshold = 0 } = {}) { + if (expected.width !== actual.width || expected.height !== actual.height) { + throw new Error( + `image dimensions differ: ${expected.width}x${expected.height} vs ` + + `${actual.width}x${actual.height}`, + ); + } + if (!Number.isInteger(channelThreshold) || channelThreshold < 0 || channelThreshold > 255) { + throw new TypeError("channelThreshold must be an integer from 0 through 255"); + } + const { width, height } = expected; + const pixelCount = width * height; + const expectedBoundary = alphaBoundaryMask(expected); + const actualBoundary = alphaBoundaryMask(actual); + const changedMask = new Uint8Array(pixelCount); + const heatmap = Buffer.alloc(pixelCount * 4); + let alphaTotal = 0; + let premultipliedRgbTotal = 0; + let maxAlpha = 0; + let maxPremultipliedRgb = 0; + let changedPixels = 0; + let boundaryPixels = 0; + let boundaryChangedPixels = 0; + let interiorPixels = 0; + let interiorAlphaTotal = 0; + let interiorRgbTotal = 0; + + for (let pixel = 0; pixel < pixelCount; pixel += 1) { + const offset = pixel * 4; + const expectedAlpha = expected.pixels[offset + 3]; + const actualAlpha = actual.pixels[offset + 3]; + const alphaDelta = Math.abs(expectedAlpha - actualAlpha); + let maxRgbDelta = 0; + let rgbDelta = 0; + for (let channel = 0; channel < 3; channel += 1) { + const expectedPremultiplied = expected.pixels[offset + channel] * expectedAlpha / 255; + const actualPremultiplied = actual.pixels[offset + channel] * actualAlpha / 255; + const delta = Math.abs(expectedPremultiplied - actualPremultiplied); + rgbDelta += delta; + maxRgbDelta = Math.max(maxRgbDelta, delta); + } + alphaTotal += alphaDelta; + premultipliedRgbTotal += rgbDelta; + maxAlpha = Math.max(maxAlpha, alphaDelta); + maxPremultipliedRgb = Math.max(maxPremultipliedRgb, maxRgbDelta); + const changed = alphaDelta > channelThreshold || maxRgbDelta > channelThreshold; + if (changed) { + changedMask[pixel] = 1; + changedPixels += 1; + } + const boundary = expectedBoundary[pixel] || actualBoundary[pixel]; + if (boundary) { + boundaryPixels += 1; + if (changed) boundaryChangedPixels += 1; + } else if (expectedAlpha === 255 && actualAlpha === 255) { + interiorPixels += 1; + interiorAlphaTotal += alphaDelta; + interiorRgbTotal += rgbDelta; + } + heatmap[offset] = Math.min(255, Math.round(alphaDelta * 4)); + heatmap[offset + 1] = Math.min(255, Math.round(maxRgbDelta * 4)); + heatmap[offset + 2] = changed ? 96 : 0; + heatmap[offset + 3] = 255; + } + + return Object.freeze({ + metrics: Object.freeze({ + width, + height, + pixelCount, + exactPixels: pixelCount - changedPixels, + changedPixels, + changedPixelRatio: changedPixels / pixelCount, + meanAlpha: alphaTotal / pixelCount, + maxAlpha, + meanPremultipliedRgb: premultipliedRgbTotal / (pixelCount * 3), + maxPremultipliedRgb, + boundaryPixels, + boundaryChangedPixels, + boundaryChangedPixelRatio: boundaryPixels ? boundaryChangedPixels / boundaryPixels : 0, + interiorPixels, + interiorMeanAlpha: interiorPixels ? interiorAlphaTotal / interiorPixels : 0, + interiorMeanPremultipliedRgb: interiorPixels ? interiorRgbTotal / (interiorPixels * 3) : 0, + connectedRegions: connectedRegions(changedMask, width, height), + }), + heatmap: Object.freeze({ width, height, pixels: heatmap }), + }); +} diff --git a/scripts/runtime-regressions.mjs b/scripts/runtime-regressions.mjs index 8abb0a7..27f5f76 100644 --- a/scripts/runtime-regressions.mjs +++ b/scripts/runtime-regressions.mjs @@ -1,14 +1,30 @@ #!/usr/bin/env node -import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { + createReadStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; import { createServer } from "node:http"; import { extname, join, relative, resolve, sep } from "node:path"; import { dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; import { createHash } from "node:crypto"; const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const PLAYWRIGHT_CLI_PACKAGE = "@playwright/cli@0.1.17"; +const require = createRequire(import.meta.url); + +function moduleFiles(directory, extension) { + return readdirSync(join(PROJECT_ROOT, directory), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) + .map((entry) => `${directory}/${entry.name}`); +} + const SOURCE_FILES = Object.freeze([ "package.json", "package-lock.json", @@ -21,31 +37,9 @@ const SOURCE_FILES = Object.freeze([ "bench/imports/grandchild.css", "bench/imports/root.css", "scripts/runtime-regressions.mjs", - "src/auto-runtime.mts", - "src/auto.mts", - "src/backends.mts", - "src/background.mts", - "src/geometry.mts", - "src/gradients.mts", - "src/images.mts", - "src/identity.mts", - "src/native.mts", - "src/paint.mts", - "src/runtime.mts", - "src/values.mts", - "dist/auto-runtime.mjs", - "dist/auto.mjs", - "dist/backends.mjs", - "dist/background.mjs", - "dist/geometry.mjs", - "dist/gradients.mjs", - "dist/images.mjs", - "dist/identity.mjs", - "dist/native.mjs", - "dist/paint.mjs", - "dist/runtime.mjs", - "dist/values.mjs", -]); + ...moduleFiles("src", ".mts"), + ...moduleFiles("dist", ".mjs"), +].sort()); const MIME = Object.freeze({ ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", @@ -56,13 +50,11 @@ const MIME = Object.freeze({ function locatePlaywrightModule() { const explicit = process.env.CORNERFILL_PLAYWRIGHT_MODULE; if (explicit) return resolve(explicit); - const lookup = spawnSync( - "npx", - ["--yes", "--package", PLAYWRIGHT_CLI_PACKAGE, "sh", "-c", "command -v playwright-cli"], - { encoding: "utf8" }, - ); - if (lookup.status !== 0 || !lookup.stdout.trim()) throw new Error("Playwright is unavailable"); - return join(resolve(dirname(lookup.stdout.trim()), ".."), "playwright", "index.mjs"); + try { + return require.resolve("playwright"); + } catch { + throw new Error("Playwright is unavailable; run npm install"); + } } function sourceIdentity(path) { @@ -150,7 +142,8 @@ async function drivePointerStates(page) { const selected = browsers(process.argv.slice(2)); const out = join(PROJECT_ROOT, "output", "playwright", "runtime-hardening", new Date().toISOString().replaceAll(":", "-")); mkdirSync(out, { recursive: true }); -const playwright = await import(pathToFileURL(locatePlaywrightModule()).href); +const playwrightModule = await import(pathToFileURL(locatePlaywrightModule()).href); +const playwright = playwrightModule.default ?? playwrightModule; const server = await startServer(); const reports = []; try { diff --git a/src/auto-runtime.mts b/src/auto-runtime.mts index 60c671a..eb9156d 100644 --- a/src/auto-runtime.mts +++ b/src/auto-runtime.mts @@ -1,12 +1,13 @@ import { installCornerfill } from "./runtime.mjs"; import type { CornerfillControllerHandle, + CornerfillControllerStats, CornerfillEntryExplanation, CornerfillHandle, CornerfillInstallOptions, } from "./runtime.mjs"; -import { CORNERFILL_ORACLE_QUALIFICATION } from "./native.mjs"; import type { CornerfillNativeQualification } from "./native.mjs"; +import { CORNERFILL_ORACLE_QUALIFICATION } from "./qualification.mjs"; import { parseCornerShape, parseCornerShapeValue, @@ -37,7 +38,7 @@ interface TextReplacement { readonly value: string; } -interface SelectorObservation { +export interface SelectorObservation { readonly attributes: readonly string[]; readonly characterData: boolean; readonly conservative: boolean; @@ -127,7 +128,7 @@ interface DiagnosticDetails { readonly source?: string | undefined; } -interface DiagnosticRecord { +export interface DiagnosticRecord { readonly context: string; readonly declaration: string | null; readonly message: string; @@ -251,7 +252,7 @@ interface MediaListenerRecord { readonly listener: (event: MediaQueryListEvent) => void; } -interface ObservationState extends SelectorObservation { +export interface ObservationState extends SelectorObservation { readonly mediaQueries: readonly string[]; } @@ -267,12 +268,15 @@ interface AutomaticCounters { sourceReads: number; } +export type CornerfillAutomaticCounters = Readonly; + export interface CornerfillAutoOptions extends CornerfillInstallOptions { readonly adoptedStyleSheets?: boolean | undefined; readonly autoObserve?: boolean | undefined; readonly controller?: CornerfillControllerHandle | undefined; readonly onError?: ((error: unknown, context: string) => void) | undefined; readonly root?: AutoRoot | undefined; + readonly stylesheetTimeoutMs?: number | undefined; } interface InternalCornerfillAutoOptions extends CornerfillAutoOptions { @@ -281,14 +285,38 @@ interface InternalCornerfillAutoOptions extends CornerfillAutoOptions { export interface CornerfillAutoExplanation { readonly attached: number; + readonly automatic?: Readonly<{ + adoptedStylesheets: number; + counters: CornerfillAutomaticCounters; + cssomInsertDeleteAfterInstallation: true; + inlineStyleAttributes: true; + limitations: readonly string[]; + observation: Readonly; + observedSourceClassStyleStateAndViewportChanges: boolean; + observing: boolean; + readableStyleElements: true; + sameOriginAndCorsStylesheetLinks: true; + selectorAndConditionalCascade: true; + }> | undefined; + readonly decision: Readonly<{ + reason: "fallback-forced" | "native-requirements-satisfied" | "native-requirements-unresolved"; + selected: "fallback" | "native"; + unresolvedNativeRequirements: readonly string[]; + }>; readonly errors: readonly Readonly[]; readonly fallbackLoaded: boolean; + readonly implementation: Readonly<{ + automaticDiscovery: "BYPASSED_NATIVE" | "IMPLEMENTED"; + fallbackRenderer: "IMPLEMENTED" | "NOT_LOADED" | "NOT_SELECTED"; + }>; readonly inlineElements: number; readonly mode: "fallback" | "native"; + readonly nativeQualification: Readonly; + readonly oracleQualification: typeof CORNERFILL_ORACLE_QUALIFICATION; + readonly runtime: Readonly | null; readonly schema: "cornerfill-auto@1"; readonly scopes: number; readonly stylesheets: number; - readonly [property: string]: unknown; } export interface CornerfillAutoControllerHandle { @@ -334,77 +362,42 @@ const AUTO_STYLESHEET_ATTRIBUTE = "data-cornerfill-auto-styles"; const AUTO_UNSET = "__cornerfill_unset__"; const AUTO_PHYSICAL_SHAPE = "--cornerfill-auto-physical-shape"; const AUTO_LOGICAL_SHAPE = "--cornerfill-auto-logical-shape"; -const AUTO_UNSUPPORTED_SHAPE = "--cornerfill-auto-unsupported-shape"; -const AUTO_PHYSICAL_RADIUS = "--cornerfill-auto-physical-radius"; -const AUTO_LOGICAL_RADIUS = "--cornerfill-auto-logical-radius"; -const AUTO_UNSUPPORTED_OWNED = "--cornerfill-auto-unsupported-owned"; const CARRIER_REGISTRATIONS = new WeakMap(); -const PHYSICAL_SHAPE_PROPERTIES: readonly ShapeProperty[] = Object.freeze([ +const PHYSICAL_SHAPE_PROPERTIES: readonly Exclude[] = Object.freeze([ "corner-top-left-shape", "corner-top-right-shape", "corner-bottom-right-shape", "corner-bottom-left-shape", ]); -const LOGICAL_SHAPE_PROPERTIES: readonly ShapeProperty[] = Object.freeze([ +const LOGICAL_SHAPE_PROPERTIES: readonly Exclude[] = Object.freeze([ "corner-start-start-shape", "corner-start-end-shape", "corner-end-end-shape", "corner-end-start-shape", ]); -type OwnedCarrierKind = "radius-logical" | "radius-physical" | "url"; -type OwnedPropertyCarrier = readonly [property: string, carrier: string, kind?: OwnedCarrierKind]; - -const OWNED_PROPERTY_CARRIERS: readonly OwnedPropertyCarrier[] = Object.freeze([ - ["border-top-left-radius", "--cornerfill-border-top-left-radius", "radius-physical"], - ["border-top-right-radius", "--cornerfill-border-top-right-radius", "radius-physical"], - ["border-bottom-right-radius", "--cornerfill-border-bottom-right-radius", "radius-physical"], - ["border-bottom-left-radius", "--cornerfill-border-bottom-left-radius", "radius-physical"], - ["border-start-start-radius", "--cornerfill-border-start-start-radius", "radius-logical"], - ["border-start-end-radius", "--cornerfill-border-start-end-radius", "radius-logical"], - ["border-end-end-radius", "--cornerfill-border-end-end-radius", "radius-logical"], - ["border-end-start-radius", "--cornerfill-border-end-start-radius", "radius-logical"], - ["background-color", "--cornerfill-background-color"], - ["background-image", "--cornerfill-background-image", "url"], - ["background-size", "--cornerfill-background-size"], - ["background-position", "--cornerfill-background-position"], - ["background-repeat", "--cornerfill-background-repeat"], - ["background-origin", "--cornerfill-background-origin"], - ["background-clip", "--cornerfill-background-clip"], - ["background-blend-mode", "--cornerfill-background-blend-mode"], - ["background-attachment", "--cornerfill-background-attachment"], - ["image-rendering", "--cornerfill-image-rendering"], - ["border-top-color", "--cornerfill-border-top-color"], - ["border-right-color", "--cornerfill-border-right-color"], - ["border-bottom-color", "--cornerfill-border-bottom-color"], - ["border-left-color", "--cornerfill-border-left-color"], - ["box-shadow", "--cornerfill-box-shadow"], - ["outline-width", "--cornerfill-outline-width"], - ["outline-style", "--cornerfill-outline-style"], - ["outline-color", "--cornerfill-outline-color"], - ["outline-offset", "--cornerfill-outline-offset"], -]); +const SHAPE_STATUS_PROPERTIES = Object.freeze(Object.fromEntries( + [...PHYSICAL_SHAPE_PROPERTIES, ...LOGICAL_SHAPE_PROPERTIES].map((property) => ( + [property, `--cornerfill-auto-status-${property}`] + )), +)) as Readonly, string>>; +const SHAPE_STATUS_CARRIERS = Object.freeze(Object.values(SHAPE_STATUS_PROPERTIES)); -const OWNED_CARRIERS = Object.freeze(OWNED_PROPERTY_CARRIERS.map(([, carrier]) => carrier)); const AUTO_CARRIERS = Object.freeze([ ...new Set([ ...SHAPE_CARRIERS, - ...OWNED_CARRIERS, + ...SHAPE_STATUS_CARRIERS, AUTO_PHYSICAL_SHAPE, AUTO_LOGICAL_SHAPE, - AUTO_UNSUPPORTED_SHAPE, - AUTO_PHYSICAL_RADIUS, - AUTO_LOGICAL_RADIUS, - AUTO_UNSUPPORTED_OWNED, ]), ]); const SHAPE_MARKERS = Object.freeze([ + ...SHAPE_STATUS_CARRIERS, AUTO_PHYSICAL_SHAPE, AUTO_LOGICAL_SHAPE, - AUTO_UNSUPPORTED_SHAPE, ]); const AUTOMATIC_DISCOVERY = Object.freeze({ @@ -419,7 +412,7 @@ const AUTOMATIC_DISCOVERY = Object.freeze({ "adopted stylesheets unless explicitly enabled for a registered open shadow root", "adopted stylesheet corner-shape source unless supplied to refreshAdoptedStyleSheet()", "mixed physical/logical declaration families", - "corner-shape or paint changes driven by CSS keyframes", + "corner-shape or paint changes driven by CSS animations or transitions", "alternate stylesheet sets", "corner-shape rules inserted through CSSOM before Cornerfill starts", "unsupported declarations assigned through CSSStyleDeclaration, which the browser discards", @@ -463,76 +456,14 @@ function isCssWhitespaceOrComments(value: string): boolean { return value.replaceAll(/\/\*[\s\S]*?\*\//gu, "").trim() === ""; } -/** - * Rename authored corner-shape declarations to durable custom properties. - * Strings, comments, selectors, @supports conditions, and declaration values - * are left untouched. The browser still performs the actual CSS parse. - */ -export function transportCornerShapeDeclarations(source: string): string { - if (typeof source !== "string") throw new TypeError("CSS source must be a string"); - const replacements: TextReplacement[] = []; - let statementStart = 0; - let quote: string | null = null; - let comment = false; - let escaped = false; +type CssTokenVisitor = ( + index: number, + character: string, + parentheses: number, + brackets: number, +) => boolean | void; - for (let index = 0; index < source.length; index += 1) { - const character = source[index]!; - const next = source[index + 1]; - if (comment) { - if (character === "*" && next === "/") { - comment = false; - index += 1; - } - continue; - } - if (quote !== null) { - if (escaped) escaped = false; - else if (character === "\\") escaped = true; - else if (character === quote) quote = null; - continue; - } - if (character === "/" && next === "*") { - comment = true; - index += 1; - continue; - } - if (character === "\"" || character === "'") { - quote = character; - continue; - } - if (character === ":") { - const statement = source.slice(statementStart, index); - const match = /([\w-]+)\s*$/u.exec(statement); - if (match && isCssWhitespaceOrComments(statement.slice(0, match.index))) { - const property = match[1]!.toLowerCase(); - if (!isShapeProperty(property)) continue; - const carrier = SHAPE_PROPERTIES[property]; - if (carrier) { - replacements.push(Object.freeze({ - start: statementStart + match.index, - end: statementStart + match.index + match[1]!.length, - value: carrier, - })); - } - } - continue; - } - if (character === ";" || character === "{" || character === "}") statementStart = index + 1; - } - - if (replacements.length === 0) return source; - let output = ""; - let cursor = 0; - for (const replacement of replacements) { - output += source.slice(cursor, replacement.start); - output += replacement.value; - cursor = replacement.end; - } - return output + source.slice(cursor); -} - -function declarationEnd(source: string, start: number): number { +function scanCssSyntax(source: string, start: number, visit: CssTokenVisitor): void { let quote: string | null = null; let comment = false; let escaped = false; @@ -554,6 +485,14 @@ function declarationEnd(source: string, start: number): number { else if (character === quote) quote = null; continue; } + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } if (character === "/" && next === "*") { comment = true; index += 1; @@ -563,13 +502,22 @@ function declarationEnd(source: string, start: number): number { quote = character; continue; } + if (visit(index, character, parentheses, brackets) === false) return; if (character === "(") parentheses += 1; else if (character === ")") parentheses = Math.max(0, parentheses - 1); else if (character === "[") brackets += 1; else if (character === "]") brackets = Math.max(0, brackets - 1); - else if (parentheses === 0 && brackets === 0 && (character === ";" || character === "}")) return index; } - return source.length; +} + +function declarationEnd(source: string, start: number): number { + let end = source.length; + scanCssSyntax(source, start, (index, character, parentheses, brackets) => { + if (parentheses !== 0 || brackets !== 0 || (character !== ";" && character !== "}")) return; + end = index; + return false; + }); + return end; } function declarationValue(raw: string): Readonly<{ priority: string; value: string }> { @@ -581,106 +529,130 @@ function declarationValue(raw: string): Readonly<{ priority: string; value: stri }); } +function shapeStatusDeclarations( + properties: readonly Exclude[], + status: "ok" | "unsupported", + priority: string, +): string { + return properties.map((property) => ( + `${SHAPE_STATUS_PROPERTIES[property]}:${status}${priority};` + )).join(""); +} + +function shapeCssWideDeclaration( + property: ShapeProperty, + value: string, + priority: string, + longhands: readonly Exclude[], + marker: string, +): string { + const carrierValue = /^(?:initial|unset)$/iu.test(value) ? AUTO_UNSET : value; + const carriers = property === "corner-shape" + ? longhands.map((longhand) => SHAPE_PROPERTIES[longhand]) + : [SHAPE_PROPERTIES[property]]; + return `${carriers.map((carrier) => `${carrier}:${carrierValue}${priority};`).join("")}` + + `${longhands.map((longhand) => `${SHAPE_STATUS_PROPERTIES[longhand]}:${carrierValue}${priority};`).join("")}` + + `${marker}:${carrierValue}${priority};`; +} + +function potentiallyValidUnsupportedShape(value: string): boolean { + const functionValue = /^superellipse\(\s*((?:calc|min|max|clamp)\([\s\S]*\))\s*\)$/iu.exec(value); + if (!functionValue) return false; + const expression = functionValue[1]!; + for (let index = 0; index < expression.length; index += 1) { + const character = expression[index]; + if (character !== "+" && character !== "-") continue; + const before = expression[index - 1] ?? ""; + const after = expression[index + 1] ?? ""; + const prefix = expression.slice(0, index).trimEnd(); + const unary = prefix === "" + || /[,(+\-*/]$/u.test(prefix) + || /[eE]$/u.test(prefix); + if (!unary && (!/\s/u.test(before) || !/\s/u.test(after))) return false; + } + return true; +} + function shapeCarrierDeclaration(property: ShapeProperty, rawValue: string): string { const { value, priority } = declarationValue(rawValue); + const longhands: readonly Exclude[] = property === "corner-shape" + ? PHYSICAL_SHAPE_PROPERTIES + : [property as Exclude]; + const marker = property === "corner-shape" || PHYSICAL_SHAPE_PROPERTIES.includes(property) + ? AUTO_PHYSICAL_SHAPE + : AUTO_LOGICAL_SHAPE; + if (/^(?:inherit|initial|revert|revert-layer|revert-rule|unset)$/iu.test(value)) { + return shapeCssWideDeclaration(property, value, priority, longhands, marker); + } try { if (/\bvar\s*\(/iu.test(value)) { const carrier = SHAPE_PROPERTIES[property]; - const marker = property === "corner-shape" || PHYSICAL_SHAPE_PROPERTIES.includes(property) - ? AUTO_PHYSICAL_SHAPE - : AUTO_LOGICAL_SHAPE; - return `${carrier}:${value}${priority};${marker}:1${priority};`; + return `${carrier}:${value}${priority};${shapeStatusDeclarations(longhands, "ok", priority)}${marker}:1${priority};`; } if (property === "corner-shape") { const values = parseCornerShape(value); return `${PHYSICAL_SHAPE_PROPERTIES.map((longhand, index) => ( `${SHAPE_PROPERTIES[longhand]}:${serializeShapeParameter(values[index]!)}${priority};` - )).join("")}${AUTO_PHYSICAL_SHAPE}:1${priority};`; + )).join("")}${shapeStatusDeclarations(longhands, "ok", priority)}${AUTO_PHYSICAL_SHAPE}:1${priority};`; } const carrier = SHAPE_PROPERTIES[property]; const parsed = serializeShapeParameter(parseCornerShapeValue(value)); - const marker = LOGICAL_SHAPE_PROPERTIES.includes(property) - ? AUTO_LOGICAL_SHAPE - : AUTO_PHYSICAL_SHAPE; - return `${carrier}:${parsed}${priority};${marker}:1${priority};`; + return `${carrier}:${parsed}${priority};${shapeStatusDeclarations(longhands, "ok", priority)}${marker}:1${priority};`; } catch { - return `${AUTO_UNSUPPORTED_SHAPE}:1${priority};`; + return potentiallyValidUnsupportedShape(value) + ? `${shapeStatusDeclarations(longhands, "unsupported", priority)}${marker}:1${priority};` + : ""; } } +function allCarrierDeclaration(rawDeclaration: string, rawValue: string): string | null { + const { value, priority } = declarationValue(rawValue); + if (!/^(?:inherit|initial|revert|revert-layer|revert-rule|unset)$/iu.test(value)) return null; + const carrierValue = /^(?:initial|unset)$/iu.test(value) ? AUTO_UNSET : value; + return `${rawDeclaration};${AUTO_CARRIERS.map((carrier) => ( + `${carrier}:${carrierValue}${priority};` + )).join("")}`; +} + function canonicalizeCornerShapeDeclarations( source: string, authoredDeclarations: string[] | null = null, ): string { const replacements: TextReplacement[] = []; let statementStart = 0; - let quote: string | null = null; - let comment = false; - let escaped = false; - let parentheses = 0; - let brackets = 0; - - for (let index = 0; index < source.length; index += 1) { - const character = source[index]!; - const next = source[index + 1]; - if (comment) { - if (character === "*" && next === "/") { - comment = false; - index += 1; - } - continue; - } - if (quote !== null) { - if (escaped) escaped = false; - else if (character === "\\") escaped = true; - else if (character === quote) quote = null; - continue; - } - if (character === "/" && next === "*") { - comment = true; - index += 1; - continue; - } - if (character === "\"" || character === "'") { - quote = character; - continue; - } - if (character === "(") { - parentheses += 1; - continue; - } - if (character === ")") { - parentheses = Math.max(0, parentheses - 1); - continue; - } - if (character === "[") { - brackets += 1; - continue; - } - if (character === "]") { - brackets = Math.max(0, brackets - 1); - continue; - } - if (parentheses !== 0 || brackets !== 0) continue; + let skipThrough = -1; + scanCssSyntax(source, 0, (index, character, parentheses, brackets) => { + if (index <= skipThrough) return; + if (parentheses !== 0 || brackets !== 0) return; if (character === ":") { const statement = source.slice(statementStart, index); const match = /([\w-]+)\s*$/u.exec(statement); - if (!match || !isCssWhitespaceOrComments(statement.slice(0, match.index))) continue; + if (!match || !isCssWhitespaceOrComments(statement.slice(0, match.index))) return; const property = match[1]!.toLowerCase(); - if (!isShapeProperty(property)) continue; + if (!isShapeProperty(property) && property !== "all") return; const end = declarationEnd(source, index + 1); const start = statementStart + match.index; + if (property === "all") { + const replacement = allCarrierDeclaration( + source.slice(start, end), + source.slice(index + 1, end), + ); + if (!replacement) return; + replacements.push(Object.freeze({ start, end, value: replacement })); + skipThrough = end - 1; + return; + } authoredDeclarations?.push(source.slice(start, end).trim()); replacements.push(Object.freeze({ start, end, - value: shapeCarrierDeclaration(property, source.slice(index + 1, end)), + value: shapeCarrierDeclaration(property as ShapeProperty, source.slice(index + 1, end)), })); - index = Math.max(index, end - 1); - continue; + skipThrough = end - 1; + return; } if (character === ";" || character === "{" || character === "}") statementStart = index + 1; - } + }); if (replacements.length === 0) return source; let output = ""; @@ -693,63 +665,27 @@ function canonicalizeCornerShapeDeclarations( return output + source.slice(cursor); } -function cssString(value: unknown): string { - return `"${String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`; -} - -function resolveCssUrls(value: string, baseUrl: string): string { - return String(value).replaceAll( - /url\(\s*(?:"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|([^\s)'"\\]+))\s*\)/giu, - (source: string, doubleQuoted: string, singleQuoted: string, unquoted: string) => { - const raw = (doubleQuoted ?? singleQuoted ?? unquoted ?? "").replaceAll(/\\([()'"\\])/gu, "$1"); - try { - return `url(${cssString(new URL(raw, baseUrl).href)})`; - } catch { - return source; - } - }, - ); -} - function serializedDeclaration( style: CSSStyleDeclaration, property: string, value: string, - outputProperty = property, ): string { const priority = style.getPropertyPriority(property); - return `${outputProperty}:${value}${priority ? " !important" : ""};`; + return `${property}:${value}${priority ? " !important" : ""};`; } function carrierDeclarations( style: CSSStyleDeclaration | null | undefined, - baseUrl: string, ): Readonly<{ css: string; shape: boolean }> { if (!style?.getPropertyValue) return Object.freeze({ css: "", shape: false }); let css = ""; let shape = false; - let physicalRadius = false; - let logicalRadius = false; for (const property of [...SHAPE_CARRIERS, ...SHAPE_MARKERS]) { const value = style.getPropertyValue(property).trim(); if (!value) continue; css += serializedDeclaration(style, property, value); shape = true; } - for (const [property, carrier, kind] of OWNED_PROPERTY_CARRIERS) { - let value = style.getPropertyValue(property).trim(); - if (!value) continue; - if (/^(?:inherit|revert|revert-layer)$/iu.test(value)) { - css += `${AUTO_UNSUPPORTED_OWNED}:1${style.getPropertyPriority(property) ? " !important" : ""};`; - continue; - } - if (kind === "url") value = resolveCssUrls(value, baseUrl); - css += serializedDeclaration(style, property, value, carrier); - if (kind === "radius-physical") physicalRadius = true; - else if (kind === "radius-logical") logicalRadius = true; - } - if (physicalRadius) css += `${AUTO_PHYSICAL_RADIUS}:1;`; - if (logicalRadius) css += `${AUTO_LOGICAL_RADIUS}:1;`; return Object.freeze({ css, shape }); } @@ -762,7 +698,9 @@ function diagnosticShapeDeclarations(style: CSSStyleDeclaration): readonly strin const priority = style.getPropertyPriority(carrier); declarations.push(`${property}: ${value}${priority ? " !important" : ""}`); } - if (declarations.length === 0 && style.getPropertyValue(AUTO_UNSUPPORTED_SHAPE).trim()) { + if (declarations.length === 0 && SHAPE_STATUS_CARRIERS.some((property) => ( + style.getPropertyValue(property).trim() === "unsupported" + ))) { declarations.push("corner-shape: "); } return Object.freeze(declarations); @@ -774,36 +712,13 @@ function ruleHeader(rule: CSSRule): string { } function matchingParenthesis(value: string, start: number): number { - let depth = 0; - let quote: string | null = null; - let escaped = false; - for (let index = start; index < value.length; index += 1) { - const character = value[index]!; - if (quote !== null) { - if (escaped) escaped = false; - else if (character === "\\") escaped = true; - else if (character === quote) quote = null; - continue; - } - if (escaped) { - escaped = false; - continue; - } - if (character === "\\") { - escaped = true; - continue; - } - if (character === "\"" || character === "'") { - quote = character; - continue; - } - if (character === "(") depth += 1; - else if (character === ")") { - depth -= 1; - if (depth === 0) return index; - } - } - return -1; + let end = -1; + scanCssSyntax(value, start, (index, character, parentheses) => { + if (character !== ")" || parentheses !== 1) return; + end = index; + return false; + }); + return end; } function supportsShapeValue(property: string, value: string): boolean { @@ -868,7 +783,7 @@ function serializeCarrierRules( const rule = rawRule as CarrierRule; const header = ruleHeader(rule); if (/^@(?:-webkit-)?keyframes\b/iu.test(header)) continue; - const declarations = carrierDeclarations(rule.style, baseUrl); + const declarations = carrierDeclarations(rule.style); if (typeof rule.selectorText === "string" && (rule.cssRules?.length ?? 0) > 0) { throw new SyntaxError(`Automatic CSS cannot preserve nested selector rule: ${rule.selectorText}`); } @@ -1043,41 +958,16 @@ function parseCarrierSheet( } function cssStatementEnd(source: string, start: number): number { - let quote: string | null = null; - let comment = false; - let escaped = false; - let parentheses = 0; - for (let index = start; index < source.length; index += 1) { - const character = source[index]!; - const next = source[index + 1]; - if (comment) { - if (character === "*" && next === "/") { - comment = false; - index += 1; - } - continue; - } - if (quote !== null) { - if (escaped) escaped = false; - else if (character === "\\") escaped = true; - else if (character === quote) quote = null; - continue; - } - if (character === "/" && next === "*") { - comment = true; - index += 1; - continue; - } - if (character === "\"" || character === "'") { - quote = character; - continue; - } - if (character === "(") parentheses += 1; - else if (character === ")") parentheses = Math.max(0, parentheses - 1); - else if (parentheses === 0 && character === ";") return index; - else if (parentheses === 0 && character === "{") return -1; - } - return -1; + let end = -1; + scanCssSyntax(source, start, (index, character, parentheses, brackets) => { + if (parentheses !== 0 || brackets !== 0) return; + if (character === ";") { + end = index; + return false; + } + if (character === "{") return false; + }); + return end; } function skipCssTrivia(source: string, start: number): number { @@ -1298,14 +1188,60 @@ function mutateStylesheetModel( function computedCarrier(computed: CSSStyleDeclaration, property: string): string { const value = computed.getPropertyValue(property).trim(); - return value === AUTO_UNSET ? "" : value; -} + return value === AUTO_UNSET || /^(?:initial|unset)$/iu.test(value) ? "" : value; +} + +const AUTOMATIC_COMPUTED_PROPERTIES = Object.freeze([ + "background-attachment", + "background-blend-mode", + "background-clip", + "background-color", + "background-image", + "background-origin", + "background-position", + "background-repeat", + "background-size", + "border-bottom-color", + "border-bottom-left-radius", + "border-bottom-right-radius", + "border-bottom-style", + "border-bottom-width", + "border-image-source", + "border-left-color", + "border-left-style", + "border-left-width", + "border-right-color", + "border-right-style", + "border-right-width", + "border-top-color", + "border-top-left-radius", + "border-top-right-radius", + "border-top-style", + "border-top-width", + "box-shadow", + "box-sizing", + "color", + "height", + "image-rendering", + "outline-color", + "outline-offset", + "outline-style", + "outline-width", + "overflow-x", + "overflow-y", + "padding-bottom", + "padding-left", + "padding-right", + "padding-top", + "width", +]); function automaticComputedSignature(computed: CSSStyleDeclaration): string { return [ computed.visibility, computed.direction, computed.writingMode, + ...AUTOMATIC_COMPUTED_PROPERTIES.map((property) => computed.getPropertyValue(property)), ...AUTO_CARRIERS.map((property) => computedCarrier(computed, property)), ].join("\n"); } @@ -1334,12 +1270,9 @@ function automaticStyleMutationSignature(value: unknown): string { } function carrierProblem(computed: CSSStyleDeclaration): string | null { - if (computedCarrier(computed, AUTO_UNSUPPORTED_SHAPE)) { + if (SHAPE_STATUS_CARRIERS.some((property) => computedCarrier(computed, property) === "unsupported")) { return "Automatic CSS cannot resolve this corner-shape value; use cornerfill/runtime for explicit state."; } - if (computedCarrier(computed, AUTO_UNSUPPORTED_OWNED)) { - return "Automatic CSS cannot preserve an inherited or reverted paint-owned declaration; use cornerfill/runtime for explicit state."; - } const variableShorthand = computedCarrier(computed, SHAPE_PROPERTIES["corner-shape"]); const competingLonghand = [...PHYSICAL_SHAPE_PROPERTIES, ...LOGICAL_SHAPE_PROPERTIES] .some((property) => computedCarrier(computed, SHAPE_PROPERTIES[property])); @@ -1350,10 +1283,6 @@ function carrierProblem(computed: CSSStyleDeclaration): string | null { && computedCarrier(computed, AUTO_LOGICAL_SHAPE)) { return "Automatic CSS refuses mixed physical and logical corner-shape declarations because their cross-family cascade cannot be preserved."; } - if (computedCarrier(computed, AUTO_PHYSICAL_RADIUS) - && computedCarrier(computed, AUTO_LOGICAL_RADIUS)) { - return "Automatic CSS refuses mixed physical and logical border-radius declarations because their cross-family cascade cannot be preserved."; - } return null; } @@ -1452,7 +1381,7 @@ function inlineCarrierRecords( const transformed = canonicalizeCornerShapeDeclarations(String(source), authoredDeclarations); const scratch = document.createElement("div"); scratch.setAttribute("style", transformed); - const compiled = carrierDeclarations(scratch.style, document.baseURI); + const compiled = carrierDeclarations(scratch.style); if (!compiled.css) return Object.freeze({ declarations: Object.freeze([]), shape: false, @@ -1488,6 +1417,7 @@ function runtimeOptions( adoptedStyleSheets: _adoptedStyleSheets, parentAuto: _parentAuto, onError: _onError, + stylesheetTimeoutMs: _stylesheetTimeoutMs, ...runtime } = options; return { ...runtime, document }; @@ -1543,6 +1473,7 @@ class CornerfillAutoController { declare sourceRequested: boolean; declare readonly sourceRequests: Map; declare readonly stylesheets: Map>; + declare readonly stylesheetTimeoutMs: number; declare workRequested: boolean; constructor(options: Readonly = {}) { @@ -1550,6 +1481,10 @@ class CornerfillAutoController { if (!document?.defaultView) throw new TypeError("installCornerfillAuto() requires a browser document"); this.document = document as RuntimeDocument; this.root = options.root ?? this.document; + this.stylesheetTimeoutMs = options.stylesheetTimeoutMs ?? 3_000; + if (!Number.isFinite(this.stylesheetTimeoutMs) || this.stylesheetTimeoutMs <= 0) { + throw new TypeError("stylesheetTimeoutMs must be a finite positive number"); + } this.nonce = options.nonce ?? stylesheetElements(this.root).map(nonceValue).find(Boolean) ?? nonceValue(this.document.querySelector("script[nonce],style[nonce],link[nonce]")) ?? null; @@ -1952,7 +1887,7 @@ class CornerfillAutoController { request.cancelWait = cancel; const timer = this.document.defaultView.setTimeout(() => finish( new Error(`browser stylesheet load timed out: ${owner.href}`), - ), 3_000); + ), this.stylesheetTimeoutMs); owner.addEventListener("load", loaded, { once: true }); owner.addEventListener("error", failed, { once: true }); this.pendingStylesheetWaits.add(cancel); @@ -2671,7 +2606,7 @@ class CornerfillAutoController { this._clearErrors(element); if (!hasShapeCarrier(computed)) continue; try { - const handle = this.controller.attach(element, { dynamicCarriers: true }); + const handle = this.controller.attach(element); this.automaticCounters.handleAttaches += 1; this.handles.set(element, handle); this.handleSignatures.set(element, automaticComputedSignature(computed)); @@ -2826,18 +2761,10 @@ class CornerfillAutoController { } async _start(): Promise | Readonly | null> { - if (this.document.readyState === "loading") { - await new Promise((resolve) => this.document.addEventListener( - "DOMContentLoaded", - () => resolve(), - { once: true }, - )); - } if (this.destroyed || this.native) return this.explain(); this._ensureCarrierRegistration(); - const result = await this.refresh(); this._installObserver(); - return result; + return this.refresh(); } _queueRefresh( @@ -2948,6 +2875,7 @@ class CornerfillAutoController { autoObserve: options.autoObserve ?? this.autoObserve, adoptedStyleSheets: options.adoptedStyleSheets === true, onError: options.onError ?? this.onError ?? undefined, + stylesheetTimeoutMs: this.stylesheetTimeoutMs, }); this.scopes.set(root, scope); return scope; @@ -2985,7 +2913,7 @@ class CornerfillAutoController { }), implementation: Object.freeze({ automaticDiscovery: this.native ? "BYPASSED_NATIVE" : "IMPLEMENTED", - fallbackRenderer: this.native ? "NOT_LOADED" : "IMPLEMENTED", + fallbackRenderer: this.native ? "NOT_SELECTED" : "IMPLEMENTED", }), oracleQualification: CORNERFILL_ORACLE_QUALIFICATION, automatic: Object.freeze({ diff --git a/src/auto.mts b/src/auto.mts index 6d47da6..924c055 100644 --- a/src/auto.mts +++ b/src/auto.mts @@ -1,5 +1,6 @@ -import { CORNERFILL_ORACLE_QUALIFICATION, qualifyNativeCornerShape } from "./native.mjs"; +import { qualifyNativeCornerShape } from "./native.mjs"; import type { CornerfillNativeQualification } from "./native.mjs"; +import { CORNERFILL_ORACLE_QUALIFICATION } from "./qualification.mjs"; import type { CornerfillAutoControllerHandle, CornerfillAutoExplanation, diff --git a/src/backends.mts b/src/backends.mts index f53efea..0fd9272 100644 --- a/src/backends.mts +++ b/src/backends.mts @@ -34,10 +34,30 @@ export interface CornerfillSurface { readonly schema: typeof CORNERFILL_SURFACE_SCHEMA; readonly size: Readonly; commit(): void; + /** Releases backend resources. Cleanup is best-effort and does not throw. */ dispose(): void; resize(cssWidth: number, cssHeight: number, dpr: number): boolean; } +export interface SurfaceResourceStats { + readonly firefox: Readonly<{ + registrations: number; + unregisterFailures: number; + }>; + readonly schema: "cornerfill-surface-resources@1"; + readonly webkit: Readonly<{ + activeCanvases: number; + pooledCanvases: number; + pooledPixels: number; + prefixes: number; + retainedCanvases: number; + retainedPixels: number; + retiredCanvases: number; + retiredPixels: number; + shrinkFailures: number; + }>; +} + export interface SurfaceCapabilities { readonly schema: "cornerfill-surface-capabilities@1"; readonly webkitCanvas: boolean; @@ -79,12 +99,6 @@ interface WebkitReleaseOptions { readonly shrunk: boolean; } -type CssSupportHost = typeof globalThis & { - CSS?: { - supports?: (property: string, value: string) => boolean; - }; -}; - type SurfaceDocument = Document & { getCSSCanvasContext?: ( contextId: "2d", @@ -95,9 +109,9 @@ type SurfaceDocument = Document & { mozSetImageElement?: (id: string, element: Element | null) => void; }; -const hiddenRoots = new WeakMap(); const webkitSurfacePools = new WeakMap(); const mozRegistrationCounts = new WeakMap(); +const mozUnregisterFailureCounts = new WeakMap(); const DEFAULT_MAX_WEBKIT_POOL_ENTRIES = 256; const DEFAULT_MAX_WEBKIT_POOL_PREFIXES = 16; @@ -188,41 +202,11 @@ function backingDimensions( return Object.freeze({ width, height }); } -function getHiddenRoot(document: Document): HTMLDivElement { - let root = hiddenRoots.get(document); - if (root?.isConnected) return root; - root = document.createElement("div"); - root.setAttribute("data-cornerfill-surfaces", ""); - root.setAttribute("aria-hidden", "true"); - Object.assign(root.style, { - position: "fixed", - left: "-100000px", - top: "-100000px", - width: "1px", - height: "1px", - overflow: "hidden", - pointerEvents: "none", - visibility: "hidden", - }); - (document.body ?? document.documentElement).append(root); - hiddenRoots.set(document, root); - return root; -} - -function maybeRemoveHiddenRoot(document: Document): void { - const root = hiddenRoots.get(document); - if (root && root.childElementCount === 0) { - root.remove(); - hiddenRoots.delete(document); - } -} - export function detectSurfaceCapabilities(document: Document): Readonly { - const view = (document?.defaultView ?? globalThis) as CssSupportHost; - const cssSupports = view.CSS?.supports?.bind(view.CSS); const webkitCanvas = typeof (document as SurfaceDocument)?.getCSSCanvasContext === "function"; const mozRegistration = typeof (document as SurfaceDocument)?.mozSetImageElement === "function"; - const mozElement = mozRegistration || Boolean(cssSupports?.("background-image", "-moz-element(#cornerfill-probe)")); + // Syntax support cannot expose a Canvas by ID; registration is the actual backend capability. + const mozElement = mozRegistration; return Object.freeze({ schema: "cornerfill-surface-capabilities@1", webkitCanvas, @@ -233,7 +217,7 @@ export function detectSurfaceCapabilities(document: Document): Readonly { const webkit = webkitSurfacePools.get(document); return Object.freeze({ schema: "cornerfill-surface-resources@1", @@ -250,6 +234,7 @@ export function getSurfaceResourceStats(document: Document) { }), firefox: Object.freeze({ registrations: mozRegistrationCounts.get(document) ?? 0, + unregisterFailures: mozUnregisterFailureCounts.get(document) ?? 0, }), }); } @@ -350,7 +335,6 @@ function createMozSurface( const canvas = document.createElement("canvas"); canvas.id = id; canvas.setAttribute("aria-hidden", "true"); - const directRegistration = typeof document.mozSetImageElement === "function"; const context = canvas.getContext("2d", { alpha: true }); if (!context) throw new Error("could not create the Firefox live canvas context"); let cssWidth = 0; @@ -391,13 +375,12 @@ function createMozSurface( commit() {}, dispose() { if (disposed) return; - let unregisterError = null; - if (registered && directRegistration) { + if (registered) { try { document.mozSetImageElement!(id, null); mozRegistrationCounts.set(document, Math.max(0, (mozRegistrationCounts.get(document) ?? 1) - 1)); - } catch (error) { - unregisterError = error; + } catch { + mozUnregisterFailureCounts.set(document, (mozUnregisterFailureCounts.get(document) ?? 0) + 1); } } registered = false; @@ -405,29 +388,21 @@ function createMozSurface( canvas.width = 1; canvas.height = 1; disposed = true; - maybeRemoveHiddenRoot(document); - if (unregisterError) throw unregisterError; }, }; try { surface.resize(options.cssWidth, options.cssHeight, options.dpr); - if (directRegistration) { - document.mozSetImageElement!(id, canvas); - registered = true; - mozRegistrationCounts.set(document, (mozRegistrationCounts.get(document) ?? 0) + 1); - } else { - getHiddenRoot(document).append(canvas); - registered = true; - } + document.mozSetImageElement!(id, canvas); + registered = true; + mozRegistrationCounts.set(document, (mozRegistrationCounts.get(document) ?? 0) + 1); } catch (error) { - if (directRegistration) { - try { document.mozSetImageElement!(id, null); } catch {} + try { document.mozSetImageElement!(id, null); } catch { + mozUnregisterFailureCounts.set(document, (mozUnregisterFailureCounts.get(document) ?? 0) + 1); } registered = false; canvas.remove(); canvas.width = 1; canvas.height = 1; - maybeRemoveHiddenRoot(document); disposed = true; throw error; } @@ -493,7 +468,6 @@ function createStaticSurface( return surface; } -export function createSurface(document: Document, options: SurfaceCreateOptions): CornerfillSurface; export function createSurface(document: Document, { cssWidth, cssHeight, @@ -504,7 +478,7 @@ export function createSurface(document: Document, { maxSurfacePixels = 16_777_216, maxWebkitPoolEntries = DEFAULT_MAX_WEBKIT_POOL_ENTRIES, maxWebkitPoolPrefixes = DEFAULT_MAX_WEBKIT_POOL_PREFIXES, -}: Partial = {}): CornerfillSurface { +}: Readonly): CornerfillSurface { const capabilities = detectSurfaceCapabilities(document); let selected: SelectedSurfaceBackend = backend; if (selected === "auto") { @@ -528,7 +502,7 @@ export function createSurface(document: Document, { if (selected === "none") { throw new Error("no live Cornerfill surface backend is available and static fallback is disabled"); } - backingDimensions(cssWidth!, cssHeight!, dpr, maxSurfacePixels); + backingDimensions(cssWidth, cssHeight, dpr, maxSurfacePixels); if (!Number.isSafeInteger(maxWebkitPoolEntries) || maxWebkitPoolEntries < 0) { throw new TypeError("maxWebkitPoolEntries must be a non-negative integer"); } @@ -536,8 +510,8 @@ export function createSurface(document: Document, { throw new TypeError("maxWebkitPoolPrefixes must be a non-negative integer"); } const options: ResolvedSurfaceOptions = { - cssWidth: cssWidth!, - cssHeight: cssHeight!, + cssWidth, + cssHeight, dpr, idPrefix, maxSurfacePixels, diff --git a/src/background.mts b/src/background.mts index 9f6f0d6..b542891 100644 --- a/src/background.mts +++ b/src/background.mts @@ -59,9 +59,16 @@ export interface BackgroundRepeat { readonly y: BackgroundRepeatMode; } +export type BackgroundBoxSideInput = number | Four | Readonly<{ + bottom: number; + left: number; + right: number; + top: number; +}>; + export interface BackgroundBoxMetricsInput { - readonly border?: unknown; - readonly padding?: unknown; + readonly border?: BackgroundBoxSideInput | null | undefined; + readonly padding?: BackgroundBoxSideInput | null | undefined; } export interface BackgroundBoxMetrics { diff --git a/src/geometry.mts b/src/geometry.mts index c9916c4..642b37c 100644 --- a/src/geometry.mts +++ b/src/geometry.mts @@ -107,12 +107,13 @@ type InsetCacheValue = InsetCornerGeometry | typeof UNSUPPORTED_INSET_TOPOLOGY; type InsetCache = Map; const CORNER_COUNT = 4; -const DEFAULT_SEGMENTS = 64; const INTERSECTION_EPSILON = 1e-9; const FLOATING_SHAPE_LIMIT = 54; const CONCAVE_SAFETY_SEGMENTS = 256; const CONCAVE_SAFETY_MARGIN = 1e-5; const MAX_INSET_CACHE_ENTRIES = 16; +// 52 bisections reach the precision limit of a finite IEEE-754 mantissa. +const INTERSECTION_BISECTION_ITERATIONS = 52; const UNSUPPORTED_INSET_TOPOLOGY = Symbol("unsupported inset topology"); const INSET_TOPOLOGY_ERROR = "shaped inset contour self-intersects after clipping and is unsupported"; const insetGeometryCache = new WeakMap(); @@ -462,7 +463,7 @@ function highestNonOverlappingScale(first: CornerHull, second: CornerHull, maxim if (!overlaps(maximum)) return maximum; let low = 0; let high = maximum; - for (let iteration = 0; iteration < 52; iteration += 1) { + for (let iteration = 0; iteration < INTERSECTION_BISECTION_ITERATIONS; iteration += 1) { const middle = (low + high) / 2; if (overlaps(middle)) high = middle; else low = middle; @@ -599,7 +600,7 @@ export function contourPoints({ const resolved = radiiAreResolved ? radii : resolveCornerRadii(width, height, radii, shapeParameters).radii; - const options = samplingOptions({ segments: segments ?? (tolerance === undefined ? DEFAULT_SEGMENTS : undefined), tolerance, dpr }); + const options = samplingOptions({ segments, tolerance, dpr }); const curves = resolved.map((radius, index) => cornerCurve( index, width, @@ -897,7 +898,6 @@ function cornerVertices( width: number, height: number, radius: Radius, - _shapeParameter: number, ): CornerVertices { const { rx, ry } = radius; if (index === 0) return { start: [0, ry], outer: [0, 0], end: [rx, 0], center: [rx, ry] }; @@ -1145,7 +1145,7 @@ export function insetCornerGeometry(geometry: CornerGeometry, insets: CornerInse const startInsets: Four = [left, top, right, bottom]; const endInsets: Four = [top, right, bottom, left]; const corners = geometry.radii.map((radius, index) => adjustCornerForInsets( - cornerVertices(index, geometry.width, geometry.height, radius, geometry.shapeParameters[index]!), + cornerVertices(index, geometry.width, geometry.height, radius), index, geometry.shapeParameters[index]!, startInsets[index]!, diff --git a/src/index.mts b/src/index.mts deleted file mode 100644 index e3499f3..0000000 --- a/src/index.mts +++ /dev/null @@ -1,61 +0,0 @@ -export { - CORNERFILL_LIMITATIONS, - CORNERFILL_RUNTIME_SCHEMA, - detectCornerfillCapabilities, - installCornerfill, -} from "./runtime.mjs"; - -export { - installCornerfillAuto, - transportCornerShapeDeclarations, -} from "./auto-runtime.mjs"; - -export { - CORNER_SHAPE_PARAMETERS, - diagonalToShapeParameter, - interpolateCornerShape, - logicalCornerToPhysical, - parseBorderRadius, - parseCornerRadius, - parseCornerShape, - parseCornerShapeValue, - parseLengthPercentage, - resolveBorderRadius, - resolveBorderRadiusDeclarations, - resolveCornerRadiusLonghands, - resolveCornerShapeDeclarations, - resolveCornerShape, - resolveLengthPercentage, - serializeShapeParameter, - shapeParameterToDiagonal, -} from "./values.mjs"; - -export { - buildCornerGeometry, - contourPoints, - convexPolygonsOverlap, - cornerCarveOuts, - insetGeometry, - oppositeCornerScaleFactor, - resolveCornerRadii, - resolveRadii, - sampleCanonicalCorner, -} from "./geometry.mjs"; - -export { - CORNERFILL_PAINTER_SCHEMA, - createPreparedOpaqueImageProgram, - explainPreparedOpaqueImage, - paintCornerfill, - paintOwnedLayer, - repaintPreparedOpaqueImage, - traceClosedPoints, - validatePreparedOpaqueImagePosition, -} from "./paint.mjs"; - -export { - CORNERFILL_SURFACE_SCHEMA, - createSurface, - detectSurfaceCapabilities, - getSurfaceResourceStats, -} from "./backends.mjs"; diff --git a/src/native.mts b/src/native.mts index 9f9a180..a604411 100644 --- a/src/native.mts +++ b/src/native.mts @@ -1,18 +1,4 @@ export const CORNERFILL_NATIVE_QUALIFICATION_SCHEMA = "cornerfill-native-qualification@1"; -export const CORNERFILL_ORACLE_QUALIFICATION = Object.freeze({ - schema: "cornerfill-oracle-qualification@1", - nativeCalibration: Object.freeze({ - status: "PASS", - scope: "same-fixture native A/A capture", - approvedTolerance: true, - exactZeroTolerance: true, - }), - candidate: Object.freeze({ - status: "UNQUALIFIED", - approvedTolerance: false, - reason: "No native-versus-candidate pixel tolerance has been approved.", - }), -}); export interface CornerfillNativeSyntaxProbes { readonly shorthand: boolean; @@ -37,7 +23,23 @@ export interface CornerfillNativeRequirements { readonly shapedBehavior: Readonly; } +export type CornerfillNativeCapabilityStatus = "supported" | "unsupported" | "unobserved"; + +export interface CornerfillNativeCapabilities { + readonly animation: CornerfillNativeCapabilityStatus; + readonly backgroundClip: CornerfillNativeCapabilityStatus; + readonly computedValues: CornerfillNativeCapabilityStatus; + readonly innerBorderContour: CornerfillNativeCapabilityStatus; + readonly outerPaint: CornerfillNativeCapabilityStatus; + readonly outlines: CornerfillNativeCapabilityStatus; + readonly overflowClip: CornerfillNativeCapabilityStatus; + readonly shadows: CornerfillNativeCapabilityStatus; + readonly shapedHitTesting: CornerfillNativeCapabilityStatus; + readonly syntax: CornerfillNativeCapabilityStatus; +} + export interface CornerfillNativeQualification { + readonly capabilities: Readonly; readonly schema: typeof CORNERFILL_NATIVE_QUALIFICATION_SCHEMA; readonly qualified: boolean; readonly requirements: Readonly; @@ -62,6 +64,28 @@ const LONGHANDS = Object.freeze([ "corner-bottom-left-shape", ]); const EXPECTED_LONGHANDS = Object.freeze(["bevel", "scoop", "round", "notch"]); +const EXPECTED_PARAMETERS = Object.freeze([0, -1, 1, Number.NEGATIVE_INFINITY]); +const SHAPE_ALIASES = Object.freeze({ + bevel: 0, + notch: Number.NEGATIVE_INFINITY, + round: 1, + scoop: -1, + square: Number.POSITIVE_INFINITY, + squircle: 2, +}); + +function computedShapeParameter(source: string): number | null { + const value = source.trim().toLowerCase(); + if (Object.hasOwn(SHAPE_ALIASES, value)) { + return SHAPE_ALIASES[value as keyof typeof SHAPE_ALIASES]; + } + const match = /^superellipse\(\s*(infinity|-infinity|[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*\)$/iu.exec(value); + if (!match) return null; + if (match[1] === "infinity") return Number.POSITIVE_INFINITY; + if (match[1] === "-infinity") return Number.NEGATIVE_INFINITY; + const number = Number(match[1]); + return Number.isFinite(number) ? (Object.is(number, -0) ? 0 : number) : null; +} function requirement( supported: boolean, @@ -83,13 +107,28 @@ function qualification( ): Readonly { const unresolved = unresolvedRequirements(requirements); const qualified = unresolved.length === 0; + const observed = (supported: boolean): CornerfillNativeCapabilityStatus => ( + supported ? "supported" : "unsupported" + ); return Object.freeze({ schema: CORNERFILL_NATIVE_QUALIFICATION_SCHEMA, qualified, + capabilities: Object.freeze({ + syntax: observed(requirements.syntax.supported), + computedValues: observed(requirements.computedValues.supported), + shapedHitTesting: observed(requirements.shapedBehavior.supported), + outerPaint: "unobserved", + innerBorderContour: "unobserved", + backgroundClip: "unobserved", + overflowClip: "unobserved", + shadows: "unobserved", + outlines: "unobserved", + animation: "unobserved", + }), requirements: Object.freeze(requirements), unresolved, reason: qualified - ? "Required corner-shape syntax, computed values, and shaped behavior were observed." + ? "The conservative native-selection probes passed; unprobed capabilities remain explicitly unobserved." : `Native corner-shape is unqualified: ${unresolved.join(", ") || "probe failure"}.`, ...(error ? { error: error instanceof Error ? error.message : String(error) } : {}), }); @@ -106,21 +145,28 @@ function syntaxRequirement(view: Window): Readonly return requirement(Object.values(probes).every(Boolean), { probes }); } +function setProbeStyle(element: HTMLElement, declarations: Readonly>): void { + for (const [property, value] of Object.entries(declarations)) { + element.style.setProperty(property, value, "important"); + } +} + function probeElement(document: Document): HTMLDivElement { const element = document.createElement("div"); element.setAttribute("aria-hidden", "true"); - Object.assign(element.style, { + setProbeStyle(element, { all: "initial", background: "rgb(1, 2, 3)", border: "0 solid transparent", - boxSizing: "border-box", + "box-sizing": "border-box", contain: "strict", display: "block", margin: "0", + opacity: "0", padding: "0", - pointerEvents: "auto", + "pointer-events": "auto", position: "fixed", - zIndex: "2147483647", + "z-index": "2147483647", }); return element; } @@ -129,15 +175,17 @@ function computedRequirement( document: Document, element: HTMLElement, ): Readonly { - element.style.setProperty("corner-shape", EXPECTED_LONGHANDS.join(" ")); + element.style.setProperty("corner-shape", EXPECTED_LONGHANDS.join(" "), "important"); const computed = document.defaultView!.getComputedStyle(element); const shorthand = computed.getPropertyValue("corner-shape").trim(); const longhands = Object.freeze(LONGHANDS.map((property) => ( computed.getPropertyValue(property).trim() ))); return requirement( - shorthand === EXPECTED_LONGHANDS.join(" ") - && longhands.every((value, index) => value === EXPECTED_LONGHANDS[index]), + longhands.every((value, index) => Object.is( + computedShapeParameter(value), + EXPECTED_PARAMETERS[index], + )), { shorthand, longhands }, ); } @@ -154,8 +202,8 @@ function behaviorRequirement( const size = Math.min(100, available); const left = 2; const top = 2; - Object.assign(element.style, { - borderRadius: `${size / 2}px`, + setProbeStyle(element, { + "border-radius": `${size / 2}px`, height: `${size}px`, left: `${left}px`, top: `${top}px`, @@ -163,10 +211,10 @@ function behaviorRequirement( }); const x = left + Math.round(size * 0.15); const y = top + Math.round(size * 0.2); - element.style.setProperty("corner-shape", "bevel"); + element.style.setProperty("corner-shape", "bevel", "important"); view.getComputedStyle(element).getPropertyValue("corner-shape"); const bevelExcludes = document.elementFromPoint(x, y) !== element; - element.style.setProperty("corner-shape", "round"); + element.style.setProperty("corner-shape", "round", "important"); view.getComputedStyle(element).getPropertyValue("corner-shape"); const roundIncludes = document.elementFromPoint(x, y) === element; return requirement(bevelExcludes && roundIncludes, { @@ -182,12 +230,12 @@ function isolatedProbeDocument(document: Document): Readonly<{ }> { const frame = document.createElement("iframe"); frame.setAttribute("aria-hidden", "true"); - Object.assign(frame.style, { + setProbeStyle(frame, { border: "0", height: "128px", left: "0", opacity: "0", - pointerEvents: "none", + "pointer-events": "none", position: "fixed", top: "0", width: "128px", @@ -232,6 +280,7 @@ export function qualifyNativeCornerShape( result = qualification({ syntax, computedValues: computedRequirement(isolated.document, element), + // Paint-only fallback cannot supply hit testing, so partial native behavior must not qualify. shapedBehavior: behaviorRequirement(isolated.document, element), }); } catch (error) { diff --git a/src/paint.mts b/src/paint.mts index 9b4b798..186cdc5 100644 --- a/src/paint.mts +++ b/src/paint.mts @@ -14,6 +14,17 @@ import type { Four } from "./values.mjs"; export const CORNERFILL_PAINTER_SCHEMA = "cornerfill-production-painter@1"; +const ZERO_ALPHA = String.raw`(?:0+(?:\.0*)?|\.0+)(?:e[+-]?\d+)?%?`; +const ZERO_SLASH_ALPHA = new RegExp(`/\\s*${ZERO_ALPHA}\\s*\\)$`, "iu"); +const ZERO_LEGACY_ALPHA = new RegExp(`^(?:rgba|hsla)\\([\\s\\S]*,\\s*${ZERO_ALPHA}\\s*\\)$`, "iu"); + +export function isFullyTransparentCssColor(value: string): boolean { + const color = String(value).trim(); + return /^transparent$/iu.test(color) + || ZERO_SLASH_ALPHA.test(color) + || ZERO_LEGACY_ALPHA.test(color); +} + export interface RasterPaintState { readonly backgroundPosition?: PixelPair | undefined; readonly backgroundSize?: PixelPair | undefined; @@ -99,6 +110,8 @@ export interface BorderPaintState { } export interface OwnedBorderPaintState extends BorderPaintState { + readonly colors?: Four | undefined; + readonly styles?: Four | undefined; readonly widths: Four; } @@ -146,6 +159,93 @@ export interface CornerfillPaintOptions { readonly shadow?: InsetShadowPaintState | null | undefined; } +export type PixelRect = readonly [x: number, y: number, width: number, height: number]; + +export interface ImageLayerPaintResult { + readonly blendMode?: "multiply" | undefined; + readonly destinationRect: PixelRect | null; + readonly imageSize: PixelPair; + readonly kind: "image"; + readonly repeat?: Readonly | "no-repeat" | undefined; + readonly sourceRect: PixelRect | null; + readonly tileSize?: PixelPair | undefined; + readonly tilesDrawn?: number | undefined; +} + +export interface GradientLayerPaintResult { + readonly kind: "conic-gradient" | "linear-gradient" | "radial-gradient"; + readonly tilesDrawn: number; +} + +export interface SolidLayerPaintResult { + readonly color: string; + readonly kind: "solid"; +} + +export interface EmptyLayerPaintResult { + readonly kind: "none"; +} + +export type OwnedLayerPaintResult = + | ImageLayerPaintResult + | GradientLayerPaintResult + | SolidLayerPaintResult + | EmptyLayerPaintResult; + +export interface EmptyClipPaintResult { + readonly emptyClip: true; + readonly kind: OwnedPaintLayer["kind"]; +} + +export type ClippedOwnedLayerPaintResult = OwnedLayerPaintResult | EmptyClipPaintResult; + +export interface LayerStackPaintResult { + readonly color: Readonly | null; + readonly kind: "layers"; + readonly layers: readonly Readonly[]; +} + +export type PaintLayerResult = OwnedLayerPaintResult | EmptyClipPaintResult | LayerStackPaintResult; + +export interface CornerfillPaintResult { + readonly border: Readonly<{ + color: string; + kind: "solid-shaped-ring"; + widths: Four; + }> | null; + readonly layer: Readonly; + readonly outline: Readonly | null; + readonly painter: typeof CORNERFILL_PAINTER_SCHEMA; + readonly shadow: Readonly | null; +} + +export interface PreparedOpaqueImageExplanation { + readonly border: null; + readonly layer: Readonly; + readonly painter: typeof CORNERFILL_PAINTER_SCHEMA; + readonly update: "prepared-opaque-source-in"; +} + +export interface OpaqueCornerfillPaintResult { + readonly border: null; + readonly layer: Readonly; + readonly painter: typeof CORNERFILL_PAINTER_SCHEMA; + readonly update: "opaque-source-in"; +} + +export type CornerfillPaintExplanation = + | CornerfillPaintResult + | PreparedOpaqueImageExplanation + | OpaqueCornerfillPaintResult; + +function freezePair(first: number, second: number): PixelPair { + return Object.freeze([first, second]); +} + +function freezeRect(x: number, y: number, width: number, height: number): PixelRect { + return Object.freeze([x, y, width, height]); +} + function imageDimensions(image: CornerfillRasterSource): PixelPair { const width = image?.naturalWidth ?? image?.videoWidth ?? image?.width; const height = image?.naturalHeight ?? image?.videoHeight ?? image?.height; @@ -153,7 +253,7 @@ function imageDimensions(image: CornerfillRasterSource): PixelPair { || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { throw new TypeError("raster paint requires a decoded image with intrinsic dimensions"); } - return [width, height]; + return freezePair(width, height); } export function traceClosedPoints( @@ -200,7 +300,7 @@ function drawNoRepeatImage( paint: RasterPaintState, width: number, height: number, -) { +): Readonly { const image = paint.image; const [intrinsicWidth, intrinsicHeight] = imageDimensions(image); if (paint.sourceSize) { @@ -225,7 +325,7 @@ function drawNoRepeatImage( if (destinationRight <= destinationLeft || destinationBottom <= destinationTop) { return Object.freeze({ kind: "image", - imageSize: Object.freeze([intrinsicWidth, intrinsicHeight]), + imageSize: freezePair(intrinsicWidth, intrinsicHeight), sourceRect: null, destinationRect: null, }); @@ -250,14 +350,14 @@ function drawNoRepeatImage( ); return Object.freeze({ kind: "image", - imageSize: Object.freeze([intrinsicWidth, intrinsicHeight]), - sourceRect: Object.freeze([sourceX, sourceY, sourceWidth, sourceHeight]), - destinationRect: Object.freeze([ + imageSize: freezePair(intrinsicWidth, intrinsicHeight), + sourceRect: freezeRect(sourceX, sourceY, sourceWidth, sourceHeight), + destinationRect: freezeRect( destinationLeft, destinationTop, destinationRight - destinationLeft, destinationBottom - destinationTop, - ]), + ), }); } @@ -271,7 +371,7 @@ function drawRasterImage( paint: RasterPaintState, width: number, height: number, -) { +): Readonly { if (noRepeat(paint.repeat)) return drawNoRepeatImage(context, paint, width, height); const image = paint.image; const [intrinsicWidth, intrinsicHeight] = imageDimensions(image); @@ -313,13 +413,13 @@ function drawRasterImage( } return Object.freeze({ kind: "image", - imageSize: Object.freeze([intrinsicWidth, intrinsicHeight]), - tileSize: Object.freeze([backgroundWidth, backgroundHeight]), + imageSize: freezePair(intrinsicWidth, intrinsicHeight), + tileSize: freezePair(backgroundWidth, backgroundHeight), tilesDrawn, repeat: paint.repeat, - sourceRect: tilesDrawn ? Object.freeze([0, 0, intrinsicWidth, intrinsicHeight]) : null, + sourceRect: tilesDrawn ? freezeRect(0, 0, intrinsicWidth, intrinsicHeight) : null, destinationRect: tilesDrawn - ? Object.freeze([xPositions[0], yPositions[0], backgroundWidth, backgroundHeight]) + ? freezeRect(xPositions[0]!, yPositions[0]!, backgroundWidth, backgroundHeight) : null, }); } @@ -444,7 +544,7 @@ function paintGradient( paint: GradientPaintState, width: number, height: number, -) { +): Readonly { const tiles = gradientTiles(paint, width, height); for (const [x, y, tileWidth, tileHeight] of tiles) { if (paint.kind === "linear-gradient") { @@ -463,7 +563,7 @@ export function paintOwnedLayer( paint: OwnedPaintLayer, width: number, height: number, -) { +): Readonly { if (!paint || typeof paint !== "object") throw new TypeError("paint state is required"); if (paint.kind === "solid") { fillRect(context, paint.color, width, height); @@ -571,13 +671,13 @@ function preparedImageRect( program: Readonly, positionX: number, positionY: number, -) { - return { +): Readonly<{ sourceHeight: number; sourceWidth: number; sourceX: number; sourceY: number }> { + return Object.freeze({ sourceX: positionX === 0 ? 0 : -positionX * program.sourceScaleX, sourceY: positionY === 0 ? 0 : -positionY * program.sourceScaleY, sourceWidth: program.sourceWidth, sourceHeight: program.sourceHeight, - }; + }); } export function preparePreparedOpaqueImageContext( @@ -615,14 +715,13 @@ export function drawPreparedOpaqueImage( positionY: number, ): void { validatePreparedOpaqueImagePosition(program, positionX, positionY); - const sourceX = positionX === 0 ? 0 : -positionX * program.sourceScaleX; - const sourceY = positionY === 0 ? 0 : -positionY * program.sourceScaleY; + const rect = preparedImageRect(program, positionX, positionY); context.drawImage( program.image, - sourceX, - sourceY, - program.sourceWidth, - program.sourceHeight, + rect.sourceX, + rect.sourceY, + rect.sourceWidth, + rect.sourceHeight, 0, 0, program.width, @@ -630,34 +729,24 @@ export function drawPreparedOpaqueImage( ); } -export function repaintPreparedOpaqueImage( - context: CanvasRenderingContext2D, - program: Readonly, - positionX: number, - positionY: number, -): void { - preparePreparedOpaqueImageContext(context, program); - drawPreparedOpaqueImage(context, program, positionX, positionY); -} - export function explainPreparedOpaqueImage( program: Readonly, positionX: number, positionY: number, -) { +): Readonly { const rect = preparedImageRect(program, positionX, positionY); return Object.freeze({ painter: CORNERFILL_PAINTER_SCHEMA, layer: Object.freeze({ kind: "image", - imageSize: Object.freeze([program.intrinsicWidth, program.intrinsicHeight]), - sourceRect: Object.freeze([ + imageSize: freezePair(program.intrinsicWidth, program.intrinsicHeight), + sourceRect: freezeRect( rect.sourceX, rect.sourceY, rect.sourceWidth, rect.sourceHeight, - ]), - destinationRect: Object.freeze([0, 0, program.width, program.height]), + ), + destinationRect: freezeRect(0, 0, program.width, program.height), }), border: null, update: "prepared-opaque-source-in", @@ -678,7 +767,7 @@ export function repaintOpaqueCornerfill(context: CanvasRenderingContext2D, { outline?: ContainedOutlinePaintState | null | undefined; paint: CornerfillPaintState; shadow?: InsetShadowPaintState | null | undefined; -}>) { +}>): Readonly | null { if (!geometry || typeof geometry !== "object") throw new TypeError("resolved geometry is required"); if (border || shadow || outline || !fullyCoversBox(paint, geometry.width, geometry.height)) return null; context.save(); @@ -722,8 +811,8 @@ function supportedBorder( throw new TypeError("painted border sides must use solid style"); } return Object.freeze({ - ...border, widths: Object.freeze([...widths]) as Four, + width: widths.every((width) => width === widths[0]) ? widths[0]! : null, color: String(border.color), }); } @@ -826,8 +915,6 @@ function clipToBackgroundArea( return true; } -type PaintLayerResult = Readonly<{ kind: string; [key: string]: unknown }>; - function paintBackground( context: CanvasRenderingContext2D, geometry: CornerGeometry, @@ -836,25 +923,23 @@ function paintBackground( const paintClipped = ( layer: OwnedPaintLayer, clipArea: Readonly | null | undefined, - ): PaintLayerResult => { + ): ClippedOwnedLayerPaintResult => { context.save(); const visible = clipToBackgroundArea(context, geometry, clipArea); const result = visible - ? paintOwnedLayer(context, layer, geometry.width, geometry.height) as PaintLayerResult + ? paintOwnedLayer(context, layer, geometry.width, geometry.height) : Object.freeze({ kind: layer.kind, emptyClip: true }); context.restore(); return result; }; const transparent = (color: string | undefined): boolean => !color - || color === "transparent" - || /^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/iu.test(color) - || /\/\s*0(?:\.0+)?\s*\)$/u.test(color); + || isFullyTransparentCssColor(color); let layer: PaintLayerResult; if (paint.kind === "layers") { const color = transparent(paint.color) ? null : paintClipped({ kind: "solid", color: paint.color }, paint.colorClipArea); - const results: PaintLayerResult[] = new Array(paint.layers.length); + const results: ClippedOwnedLayerPaintResult[] = new Array(paint.layers.length); for (let index = paint.layers.length - 1; index >= 0; index -= 1) { results[index] = paintClipped(paint.layers[index]!, paint.layers[index]!.clipArea); } diff --git a/src/runtime.mts b/src/runtime.mts index b4b8f2d..8358e4f 100644 --- a/src/runtime.mts +++ b/src/runtime.mts @@ -26,6 +26,7 @@ import type { ConcreteSurfaceBackend, CornerfillSurface, SurfaceBackend, + SurfaceResourceStats, } from "./backends.mjs"; import { buildCornerGeometry, @@ -34,12 +35,14 @@ import type { CornerGeometry } from "./geometry.mjs"; import { ImageCache } from "./images.mjs"; import type { ImageLease } from "./images.mjs"; import { nextDocumentId } from "./identity.mjs"; -import { CORNERFILL_ORACLE_QUALIFICATION, qualifyNativeCornerShape } from "./native.mjs"; +import { qualifyNativeCornerShape } from "./native.mjs"; import type { CornerfillNativeQualification } from "./native.mjs"; +import { CORNERFILL_ORACLE_QUALIFICATION } from "./qualification.mjs"; import { createPreparedOpaqueImageProgram, drawPreparedOpaqueImage, explainPreparedOpaqueImage, + isFullyTransparentCssColor, paintCornerfill, preparePreparedOpaqueImageContext, repaintOpaqueCornerfill, @@ -47,6 +50,7 @@ import { } from "./paint.mjs"; import type { ContainedOutlinePaintState, + CornerfillPaintExplanation, InsetShadowPaintState, OwnedBorderPaintState, PreparedOpaqueImageProgram, @@ -86,6 +90,39 @@ export type RadiusSource = | BorderRadiusDeclarations | Readonly<{ kind: "longhands"; values: Four }>; export type PaintSource = NormalizedPaintDescriptor; +export type CornerfillSideValues = T | Four | Readonly<{ + bottom: T; + left: T; + right: T; + top: T; +}>; + +export interface CornerfillBorderDescriptor { + readonly color?: CornerfillSideValues | undefined; + readonly colors?: CornerfillSideValues | undefined; + readonly style?: CornerfillSideValues | undefined; + readonly styles?: CornerfillSideValues | undefined; + readonly width?: CornerfillSideValues | null | undefined; + readonly widths?: CornerfillSideValues | undefined; +} + +export interface CornerfillInsetShadowDescriptor { + readonly blur?: number | undefined; + readonly color: string; + readonly inset?: true | undefined; + readonly kind?: "inset-solid-ring" | undefined; + readonly offset?: PixelPair | undefined; + readonly offsetX?: number | undefined; + readonly offsetY?: number | undefined; + readonly spread?: number | undefined; +} + +export interface CornerfillOutlineDescriptor { + readonly color: string; + readonly offset?: number | string | undefined; + readonly style?: "none" | "solid" | undefined; + readonly width: number | string; +} export interface CornerfillFallbackRequirements { readonly backdropFilterClip?: boolean | undefined; @@ -128,17 +165,17 @@ export interface ResolvedCornerfillOptions { } export interface CornerfillAttachConfig { - readonly border?: unknown; + readonly border?: Readonly | null | undefined; readonly borderRadius?: RadiusSource | undefined; readonly cornerShape?: CornerShapeSource | undefined; readonly dynamicCarriers?: boolean | undefined; - readonly mode?: string | undefined; + readonly mode?: "paint" | undefined; readonly observeBackgroundPosition?: boolean | undefined; - readonly outline?: unknown; + readonly outline?: Readonly | null | undefined; readonly paint?: PaintSource | undefined; readonly rasterIsOpaque?: boolean | undefined; readonly requirements?: Readonly | undefined; - readonly shadow?: unknown; + readonly shadow?: string | Readonly | null | undefined; readonly visible?: boolean | undefined; } @@ -177,7 +214,7 @@ interface OwnershipSurfaceRule { readonly rule: CSSStyleRule; } -interface HostComposition { +export interface HostComposition { readonly filter: "browser-compositor"; readonly fragmentCount: number; readonly opacity: "browser-compositor"; @@ -221,6 +258,9 @@ interface ControllerCounters extends EntryCounters { staleRefreshes: number; } +export type CornerfillEntryCounters = Readonly; +export type CornerfillControllerCounters = Readonly; + interface EntryWaiter { readonly reject: (reason?: unknown) => void; readonly resolve: (value: CornerfillEntryExplanation) => void; @@ -238,7 +278,7 @@ interface EntryDynamicSources { } interface InitialSources { - readonly borderSource: unknown; + readonly borderSource: Readonly | null; readonly dynamic: Readonly; readonly dynamicCarriers: boolean; readonly initialBackground: Readonly<{ @@ -253,12 +293,12 @@ interface InitialSources { backgroundSize: string; imageRendering: string; }>; - readonly outlineSource: unknown; + readonly outlineSource: Readonly | null; readonly paintSource: PaintSource; readonly radiusCarrierBaseline: PhysicalRadiusValues | null; readonly radiusSource: RadiusSource; readonly rasterIsOpaque: boolean; - readonly shadowSource: unknown; + readonly shadowSource: Readonly | null; readonly shapeCarrierBaseline: Readonly<{ physical: Readonly>; shorthand: string; @@ -267,12 +307,12 @@ interface InitialSources { } interface EntryState { - border?: unknown; + border?: Readonly | null | undefined; borderRadius?: RadiusSource | undefined; cornerShape?: CornerShapeSource | undefined; - outline?: unknown; + outline?: Readonly | null | undefined; paint?: PaintSource | undefined; - shadow?: unknown; + shadow?: string | Readonly | null | undefined; } interface RuntimeEntry { @@ -308,7 +348,7 @@ interface RuntimeEntry { lastError: Error | null; lastInvalidationReason: string | null; layerImageLeases: Map>; - mode: string; + mode: "paint"; native: boolean; needsFullPreparedPaint: boolean; needsPaint: boolean; @@ -319,20 +359,20 @@ interface RuntimeEntry { ownershipToken: string | null; ownershipVerified: boolean; paintKey: string | null; - paintResult: unknown; + paintResult: Readonly | null; pendingReason: string | null; positionX: number; positionY: number; prepared: boolean; preparedBorderRadius: RadiusSource | undefined; - preparedBorderSource: unknown; + preparedBorderSource: Readonly | null; preparedCornerShape: CornerShapeSource | undefined; preparedLayoutChain: Promise | null; - preparedOutlineSource: unknown; + preparedOutlineSource: Readonly | null; preparedPaintProgram: Readonly | null; preparedPaintSource: PaintSource; preparedResolvedPaint: ResolvedPaintDescriptor | null; - preparedShadowSource: unknown; + preparedShadowSource: string | Readonly | null; ready: Promise | null; requestedVisible: boolean; resolvedImage: CornerfillRasterSource | null; @@ -352,22 +392,57 @@ interface RuntimeEntry { } export interface CornerfillEntryExplanation { - readonly backend: string; + readonly backend: ConcreteSurfaceBackend | "native-corner-shape" | "pending"; + readonly border: Readonly | null; + readonly composition: Readonly | Readonly<{ + originalElement: true; + semantics: "browser-native"; + }> | null; + readonly counters: CornerfillEntryCounters; + readonly effects: Readonly<{ + outline: Readonly | null; + shadow: Readonly | null; + }>; readonly error: string | null; + readonly geometry: Readonly<{ + dpr: number; + height: number; + oppositeScale: number; + radii: Four; + shapeParameters: Four; + width: number; + }> | null; readonly implementationStatus: "IMPLEMENTED" | "NATIVE"; + readonly lastInvalidationReason: string | null; readonly lastError: string | null; - readonly mode: string; + readonly limitations: Readonly>; + readonly mode: "paint"; + readonly oracleQualification: typeof CORNERFILL_ORACLE_QUALIFICATION; readonly ownershipVerified: boolean; + readonly paint: Readonly | null; readonly paintOwnership: "browser-native" | "host-background-border-and-contained-effects"; + readonly prepared: Readonly<{ + backgroundPosition: PixelPair | null; + directUpdates: true; + layoutUpdates: "explicit"; + observesStyleMutations: false; + surfaceDeferred: boolean; + visible: boolean; + }> | null; readonly runtime: typeof CORNERFILL_RUNTIME_SCHEMA; readonly schema: "cornerfill-entry-explanation@2"; readonly status: "active" | "disposed" | "error" | "initializing"; + readonly surface: Readonly<{ + backend: ConcreteSurfaceBackend; + id: string; + size: Readonly; + }> | null; readonly transformOwnedByCornerfill: false; - readonly [property: string]: unknown; + readonly visible: boolean | null; } export interface CornerfillHandle { - readonly backend: string; + readonly backend: ConcreteSurfaceBackend | "native-corner-shape" | "pending"; readonly ready: Promise; dispose(): void; explain(): Readonly; @@ -385,11 +460,17 @@ export interface CornerfillHandle { } export interface CornerfillControllerStats { + readonly activeFallbackEntries: number; + readonly activeNativeEntries: number; + readonly counters: CornerfillControllerCounters; readonly entries: number; + readonly geometryCacheEntries: number; + readonly imageCache: ReturnType; readonly runtime: typeof CORNERFILL_RUNTIME_SCHEMA; readonly schema: "cornerfill-controller-stats@2"; + readonly surfacePixels: number; + readonly surfaceResources: Readonly; readonly surfaces: number; - readonly [property: string]: unknown; } export interface CornerfillControllerHandle { @@ -455,6 +536,33 @@ interface PreparedLayoutSnapshot { readonly width: number; } +function applyPreparedLayoutSnapshot( + entry: RuntimeEntry, + snapshot: Readonly, +): void { + entry.width = snapshot.width; + entry.height = snapshot.height; + entry.dpr = snapshot.dpr; + entry.geometry = snapshot.geometry; + entry.geometryKey = "prepared"; + entry.border = snapshot.border; + entry.borderKey = snapshot.border ? JSON.stringify(snapshot.border) : "none"; + entry.shadow = snapshot.shadow; + entry.outline = snapshot.outline; + entry.effectsKey = JSON.stringify([snapshot.shadow, snapshot.outline]); + entry.composition = snapshot.composition; + entry.preparedResolvedPaint = snapshot.paint; + entry.preparedPaintSource = snapshot.descriptor; + entry.preparedBorderSource = snapshot.border; + entry.preparedShadowSource = snapshot.shadow; + entry.preparedOutlineSource = snapshot.outline; + entry.preparedBorderRadius = snapshot.borderRadius; + entry.preparedCornerShape = snapshot.cornerShape; + entry.positionX = snapshot.paint.kind === "image" ? snapshot.paint.backgroundPosition[0] : 0; + entry.positionY = snapshot.paint.kind === "image" ? snapshot.paint.backgroundPosition[1] : 0; + entry.preparedPaintProgram = snapshot.program; +} + function createEntryCounters(): EntryCounters { return { dynamicPaintUpdates: 0, @@ -939,7 +1047,7 @@ function applyNativeShapeSource(element: CornerfillElement, source: unknown): vo function readCarrier(computed: CSSStyleDeclaration, name: string): string { const value = computed.getPropertyValue(name).trim(); - return value === "__cornerfill_unset__" ? "" : value; + return value === "__cornerfill_unset__" || /^(?:initial|unset)$/iu.test(value) ? "" : value; } function readColorCarrier(computed: CSSStyleDeclaration, name: string): string { @@ -1381,12 +1489,18 @@ function normalizeBorder(border: unknown): Readonly | nul } const color = paintedColors[0]; if (color === undefined) throw new TypeError("painted border sides require colors"); + const normalizedColors = colorSides.map((sideColor) => String(sideColor ?? color)); const normalized: Readonly = Object.freeze({ widths: Object.freeze(widths), width: widths.every((width) => width === widths[0]) ? widths[0] : null, color, - colors: Object.freeze(colorSides.map((sideColor) => String(sideColor ?? color))), - styles: Object.freeze(styleSides), + colors: frozenFour( + normalizedColors[0]!, + normalizedColors[1]!, + normalizedColors[2]!, + normalizedColors[3]!, + ), + styles: frozenFour(styleSides[0]!, styleSides[1]!, styleSides[2]!, styleSides[3]!), }); return normalized; } @@ -1510,17 +1624,20 @@ function captureInitialSources( physical: physicalShapeValues(computed), }); const shapeCapture = captureShapeCarriers(computed, shapeBaseline); - const radiusSource: RadiusSource = config.borderRadius ?? (radiusCapture?.present - ? radiusCapture.source - : Object.freeze({ - kind: "longhands", + const computedRadiusSource = Object.freeze({ + kind: "longhands" as const, values: frozenFour( computed.borderTopLeftRadius, computed.borderTopRightRadius, computed.borderBottomRightRadius, computed.borderBottomLeftRadius, ), - })); + }); + const radiusSource: RadiusSource = config.borderRadius ?? (dynamicCarriers + ? computedRadiusSource + : radiusCapture?.present + ? radiusCapture.source + : computedRadiusSource); const hasComputedShapeLonghands = Object.keys(shapeBaseline.physical).length > 0; const shapeSource = config.cornerShape ?? (shapeCapture.present ? shapeCapture.source @@ -1628,8 +1745,8 @@ function currentSources( if (!initial || !state) throw new TypeError("current sources require a dynamic Cornerfill entry"); let radiusSource = state.borderRadius ?? initial.radiusSource; if (state.borderRadius === undefined && initial.dynamic.radius) { - radiusSource = captureRadiusCarriers(computed, initial.radiusCarrierBaseline)?.source - ?? Object.freeze({ + radiusSource = initial.dynamicCarriers + ? Object.freeze({ kind: "longhands", values: Object.freeze([ computed.borderTopLeftRadius, @@ -1637,7 +1754,17 @@ function currentSources( computed.borderBottomRightRadius, computed.borderBottomLeftRadius, ]) as Four, - }); + }) + : captureRadiusCarriers(computed, initial.radiusCarrierBaseline)?.source + ?? Object.freeze({ + kind: "longhands", + values: Object.freeze([ + computed.borderTopLeftRadius, + computed.borderTopRightRadius, + computed.borderBottomRightRadius, + computed.borderBottomLeftRadius, + ]) as Four, + }); } let shapeSource = state.cornerShape ?? initial.shapeSource; if (state.cornerShape === undefined && initial.dynamic.shape) { @@ -2472,9 +2599,7 @@ class CornerfillController { ? image === surface.cssImage || image.includes(surface.cssImage.slice(5, -2)) : image.includes(surface.id); - const transparent = computed.backgroundColor === "transparent" - || (/^rgba\(/u.test(computed.backgroundColor) && /,\s*0(?:\.0+)?\s*\)$/u.test(computed.backgroundColor)) - || (/\/\s*0(?:\.0+)?\s*\)$/u.test(computed.backgroundColor)); + const transparent = isFullyTransparentCssColor(computed.backgroundColor); const radiiOwned = RADIUS_LONGHANDS.every((property) => ( numberFromPx(computed.getPropertyValue(property)) === 0 )); @@ -2483,9 +2608,7 @@ class CornerfillController { computed.borderRightColor, computed.borderBottomColor, computed.borderLeftColor, - ].every((color) => color === "transparent" - || (/^rgba\(/u.test(color) && /,\s*0(?:\.0+)?\s*\)$/u.test(color)) - || (/\/\s*0(?:\.0+)?\s*\)$/u.test(color))); + ].every(isFullyTransparentCssColor); const layoutOwned = computed.backgroundRepeat === "no-repeat" && computed.backgroundOrigin === "border-box" && computed.backgroundClip === "border-box" @@ -3783,27 +3906,7 @@ class CornerfillController { this.counters.surfaceResizes += 1; entry.counters.surfaceResizes += 1; } - entry.width = snapshot.width; - entry.height = snapshot.height; - entry.dpr = snapshot.dpr; - entry.geometry = snapshot.geometry; - entry.geometryKey = "prepared"; - entry.border = snapshot.border; - entry.borderKey = snapshot.border ? JSON.stringify(snapshot.border) : "none"; - entry.shadow = snapshot.shadow; - entry.outline = snapshot.outline; - entry.effectsKey = JSON.stringify([snapshot.shadow, snapshot.outline]); - entry.composition = snapshot.composition; - entry.preparedResolvedPaint = snapshot.paint; - entry.preparedPaintSource = snapshot.descriptor; - entry.preparedBorderSource = snapshot.border; - entry.preparedShadowSource = snapshot.shadow; - entry.preparedOutlineSource = snapshot.outline; - entry.preparedBorderRadius = snapshot.borderRadius; - entry.preparedCornerShape = snapshot.cornerShape; - entry.positionX = snapshot.paint.kind === "image" ? snapshot.paint.backgroundPosition[0] : 0; - entry.positionY = snapshot.paint.kind === "image" ? snapshot.paint.backgroundPosition[1] : 0; - entry.preparedPaintProgram = snapshot.program; + applyPreparedLayoutSnapshot(entry, snapshot); entry.needsPaint = true; entry.needsFullPreparedPaint = true; if (entry.visible) { @@ -3855,27 +3958,7 @@ class CornerfillController { const revision = entry.revision; const snapshot = await this._resolvePreparedLayout(entry, config, revision, true); this._assertEntryCurrent(entry, revision); - entry.width = snapshot.width; - entry.height = snapshot.height; - entry.dpr = snapshot.dpr; - entry.geometry = snapshot.geometry; - entry.geometryKey = "prepared"; - entry.border = snapshot.border; - entry.borderKey = snapshot.border ? JSON.stringify(snapshot.border) : "none"; - entry.shadow = snapshot.shadow; - entry.outline = snapshot.outline; - entry.effectsKey = JSON.stringify([snapshot.shadow, snapshot.outline]); - entry.composition = snapshot.composition; - entry.preparedResolvedPaint = snapshot.paint; - entry.preparedPaintSource = snapshot.descriptor; - entry.preparedBorderSource = snapshot.border; - entry.preparedShadowSource = snapshot.shadow; - entry.preparedOutlineSource = snapshot.outline; - entry.preparedBorderRadius = snapshot.borderRadius; - entry.preparedCornerShape = snapshot.cornerShape; - entry.positionX = snapshot.paint.kind === "image" ? snapshot.paint.backgroundPosition[0] : 0; - entry.positionY = snapshot.paint.kind === "image" ? snapshot.paint.backgroundPosition[1] : 0; - entry.preparedPaintProgram = snapshot.program; + applyPreparedLayoutSnapshot(entry, snapshot); entry.initialized = true; if (entry.visible || !entry.deferHiddenSurface) { this._createPreparedSurface(entry, false); diff --git a/src/spec.mts b/src/spec.mts new file mode 100644 index 0000000..8574ca2 --- /dev/null +++ b/src/spec.mts @@ -0,0 +1,74 @@ +export const CORNERFILL_SPEC_REVISION = Object.freeze({ + module: "CSS Borders and Box Decorations Level 4", + workingDraftDate: "2026-03-26", + editorsDraft: "https://drafts.csswg.org/css-borders-4/", + sourceCommit: "13b14ec48af0219c893713d670cf80d8c014a648", + sourceUrl: "https://github.com/w3c/csswg-drafts/blob/13b14ec48af0219c893713d670cf80d8c014a648/css-borders-4/Overview.bs", + wptCommit: "4a5810a124fa0523dd2494996bf1542d4b67f394", +}); + +export type CornerfillPropertySupport = "automatic" | "native-computed" | "not-implemented"; + +export const CORNERFILL_PROPERTY_SUPPORT = Object.freeze({ + automatic: Object.freeze([ + "corner-shape", + "corner-top-left-shape", + "corner-top-right-shape", + "corner-bottom-right-shape", + "corner-bottom-left-shape", + "corner-start-start-shape", + "corner-start-end-shape", + "corner-end-end-shape", + "corner-end-start-shape", + ] as const), + nativeComputed: Object.freeze([ + "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + "border-start-start-radius", + "border-start-end-radius", + "border-end-end-radius", + "border-end-start-radius", + ] as const), + notImplemented: Object.freeze([ + "corner-top-shape", + "corner-right-shape", + "corner-bottom-shape", + "corner-left-shape", + "corner-block-start-shape", + "corner-block-end-shape", + "corner-inline-start-shape", + "corner-inline-end-shape", + "corner-top-left", + "corner-top-right", + "corner-bottom-right", + "corner-bottom-left", + "corner-start-start", + "corner-start-end", + "corner-end-end", + "corner-end-start", + "corner-top", + "corner-right", + "corner-bottom", + "corner-left", + "corner-block-start", + "corner-block-end", + "corner-inline-start", + "corner-inline-end", + "corner", + "border-top-radius", + "border-right-radius", + "border-bottom-radius", + "border-left-radius", + "border-block-start-radius", + "border-block-end-radius", + "border-inline-start-radius", + "border-inline-end-radius", + ] as const), + targets: Object.freeze({ + elements: "automatic", + pseudoElements: "not-implemented", + }), +}); diff --git a/src/values.mts b/src/values.mts index 2b385d1..93f1335 100644 --- a/src/values.mts +++ b/src/values.mts @@ -158,7 +158,42 @@ function freezeLengthPercentage(px: number, percent: number, source: string): Re const NUMBER = String.raw`(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?`; const SIMPLE_LENGTH_PERCENTAGE = new RegExp(`^([+-]?${NUMBER})(px|%)?$`, "iu"); -const CALC_TERM = new RegExp(`([+-]?)(${NUMBER})(px|%)?`, "ig"); + +interface AdditiveCalcTerm { + readonly number: number; + readonly unit: string; +} + +function additiveCalcTerms( + expression: string, + source: string, + label: string, +): readonly Readonly[] { + const first = new RegExp(`^\\s*([+-]?)(${NUMBER})(px|%)?`, "iu").exec(expression); + if (!first) throw syntaxError(label, source, "uses unsupported calc() arithmetic"); + const terms: AdditiveCalcTerm[] = [{ + number: Number(`${first[1]}${first[2]}`), + unit: (first[3] ?? "").toLowerCase(), + }]; + let cursor = first[0].length; + const next = new RegExp(`^\\s+([+-])\\s+([+-]?)(${NUMBER})(px|%)?`, "iu"); + while (cursor < expression.length) { + const rest = expression.slice(cursor); + if (/^\s*$/u.test(rest)) { + cursor = expression.length; + break; + } + const match = next.exec(rest); + if (!match) throw syntaxError(label, source, "uses unsupported calc() arithmetic"); + const binarySign = match[1] === "-" ? -1 : 1; + terms.push({ + number: binarySign * Number(`${match[2]}${match[3]}`), + unit: (match[4] ?? "").toLowerCase(), + }); + cursor += match[0].length; + } + return Object.freeze(terms.map((term) => Object.freeze(term))); +} export function parseLengthPercentage( input: string, @@ -177,29 +212,17 @@ export function parseLengthPercentage( const match = /^calc\((.*)\)$/isu.exec(source); if (!match) throw syntaxError(label, source, "is outside the supported px/% syntax"); - const expression = match[1]!.replaceAll(/\s+/gu, ""); - if (!expression) throw syntaxError(label, source, "contains an empty calc()"); - let cursor = 0; + const expression = match[1]!; + if (!expression.trim()) throw syntaxError(label, source, "contains an empty calc()"); let px = 0; let percent = 0; - let terms = 0; - CALC_TERM.lastIndex = 0; - for (let term = CALC_TERM.exec(expression); term; term = CALC_TERM.exec(expression)) { - if (term.index !== cursor || (terms > 0 && !term[1])) { - throw syntaxError(label, source, "uses unsupported calc() arithmetic"); - } - const number = Number(`${term[1]}${term[2]}`); - const unit = (term[3] ?? "").toLowerCase(); + for (const term of additiveCalcTerms(expression, source, label)) { + const { number, unit } = term; if (!Number.isFinite(number) || (!unit && number !== 0)) { throw syntaxError(label, source, "requires px, %, or unitless zero terms"); } if (unit === "%") percent += number / 100; else px += number; - cursor = CALC_TERM.lastIndex; - terms += 1; - } - if (terms === 0 || cursor !== expression.length) { - throw syntaxError(label, source, "uses unsupported calc() arithmetic"); } return freezeLengthPercentage(px, percent, source); } @@ -302,7 +325,34 @@ export function resolveCornerRadiusLonghands( if (!Array.isArray(values) || values.length !== CORNER_COUNT) { throw new TypeError("corner radius longhands must contain four values"); } - return resolveParsedRadii(values.map(parseCornerRadius), width, height); + const resolveComputedValue = (source: string, reference: number, label: string): number => { + try { + return resolveLengthPercentage(source, reference); + } catch (originalError) { + const match = /^(min|max|clamp)\(([\s\S]*)\)$/iu.exec(source.trim()); + if (!match) throw originalError; + const operation = match[1]!.toLowerCase(); + const argumentsList = splitTopLevelCommas(match[2]!).map((argument) => ( + resolveComputedValue(argument, reference, label) + )); + if (operation === "clamp") { + if (argumentsList.length !== 3) throw syntaxError(label, source, "requires three clamp() arguments"); + return Math.max(argumentsList[0]!, Math.min(argumentsList[1]!, argumentsList[2]!)); + } + if (argumentsList.length < 1) throw syntaxError(label, source, `requires ${operation}() arguments`); + return operation === "min" ? Math.min(...argumentsList) : Math.max(...argumentsList); + } + }; + return Object.freeze(values.map((source) => { + const tokens = splitTopLevelWhitespace(source); + if (tokens.length < 1 || tokens.length > 2) { + throw syntaxError("corner radius", source, "requires one or two computed values"); + } + return Object.freeze({ + rx: Math.max(0, resolveComputedValue(tokens[0]!, width, "corner horizontal radius")), + ry: Math.max(0, resolveComputedValue(tokens[1] ?? tokens[0]!, height, "corner vertical radius")), + }); + })) as Four>; } const PHYSICAL_CORNER_INDEX = Object.freeze({ @@ -388,7 +438,7 @@ export function parseCornerShapeValue(input: string): number { const match = /^superellipse\((.*)\)$/isu.exec(source); if (!match) throw syntaxError("corner-shape", input, "contains an unsupported value"); const argument = match[1]!.trim(); - if (argument === "infinity" || argument === "+infinity") return Number.POSITIVE_INFINITY; + if (argument === "infinity") return Number.POSITIVE_INFINITY; if (argument === "-infinity") return Number.NEGATIVE_INFINITY; const simpleNumber = new RegExp(`^[+-]?${NUMBER}$`, "iu"); let value: number; @@ -398,21 +448,16 @@ export function parseCornerShapeValue(input: string): number { if (!calculation) { throw syntaxError("corner-shape", input, "requires a finite number or signed infinity"); } - const expression = calculation[1]!.replaceAll(/\s+/gu, ""); - let cursor = 0; - let terms = 0; + const expression = calculation[1]!; + if (!expression.trim()) { + throw syntaxError("corner-shape", input, "contains an empty calc()"); + } value = 0; - CALC_TERM.lastIndex = 0; - for (let term = CALC_TERM.exec(expression); term; term = CALC_TERM.exec(expression)) { - if (term.index !== cursor || term[3] || (terms > 0 && !term[1])) { + for (const term of additiveCalcTerms(expression, String(input), "corner-shape")) { + if (term.unit) { throw syntaxError("corner-shape", input, "uses unsupported calc() arithmetic"); } - value += Number(`${term[1]}${term[2]}`); - cursor = CALC_TERM.lastIndex; - terms += 1; - } - if (terms === 0 || cursor !== expression.length) { - throw syntaxError("corner-shape", input, "uses unsupported calc() arithmetic"); + value += term.number; } } if (!Number.isFinite(value)) throw syntaxError("corner-shape", input, "contains an invalid number"); diff --git a/test/auto.test.mjs b/test/auto.test.mjs index fa97bfb..11b3154 100644 --- a/test/auto.test.mjs +++ b/test/auto.test.mjs @@ -1,29 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { - installCornerfillAuto, - transportCornerShapeDeclarations, -} from "../dist/auto-runtime.mjs"; - -test("automatic CSS transport changes declarations without touching selectors, values, or conditions", () => { - const source = ` - .corner-shape:hover { - content: "corner-shape: scoop"; - /* corner-shape: notch; */ - corner-shape: bevel !important; - corner-start-start-shape: superellipse(2); - } - @supports (corner-shape: bevel) { .inside { corner-shape: scoop; } } - `; - const transported = transportCornerShapeDeclarations(source); - assert.match(transported, /\.corner-shape:hover/u); - assert.match(transported, /content: "corner-shape: scoop"/u); - assert.match(transported, /\/\* corner-shape: notch; \*\//u); - assert.match(transported, /--cornerfill-corner-shape: bevel !important/u); - assert.match(transported, /--cornerfill-corner-start-start-shape: superellipse\(2\)/u); - assert.match(transported, /@supports \(corner-shape: bevel\)/u); - assert.match(transported, /\.inside \{ --cornerfill-corner-shape: scoop/u); -}); +import { installCornerfillAuto } from "../dist/auto-runtime.mjs"; test("automatic teardown settles readiness when source application is waiting for a frame", async () => { class CSSStyleSheet { diff --git a/test/backends.test.mjs b/test/backends.test.mjs index 89c0e4d..e777be0 100644 --- a/test/backends.test.mjs +++ b/test/backends.test.mjs @@ -65,7 +65,7 @@ test("disposed WebKit named canvas identifiers are reused per document and prefi second.dispose(); }); -function firefoxDocument({ context = {}, registrationThrows = false } = {}) { +function firefoxDocument({ context = {}, registrationThrows = false, unregisterThrows = false } = {}) { const registrations = []; const canvas = { id: "", @@ -90,10 +90,33 @@ function firefoxDocument({ context = {}, registrationThrows = false } = {}) { mozSetImageElement(id, element) { registrations.push([id, element]); if (registrationThrows && element) throw new Error("registration failed"); + if (unregisterThrows && !element) throw new Error("unregister failed"); }, }; } +test("Firefox support requires live Canvas registration, not syntax alone", () => { + const document = { + defaultView: { + CSS: { supports: () => true }, + devicePixelRatio: 1, + }, + createElement() { + return { + getContext: () => ({}), + remove() {}, + setAttribute() {}, + style: {}, + }; + }, + }; + assert.throws(() => createSurface(document, { + backend: "moz-element", + cssWidth: 10, + cssHeight: 10, + }), /unavailable/u); +}); + test("Firefox validates allocation before registering a live image", () => { const document = firefoxDocument(); assert.throws(() => createSurface(document, { @@ -117,7 +140,26 @@ test("Firefox registration failure rolls back the exact ID", () => { assert.equal(document.registrations[1][1], null); assert.equal(document.canvas.width, 1); assert.equal(document.canvas.height, 1); - assert.equal(getSurfaceResourceStats(document).firefox.registrations, 0); + assert.deepEqual(getSurfaceResourceStats(document).firefox, { + registrations: 0, + unregisterFailures: 0, + }); +}); + +test("Firefox teardown completes and reports an unregister failure", () => { + const document = firefoxDocument({ unregisterThrows: true }); + const surface = createSurface(document, { + backend: "moz-element", + cssWidth: 10, + cssHeight: 10, + }); + assert.doesNotThrow(() => surface.dispose()); + assert.equal(document.canvas.width, 1); + assert.equal(document.canvas.height, 1); + assert.deepEqual(getSurfaceResourceStats(document).firefox, { + registrations: 1, + unregisterFailures: 1, + }); }); test("WebKit reuse pools are bounded and every released canvas is shrunk", () => { diff --git a/test/contract.test.mjs b/test/contract.test.mjs index fb9a075..b47193c 100644 --- a/test/contract.test.mjs +++ b/test/contract.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { CORNERFILL_ORACLE_QUALIFICATION } from "../dist/native.mjs"; +import { CORNERFILL_ORACLE_QUALIFICATION } from "../dist/qualification.mjs"; const root = dirname(dirname(fileURLToPath(import.meta.url))); @@ -19,6 +19,8 @@ test("production sources contain none of the prohibited CSS renderers", () => { }); test("oracle candidate tolerances remain deliberately unapproved", () => { + const source = JSON.parse(readFileSync(join(root, "oracle", "qualification.json"), "utf8")); + assert.deepEqual(CORNERFILL_ORACLE_QUALIFICATION, source); assert.equal(CORNERFILL_ORACLE_QUALIFICATION.nativeCalibration.status, "PASS"); assert.equal(CORNERFILL_ORACLE_QUALIFICATION.nativeCalibration.approvedTolerance, true); assert.equal(CORNERFILL_ORACLE_QUALIFICATION.nativeCalibration.exactZeroTolerance, true); diff --git a/test/geometry.test.mjs b/test/geometry.test.mjs index 4a44c1b..3c03694 100644 --- a/test/geometry.test.mjs +++ b/test/geometry.test.mjs @@ -89,7 +89,7 @@ test("Mario fixture resolves to a triangular contour", () => { assert.deepEqual(unique, [[32, 0], [64, 44], [0, 44]]); }); -test("contour points stay finite and inside the ordinary fixture box", () => { +test("adaptive contour points stay finite, bounded, and refine for device pixels", () => { const fixture = { size: [230, 170], radii: [ @@ -106,7 +106,14 @@ test("contour points stay finite and inside the ordinary fixture box", () => { radii: fixture.radii, shapeParameters: fixture.shapeParameters, }); - assert.ok(points.length > 100); + const highDprPoints = contourPoints({ + width: fixture.size[0], + height: fixture.size[1], + radii: fixture.radii, + shapeParameters: fixture.shapeParameters, + dpr: 3, + }); + assert.ok(highDprPoints.length > points.length); for (const [x, y] of points) { assert.ok(Number.isFinite(x) && Number.isFinite(y)); assert.ok(x >= 0 && x <= fixture.size[0]); diff --git a/test/native.test.mjs b/test/native.test.mjs index 35ba6e8..f72a6ef 100644 --- a/test/native.test.mjs +++ b/test/native.test.mjs @@ -11,11 +11,11 @@ test("native qualification has no fallback import closure", () => { } }); -test("the package root statically imports only native qualification", () => { +test("the package root statically imports only native and oracle qualification", () => { const source = readFileSync(new URL("../dist/auto.mjs", import.meta.url), "utf8"); const staticImports = [...source.matchAll(/^\s*import\s+[^;]+?from\s+["']([^"']+)["']/gmu)] .map((match) => match[1]); - assert.deepEqual(staticImports, ["./native.mjs"]); + assert.deepEqual(staticImports, ["./native.mjs", "./qualification.mjs"]); assert.match(source, /await import\("\.\/auto-runtime\.mjs"\)/u); }); @@ -28,20 +28,26 @@ test("syntax support alone cannot qualify native corner-shape", () => { setAttribute() {}, style, }; - const document = { + const isolated = { createElement: () => element, documentElement: { append() {} }, - elementFromPoint: () => element, defaultView: { - CSS: { supports: () => true }, getComputedStyle: () => ({ getPropertyValue: () => "" }), innerHeight: 100, innerWidth: 100, }, }; + const document = { + createElement: () => ({ contentDocument: isolated, remove() {}, setAttribute() {}, style }), + documentElement: { append() {} }, + defaultView: { CSS: { supports: () => true } }, + }; const result = qualifyNativeCornerShape(document); assert.equal(result.qualified, false); assert.equal(result.requirements.syntax.supported, true); + assert.equal(result.capabilities.syntax, "supported"); + assert.equal(result.capabilities.computedValues, "unsupported"); + assert.equal(result.capabilities.outerPaint, "unobserved"); assert.deepEqual(result.unresolved, ["computedValues", "shapedBehavior"]); }); @@ -56,12 +62,14 @@ test("an unobservable native probe is not cached", () => { }, }; const element = { remove() {}, setAttribute() {}, style }; - const document = { + return { body: { style: {} }, createElement: () => element, documentElement: { append(value) { probe = value; } }, elementFromPoint: () => style.currentShape === "round" ? probe : null, defaultView: { + innerHeight: size, + innerWidth: size, getComputedStyle: () => ({ getPropertyValue(property) { if (property === "corner-shape") return style.currentShape; @@ -71,16 +79,15 @@ test("an unobservable native probe is not cached", () => { "corner-bottom-right-shape", "corner-bottom-left-shape", ].indexOf(property); - return index >= 0 ? ["bevel", "scoop", "round", "notch"][index] : ""; + return index >= 0 + ? ["superellipse(0)", "superellipse(-1)", "superellipse(1)", "superellipse(-infinity)"][index] + : ""; }, }), - innerHeight: size, - innerWidth: size, }, }; - return document; }; - const outer = { + const document = { createElement(name) { assert.equal(name, "iframe"); attempts += 1; @@ -88,13 +95,16 @@ test("an unobservable native probe is not cached", () => { contentDocument: isolatedDocument(attempts === 1 ? 20 : 128), remove() {}, setAttribute() {}, - style: {}, + style: { setProperty() {} }, }; }, documentElement: { append() {} }, defaultView: { CSS: { supports: () => true } }, }; - assert.equal(qualifyNativeCornerShape(outer).qualified, false); - assert.equal(qualifyNativeCornerShape(outer).qualified, true); + assert.equal(qualifyNativeCornerShape(document).qualified, false); + const qualified = qualifyNativeCornerShape(document); + assert.equal(qualified.qualified, true); + assert.equal(qualified.capabilities.shapedHitTesting, "supported"); + assert.equal(qualified.capabilities.innerBorderContour, "unobserved"); assert.equal(attempts, 2); }); diff --git a/test/paint.test.mjs b/test/paint.test.mjs index 0c40ffd..708d520 100644 --- a/test/paint.test.mjs +++ b/test/paint.test.mjs @@ -4,13 +4,26 @@ import { createPreparedOpaqueImageProgram, drawPreparedOpaqueImage, explainPreparedOpaqueImage, + isFullyTransparentCssColor, paintCornerfill, paintOwnedLayer, preparePreparedOpaqueImageContext, - repaintPreparedOpaqueImage, } from "../dist/paint.mjs"; import { buildCornerGeometry } from "../dist/geometry.mjs"; +test("transparent CSS color serialization is recognized without parsing opaque colors", () => { + for (const color of [ + "transparent", + "rgba(1, 2, 3, 0)", + "rgb(1 2 3 / 0%)", + "hsl(120 50% 50% / 0.0)", + "color(display-p3 1 0 0 / 0)", + ]) assert.equal(isFullyTransparentCssColor(color), true, color); + for (const color of ["red", "rgb(1 2 3 / .1)", "rgba(1, 2, 3, 1)"]) { + assert.equal(isFullyTransparentCssColor(color), false, color); + } +}); + function contextRecorder() { const calls = []; return { @@ -40,7 +53,8 @@ test("prepared opaque crop repaints the retained contour with numeric source coo }, }); const context = contextRecorder(); - repaintPreparedOpaqueImage(context, program, -512, 0); + preparePreparedOpaqueImageContext(context, program); + drawPreparedOpaqueImage(context, program, -512, 0); const draw = context.calls.find(([name]) => name === "drawImage"); assert.deepEqual(draw, ["drawImage", image, 32, 0, 4, 4, 0, 0, 64, 44]); assert.deepEqual(explainPreparedOpaqueImage(program, -512, 0).layer.sourceRect, [32, 0, 4, 4]); @@ -58,7 +72,7 @@ test("prepared opaque updates reject positions that expose stale pixels", () => }, }); assert.throws( - () => repaintPreparedOpaqueImage(contextRecorder(), program, 1, 0), + () => drawPreparedOpaqueImage(contextRecorder(), program, 1, 0), /no longer covers/u, ); }); diff --git a/test/png.test.mjs b/test/png.test.mjs new file mode 100644 index 0000000..1833b50 --- /dev/null +++ b/test/png.test.mjs @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { + comparePngImages, + decodePngBuffer, + encodePng, + reconstructTransparencyFromBlackAndWhite, +} from "../scripts/png.mjs"; + +function image(width, height, values) { + return Object.freeze({ width, height, pixels: Buffer.from(values) }); +} + +test("RGBA PNG encode/decode round-trips exact bytes", () => { + const source = image(2, 2, [ + 255, 0, 0, 255, + 0, 255, 0, 127, + 0, 0, 255, 0, + 20, 40, 60, 200, + ]); + const decoded = decodePngBuffer(encodePng(source), "round-trip"); + assert.equal(decoded.width, source.width); + assert.equal(decoded.height, source.height); + assert.deepEqual(decoded.pixels, source.pixels); +}); + +test("identical images produce a strict zero diff", () => { + const source = image(1, 1, [12, 34, 56, 200]); + const comparison = comparePngImages(source, source); + assert.equal(comparison.metrics.changedPixels, 0); + assert.equal(comparison.metrics.meanAlpha, 0); + assert.equal(comparison.metrics.meanPremultipliedRgb, 0); +}); + +test("transparent RGB is ignored but alpha remains independently measured", () => { + const expected = image(2, 1, [255, 0, 0, 0, 100, 50, 25, 255]); + const transparentRgbOnly = image(2, 1, [0, 255, 255, 0, 100, 50, 25, 255]); + const ignored = comparePngImages(expected, transparentRgbOnly); + assert.equal(ignored.metrics.changedPixels, 0); + assert.equal(ignored.metrics.meanPremultipliedRgb, 0); + + const alphaChanged = image(2, 1, [255, 0, 0, 64, 100, 50, 25, 255]); + const measured = comparePngImages(expected, alphaChanged); + assert.equal(measured.metrics.changedPixels, 1); + assert.equal(measured.metrics.maxAlpha, 64); + assert.ok(measured.metrics.meanPremultipliedRgb > 0); +}); + +test("connected changed regions are reported separately", () => { + const expected = image(3, 1, [ + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + ]); + const actual = image(3, 1, [ + 255, 255, 255, 255, + 0, 0, 0, 0, + 255, 255, 255, 255, + ]); + const comparison = comparePngImages(expected, actual); + assert.equal(comparison.metrics.connectedRegions.length, 2); + assert.equal(comparison.metrics.connectedRegions[0].pixels, 1); +}); + +test("black and white composites reconstruct straight RGBA", () => { + const black = image(3, 1, [ + 10, 30, 50, 255, + 0, 0, 0, 255, + 12, 34, 56, 255, + ]); + const white = image(3, 1, [ + 180, 200, 220, 255, + 255, 255, 255, 255, + 12, 34, 56, 255, + ]); + const reconstructed = reconstructTransparencyFromBlackAndWhite(black, white); + assert.deepEqual(reconstructed.pixels, Buffer.from([ + 30, 90, 150, 85, + 0, 0, 0, 0, + 12, 34, 56, 255, + ])); + assert.equal(reconstructed.diagnostics.maxChannelSpread, 0); + assert.equal(reconstructed.diagnostics.pixelsWithChannelSpreadAboveOne, 0); +}); diff --git a/test/values.test.mjs b/test/values.test.mjs index e78640c..7d9c775 100644 --- a/test/values.test.mjs +++ b/test/values.test.mjs @@ -9,6 +9,7 @@ import { parseLengthPercentage, resolveBorderRadius, resolveBorderRadiusDeclarations, + resolveCornerRadiusLonghands, resolveCornerShape, resolveCornerShapeDeclarations, shapeParameterToDiagonal, @@ -33,6 +34,20 @@ test("supported calc length-percentages resolve without evaluation", () => { assert.equal(resolved[0].ry, 40); }); +test("browser-computed corner radii resolve min, max, and clamp", () => { + assert.deepEqual(resolveCornerRadiusLonghands([ + "min(20%, 32px)", + "max(10%, 12px)", + "clamp(8px, 15%, 40px)", + "calc(5% + 20px)", + ], 200, 100), [ + { rx: 32, ry: 20 }, + { rx: 20, ry: 12 }, + { rx: 30, ry: 15 }, + { rx: 30, ry: 25 }, + ]); +}); + test("corner-shape expands keywords and arbitrary superellipse parameters", () => { assert.deepEqual(parseCornerShape("bevel scoop squircle"), [0, -1, 2, -1]); assert.deepEqual(parseCornerShape("notch superellipse(-1.5) square round"), [ @@ -73,6 +88,7 @@ test("logical corner longhands resolve through writing mode and direction", () = test("corner-shape accepts supported numeric calc values", () => { assert.deepEqual(parseCornerShape("superellipse(calc(1 + 1))"), [2, 2, 2, 2]); + assert.deepEqual(parseCornerShape("superellipse(calc(1 - 1))"), [0, 0, 0, 0]); }); test("corner-shape interpolation is linear in the diagonal coordinate", () => { @@ -96,6 +112,11 @@ test("unsupported value grammar is rejected explicitly", () => { assert.throws(() => parseBorderRadius("1em"), /supported px\/% syntax/u); assert.throws(() => parseBorderRadius("-1px"), /cannot be negative/u); assert.throws(() => parseCornerShape("url(shape)"), /unsupported value/u); + assert.throws(() => parseCornerShape("superellipse(+infinity)"), /finite number or signed infinity/u); + assert.throws(() => parseCornerShape("superellipse(calc(1+1))"), /unsupported calc/u); + assert.throws(() => parseCornerShape("superellipse(calc(1 * 2))"), /unsupported calc/u); + assert.throws(() => parseCornerShape("superellipse(calc(infinity))"), /unsupported calc/u); + assert.throws(() => parseLengthPercentage("calc(10px+2%)"), /unsupported calc/u); assert.throws(() => parseLengthPercentage("calc(10px * 2)"), /unsupported calc/u); assert.throws(() => logicalCornerToPhysical("start-start", { direction: "auto" }), /unsupported direction/u); });