Current section
Files
Jump to
Current section
Files
priv/positioning-spike/spike.mjs
// CSS Anchor Positioning spike driver. Measures, per engine:
// probe — CSS.supports for the anchor-positioning primitives
// T1 — anchored open: popover top-left glued to trigger bottom-left (±1px)
// T2 — content/attr/sibling patches while open: geometry byte-stable, still open
// T3 — a patch MOVES the anchor (+150px): popover follows with zero JS
// T4 — anchor near viewport bottom: position-try flip places popover ABOVE
// T5 — window scroll: popover stays glued to the anchor
// This is a measurement spike, not a fuse: unsupported engines are recorded, not
// failed. Exit 1 only on harness malfunction. Output: positioning-results.json.
import { chromium, firefox, webkit } from 'playwright';
import { writeFileSync } from 'node:fs';
const BASE = 'http://127.0.0.1:4133';
const TOL = 1.5;
const settle = (p) => p.waitForTimeout(150);
const waitConnected = (p) =>
p.waitForFunction(() => document.querySelector('[data-phx-main]')?.classList.contains('phx-connected'), null, { timeout: 12000 });
const jsClick = (p, id) => p.evaluate((id) => document.getElementById(id).click(), id);
const waitText = (p, id, txt) =>
p.waitForFunction(([id, txt]) => document.getElementById(id)?.textContent.includes(txt), [id, txt], { timeout: 6000 });
const probe = (p) =>
p.evaluate(() => ({
anchorName: CSS.supports('anchor-name: --a'),
positionAnchor: CSS.supports('position-anchor: --a'),
positionArea: CSS.supports('position-area: block-end'),
anchorFn: CSS.supports('top: anchor(bottom)'),
tryFallbacks: CSS.supports('position-try-fallbacks: flip-block'),
}));
const geom = (p) =>
p.evaluate(() => {
const r = (el) => { const b = el.getBoundingClientRect(); return { top: b.top, left: b.left, bottom: b.bottom, right: b.right }; };
const pop = document.getElementById('pop');
let open = null; try { open = pop.matches(':popover-open'); } catch {}
return {
open,
btn: r(document.getElementById('anchor-btn')),
pop: r(pop),
rev: pop.getAttribute('data-rev'),
body: document.getElementById('pop-body')?.textContent.trim(),
};
});
const near = (a, b) => Math.abs(a - b) <= TOL;
const gluedBelow = (g) => g.open === true && near(g.pop.top, g.btn.bottom) && near(g.pop.left, g.btn.left);
const gluedAbove = (g) => g.open === true && near(g.pop.bottom, g.btn.top) && near(g.pop.left, g.btn.left);
async function fresh(p) {
await p.goto(BASE + '/');
await waitConnected(p);
await settle(p);
}
async function runSuite(p) {
const R = {};
await fresh(p);
R.probe = await probe(p);
// T1 — anchored open
await jsClick(p, 'anchor-btn');
await p.waitForFunction(() => { try { return document.getElementById('pop').matches(':popover-open'); } catch { return false; } }, null, { timeout: 4000 }).catch(() => {});
await settle(p);
R.t1_open = await geom(p);
R.t1_pass = gluedBelow(R.t1_open);
// T2 — patches while open must not disturb geometry
const before = R.t1_open;
await jsClick(p, 'btn-content'); await waitText(p, 'pop-body', 'item 1');
await jsClick(p, 'btn-attr'); await p.waitForFunction(() => document.getElementById('pop').getAttribute('data-rev') === '1', null, { timeout: 6000 });
await jsClick(p, 'btn-sib'); await waitText(p, 'sib', 'sib 1');
await settle(p);
R.t2_afterPatches = await geom(p);
R.t2_pass =
R.t2_afterPatches.open === true &&
R.t2_afterPatches.body === 'item 1' &&
near(R.t2_afterPatches.pop.top, before.pop.top) &&
near(R.t2_afterPatches.pop.left, before.pop.left) &&
gluedBelow(R.t2_afterPatches);
// T3 — patch moves the anchor; popover must follow (zero JS)
await jsClick(p, 'btn-move');
await p.waitForFunction((prev) => document.getElementById('anchor-btn').getBoundingClientRect().top > prev + 100, before.btn.top, { timeout: 6000 });
await settle(p);
R.t3_afterMove = await geom(p);
R.t3_pass =
R.t3_afterMove.btn.top > before.btn.top + 100 &&
gluedBelow(R.t3_afterMove);
// T4 — anchor near viewport bottom: fallback must flip the popover above
await jsClick(p, 'btn-bottom');
await p.waitForFunction(() => document.getElementById('anchor-btn').getBoundingClientRect().bottom > 600, null, { timeout: 6000 });
await settle(p);
R.t4_bottom = await geom(p);
R.t4_pass = gluedAbove(R.t4_bottom);
// T5 — scroll: popover stays glued to the anchor
await jsClick(p, 'btn-reset');
await p.waitForFunction(() => document.getElementById('anchor-btn').getBoundingClientRect().top < 200, null, { timeout: 6000 });
await settle(p);
await p.evaluate(() => window.scrollTo(0, 100));
await p.waitForFunction(() => window.scrollY === 100, null, { timeout: 4000 });
await settle(p);
R.t5_afterScroll = await geom(p);
R.t5_pass = gluedBelow(R.t5_afterScroll);
return R;
}
const engines = [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]];
const only = process.env.ONLY_BROWSER;
const list = only ? engines.filter(([n]) => n === only) : engines;
const all = {};
for (const [name, launcher] of list) {
console.log('=== ' + name + ' ===');
const b = await launcher.launch();
all[name] = { version: b.version(), runs: [] };
for (let r = 1; r <= 2; r++) {
const ctx = await b.newContext({ viewport: { width: 1024, height: 720 } });
const p = await ctx.newPage();
p.setDefaultTimeout(12000);
try { all[name].runs.push(await runSuite(p)); console.log(`${name} run ${r}: ok`); }
catch (e) { all[name].runs.push({ HARNESS_ERROR: String(e) }); console.log(`${name} run ${r}: HARNESS_ERROR ${e}`); }
await ctx.close();
}
await b.close();
}
writeFileSync(new URL('./positioning-results.json', import.meta.url), JSON.stringify(all, null, 2));
console.log(JSON.stringify(all, null, 2));
// Summary table (measurement, not fuse): exit 1 only on harness malfunction.
let harnessOk = true;
for (const [name, e] of Object.entries(all)) {
for (const run of e.runs) {
if (run.HARNESS_ERROR) { console.error(`HARNESS_ERROR ${name}: ${run.HARNESS_ERROR}`); harnessOk = false; continue; }
const cells = ['t1', 't2', 't3', 't4', 't5'].map((t) => `${t}=${run[`${t}_pass`] ? 'PASS' : 'fail'}`).join(' ');
console.log(`${name} (${e.version}): supports=${JSON.stringify(run.probe)} ${cells}`);
}
}
if (!harnessOk) process.exit(1);
console.log('\n✓ positioning spike measured (see positioning-results.json)');