diff --git a/CLAUDE.md b/CLAUDE.md
index 89c6e33..0390933 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,6 +29,7 @@ Modules under `src/`:
- **annotate.xsl** — the unified right-click dispatcher (edit vs create), `local:apply-annotation` (single write path for RDFa attributes), shared `local:wrap-range`/`local:unwrap-element` (used by annotations AND inline formatting), invalid-selection flash, the shared output modal (`local:show-output`).
- **edit.xsl** — the XHTML editor. **Recursive editability init** (`local:init-block`): an element is a *text host* (contenteditable) while its content model allows text and it holds no block children; mixed flow containers (`li`/`td`/`dd`/… with blocks) and structural containers (blockquote, lists, figure, table) lock their own markup and recurse into their parts (`figure` is always composite: image island + caption host); `img` never editable but given `tabindex="-1"` so a block image is a focusable **navigation island**. **Run wrappers**: the stray inline runs of a mixed flow container (`
` is foster-parented out by the HTML fragment parser on `innerHTML` restore, so `local:restore-snapshot` drops stray region-child chrome and re-injects (idempotent) before caret resolution.
+- **select.xsl** — region-scoped select-all and cross-host selection delete (dispatch lives in edit.xsl's keydown/body/paste templates, mirroring the tables.xsl split). **Two-stage Ctrl/Cmd+A** (Docs-style): stage 1 stays native — the browser scopes select-all to the focused host; stage 2 (host already fully selected per the chrome-aware `local:at-*` probes, or empty, or the selection already spans hosts) selects all blocks of the region as a **document-level range** (`local:select-region`) — it paints natively across host boundaries and never reaches the host page. Ctrl+A away from a caret also selects editor content, never the page: from the body it targets the swept region, else `local:active-root()` (selection anchor → last focused host → first region), and a focused image island is its own fully-selected unit (stage 2 directly, with Backspace/Delete then running the delete machine instead of the island's figure-delete); native page select-all happens only when no region exists. One **delete machine** (`local:delete-cross-host-selection`) serves stage-2 selections and mouse sweeps alike (`local:selection-crosses-hosts()` gates it), fired by Backspace/Delete from host *or* body focus — a sweep from the page background leaves focus on body. Deletion is **block-granular**, never one raw `deleteContents` across the range: fully covered blocks are removed whole; partial edge hosts get sub-range deletes scoped *inside* the host; composites holding a range boundary never lose structure — their covered cells are cleared and flow cells collapse back to text hosts (`local:clear-host`; B3/B4 doctrine at partial coverage) while a *fully* covered composite is removed whole; non-composite edge remnants merge Docs-style via `local:merge-into-previous` with the caret at the seam (never with `pre` — B6); emptied structural containers are pruned (`local:prune-husks`), chrome is re-injected (idempotent), and an emptied region is reseeded with a fresh `p` host (`local:seed-region`). The range is **clamped to a single region** (the start's, else the first swept) — one gesture, one region-keyed undo entry; other regions stay byte-identical. Typing/Enter/Tab/paste over a cross-host selection are suppressed (type-to-replace is future work); Ctrl+C and plain arrows stay native.
- **undo.xsl** — unified snapshot undo/redo over `#content` innerHTML: every mutating handler calls `local:push-undo` first and `local:after-mutation` last; plain typing coalesces into ~1s bursts via `ixsl:onbeforeinput`. Ctrl/Cmd+Z / Shift+Z / Ctrl+Y are intercepted (native undo is replaced). Stacks live in a hidden DOM stash (`#undo-storage`, `data-role="storage"`) — JS arrays don't survive the IXSL boundary and sequence-valued window properties keep only their first item. Caret restoration after undo is approximate (first host) by design.
- **navigate.xsl** — FontoXML-inspired navigation: ToC drawer (recursive `for-each-group group-starting-with` outline, click-to-jump, section drag-reorder via `local:section-of`), breadcrumb bar (element path + `rdfa:in-scope-subject` at the caret), lint surfacing (markers + badge + issues modal), find & replace (single-text-node matches, `nodeValue`-rewrite replace-all — annotation-safe by construction).
- **lint-rdfa.xsl** — RDFa lint logic. **Pure XSLT**, tested headless via `tests/lint-driver.xsl` (`xsl:import` resolves the output-method conflict). Reuses the extractor's resolution functions so lint verdicts can't drift from extraction semantics. Checks: term-unresolvable, empty-href, content-resource-conflict, empty-literal, about-relative.
@@ -65,6 +66,6 @@ Run `make sef` after any XSLT change; run the tests after any extractor change.
- **Undo contract**: every mutating handler pushes a snapshot first (`local:push-undo`, optionally with a pre-captured `$snapshot` when the operation can fail) and calls `local:after-mutation` last — never push from shared primitives. Undo/redo restore invalidates all node-valued window properties (cleared in `local:restore-snapshot`) and closes overlay/dialogs. The caret rides along on stash entries as (block, text-node, offset) data attributes and is restored on undo/redo.
- **Content markers**: anything written into `#content` for UI purposes must be `@class` (or `aria-*`) only — both are canonicalization-stripped. `@title` is NOT stripped and is forbidden as a marker.
- **SaxonJS gotchas**: computed numeric predicates (`[xs:integer(...)]`) are evaluated as booleans in some contexts — bind to a variable and use the bare `[$index]` form. JS arrays returned by `ixsl:call` marshal to XDM sequences (empty array = empty sequence). Large nested map literals fail the compiler with an internal assertion — build them with `map:merge` over `map:entry` (see `$cm:model`).
-- Browser tests live in `tests/browser/` (`npm run test:browser` self-serves the repo): `editor.mjs`, `features.mjs`, `fixes.mjs`, `hardening.mjs`, `multiinstance.mjs`, `tables.mjs`, `datatype.mjs`, `inspector.mjs`, `nesting.mjs` (content-model foundation), `authoring.mjs` (nesting gestures) and `invariants.mjs` (the cross-product net: a uniform gesture battery in every caret context asserting properties that must always hold — no orphan text outside an editable host, no nested hosts, chrome top-level only, run wrappers editable, zero lint issues, undo restores the exact baseline), all against `tests/fixture-nesting.html`. Scenario tests assert *semantics* (where things land); the invariant suite catches *validity/editability/undo* regressions in combinations nobody enumerated. CI runs both headless loops and the browser suites (`.github/workflows/ci.yml`).
+- Browser tests live in `tests/browser/` (`npm run test:browser` self-serves the repo): `editor.mjs`, `features.mjs`, `fixes.mjs`, `hardening.mjs`, `multiinstance.mjs`, `tables.mjs`, `datatype.mjs`, `inspector.mjs`, `nesting.mjs` (content-model foundation), `authoring.mjs` (nesting gestures), `select.mjs` (two-stage Ctrl+A, cross-host sweep delete, composite/clamp semantics) and `invariants.mjs` (the cross-product net: a uniform gesture battery in every caret context asserting properties that must always hold — no orphan text outside an editable host, no nested hosts, chrome top-level only, run wrappers editable, zero lint issues, undo restores the exact baseline), all against `tests/fixture-nesting.html`. Scenario tests assert *semantics* (where things land); the invariant suite catches *validity/editability/undo* regressions in combinations nobody enumerated. CI runs both headless loops and the browser suites (`.github/workflows/ci.yml`).
- **Sanitization**: `canonical-xhtml.xsl` is the storage boundary — it drops script/style/iframe/object/embed/applet/form controls/link/meta/base subtrees, comments/PIs, all `on*` attributes, and `javascript:`/`vbscript:`/`data:` URLs (`data:image/*` allowed in `@src`). HTML paste goes through this same mode. Lint mirrors these as `unsafe-attribute`/`unsafe-url`.
- Editor-contract CSS lives in `rdfa-editor.css` (host pages include it; the container needs ~2.5em left padding for the gutter handles); `index.html` keeps only demo styles.
diff --git a/index.html b/index.html
index fe3c264..73a33b3 100644
--- a/index.html
+++ b/index.html
@@ -280,6 +280,14 @@ Editing
block, and Backspace at the start of a block to merge it
with the one above.
+ Selecting and deleting
+ Press Ctrl/Cmd+A to select the current block's text;
+ press it again to select the whole document (never the surrounding
+ page). You can also drag a selection across several blocks with the
+ mouse. Backspace or Delete removes
+ whatever is selected, and Ctrl/Cmd+Z undoes any
+ change.
+
The toolbar
Use the toolbar (top-left) to change a block's type, make text
bold or italic, add links, lists, images and
diff --git a/src/edit.xsl b/src/edit.xsl
index 1ecb9d4..d06611d 100644
--- a/src/edit.xsl
+++ b/src/edit.xsl
@@ -553,6 +553,22 @@ version="3.0">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -678,23 +711,50 @@ version="3.0">
-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -707,6 +767,19 @@ version="3.0">
+
+
+
+
+
+
+
+
+
@@ -734,6 +807,12 @@ version="3.0">
undo covers accidents); a bare island in a mixed container is
deleted alone, never its whole list/table. The caret lands on
the previous target for Backspace, the next for Delete -->
+
+
+
+
+
+
+
diff --git a/src/index.xsl b/src/index.xsl
index 1c8955b..1f2295c 100644
--- a/src/index.xsl
+++ b/src/index.xsl
@@ -23,6 +23,7 @@ version="3.0">
- annotate.xsl right-click dispatch, apply/remove annotation, extraction modal
- edit.xsl XHTML editing: blocks, keyboard, toolbar, dialogs, drag-and-drop
- tables.xsl table blocks: insert dialog, row/column operations, cell traversal
+ - select.xsl region-scoped select-all and cross-host selection delete
-->
@@ -36,6 +37,7 @@ version="3.0">
+
diff --git a/src/select.xsl b/src/select.xsl
new file mode 100644
index 0000000..45ea91a
--- /dev/null
+++ b/src/select.xsl
@@ -0,0 +1,366 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/browser/run.mjs b/tests/browser/run.mjs
index c02c694..16bd8f0 100644
--- a/tests/browser/run.mjs
+++ b/tests/browser/run.mjs
@@ -25,7 +25,7 @@ const base = `http://localhost:${server.address().port}`;
console.log(`serving ${root} at ${base}`);
let failed = 0;
-for (const suite of ['editor', 'features', 'fixes', 'hardening', 'multiinstance', 'tables', 'datatype', 'inspector', 'nesting', 'authoring', 'invariants']) {
+for (const suite of ['editor', 'features', 'fixes', 'hardening', 'multiinstance', 'tables', 'datatype', 'inspector', 'nesting', 'authoring', 'invariants', 'select']) {
console.log(`\n=== ${suite} ===`);
const code = await new Promise(resolve => {
const child = spawn(process.execPath, [fileURLToPath(new URL(`${suite}.mjs`, import.meta.url))],
diff --git a/tests/browser/select.mjs b/tests/browser/select.mjs
new file mode 100644
index 0000000..2cbfec0
--- /dev/null
+++ b/tests/browser/select.mjs
@@ -0,0 +1,376 @@
+// Region-scoped select-all and cross-host selection delete: two-stage Ctrl/Cmd+A
+// (native in-host stage 1, whole-region stage 2, never the host page), one delete
+// machine for stage-2 and mouse-sweep selections (Backspace/Delete from a host or
+// from body), Google-Docs edge-remnant merge, composite (table/figure) grid
+// preservation under partial sweeps, cross-region clamping, gesture suppression
+// (typing/Enter/paste inert over a cross-host selection), and the I1-I5 invariants
+// plus exact one-undo baseline restore after every mutating case
+// (fixture-nesting.html).
+import { chromium } from 'playwright';
+
+const BASE = process.env.BASE_URL ?? 'http://localhost:8080';
+
+const errors = [];
+const results = {};
+const browser = await chromium.launch();
+const page = await browser.newPage();
+page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
+page.on('pageerror', err => errors.push(String(err)));
+page.on('dialog', d => d.accept());
+
+// stage 1 relies on the browser's NATIVE select-all, which follows the platform
+// modifier (CI is Linux; local macOS needs Meta) - the dispatcher accepts both
+const selectAllKey = (process.platform === 'darwin' ? 'Meta' : 'Control') + '+a';
+const undoKey = 'Control+z';
+
+const load = async () => {
+ await page.goto(BASE + '/tests/fixture-nesting.html');
+ await page.waitForSelector('#content > * > [data-role=chrome]', { state: 'attached', timeout: 15000 })
+ .catch(() => errors.push('chrome never injected'));
+};
+// caret inside the editable host whose direct text carries the needle
+const caretIn = (needle, offset = 2) => page.evaluate(([txt, off]) => {
+ const host = [...document.querySelectorAll('#content [contenteditable=true]')]
+ .find(el => [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.includes(txt)));
+ if (!host) return false;
+ host.focus();
+ const t = [...host.childNodes].find(n => n.nodeType === 3 && n.textContent.includes(txt));
+ window.getSelection().collapse(t, off === -1 ? t.textContent.length : off);
+ return true;
+}, [needle, offset]);
+// a document-level range between two text offsets, located by needle (any region)
+const sweep = (fromNeedle, fromOffset, toNeedle, toOffset) => page.evaluate(([fn, fo, tn, to]) => {
+ const textIn = needle => {
+ const host = [...document.querySelectorAll('[contenteditable=true]')]
+ .find(el => [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.includes(needle)));
+ return [...host.childNodes].find(n => n.nodeType === 3 && n.textContent.includes(needle));
+ };
+ const r = document.createRange();
+ r.setStart(textIn(fn), fo);
+ r.setEnd(textIn(tn), to);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(r);
+ return sel.toString().length > 0;
+}, [fromNeedle, fromOffset, toNeedle, toOffset]);
+
+// the I1-I5 net from invariants.mjs
+const invariants = () => page.evaluate(() => {
+ const v = [];
+ const region = document.getElementById('content');
+ const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
+ for (let n; (n = walker.nextNode());) {
+ if (!n.textContent.trim()) continue;
+ if (n.parentElement.closest('[data-role]')) continue;
+ if (!n.parentElement.closest('[contenteditable=true]'))
+ v.push('orphan text: "' + n.textContent.trim().slice(0, 30) + '"');
+ }
+ for (const h of region.querySelectorAll('[contenteditable=true] [contenteditable=true]'))
+ v.push('nested host: ' + h.tagName.toLowerCase());
+ for (const c of region.querySelectorAll('[data-role=chrome]'))
+ if (c.parentElement.parentElement !== region)
+ v.push('nested chrome in: ' + c.parentElement.tagName.toLowerCase());
+ for (const r of region.querySelectorAll('.rdfa-editor-run')) {
+ if (r.tagName !== 'P') v.push('run wrapper is ' + r.tagName.toLowerCase());
+ if (r.getAttribute('contenteditable') !== 'true') v.push('uneditable run wrapper');
+ if (r.parentElement === region) v.push('top-level run wrapper');
+ }
+ if (document.getElementById('lint-badge').style.display !== 'none')
+ v.push('lint: ' + document.getElementById('lint-badge').textContent);
+ return v;
+});
+const baselineOf = () => page.evaluate(() => document.getElementById('content').innerHTML);
+// every mutating case must satisfy the invariants and unwind with ONE undo
+const settle = async (name, baseline) => {
+ const v = await invariants();
+ if (v.length) errors.push(`${name} invariants: ${v.join('; ')}`);
+ await page.keyboard.press(undoKey);
+ return page.evaluate(html => document.getElementById('content').innerHTML === html, baseline);
+};
+
+// ==== A. two-stage Ctrl/Cmd+A =======================================================
+await load();
+await caretIn('Intro paragraph', 3);
+await page.keyboard.press(selectAllKey);
+results.stage1 = await page.evaluate(() => {
+ const sel = window.getSelection();
+ const r = sel.getRangeAt(0);
+ const host = n => (n.nodeType === 3 ? n.parentElement : n).closest('[contenteditable=true]');
+ return {
+ selectsHostText: sel.toString() === 'Intro paragraph.',
+ confined: host(r.startContainer) === host(r.endContainer),
+ };
+});
+
+await page.keyboard.press(selectAllKey);
+results.stage2 = await page.evaluate(() => {
+ const sel = window.getSelection();
+ const r = sel.getRangeAt(0);
+ const region = document.getElementById('content');
+ return {
+ regionLevel: r.startContainer === region && r.endContainer === region,
+ coversFirst: sel.toString().includes('Nesting demo'),
+ coversLast: sel.toString().includes('caption for gestures'),
+ excludesEmbedded: !sel.toString().includes('Embedded paragraph'),
+ excludesHostPage: !sel.toString().includes('host item before'),
+ };
+});
+
+await page.keyboard.press(selectAllKey);
+results.stage2Idempotent = await page.evaluate(() => {
+ const r = window.getSelection().getRangeAt(0);
+ const region = document.getElementById('content');
+ return { still: r.startContainer === region && r.endContainer === region };
+});
+
+// an empty host escalates on the first press (nothing to select in-block)
+await load();
+await caretIn('Intro paragraph', 3);
+await page.keyboard.press(selectAllKey);
+await page.keyboard.press('Backspace'); // within-host full selection: native delete empties the host
+await page.keyboard.press(selectAllKey);
+results.emptyHostEscalates = await page.evaluate(() => {
+ const r = window.getSelection().getRangeAt(0);
+ const region = document.getElementById('content');
+ return { regionLevel: r.startContainer === region && r.endContainer === region };
+});
+
+// ==== B. stage 2 + Backspace empties and reseeds the region =========================
+await load();
+const baselineB = await baselineOf();
+await caretIn('Intro paragraph', 3);
+await page.keyboard.press(selectAllKey);
+await page.keyboard.press(selectAllKey);
+await page.keyboard.press('Backspace');
+results.deleteAll = await page.evaluate(() => {
+ const region = document.getElementById('content');
+ const blocks = [...region.children].filter(b => !b.hasAttribute('data-role'));
+ const sel = window.getSelection();
+ return {
+ seeded: blocks.length === 1 && blocks[0].tagName === 'P'
+ && blocks[0].getAttribute('contenteditable') === 'true'
+ && !!blocks[0].querySelector(':scope > [data-role=chrome]')
+ && !!blocks[0].querySelector(':scope > br'),
+ caretInSeed: sel.rangeCount === 1 && sel.isCollapsed && blocks[0].contains(sel.anchorNode),
+ embeddedIntact: document.getElementById('embedded').textContent.includes('Embedded paragraph'),
+ };
+});
+results.deleteAll.undoRestores = await settle('deleteAll', baselineB);
+
+// ==== C. sweep delete with edge-remnant merge (host-focus route) ====================
+await load();
+const baselineC = await baselineOf();
+await sweep('Intro paragraph', 6, 'Plain item', 6); // "Intro |paragraph." -> "Plain |item"
+await page.keyboard.press('Backspace');
+results.sweepMerge = await page.evaluate(() => {
+ const region = document.getElementById('content');
+ const p = [...region.querySelectorAll(':scope > p')].find(x => x.textContent.includes('Intro'));
+ const items = [...region.querySelector(':scope > ul').children].filter(c => c.tagName === 'LI');
+ const sel = window.getSelection();
+ return {
+ merged: !!p && p.textContent.replace(/⠿/g, '') === 'Intro item',
+ mergedItemGone: items.length === 2 && items[0].textContent.includes('Mixed item'),
+ caretAtSeam: sel.isCollapsed && sel.anchorNode.textContent === 'item' && sel.anchorOffset === 0,
+ };
+});
+results.sweepMerge.undoRestores = await settle('sweepMerge', baselineC);
+
+// Delete-key parity
+await load();
+await sweep('Intro paragraph', 6, 'Plain item', 6);
+await page.keyboard.press('Delete');
+results.deleteKeyParity = await page.evaluate(() => {
+ const p = [...document.querySelectorAll('#content > p')].find(x => x.textContent.includes('Intro'));
+ return { merged: !!p && p.textContent.replace(/⠿/g, '') === 'Intro item' };
+});
+results.deleteKeyParity.undoRestores = await settle('deleteKeyParity', baselineC);
+
+// same-list li -> li: items merge, the nested list keeps one item
+await load();
+const baselineC2 = await baselineOf();
+await sweep('Sub one', 5, 'Sub two', 4);
+await page.keyboard.press('Backspace');
+results.sameListMerge = await page.evaluate(() => {
+ const nested = document.querySelector('#content > ul > li > ul');
+ const items = nested ? [...nested.children].filter(c => c.tagName === 'LI') : [];
+ return { oneItem: items.length === 1, mergedText: items[0]?.textContent === 'Sub otwo' };
+});
+results.sameListMerge.undoRestores = await settle('sameListMerge', baselineC2);
+
+// ==== D. body-focus route: region-level sweep, whole blocks removed ================
+await load();
+const baselineD = await baselineOf();
+await page.evaluate(() => {
+ if (document.activeElement) document.activeElement.blur();
+ const region = document.getElementById('content');
+ const kids = [...region.childNodes];
+ const p = [...region.children].find(b => b.textContent.includes('Intro paragraph'));
+ const ul = [...region.children].find(b => b.tagName === 'UL');
+ const r = document.createRange();
+ r.setStart(region, kids.indexOf(p));
+ r.setEnd(region, kids.indexOf(ul) + 1);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(r);
+});
+results.bodyRoute = { bodyFocused: await page.evaluate(() => document.activeElement === document.body) };
+await page.keyboard.press('Backspace');
+Object.assign(results.bodyRoute, await page.evaluate(() => {
+ const region = document.getElementById('content');
+ const sel = window.getSelection();
+ return {
+ blocksGone: !region.textContent.includes('Intro paragraph') && !region.textContent.includes('Plain item'),
+ neighborsIntact: region.textContent.includes('Nesting demo')
+ && region.textContent.includes('Bare quote text'),
+ caretLanded: sel.rangeCount === 1 && sel.isCollapsed && region.contains(sel.anchorNode),
+ };
+}));
+results.bodyRoute.undoRestores = await settle('bodyRoute', baselineD);
+
+// ==== E. composites: partial sweeps clear cells, never the grid ====================
+// "af|ter" paragraph -> "Plain |cell": the container cell (Cell para + cell item)
+// is fully covered - cleared and collapsed to a text host; the boundary cell keeps
+// its remnant; the grid survives; no merge across the table
+await load();
+const baselineE = await baselineOf();
+await sweep('after', 2, 'Plain cell', 6);
+await page.keyboard.press('Backspace');
+results.compositePartial = await page.evaluate(() => {
+ const region = document.getElementById('content');
+ const table = region.querySelector(':scope > table');
+ const cells = table ? [...table.querySelectorAll('td')] : [];
+ const headP = [...region.querySelectorAll(':scope > p')].find(x => x.textContent.replace(/⠿/g, '') === 'af');
+ const sel = window.getSelection();
+ return {
+ gridIntact: !!table && cells.length === 2 && !!table.querySelector('tbody > tr'),
+ containerCellCollapsed: cells[0]?.getAttribute('contenteditable') === 'true'
+ && !cells[0]?.querySelector('p, ul') && !!cells[0]?.querySelector('br'),
+ boundaryCellRemnant: cells[1]?.textContent === 'cell',
+ headRemnantKept: !!headP,
+ noMerge: !headP?.textContent.includes('cell'),
+ caretAtHeadEnd: sel.isCollapsed && !!headP && headP.contains(sel.anchorNode),
+ };
+});
+results.compositePartial.undoRestores = await settle('compositePartial', baselineE);
+
+// fully covered composite goes whole; a boundary inside the NEXT composite only
+// clears content: "Quote |in item" -> "A caption |for gestures" removes the table
+// but keeps figure, image and the caption remnant
+await load();
+const baselineE2 = await baselineOf();
+await sweep('Quote in item', 6, 'A caption for gestures', 10);
+await page.keyboard.press('Backspace');
+results.compositeFull = await page.evaluate(() => {
+ const region = document.getElementById('content');
+ const figure = region.querySelector(':scope > figure');
+ const quoteP = [...region.querySelectorAll('li blockquote p')]
+ .find(x => x.textContent.replace(/⠿/g, '').startsWith('Quote'));
+ return {
+ tableGone: !region.querySelector(':scope > table'),
+ middleBlocksGone: !region.textContent.includes('Bare quote text')
+ && !region.textContent.includes('parser-fostered'),
+ figureKept: !!figure && !!figure.querySelector('img'),
+ captionRemnant: figure?.querySelector('figcaption')?.textContent === 'for gestures',
+ headRemnant: quoteP?.textContent.replace(/⠿/g, '') === 'Quote ',
+ noMergeAcrossFigure: !quoteP?.textContent.includes('for gestures'),
+ };
+});
+results.compositeFull.undoRestores = await settle('compositeFull', baselineE2);
+
+// ==== F. cross-region clamp: a sweep leaking into another region ===================
+await load();
+const baselineF = await baselineOf();
+const embeddedBefore = await page.evaluate(() => document.getElementById('embedded').innerHTML);
+await sweep('A caption for gestures', 2, 'Embedded quote', 6);
+await page.keyboard.press('Backspace');
+results.crossRegionClamp = await page.evaluate(html => {
+ const region = document.getElementById('content');
+ const figure = region.querySelector(':scope > figure');
+ return {
+ embeddedByteIdentical: document.getElementById('embedded').innerHTML === html,
+ captionTruncated: figure?.querySelector('figcaption')?.textContent === 'A ',
+ imageKept: !!figure?.querySelector('img'),
+ };
+}, embeddedBefore);
+results.crossRegionClamp.undoRestores = await settle('crossRegionClamp', baselineF);
+
+// ==== G. suppression: only Backspace/Delete act on a cross-host selection ==========
+await load();
+await caretIn('Intro paragraph', 3);
+await page.keyboard.press(selectAllKey);
+await page.keyboard.press(selectAllKey);
+const beforeSuppress = await baselineOf();
+await page.keyboard.type('x');
+await page.keyboard.press('Enter');
+await page.keyboard.press('Tab');
+await page.evaluate(() => {
+ const dt = new DataTransfer();
+ dt.setData('text/html', 'pasted
');
+ dt.setData('text/plain', 'pasted');
+ document.querySelector('#content [contenteditable=true]').dispatchEvent(
+ new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
+});
+results.suppression = {
+ inert: await page.evaluate(html => document.getElementById('content').innerHTML === html, beforeSuppress),
+ selectionSurvives: await page.evaluate(() => {
+ const r = window.getSelection().getRangeAt(0);
+ return r.startContainer === document.getElementById('content');
+ }),
+};
+
+// ==== H. Ctrl+A away from any caret selects the editor, never the page =============
+// fresh load, focus on body, no selection: the first region is selected
+await load();
+await page.evaluate(() => {
+ if (document.activeElement) document.activeElement.blur();
+ window.getSelection().removeAllRanges();
+});
+await page.keyboard.press(selectAllKey);
+results.bodyCtrlA = await page.evaluate(() => {
+ const sel = window.getSelection();
+ const r = sel.getRangeAt(0);
+ const region = document.getElementById('content');
+ return {
+ selectsFirstRegion: r.startContainer === region && r.endContainer === region,
+ excludesHostPage: !sel.toString().includes('host item before'),
+ };
+});
+
+// the last engaged region wins (local:active-root activeBlock precedence)
+await load();
+await page.evaluate(() => {
+ const host = [...document.querySelectorAll('#embedded [contenteditable=true]')]
+ .find(el => el.textContent.includes('Embedded paragraph'));
+ host.focus();
+ document.activeElement.blur();
+ window.getSelection().removeAllRanges();
+});
+await page.keyboard.press(selectAllKey);
+results.bodyCtrlAActiveRegion = await page.evaluate(() => {
+ const r = window.getSelection().getRangeAt(0);
+ const embedded = document.getElementById('embedded');
+ return { selectsEngagedRegion: r.startContainer === embedded && r.endContainer === embedded };
+});
+
+// ==== I. focused image island: Ctrl+A is stage 2, Backspace then runs the machine ==
+await load();
+const baselineI = await baselineOf();
+await page.evaluate(() => document.querySelector('#content figure img').focus());
+await page.keyboard.press(selectAllKey);
+results.imageCtrlA = await page.evaluate(() => {
+ const r = window.getSelection().getRangeAt(0);
+ const region = document.getElementById('content');
+ return { selectsRegion: r.startContainer === region && r.endContainer === region };
+});
+await page.keyboard.press('Backspace');
+results.imageCtrlA.deletesSelection = await page.evaluate(() => {
+ const region = document.getElementById('content');
+ const blocks = [...region.children].filter(b => !b.hasAttribute('data-role'));
+ return blocks.length === 1 && blocks[0].tagName === 'P'; // seeded, not figure-only delete
+});
+results.imageCtrlA.undoRestores = await settle('imageCtrlA', baselineI);
+
+console.log(JSON.stringify({ results, errors: errors.slice(0, 5) }, null, 2));
+await browser.close();
+const flat = JSON.stringify(results);
+process.exit(errors.length || flat.includes('false') ? 1 : 0);