From 3ae3be56e888c7bf6da42f99d084fcbe17ded2a8 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Sun, 2 Aug 2026 22:20:12 -0300 Subject: [PATCH] Harden native-first automatic runtime --- README.md | 12 +- bench/runtime-regression.mjs | 145 ++++++++++++--- notes/01-spec-contract.md | 4 +- notes/09-polycss-case-study.md | 12 +- notes/evidence/README.md | 8 +- notes/references.md | 10 +- oracle/README.md | 22 +-- package-lock.json | 2 +- package.json | 7 +- scripts/generate-qualification.mjs | 39 +++- scripts/oracle.mjs | 19 +- src/auto-runtime.mts | 281 ++++++++++++++++++++--------- src/native.mts | 40 ++-- src/runtime.mts | 60 +++++- src/spec.mts | 24 ++- test/contract.test.mjs | 14 +- test/native.test.mjs | 59 +++++- 17 files changed, 567 insertions(+), 191 deletions(-) diff --git a/README.md b/README.md index af25d8a..0f37044 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,11 @@ Cornerfill does not use `clip-path`, CSS masks, SVG or font stencils, or baked-a | Browser | Rendering path | |---|---| -| Semantically qualified native engine | Native CSS after syntax, computed-value, and shaped-behavior probes pass | +| Semantically qualified native engine | Native CSS after syntax and canonical computed-value probes pass | | Safari / WebKit | `-webkit-canvas()` when the live Canvas API is available | | Firefox | `-moz-element()` when Canvas registration is available | -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. +Native selection requires syntax support and canonical computed corner-shape longhands; syntax support alone is not enough. Shaped hit testing is probed only as a reported capability because it is not a semantic the paint-only fallback can provide and may be unobservable under page isolation policy. The qualification report marks untested outer paint, inner borders, clipping, effects, and animation as `unobserved`. Fallback backends are capability-probed. Test the exact stable browser versions in your support matrix. ## Automatic Sources and Shadow Roots @@ -102,7 +102,7 @@ Implemented support is not an oracle `PASS`. Current fallback comparisons remain ## 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. +The latest published [CSS Borders and Box Decorations Level 4 Working Draft](https://www.w3.org/TR/2025/WD-css-borders-4-20251216/) is dated 16 December 2025. Cornerfill's implementation evidence is additionally pinned to a 31 July 2026 editor's-draft repository snapshot. `cornerfill/spec` exports both identities, the exact CSSWG source blob, the WPT snapshot, and a machine-readable property matrix. | Surface | 0.0.1 status | | --- | --- | @@ -203,6 +203,8 @@ cornerfill.destroy(); `preparedFace` contains a fixed `size`, normalized `paint`, and either prepared `geometry` or explicit `borderRadius` plus `cornerShape`. Preparation owns those descriptors. Position and visibility batches are synchronous and caller-clocked. Prepared entries do not observe layout or DPR changes; call `handle.resize()` when either changes. +Prepared surfaces are allocated during attachment so the first visibility change does not allocate inside an animation frame. Set `deferHiddenSurface: true` only when saving surfaces for entries that may remain hidden is more important than reveal-time latency. + ## Limits - The fallback owns the host background, supported border, radius, shadow, and outline paint. Author `!important` declarations that prevent that ownership are rejected. @@ -213,6 +215,7 @@ cornerfill.destroy(); - 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 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. +- Pending-substitution `all: var(...)` resets are transported when they resolve to `initial`, `unset`, or the invalid-at-computed-value reset behavior. Cascade-dependent results such as `revert-layer` are reported and refused instead of leaving stale shape carriers active. - 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. - A failed linked stylesheet stays cached until its source changes or `cornerfill.refresh({ retryFailed: true })` is requested. Refusals and source failures appear in `cornerfill.explain().errors`. @@ -227,9 +230,10 @@ npm test npm run test:browser:runtime npm run oracle:smoke npm run oracle:cross +CORNERFILL_MARIO_TEXELS=/absolute/path/to/texels.webp npm run oracle:mario ``` -`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). +`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`. Smoke and cross-engine checks are self-contained; the explicit Mario stress command uses the real texel atlas and requires `CORNERFILL_MARIO_TEXELS`. 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. diff --git a/bench/runtime-regression.mjs b/bench/runtime-regression.mjs index 796552b..0d81a86 100644 --- a/bench/runtime-regression.mjs +++ b/bench/runtime-regression.mjs @@ -34,6 +34,7 @@ function host(root = document.body, id = "") { Object.assign(element.style, { width: "12px", height: "10px", + borderRadius: "5px", backgroundColor: "rgb(255, 0, 0)", }); root.append(element); @@ -107,13 +108,16 @@ await test("automatic install consumes standard corner-shape CSS and tears down" const link = document.createElement("link"); link.rel = "stylesheet"; link.href = `data:text/css,${encodeURIComponent(` - .cornerfill-auto-fixture,.cornerfill-auto-dynamic,.cornerfill-auto-inline,.cornerfill-auto-focus,.cornerfill-auto-media { + .cornerfill-auto-fixture,.cornerfill-auto-dynamic,.cornerfill-auto-inline,.cornerfill-auto-focus,.cornerfill-auto-media,.cornerfill-auto-zero,.cornerfill-auto-partial { width:12px;height:10px;border-radius:6px;background:red;border:0;outline:none } .cornerfill-auto-fixture { corner-shape:bevel;corner-top-left-shape:round } .cornerfill-auto-dynamic { corner-shape:bevel } - .cornerfill-auto-dynamic.changed { corner-shape:round;border-radius:0;background:blue } - .cornerfill-auto-focus:focus { corner-shape:scoop } + .cornerfill-auto-dynamic.changed { corner-shape:round;background:blue } + .cornerfill-auto-focus { corner-shape:bevel } + .cornerfill-auto-focus:focus { corner-shape:bevel;border-radius:4px;background:blue } + .cornerfill-auto-zero { corner-shape:bevel;border-radius:0 } + .cornerfill-auto-partial { corner-shape:bevel square scoop notch;border-radius:6px 0 0 0 } @media (prefers-color-scheme: dark) { .cornerfill-auto-media { corner-shape:bevel } } `)}`; document.head.append(link); @@ -128,6 +132,10 @@ await test("automatic install consumes standard corner-shape CSS and tears down" focus.tabIndex = 0; const media = document.createElement("div"); media.className = "cornerfill-auto-media"; + const zero = document.createElement("div"); + zero.className = "cornerfill-auto-zero"; + const partial = document.createElement("div"); + partial.className = "cornerfill-auto-partial"; const cssomStyle = document.createElement("style"); document.head.append(cssomStyle); const cssom = host(); @@ -151,7 +159,7 @@ await test("automatic install consumes standard corner-shape CSS and tears down" const escaped = host(); escaped.className = "cornerfill:escaped"; document.body.append(toggle); - document.body.append(element, dynamic, inline, focus, media); + document.body.append(element, dynamic, inline, focus, media, zero, partial); const { cornerfill: auto } = await import("../dist/auto.mjs"); await auto.ready; rootImportResources = Object.freeze(performance.getEntriesByType("resource") @@ -176,19 +184,28 @@ await test("automatic install consumes standard corner-shape CSS and tears down" assert(!initialObservation.attributes.includes("data-cornerfill-noise"), "unreferenced attributes were observed"); assert(initialObservation.mediaQueries.includes("(prefers-color-scheme: dark)"), "media dependency was not observed"); assert(auto.explain(media) === null, "inactive color-scheme media attached early"); + assert(auto.explain(zero) === null, "zero-radius shape started an unnecessary fallback surface"); + assert(auto.explain(partial)?.status === "active", "one non-round non-zero corner did not attach"); + equal(auto.explain(partial).geometry.radii.slice(1), [ + { rx: 0, ry: 0 }, { rx: 0, ry: 0 }, { rx: 0, ry: 0 }, + ], "zero-area corners were not retained beside the active corner"); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const candidatePassesBeforeNoise = auto.explain().automatic.counters.candidatePasses; element.setAttribute("data-cornerfill-noise", "1"); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); assert(auto.explain().automatic.counters.candidatePasses === candidatePassesBeforeNoise, "unreferenced attribute churn ran selector reconciliation"); const countersBeforeState = auto.explain().automatic.counters; + const focusPaintsBeforeState = auto.explain(focus).counters.paints; dynamic.classList.add("changed"); inline.setAttribute("style", "corner-shape:bevel"); focus.focus(); await waitFor(() => ( - auto.explain(dynamic)?.geometry?.shapeParameters?.[0] === 1 + auto.explain(dynamic) === null && auto.explain(inline)?.status === "active" - && auto.explain(focus)?.status === "active" + && auto.explain(focus)?.geometry?.radii?.[0]?.rx === 4 + && /(?:blue|0,\s*0,\s*255)/u.test(auto.explain(focus)?.paint?.layer?.color ?? "") ), "automatic dynamic CSS refresh"); + assert(auto.explain(focus).counters.paints === focusPaintsBeforeState + 1, "focus-on authored state did not repaint exactly once"); const countersAfterState = auto.explain().automatic.counters; assert(countersAfterState.sourceReads === countersBeforeState.sourceReads, "selector state refetched stylesheets"); assert(countersAfterState.sourceCompiles === countersBeforeState.sourceCompiles, "selector state reparsed stylesheets"); @@ -199,22 +216,23 @@ await test("automatic install consumes standard corner-shape CSS and tears down" assert(afterStableSourceRefresh.sourceReads === beforeStableSourceRefresh.sourceReads, "unchanged source was refetched"); assert(afterStableSourceRefresh.sourceCompiles === beforeStableSourceRefresh.sourceCompiles, "unchanged source was reparsed"); assert(afterStableSourceRefresh.handleRefreshes === beforeStableSourceRefresh.handleRefreshes, "unchanged source refreshed a handle"); - equal(auto.explain(dynamic).geometry.shapeParameters, [1, 1, 1, 1], "class shape change was not recaptured"); - 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"); - 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}`, - ); + dynamic.classList.remove("changed"); + await waitFor(() => auto.explain(dynamic)?.status === "active", "round-to-bevel fallback reattachment"); + equal(auto.explain(dynamic).geometry.shapeParameters, [0, 0, 0, 0], "class shape reattachment was not recaptured"); equal(auto.explain(inline).geometry.shapeParameters, [0, 0, 0, 0], "raw inline corner-shape was not retained"); inline.setAttribute( "style", (inline.getAttribute("style") ?? "").replace(/corner-shape\s*:\s*bevel/iu, "corner-shape:scoop"), ); await waitFor(() => auto.explain(inline)?.geometry?.shapeParameters?.[0] === -1, "raw inline read-modify-write refresh"); - equal(auto.explain(focus).geometry.shapeParameters, [-1, -1, -1, -1], "focus selector state was not refreshed"); + equal(auto.explain(focus).geometry.shapeParameters, [0, 0, 0, 0], "focus selector changed the persistent shape"); + element.tabIndex = -1; + element.focus(); + await waitFor(() => ( + auto.explain(focus)?.geometry?.radii?.[0]?.rx === 5 + && /(?:red|255,\s*0,\s*0)/u.test(auto.explain(focus)?.paint?.layer?.color ?? "") + ), "persistent authored focus state restoration"); + assert(auto.explain(focus).counters.paints === focusPaintsBeforeState + 2, "focus-off authored state did not repaint exactly once"); const inserted = cssomStyle.sheet.insertRule( ".cornerfill-auto-cssom{corner-shape:bevel;border-radius:5px;background:green}", ); @@ -325,6 +343,8 @@ await test("automatic install consumes standard corner-shape CSS and tears down" inline.remove(); focus.remove(); media.remove(); + zero.remove(); + partial.remove(); cssom.remove(); byId.remove(); attributed.remove(); @@ -491,6 +511,60 @@ await test("automatic stylesheet refresh is serialized, stale-safe, and retryabl globalThis.__CORNERFILL_TEST_STAGE__ = ""; }); +await test("automatic root and import fetches settle at the configured timeout", async () => { + const originalFetch = window.fetch; + const response = (css) => ({ + headers: { get: (name) => name.toLowerCase() === "content-type" ? "text/css" : null }, + ok: true, + status: 200, + text: async () => css, + url: "https://assets.example/root.css", + }); + const run = async (rootCss) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `data:text/css,${encodeURIComponent(".cornerfill-timeout-owner{display:block}")}`; + const loaded = new Promise((resolve, reject) => { + link.addEventListener("load", resolve, { once: true }); + link.addEventListener("error", () => reject(new Error("timeout owner did not load")), { once: true }); + }); + document.head.append(link); + await loaded; + const requests = []; + window.fetch = (_url, init = {}) => { + requests.push(init.signal); + if (rootCss !== null && requests.length === 1) return Promise.resolve(response(rootCss)); + return new Promise(() => {}); + }; + const errors = []; + const auto = installCornerfillAuto(options({ + autoObserve: false, + stylesheetTimeoutMs: 25, + onError(error) { errors.push(error.message); }, + })); + try { + await Promise.race([ + auto.ready, + new Promise((_resolve, reject) => setTimeout( + () => reject(new Error("automatic stylesheet timeout did not settle readiness")), + 1_000, + )), + ]); + assert(requests.at(-1)?.aborted, "timed-out stylesheet request was not aborted"); + assert(errors.some((message) => /timed out after 25ms/u.test(message)), "stylesheet timeout was not diagnosed"); + } finally { + auto.destroy(); + link.remove(); + } + }; + try { + await run(null); + await run('@import "https://assets.example/hanging.css";'); + } finally { + window.fetch = originalFetch; + } +}); + await test("automatic imports preserve cascade and idle selector state", async () => { const originalFetch = window.fetch; const fetched = []; @@ -574,7 +648,7 @@ await test("automatic import requests are stale-safe, cycle-bounded, and failure link.href = `data:text/css,${encodeURIComponent(css)}`; if (!link.isConnected) document.head.append(link); }); - await loadLink(".cornerfill-import-stale{width:12px;height:10px;background:red}"); + await loadLink(".cornerfill-import-stale{width:12px;height:10px;border-radius:5px;background:red}"); const originalFetch = window.fetch; const requests = []; window.fetch = (url, init = {}) => new Promise((resolve) => { @@ -591,7 +665,7 @@ await test("automatic import requests are stale-safe, cycle-bounded, and failure await waitFor(() => requests.length === 1, "mock root stylesheet request"); requests[0].resolve(response('@import "./old.css";', "https://assets.example/root.css")); await waitFor(() => requests.length === 2, "mock stale import request"); - await loadLink(".cornerfill-import-stale{width:12px;height:10px;background:green}"); + await loadLink(".cornerfill-import-stale{width:12px;height:10px;border-radius:5px;background:green}"); const replacement = auto.refresh(); await waitFor(() => requests[1].init.signal.aborted, "obsolete import abort signal"); requests[1].resolve(response( @@ -606,7 +680,7 @@ await test("automatic import requests are stale-safe, cycle-bounded, and failure await Promise.all([auto.ready, replacement]); assert(auto.explain(element)?.geometry.shapeParameters.every((value) => value === Number.NEGATIVE_INFINITY), "stale imported declarations won replacement"); - await loadLink(".cornerfill-import-stale{width:12px;height:10px;background:blue}"); + await loadLink(".cornerfill-import-stale{width:12px;height:10px;border-radius:5px;background:blue}"); const cycle = auto.refresh(); await waitFor(() => requests.length === 4, "cycle root stylesheet request"); requests[3].resolve(response('@import "./a.css";', "https://assets.example/cycle/root.css")); @@ -728,7 +802,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:superellipse(calc(1 * 2));border-radius:5px;background:red}"; + style.textContent = ".cornerfill-diagnostic{corner-shape:superellipse(sin(1));border-radius:5px;background:red}"; document.head.append(style); const element = host(document.body, "cornerfill-diagnostic-owner"); element.className = "cornerfill-diagnostic"; @@ -784,11 +858,13 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe .cornerfill-layer-normal { corner-shape: bevel; background: red } .cornerfill-layer-important { corner-shape: bevel !important; background: red } .cornerfill-all-layer { corner-shape: bevel } + .cornerfill-all-var-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-all-var-layer { all: var(--cornerfill-missing-layer, revert-layer); display: block } } .cornerfill-var-inherit { corner-shape: var(--cornerfill-test-shape, bevel) } .cornerfill-var-fallback { corner-shape: var(--cornerfill-missing-shape, scoop) } @@ -804,6 +880,9 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe .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-all-var { --cornerfill-all-value: unset; corner-shape: bevel; all: var(--cornerfill-all-value); display: block } + .cornerfill-all-var-fallback { corner-shape: bevel; all: var(--cornerfill-missing-all, unset); display: block } + .cornerfill-all-var-important { --cornerfill-all-important-value: unset; corner-shape: bevel !important; all: var(--cornerfill-all-important-value) !important; display: block !important } .cornerfill-invalid-low { corner-shape: potato } #cornerfill-valid-high { corner-shape: bevel } #cornerfill-invalid-important { corner-shape: potato !important } @@ -875,6 +954,14 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe allLayer.className = "cornerfill-all-layer"; const allImportant = host(); allImportant.className = "cornerfill-all-important"; + const allVar = host(); + allVar.className = "cornerfill-all-var"; + const allVarFallback = host(); + allVarFallback.className = "cornerfill-all-var-fallback"; + const allVarImportant = host(); + allVarImportant.className = "cornerfill-all-var-important"; + const allVarLayer = host(); + allVarLayer.className = "cornerfill-all-var-layer"; const validHigh = host(document.body, "cornerfill-valid-high"); validHigh.className = "cornerfill-invalid-low"; const validBelowInvalidImportant = host(document.body, "cornerfill-invalid-important"); @@ -887,6 +974,7 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe ].map(([className, expected]) => { const target = host(); target.className = className; + target.style.removeProperty("border-radius"); target.style.width = "200px"; target.style.height = "100px"; return { target, expected }; @@ -919,6 +1007,10 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe 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"); + assert(auto.explain(allVar) === null, "all: var() did not transport its resolved carrier reset"); + assert(auto.explain(allVarFallback) === null, "all: var() fallback did not reset shape carriers"); + assert(auto.explain(allVarImportant) === null, "important all: var() did not reset important shape carriers"); + assert(auto.explain(allVarLayer) === null, "all: var(..., revert-layer) was partially owned"); 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) { @@ -934,6 +1026,11 @@ await test("automatic cascade contexts preserve supported CSS and refuse unsafe assert(/anonymous cascade layer/u.test(messages), "anonymous layer refusal was not reported"); assert(/nested selector rule/u.test(messages), "nested selector refusal was not reported"); assert(/complex corner-shape support condition/u.test(messages), "complex supports refusal was not reported"); + assert(/cannot safely transport this all: var/u.test(messages), "unsafe all: var() result was not reported"); + const allVarErrors = auto.explain().errors.filter(({ message }) => ( + /cannot safely transport this all: var/u.test(message) + )); + assert(allVarErrors.length === 1, `supported all: var() resets were refused: ${JSON.stringify(allVarErrors)}`); const ownership = document.querySelector("style[data-cornerfill-ownership-styles]"); const paintsBeforeRepair = auto.explain(layerNormal).counters.paints; const repairsBefore = auto.explain().runtime.counters.ownershipRepairs; @@ -954,7 +1051,9 @@ 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, + allReset, allBefore, allAfter, allLayer, allImportant, allVar, allVarFallback, + allVarImportant, allVarLayer, + validHigh, validBelowInvalidImportant, ...relativeRadii.map(({ target: radiusTarget }) => radiusTarget), ]) element.remove(); } @@ -1661,8 +1760,12 @@ await test("solid prepared visibility batches do not require an image program", borderRadius: "6px", cornerShape: "bevel", paint: { kind: "solid", color: "#08f" }, + visibility: false, }); await handle.ready; + assert(controller.stats().surfaces === 1, "hidden prepared entry did not preallocate its surface"); + assert(controller.stats().counters.deferredSurfaceEntries === 0, "hidden prepared entry deferred by default"); + controller.updatePreparedBatch([{ element, visible: true }]); controller.updatePreparedBatch([{ element, visible: false }]); controller.updatePreparedBatch([{ element, visible: true }]); assert(handle.explain().status === "active", "solid visibility batch failed"); diff --git a/notes/01-spec-contract.md b/notes/01-spec-contract.md index 4a22618..990206b 100644 --- a/notes/01-spec-contract.md +++ b/notes/01-spec-contract.md @@ -1,9 +1,9 @@ # Spec contract -Status: current semantic synthesis against the 26 March 2026 Working Draft. +Status: current semantic synthesis against a pinned 31 July 2026 editor's-draft repository snapshot. 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). +Primary target: [CSS Borders and Box Decorations Level 4](https://drafts.csswg.org/css-borders-4/). The latest published Working Draft is dated 16 December 2025; the editor's draft is live and can change, so the exact 31 July 2026 repository snapshot used for this research is listed in [references](references.md). ## `corner-shape` value model diff --git a/notes/09-polycss-case-study.md b/notes/09-polycss-case-study.md index b9f88cd..157d783 100644 --- a/notes/09-polycss-case-study.md +++ b/notes/09-polycss-case-study.md @@ -7,7 +7,7 @@ This is the first high-value consumer for Cornerfill. It is also an unusually cl ## 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 inspected Super Mario 64 adapter in a sibling `cssGraphics` checkout emits 1,213 retained face leaves and an 820-frame loop. The common leaf style is: @@ -52,11 +52,11 @@ 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`. +- package CSS: `src/adapters/super-mario-64/package.mjs` in the inspected `cssGraphics` checkout; +- player leaf creation: `src/adapters/super-mario-64/player/scene.ts`; +- audit leaf creation: `src/adapters/super-mario-64/audit/scene.ts`; +- prepared playback contract: `src/adapters/super-mario-64/stages/playbackPacket.mjs`; +- prepared lighting contract: `src/adapters/super-mario-64/stages/lighting.mjs`. ## What Cornerfill changes diff --git a/notes/evidence/README.md b/notes/evidence/README.md index e475f44..e77fcba 100644 --- a/notes/evidence/README.md +++ b/notes/evidence/README.md @@ -40,12 +40,10 @@ document.documentElement.dataset.phase = "0" | "1" 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) +- WebKit initial and repaint captures were retained in the original local `cssGraphics/.playwright-cli` session. +- Firefox initial and repaint captures were retained in the original local `cssGraphics/.playwright-cli` session. -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. +The original scratch probe was run from `cssGraphics/output/playwright/live-paint-surface-probe.html`; the copy beside this record is the durable research artifact. ## What this establishes diff --git a/notes/references.md b/notes/references.md index 189fbc5..5a2da79 100644 --- a/notes/references.md +++ b/notes/references.md @@ -91,11 +91,11 @@ Pinned Firefox source snapshot: [`56ad29049a11ced909e25d7e1fabcc6155e1a516`](htt - [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. +- `src/adapters/super-mario-64/package.mjs` in the inspected `cssGraphics` checkout: current paint declarations and atlas source. +- `src/adapters/super-mario-64/player/scene.ts`: current retained `u` face creation. +- `src/adapters/super-mario-64/stages/playbackPacket.mjs`: 820-frame, 1,213-leaf prepared playback contract. +- `src/adapters/super-mario-64/stages/lighting.mjs`: changed-only prepared lighting state and runtime contract. +- `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 diff --git a/oracle/README.md b/oracle/README.md index b0ae311..8e0d95b 100644 --- a/oracle/README.md +++ b/oracle/README.md @@ -45,7 +45,7 @@ The driver uses direct Playwright page code because it needs clipped raster evid 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: +The default is Chromium only and excludes the external Mario asset: ```bash npm run build @@ -70,22 +70,22 @@ List the fixed corpus: node scripts/oracle.mjs list ``` -Run the small integration proof: +Run the portable small integration proof: ```bash -node scripts/oracle.mjs run --cases=bevel,round,mario-texel-face +npm run oracle:smoke ``` -Run the representative Chrome/WebKit/Firefox proof, sequentially. It includes a compound 3D transform and the Mario crop: +Run the portable Chrome/WebKit/Firefox proof sequentially. It includes a compound 3D transform: ```bash -node scripts/oracle.mjs run --browsers=chrome,webkit,firefox --cases=bevel,bevel-rotated,mario-texel-face +npm run oracle:cross ``` -Run selected cases: +Run the real Mario texel-face stress case in all three engines: ```bash -node scripts/oracle.mjs run --cases=bevel,squircle,mario-texel-face +CORNERFILL_MARIO_TEXELS=/absolute/path/to/texels.webp npm run oracle:mario ``` Choose an explicit output directory: @@ -133,13 +133,7 @@ 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 `mario-texel-face` case reads, but does not copy or modify, the file supplied through `CORNERFILL_MARIO_TEXELS` or `--mario-texels=`. The run manifest hashes the exact file. The fixture uses prepared face index 7: diff --git a/package-lock.json b/package-lock.json index 3c1a0b2..35c69cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "typescript": "7.0.2" }, "engines": { - "node": ">=18.17.0" + "node": ">=22" } }, "node_modules/@playwright/cli": { diff --git a/package.json b/package.json index ed4224e..58aa68c 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "access": "public" }, "engines": { - "node": ">=18.17.0" + "node": ">=22" }, "scripts": { "clean": "node --input-type=module --eval \"import { rmSync } from 'node:fs'; rmSync('dist', { force: true, recursive: true }); rmSync('src/qualification.mts', { force: true });\"", @@ -74,8 +74,9 @@ "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" + "oracle:smoke": "npm run build && node scripts/oracle.mjs run --cases=bevel,round", + "oracle:cross": "npm run build && node scripts/oracle.mjs run --browsers=chrome,webkit,firefox --cases=bevel,bevel-rotated", + "oracle:mario": "npm run build && node scripts/oracle.mjs run --browsers=chrome,webkit,firefox --cases=mario-texel-face" }, "devDependencies": { "@playwright/cli": "0.1.17", diff --git a/scripts/generate-qualification.mjs b/scripts/generate-qualification.mjs index ceff252..7056d0b 100644 --- a/scripts/generate-qualification.mjs +++ b/scripts/generate-qualification.mjs @@ -2,14 +2,49 @@ import { readFileSync, writeFileSync } from "node:fs"; const input = new URL("../oracle/qualification.json", import.meta.url); +const tolerancesInput = new URL("../oracle/tolerances.json", import.meta.url); const output = new URL("../src/qualification.mts", import.meta.url); const qualification = JSON.parse(readFileSync(input, "utf8")); +const tolerances = JSON.parse(readFileSync(tolerancesInput, "utf8")); +const candidateStatuses = new Set(["UNQUALIFIED", "PASS", "FAIL", "INVALID"]); +const exactCalibration = [ + "maxMeanAlpha", + "maxMeanPremultipliedRgb", + "maxChangedPixelRatio", + "channelThreshold", +].every((key) => tolerances.calibration?.[key] === 0); +const candidateStatus = qualification.candidate?.status; +const candidateApproved = qualification.candidate?.approvedTolerance; +const candidateStateIsConsistent = candidateStatus === "UNQUALIFIED" + ? candidateApproved === false + : candidateStatus === "PASS" || candidateStatus === "FAIL" + ? candidateApproved === true + : candidateStatus === "INVALID"; +const candidateEvidence = qualification.candidate?.evidence; +const nonEmptyRecord = (value) => value && typeof value === "object" + && !Array.isArray(value) && Object.keys(value).length > 0; +const approvedCandidateHasEvidence = candidateApproved !== true || ( + typeof candidateEvidence?.sourceCommit === "string" && candidateEvidence.sourceCommit.length > 0 + && typeof candidateEvidence?.specRevision === "string" && candidateEvidence.specRevision.length > 0 + && nonEmptyRecord(candidateEvidence?.browserVersions) + && Array.isArray(candidateEvidence?.cases) && candidateEvidence.cases.length > 0 + && nonEmptyRecord(candidateEvidence?.fixtureHashes) + && typeof candidateEvidence?.artifactHash === "string" && candidateEvidence.artifactHash.length > 0 + && typeof candidateEvidence?.generatedAt === "string" && !Number.isNaN(Date.parse(candidateEvidence.generatedAt)) + && typeof candidateEvidence?.reviewedBy === "string" && candidateEvidence.reviewedBy.length > 0 +); if (qualification?.schema !== "cornerfill-oracle-qualification@1" + || tolerances?.schema !== "cornerfill-oracle-tolerances@1" || qualification.nativeCalibration?.status !== "PASS" || qualification.nativeCalibration?.approvedTolerance !== true - || qualification.candidate?.status !== "UNQUALIFIED" - || qualification.candidate?.approvedTolerance !== false) { + || qualification.nativeCalibration?.exactZeroTolerance !== true + || tolerances.calibration?.approved !== true + || !exactCalibration + || !candidateStatuses.has(candidateStatus) + || !candidateStateIsConsistent + || !approvedCandidateHasEvidence + || candidateApproved !== tolerances.candidate?.approved) { throw new TypeError("oracle/qualification.json does not satisfy the release qualification contract"); } diff --git a/scripts/oracle.mjs b/scripts/oracle.mjs index 223c29d..9b4dd7b 100644 --- a/scripts/oracle.mjs +++ b/scripts/oracle.mjs @@ -25,7 +25,6 @@ import { 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"; @@ -36,9 +35,9 @@ function usage() { Options: --browsers= Sequential browser list. Default: chrome - --cases= Fixture ids. Default: all + --cases= Fixture ids. Default: all portable cases --out= Run output. Default: oracle/results/ - --mario-texels= Existing texels.webp source path + --mario-texels= Existing texels.webp source path, required by the Mario case --enforce-candidate Exit nonzero unless approved candidate tolerances pass Environment: @@ -58,9 +57,11 @@ function parseArguments(argv) { const values = { command, browsers: ["chrome"], - cases: oracleCases.map(({ id }) => id), + cases: oracleCases.filter(({ id }) => id !== "mario-texel-face").map(({ id }) => id), out: null, - marioTexels: process.env.CORNERFILL_MARIO_TEXELS || DEFAULT_MARIO_TEXELS, + marioTexels: process.env.CORNERFILL_MARIO_TEXELS + ? resolve(process.env.CORNERFILL_MARIO_TEXELS) + : null, enforceCandidate: false, }; for (const argument of argv) { @@ -153,7 +154,7 @@ async function startFixtureServer(marioTexels) { try { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (url.pathname === "/__mario/texels.webp") { - if (!existsSync(marioTexels)) { + if (!marioTexels || !existsSync(marioTexels)) { response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); response.end("Mario texels source is unavailable\n"); return; @@ -478,8 +479,10 @@ async function runOracle(options) { 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}`); + if (needsMario && (!options.marioTexels || !existsSync(options.marioTexels))) { + throw new Error( + "Mario case requires texels.webp; set CORNERFILL_MARIO_TEXELS or pass --mario-texels=", + ); } const cli = locatePlaywrightCli(); const driverDirectory = join(runDirectory, "driver"); diff --git a/src/auto-runtime.mts b/src/auto-runtime.mts index eb9156d..1fc0f97 100644 --- a/src/auto-runtime.mts +++ b/src/auto-runtime.mts @@ -1,5 +1,6 @@ import { installCornerfill } from "./runtime.mjs"; import type { + CornerfillAuthoredStyleInspection, CornerfillControllerHandle, CornerfillControllerStats, CornerfillEntryExplanation, @@ -362,6 +363,11 @@ 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_ALL_PENDING = "--cornerfill-auto-all-pending"; +const AUTO_ALL_VALUE = "--cornerfill-auto-all-value"; +const AUTO_SHAPE_SOURCE = "--cornerfill-auto-shape-source"; +const AUTO_ALL_SENTINEL = "__cornerfill_all__"; +const SUPPORTED_ALL_VALUE = new RegExp(`^(?:${AUTO_ALL_SENTINEL}\\s+(?:initial|unset))?$`, "iu"); const CARRIER_REGISTRATIONS = new WeakMap(); const PHYSICAL_SHAPE_PROPERTIES: readonly Exclude[] = Object.freeze([ @@ -385,7 +391,7 @@ const SHAPE_STATUS_PROPERTIES = Object.freeze(Object.fromEntries( )) as Readonly, string>>; const SHAPE_STATUS_CARRIERS = Object.freeze(Object.values(SHAPE_STATUS_PROPERTIES)); -const AUTO_CARRIERS = Object.freeze([ +const CASCADE_CARRIERS = Object.freeze([ ...new Set([ ...SHAPE_CARRIERS, ...SHAPE_STATUS_CARRIERS, @@ -393,11 +399,24 @@ const AUTO_CARRIERS = Object.freeze([ AUTO_LOGICAL_SHAPE, ]), ]); +const ALL_RESET_CARRIERS = Object.freeze([ + ...CASCADE_CARRIERS, + AUTO_ALL_PENDING, + AUTO_ALL_VALUE, +]); +const AUTO_CARRIERS = Object.freeze([ + ...ALL_RESET_CARRIERS, + AUTO_SHAPE_SOURCE, +]); +const AUTO_CARRIER_SET = new Set(AUTO_CARRIERS); const SHAPE_MARKERS = Object.freeze([ ...SHAPE_STATUS_CARRIERS, AUTO_PHYSICAL_SHAPE, AUTO_LOGICAL_SHAPE, + AUTO_ALL_PENDING, + AUTO_ALL_VALUE, + AUTO_SHAPE_SOURCE, ]); const AUTOMATIC_DISCOVERY = Object.freeze({ @@ -412,6 +431,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", + "all: var(...) results that require inherit, revert, or revert-layer cascade reconstruction", "corner-shape or paint changes driven by CSS animations or transitions", "alternate stylesheet sets", "corner-shape rules inserted through CSSOM before Cornerfill starts", @@ -539,6 +559,12 @@ function shapeStatusDeclarations( )).join(""); } +function shapeDeclarationState(priority: string): string { + return `${AUTO_SHAPE_SOURCE}:1${priority};` + + `${AUTO_ALL_PENDING}:${AUTO_UNSET}${priority};` + + `${AUTO_ALL_VALUE}:${AUTO_UNSET}${priority};`; +} + function shapeCssWideDeclaration( property: ShapeProperty, value: string, @@ -555,8 +581,14 @@ function shapeCssWideDeclaration( + `${marker}:${carrierValue}${priority};`; } +const CSS_MATH_FUNCTION = "(?:calc|min|max|clamp|round|mod|rem|sin|cos|tan|asin|acos|atan|atan2|pow|sqrt|hypot|log|exp|abs|sign)"; +const POTENTIALLY_VALID_UNSUPPORTED_SHAPE = new RegExp( + `^superellipse\\(\\s*(${CSS_MATH_FUNCTION}\\([\\s\\S]*\\))\\s*\\)$`, + "iu", +); + function potentiallyValidUnsupportedShape(value: string): boolean { - const functionValue = /^superellipse\(\s*((?:calc|min|max|clamp)\([\s\S]*\))\s*\)$/iu.exec(value); + const functionValue = POTENTIALLY_VALID_UNSUPPORTED_SHAPE.exec(value); if (!functionValue) return false; const expression = functionValue[1]!; for (let index = 0; index < expression.length; index += 1) { @@ -564,6 +596,7 @@ function potentiallyValidUnsupportedShape(value: string): boolean { if (character !== "+" && character !== "-") continue; const before = expression[index - 1] ?? ""; const after = expression[index + 1] ?? ""; + if (character === "-" && /[a-z_]/iu.test(before) && /[a-z_]/iu.test(after)) continue; const prefix = expression.slice(0, index).trimEnd(); const unary = prefix === "" || /[,(+\-*/]$/u.test(prefix) @@ -581,37 +614,45 @@ function shapeCarrierDeclaration(property: ShapeProperty, rawValue: string): str const marker = property === "corner-shape" || PHYSICAL_SHAPE_PROPERTIES.includes(property) ? AUTO_PHYSICAL_SHAPE : AUTO_LOGICAL_SHAPE; + const state = shapeDeclarationState(priority); if (/^(?:inherit|initial|revert|revert-layer|revert-rule|unset)$/iu.test(value)) { - return shapeCssWideDeclaration(property, value, priority, longhands, marker); + return `${shapeCssWideDeclaration(property, value, priority, longhands, marker)}${state}`; } try { if (/\bvar\s*\(/iu.test(value)) { const carrier = SHAPE_PROPERTIES[property]; - return `${carrier}:${value}${priority};${shapeStatusDeclarations(longhands, "ok", priority)}${marker}:1${priority};`; + return `${carrier}:${value}${priority};${shapeStatusDeclarations(longhands, "ok", priority)}${marker}:1${priority};${state}`; } if (property === "corner-shape") { const values = parseCornerShape(value); return `${PHYSICAL_SHAPE_PROPERTIES.map((longhand, index) => ( `${SHAPE_PROPERTIES[longhand]}:${serializeShapeParameter(values[index]!)}${priority};` - )).join("")}${shapeStatusDeclarations(longhands, "ok", priority)}${AUTO_PHYSICAL_SHAPE}:1${priority};`; + )).join("")}${shapeStatusDeclarations(longhands, "ok", priority)}${AUTO_PHYSICAL_SHAPE}:1${priority};${state}`; } const carrier = SHAPE_PROPERTIES[property]; const parsed = serializeShapeParameter(parseCornerShapeValue(value)); - return `${carrier}:${parsed}${priority};${shapeStatusDeclarations(longhands, "ok", priority)}${marker}:1${priority};`; + return `${carrier}:${parsed}${priority};${shapeStatusDeclarations(longhands, "ok", priority)}${marker}:1${priority};${state}`; } catch { return potentiallyValidUnsupportedShape(value) - ? `${shapeStatusDeclarations(longhands, "unsupported", priority)}${marker}:1${priority};` + ? `${shapeStatusDeclarations(longhands, "unsupported", priority)}${marker}:1${priority};${state}` : ""; } } 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("")}`; + const cssWide = /^(?:inherit|initial|revert|revert-layer|revert-rule|unset)$/iu.test(value); + if (!cssWide && !/\bvar\s*\(/iu.test(value)) return null; + if (cssWide) { + const carrierValue = /^(?:initial|unset)$/iu.test(value) ? AUTO_UNSET : value; + return `${rawDeclaration};${ALL_RESET_CARRIERS.map((carrier) => ( + `${carrier}:${carrierValue}${priority};` + )).join("")}`; + } + return `${rawDeclaration};${CASCADE_CARRIERS.map((carrier) => ( + `${carrier}:${value}${priority};` + )).join("")}${AUTO_ALL_PENDING}:1${priority};` + + `${AUTO_ALL_VALUE}:${AUTO_ALL_SENTINEL} ${value}${priority};`; } function canonicalizeCornerShapeDeclarations( @@ -698,9 +739,10 @@ function diagnosticShapeDeclarations(style: CSSStyleDeclaration): readonly strin const priority = style.getPropertyPriority(carrier); declarations.push(`${property}: ${value}${priority ? " !important" : ""}`); } - if (declarations.length === 0 && SHAPE_STATUS_CARRIERS.some((property) => ( - style.getPropertyValue(property).trim() === "unsupported" - ))) { + if (declarations.length === 0 && ( + SHAPE_STATUS_CARRIERS.some((property) => style.getPropertyValue(property).trim() === "unsupported") + || style.getPropertyValue(AUTO_ALL_PENDING).trim() === "1" + )) { declarations.push("corner-shape: "); } return Object.freeze(declarations); @@ -1186,8 +1228,8 @@ function mutateStylesheetModel( } } -function computedCarrier(computed: CSSStyleDeclaration, property: string): string { - const value = computed.getPropertyValue(property).trim(); +function normalizedCarrier(source: string): string { + const value = source.trim(); return value === AUTO_UNSET || /^(?:initial|unset)$/iu.test(value) ? "" : value; } @@ -1236,14 +1278,29 @@ const AUTOMATIC_COMPUTED_PROPERTIES = Object.freeze([ "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"); +const AUTOMATIC_SIGNATURE_PROPERTIES = Object.freeze([ + "visibility", + "direction", + "writing-mode", + ...AUTOMATIC_COMPUTED_PROPERTIES, + ...AUTO_CARRIERS, +]); + +function inspectionCarrier( + inspection: Readonly, + property: string, +): string { + return normalizedCarrier(inspection.values[property] ?? ""); +} + +function automaticComputedSignature( + inspection: Readonly, +): string { + return AUTOMATIC_SIGNATURE_PROPERTIES.map((property) => ( + AUTO_CARRIER_SET.has(property) + ? inspectionCarrier(inspection, property) + : inspection.values[property] ?? "" + )).join("\n"); } function automaticStyleMutationSignature(value: unknown): string { @@ -1269,25 +1326,32 @@ function automaticStyleMutationSignature(value: unknown): string { .join(";"); } -function carrierProblem(computed: CSSStyleDeclaration): string | null { - if (SHAPE_STATUS_CARRIERS.some((property) => computedCarrier(computed, property) === "unsupported")) { +function carrierProblem(inspection: Readonly): string | null { + if (inspectionCarrier(inspection, AUTO_ALL_PENDING) + && inspectionCarrier(inspection, AUTO_SHAPE_SOURCE)) { + const resolved = inspectionCarrier(inspection, AUTO_ALL_VALUE); + if (!SUPPORTED_ALL_VALUE.test(resolved)) { + return "Automatic CSS cannot safely transport this all: var(...) result; use cornerfill/runtime for explicit state."; + } + } + if (SHAPE_STATUS_CARRIERS.some((property) => inspectionCarrier(inspection, property) === "unsupported")) { return "Automatic CSS cannot resolve this corner-shape value; use cornerfill/runtime for explicit state."; } - const variableShorthand = computedCarrier(computed, SHAPE_PROPERTIES["corner-shape"]); + const variableShorthand = inspectionCarrier(inspection, SHAPE_PROPERTIES["corner-shape"]); const competingLonghand = [...PHYSICAL_SHAPE_PROPERTIES, ...LOGICAL_SHAPE_PROPERTIES] - .some((property) => computedCarrier(computed, SHAPE_PROPERTIES[property])); + .some((property) => inspectionCarrier(inspection, SHAPE_PROPERTIES[property])); if (variableShorthand && competingLonghand) { return "Automatic CSS refuses a variable corner-shape shorthand combined with longhands because their cascade order cannot be preserved."; } - if (computedCarrier(computed, AUTO_PHYSICAL_SHAPE) - && computedCarrier(computed, AUTO_LOGICAL_SHAPE)) { + if (inspectionCarrier(inspection, AUTO_PHYSICAL_SHAPE) + && inspectionCarrier(inspection, AUTO_LOGICAL_SHAPE)) { return "Automatic CSS refuses mixed physical and logical corner-shape declarations because their cross-family cascade cannot be preserved."; } return null; } -function hasShapeCarrier(computed: CSSStyleDeclaration): boolean { - return SHAPE_CARRIERS.some((property) => computedCarrier(computed, property)); +function hasShapeCarrier(inspection: Readonly): boolean { + return SHAPE_CARRIERS.some((property) => inspectionCarrier(inspection, property)); } function stylesheetElements(root: AutoRoot): StylesheetOwner[] { @@ -1685,6 +1749,25 @@ class CornerfillAutoController { this.registrationStyle = null; } + async _boundedStylesheetTask( + controller: AbortController, + label: string, + task: () => Promise, + ): Promise { + let timer = 0; + const timeout = new Promise((_resolve, reject) => { + timer = this.document.defaultView.setTimeout(() => { + controller.abort(); + reject(new Error(`${label} timed out after ${this.stylesheetTimeoutMs}ms`)); + }, this.stylesheetTimeoutMs); + }); + try { + return await Promise.race([task(), timeout]); + } finally { + this.document.defaultView.clearTimeout(timer); + } + } + async _source( owner: StylesheetOwner, request: SourceRequest, @@ -1712,16 +1795,18 @@ class CornerfillAutoController { if (owner.integrity) init.integrity = owner.integrity; if (owner.referrerPolicy) init.referrerPolicy = owner.referrerPolicy as ReferrerPolicy; try { - const response = await this.document.defaultView.fetch(url.href, init); - if (!response.ok) throw new Error(`stylesheet request failed with HTTP ${response.status}: ${url.href}`); - const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; - if (contentType !== "text/css") { - throw new TypeError(`stylesheet response has invalid CSS MIME type ${contentType || "(missing)"}: ${url.href}`); - } - return Object.freeze({ - text: await response.text(), - baseUrl: response.url || url.href, - sourceUrl: response.url || url.href, + return await this._boundedStylesheetTask(controller, `stylesheet request ${url.href}`, async () => { + const response = await this.document.defaultView.fetch(url.href, init); + if (!response.ok) throw new Error(`stylesheet request failed with HTTP ${response.status}: ${url.href}`); + const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + if (contentType !== "text/css") { + throw new TypeError(`stylesheet response has invalid CSS MIME type ${contentType || "(missing)"}: ${url.href}`); + } + return Object.freeze({ + text: await response.text(), + baseUrl: response.url || url.href, + sourceUrl: response.url || url.href, + }); }); } finally { this.pendingFetches.delete(controller); @@ -1766,17 +1851,19 @@ class CornerfillAutoController { if (referrerPolicy) init.referrerPolicy = referrerPolicy as ReferrerPolicy; const task = (async () => { try { - const response = await this.document.defaultView.fetch(url, init); - if (!response.ok) throw new Error(`stylesheet request failed with HTTP ${response.status}`); - const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; - if (contentType !== "text/css") { - throw new TypeError(`stylesheet response has invalid CSS MIME type ${contentType || "(missing)"}`); - } - const sourceUrl = response.url || url; - return Object.freeze({ - text: await response.text(), - baseUrl: sourceUrl, - sourceUrl, + return await this._boundedStylesheetTask(controller, `@import request ${url}`, async () => { + const response = await this.document.defaultView.fetch(url, init); + if (!response.ok) throw new Error(`stylesheet request failed with HTTP ${response.status}`); + const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + if (contentType !== "text/css") { + throw new TypeError(`stylesheet response has invalid CSS MIME type ${contentType || "(missing)"}`); + } + const sourceUrl = response.url || url; + return Object.freeze({ + text: await response.text(), + baseUrl: sourceUrl, + sourceUrl, + }); }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -2325,6 +2412,8 @@ class CornerfillAutoController { this._waitForLinkedStylesheet(owner, request), ]); } catch (error) { + request.controller?.abort(); + request.cancelWait?.(); if (this.destroyed || request.aborted || this.sourceRequests.get(owner) !== request || !stylesheetElementIsEligible(owner) || stylesheetKey(owner) !== key) return; const ownerIdentity = isStylesheetLink(owner) ? owner.href : this._ownerIdentity(owner); @@ -2554,36 +2643,45 @@ class CornerfillAutoController { const candidates = this.candidates; const ready: Promise[] = []; for (const [element, handle] of [...this.handles]) { - this.automaticCounters.computedChecks += 1; - const computed = element.isConnected - ? this.document.defaultView.getComputedStyle(element) - : null; - const problem = computed ? carrierProblem(computed) : null; - if (candidates.has(element) && computed && hasShapeCarrier(computed) && !problem) { - this._clearErrors(element); - const signature = automaticComputedSignature(computed); - if (this.handleSignatures.get(element) === signature) { - try { - handle.verify(); - } catch (error) { - this._recordElementError(error, element); - handle.dispose(); - this.handles.delete(element); - this.handleSignatures.delete(element); + let failure: unknown = null; + if (candidates.has(element) && element.isConnected) { + this.automaticCounters.computedChecks += 1; + try { + const inspection = this.controller.inspectAuthoredStyle( + element, + AUTOMATIC_SIGNATURE_PROPERTIES, + ); + const problem = carrierProblem(inspection); + if (problem) failure = new TypeError(problem); + else if (hasShapeCarrier(inspection) && inspection.requiresFallback) { + this._clearErrors(element); + const signature = automaticComputedSignature(inspection); + if (this.handleSignatures.get(element) === signature) { + try { + handle.verify(); + } catch (error) { + this._recordElementError(error, element); + handle.dispose(); + this.handles.delete(element); + this.handleSignatures.delete(element); + } + continue; + } + this.handleSignatures.set(element, signature); + this.automaticCounters.handleRefreshes += 1; + ready.push(handle.refresh().catch((error) => { + this._recordElementError(error, element); + handle.dispose(); + this.handles.delete(element); + this.handleSignatures.delete(element); + })); + continue; } - continue; + } catch (error) { + failure = error; } - this.handleSignatures.set(element, signature); - this.automaticCounters.handleRefreshes += 1; - ready.push(handle.refresh().catch((error) => { - this._recordElementError(error, element); - handle.dispose(); - this.handles.delete(element); - this.handleSignatures.delete(element); - })); - continue; } - if (problem) this._recordElementError(new TypeError(problem), element); + if (failure) this._recordElementError(failure, element); else this._clearErrors(element); handle.dispose(); this.automaticCounters.handleDetaches += 1; @@ -2597,19 +2695,22 @@ class CornerfillAutoController { } if (this.handles.has(element)) continue; this.automaticCounters.computedChecks += 1; - const computed = this.document.defaultView.getComputedStyle(element); - const problem = carrierProblem(computed); - if (problem) { - this._recordElementError(new TypeError(problem), element); - continue; - } - this._clearErrors(element); - if (!hasShapeCarrier(computed)) continue; try { + const inspection = this.controller.inspectAuthoredStyle( + element, + AUTOMATIC_SIGNATURE_PROPERTIES, + ); + const problem = carrierProblem(inspection); + if (problem) { + this._recordElementError(new TypeError(problem), element); + continue; + } + this._clearErrors(element); + if (!hasShapeCarrier(inspection) || !inspection.requiresFallback) continue; const handle = this.controller.attach(element); this.automaticCounters.handleAttaches += 1; this.handles.set(element, handle); - this.handleSignatures.set(element, automaticComputedSignature(computed)); + this.handleSignatures.set(element, automaticComputedSignature(inspection)); ready.push(handle.ready.catch((error) => { this._recordElementError(error, element); handle.dispose(); diff --git a/src/native.mts b/src/native.mts index a604411..9401f70 100644 --- a/src/native.mts +++ b/src/native.mts @@ -97,7 +97,7 @@ function requirement( function unresolvedRequirements( requirements: CornerfillNativeRequirements, ): readonly (keyof CornerfillNativeRequirements)[] { - return Object.freeze((Object.keys(requirements) as (keyof CornerfillNativeRequirements)[]) + return Object.freeze((["syntax", "computedValues"] as const) .filter((name) => !requirements[name].supported)); } @@ -107,16 +107,20 @@ function qualification( ): Readonly { const unresolved = unresolvedRequirements(requirements); const qualified = unresolved.length === 0; - const observed = (supported: boolean): CornerfillNativeCapabilityStatus => ( - supported ? "supported" : "unsupported" + const observed = ( + requirement: Readonly, + ): CornerfillNativeCapabilityStatus => ( + requirement.observable === false + ? "unobserved" + : requirement.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), + syntax: observed(requirements.syntax), + computedValues: observed(requirements.computedValues), + shapedHitTesting: observed(requirements.shapedBehavior), outerPaint: "unobserved", innerBorderContour: "unobserved", backgroundClip: "unobserved", @@ -128,7 +132,7 @@ function qualification( requirements: Object.freeze(requirements), unresolved, reason: qualified - ? "The conservative native-selection probes passed; unprobed capabilities remain explicitly unobserved." + ? "Native corner-shape syntax and computed-value probes passed; behavior capabilities are reported separately." : `Native corner-shape is unqualified: ${unresolved.join(", ") || "probe failure"}.`, ...(error ? { error: error instanceof Error ? error.message : String(error) } : {}), }); @@ -273,15 +277,20 @@ export function qualifyNativeCornerShape( let element: HTMLElement | null = null; let result: Readonly; try { - const isolated = isolatedProbeDocument(document); - frame = isolated.frame; - element = probeElement(isolated.document); - isolated.document.documentElement.append(element); + let probeDocument = document; + try { + const isolated = isolatedProbeDocument(document); + frame = isolated.frame; + probeDocument = isolated.document; + } catch { + // CSP can block iframe documents. Syntax/computed-value selection still has a host probe. + } + element = probeElement(probeDocument); + probeDocument.documentElement.append(element); 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), + computedValues: computedRequirement(probeDocument, element), + shapedBehavior: behaviorRequirement(probeDocument, element), }); } catch (error) { result = qualification({ @@ -293,8 +302,7 @@ export function qualifyNativeCornerShape( element?.remove(); frame?.remove(); } - const observable = result.requirements.computedValues.observable !== false - && result.requirements.shapedBehavior.observable !== false; + const observable = result.requirements.computedValues.observable !== false; if (observable) CACHE.set(document, result); return result; } diff --git a/src/runtime.mts b/src/runtime.mts index 8358e4f..e2d8c55 100644 --- a/src/runtime.mts +++ b/src/runtime.mts @@ -473,6 +473,11 @@ export interface CornerfillControllerStats { readonly surfaces: number; } +export interface CornerfillAuthoredStyleInspection { + readonly requiresFallback: boolean; + readonly values: Readonly>; +} + export interface CornerfillControllerHandle { readonly capabilities: ReturnType; readonly document: Document; @@ -489,6 +494,10 @@ export interface CornerfillControllerHandle { detach(element: CornerfillElement): boolean; explain(element: CornerfillElement): Readonly | null; flushPrepared(): number; + inspectAuthoredStyle( + element: CornerfillElement, + properties: readonly string[], + ): Readonly; refresh(): Promise[]>; resizePrepared( element: CornerfillElement, @@ -1891,6 +1900,36 @@ function resolveRadiusSource( throw new TypeError("unsupported border-radius source"); } +function authoredCornerRequiresFallback( + element: CornerfillElement, + computed: CSSStyleDeclaration, +): boolean { + const flow = flowFromComputed(computed); + const shapeCapture = captureShapeCarriers(computed); + const shapeSource: CornerShapeSource = shapeCapture?.present + ? shapeCapture.source + : computed.getPropertyValue("corner-shape").trim() || "round"; + const shapes = resolveCornerShape(shapeSource, flow); + if (shapes.every((shape) => shape === 1)) return false; + const { width, height } = measureBorderBox(element, computed); + const radiusCapture = captureRadiusCarriers(computed); + const radiusSource: RadiusSource = radiusCapture?.present + ? radiusCapture.source + : Object.freeze({ + kind: "longhands", + values: frozenFour( + computed.getPropertyValue(RADIUS_LONGHANDS[0]!), + computed.getPropertyValue(RADIUS_LONGHANDS[1]!), + computed.getPropertyValue(RADIUS_LONGHANDS[2]!), + computed.getPropertyValue(RADIUS_LONGHANDS[3]!), + ), + }); + const radii = resolveRadiusSource(radiusSource, width, height, flow); + return shapes.some((shape, index) => ( + shape !== 1 && radii[index]!.rx > 0 && radii[index]!.ry > 0 + )); +} + function shapeKey(value: number): string { if (value === Number.POSITIVE_INFINITY) return "+inf"; if (value === Number.NEGATIVE_INFINITY) return "-inf"; @@ -4069,7 +4108,7 @@ class CornerfillController { requestedVisible: Boolean(visible), styleVisible: true, visible: Boolean(visible), - deferHiddenSurface: config.deferHiddenSurface !== false, + deferHiddenSurface: config.deferHiddenSurface === true, surfaceWasDeferred: false, needsPaint: false, needsFullPreparedPaint: false, @@ -4476,6 +4515,25 @@ class CornerfillController { }) as Readonly; } + inspectAuthoredStyle( + element: CornerfillElement, + properties: readonly string[], + ): Readonly { + if (element.ownerDocument !== this.document) { + throw new TypeError("Cornerfill cannot inspect an element from another document"); + } + const inspect = (computed: CSSStyleDeclaration) => Object.freeze({ + requiresFallback: authoredCornerRequiresFallback(element, computed), + values: Object.freeze(Object.fromEntries(properties.map((property) => ( + [property, computed.getPropertyValue(property)] + )))), + }); + const entry = this.entryByElement.get(element); + return entry + ? withAuthoredComputedStyle(this.view, entry, inspect) + : inspect(this.view.getComputedStyle(element)); + } + explain(element: CornerfillElement): Readonly | null { const entry = this.entryByElement.get(element); return entry ? entryExplanation(entry) : null; diff --git a/src/spec.mts b/src/spec.mts index 8574ca2..8c41b00 100644 --- a/src/spec.mts +++ b/src/spec.mts @@ -1,10 +1,24 @@ 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", + publishedWorkingDraft: Object.freeze({ + date: "2025-12-16", + url: "https://www.w3.org/TR/2025/WD-css-borders-4-20251216/", + }), + editorsDraft: Object.freeze({ + url: "https://drafts.csswg.org/css-borders-4/", + snapshotDate: "2026-07-31", + repositoryCommit: "13b14ec48af0219c893713d670cf80d8c014a648", + sourcePath: "css-borders-4/Overview.bs", + sourceBlobSha: "502d9ddb0803bdb7015f4b84f345d9d983dc9093", + sourceUrl: "https://github.com/w3c/csswg-drafts/blob/13b14ec48af0219c893713d670cf80d8c014a648/css-borders-4/Overview.bs", + }), + wptSnapshot: Object.freeze({ + repositoryCommit: "4a5810a124fa0523dd2494996bf1542d4b67f394", + cornerShapeDirectory: "css/css-borders/corner-shape", + cornerShapeDirectoryTreeSha: "dfdebd08723bef1b1761a90c420a41770275cd25", + hitTestPath: "css/css-borders/corner-shape/corner-shape-hittest.html", + hitTestBlobSha: "d6655acc330ac4153ad80575a87391c86d01804a", + }), }); export type CornerfillPropertySupport = "automatic" | "native-computed" | "not-implemented"; diff --git a/test/contract.test.mjs b/test/contract.test.mjs index b47193c..2f0c420 100644 --- a/test/contract.test.mjs +++ b/test/contract.test.mjs @@ -18,12 +18,20 @@ test("production sources contain none of the prohibited CSS renderers", () => { assert.doesNotMatch(source, /setProperty\(["']transform["']/u); }); -test("oracle candidate tolerances remain deliberately unapproved", () => { +test("generated oracle qualification stays consistent with tracked tolerances", () => { const source = JSON.parse(readFileSync(join(root, "oracle", "qualification.json"), "utf8")); + const tolerances = JSON.parse(readFileSync(join(root, "oracle", "tolerances.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); - assert.equal(CORNERFILL_ORACLE_QUALIFICATION.candidate.status, "UNQUALIFIED"); - assert.equal(CORNERFILL_ORACLE_QUALIFICATION.candidate.approvedTolerance, false); + assert.equal(tolerances.calibration.approved, true); + assert.equal( + CORNERFILL_ORACLE_QUALIFICATION.candidate.approvedTolerance, + tolerances.candidate.approved, + ); + const { approvedTolerance, status } = CORNERFILL_ORACLE_QUALIFICATION.candidate; + assert.ok(["UNQUALIFIED", "PASS", "FAIL", "INVALID"].includes(status)); + if (status === "UNQUALIFIED") assert.equal(approvedTolerance, false); + if (status === "PASS" || status === "FAIL") assert.equal(approvedTolerance, true); }); diff --git a/test/native.test.mjs b/test/native.test.mjs index f72a6ef..24a1a41 100644 --- a/test/native.test.mjs +++ b/test/native.test.mjs @@ -48,10 +48,10 @@ test("syntax support alone cannot qualify native corner-shape", () => { assert.equal(result.capabilities.syntax, "supported"); assert.equal(result.capabilities.computedValues, "unsupported"); assert.equal(result.capabilities.outerPaint, "unobserved"); - assert.deepEqual(result.unresolved, ["computedValues", "shapedBehavior"]); + assert.deepEqual(result.unresolved, ["computedValues"]); }); -test("an unobservable native probe is not cached", () => { +test("unobservable shaped behavior does not demote or invalidate computed native support", () => { let attempts = 0; const isolatedDocument = (size) => { let probe = null; @@ -101,10 +101,59 @@ test("an unobservable native probe is not cached", () => { documentElement: { append() {} }, defaultView: { CSS: { supports: () => 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.shapedHitTesting, "unobserved"); + assert.equal(qualifyNativeCornerShape(document), qualified); assert.equal(qualified.capabilities.innerBorderContour, "unobserved"); - assert.equal(attempts, 2); + assert.equal(attempts, 1); +}); + +test("native computed-value qualification falls back to the host document when iframe isolation is blocked", () => { + let probe = null; + let frameRemoved = false; + const style = { + currentShape: "", + setProperty(property, value) { + if (property === "corner-shape") this.currentShape = value; + }, + }; + const element = { remove() { probe = null; }, setAttribute() {}, style }; + const document = { + createElement(name) { + if (name === "iframe") return { + contentDocument: null, + remove() { frameRemoved = true; }, + setAttribute() {}, + style: { setProperty() {} }, + }; + assert.equal(name, "div"); + return element; + }, + documentElement: { append(value) { probe = value; } }, + elementFromPoint: () => style.currentShape === "round" ? probe : null, + defaultView: { + CSS: { supports: () => true }, + innerHeight: 128, + innerWidth: 128, + getComputedStyle: () => ({ + getPropertyValue(property) { + if (property === "corner-shape") return style.currentShape; + const index = [ + "corner-top-left-shape", + "corner-top-right-shape", + "corner-bottom-right-shape", + "corner-bottom-left-shape", + ].indexOf(property); + return index >= 0 + ? ["superellipse(0)", "superellipse(-1)", "superellipse(1)", "superellipse(-infinity)"][index] + : ""; + }, + }), + }, + }; + const result = qualifyNativeCornerShape(document); + assert.equal(result.qualified, true); + assert.equal(result.capabilities.shapedHitTesting, "supported"); + assert.equal(frameRemoved, true); });