Skip to content

Commit ce348d6

Browse files
fix(preview): self-heal re-anchor + tight-div nest-out reland (e2e)
Two real-browser bugs found while getting the Hub-Client E2E suite green (e2e had never been run on this branch before the rebase), plus the test corrections needed once the nesting cursor became on-by-default. Production fixes: - outerBlocks.ts findReanchorCandidate: re-anchor CONTENT-first (match the edited slice, nearest offset in EITHER direction) instead of "nearest r[0] >= anchorR0, then content-verify". The offset-only search dropped a block whenever an edit ABOVE it shrank the document and shifted it to a LOWER offset (the click-switch B-drop), and also dropped a block a preceding same-offset edit displaced. This is the long-known self-heal KEEP bug bd-k1evg0g1 — its real-browser tripwire (test.fail) now passes and is un-fixme'd into a green regression guard. - nestingNav.ts relocateSurface: tolerate a small start-line shift when relocating a committed surface. Editing a tight div (Plain body) makes the writer reserialize it loose (Para + blank lines), moving the paragraph down one line; the nest-out reland relocated by the pre-commit (startLine, depth) with an exact-line match, missed the shifted surface, and failed to reland (no edit surface after nest-out). Now prefers the exact match, then the nearest depth-matching surface within a 2-line window. Test corrections (the nesting cursor is now on-by-default, ace639c): - locked-mode tests must pin unlockNestingCursor:false: q2-preview-locked -hover, p2-5b "locked resolution" suite, inline-edit (they assert whole-block / outer-block resolution, which only exists in locked mode). - breadcrumb-geometry 4c/4d: rewrite to the shipped G1 left-spill model (◀ clamps at the page edge for a shallow/zero gutter, crumbs spill into the margin) — the old assertions encoded the superseded pre-G1 "3b" model and were never browser-validated. - breadcrumb-isolation: replace the quarantined wrong-assertion test with a real tight-div nest-out reland guard. New unit coverage: findReanchorCandidate negative-shift case; relocateSurface start-line-shift case. All fail-on-revert verified.
1 parent c38bb5e commit ce348d6

10 files changed

Lines changed: 290 additions & 157 deletions

hub-client/e2e/q2-preview-block-nav-p2-5b.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,20 @@ test.describe('P2.5b — Block navigation & locked resolution (real browser)', (
187187
if (testInfo.workerIndex > 0) {
188188
await page.waitForTimeout(1000);
189189
}
190+
// This suite asserts LOCKED-mode (whole-block / prefixing-atomic)
191+
// resolution. The nesting cursor is ON by default (P3.2, commit
192+
// ace639c8), so pin it OFF here as the suite baseline. The two
193+
// unlock-mode tests (T21a/T21b) register their own addInitScript with
194+
// unlockNestingCursor:true AFTER this beforeEach, so they override it.
195+
await page.addInitScript(() => {
196+
localStorage.setItem('quarto-hub:preferences', JSON.stringify({
197+
version: 1,
198+
scrollSyncEnabled: true,
199+
errorOverlayCollapsed: true,
200+
colorScheme: 'auto',
201+
unlockNestingCursor: false,
202+
}));
203+
});
190204
});
191205

192206
// ── Diagnostic: isOnLastVisualLine geometry ──────────────────────────────

hub-client/e2e/q2-preview-breadcrumb-geometry.spec.ts

Lines changed: 85 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ test.describe('Phase 4 — Breadcrumb chip geometry (real browser)', () => {
433433
// rightmost crumb right no longer meets textarea position → RED.
434434
// -------------------------------------------------------------------------
435435

436-
test('4c — gutter-fill: crumb row spans from column margin to surface left for indented block (guards 3b)', async ({ page }) => {
436+
test('4c — shallow indent: ◀ clamps at the page edge and the crumb band spills left into the margin (G1 left-spill)', async ({ page }) => {
437437
await page.addInitScript(() => {
438438
localStorage.setItem('quarto-hub:preferences', JSON.stringify({
439439
version: 1,
@@ -503,6 +503,7 @@ test.describe('Phase 4 — Breadcrumb chip geometry (real browser)', () => {
503503
const outRect = outArrow.getBoundingClientRect();
504504
const crumbRects = crumbs.map((el) => el.getBoundingClientRect());
505505
return {
506+
outArrowLeft: outRect.left,
506507
outArrowRight: outRect.right,
507508
crumbLeft: Math.min(...crumbRects.map((r) => r.left)),
508509
crumbRight: Math.max(...crumbRects.map((r) => r.right)),
@@ -514,81 +515,79 @@ test.describe('Phase 4 — Breadcrumb chip geometry (real browser)', () => {
514515

515516
console.log(
516517
`4c: colLeft=${colLeft.toFixed(2)}, surfaceLeft=${surfaceLeft.toFixed(2)}, ` +
517-
`outArrowRight=${chipGeom.outArrowRight.toFixed(2)}, ` +
518+
`outArrowLeft=${chipGeom.outArrowLeft.toFixed(2)}, outArrowRight=${chipGeom.outArrowRight.toFixed(2)}, ` +
518519
`crumbLeft=${chipGeom.crumbLeft.toFixed(2)}, crumbRight=${chipGeom.crumbRight.toFixed(2)}`,
519520
);
520521

521-
// (a) ◀ right edge ≈ colLeft (±3px): ◀ sits in the outer margin, flush
522-
// at the text-column left. Guards the margin-anchor positioning logic.
523-
const TOL_COL = 3;
524-
expect(
525-
chipGeom.outArrowRight,
526-
`◀ right (${chipGeom.outArrowRight.toFixed(2)}) must be ≈ colLeft (${colLeft.toFixed(2)}) ±${TOL_COL}px. ` +
527-
`Guards ◀ anchored flush at the text-column margin.`,
528-
).toBeGreaterThanOrEqual(colLeft - TOL_COL);
522+
// G1 left-spill model (NOT the pre-G1 "3b" ◀-at-colLeft model — that was
523+
// superseded in the same commit and never browser-validated). A loose
524+
// list item's indent gutter (here ~34px) is narrower than the comfortable
525+
// 2-crumb band (2·CRUMB_W = 44px), so computeChipGeometry pushes the chip
526+
// LEFT until ◀ clamps at the page edge (chipLeft = 0). See the unit suite
527+
// BreadcrumbChip.geometry.test.ts cases (b)/(c) for the blessed contract.
528+
const TOL = 3;
529+
530+
// (a) ◀ is clamped at the page edge (chipLeft ≈ 0), NOT anchored at colLeft.
531+
// This is THE binding assertion for the left-spill clamp: reverting to
532+
// the ◀-at-colLeft anchor moves outArrowLeft to ≈ colLeft − MIN_GLYPH_W
533+
// (~9.5px), failing this.
529534
expect(
530-
chipGeom.outArrowRight,
531-
`◀ right (${chipGeom.outArrowRight.toFixed(2)}) must be ≈ colLeft (${colLeft.toFixed(2)}) ±${TOL_COL}px. ` +
532-
`Guards ◀ anchored flush at the text-column margin.`,
533-
).toBeLessThanOrEqual(colLeft + TOL_COL);
535+
chipGeom.outArrowLeft,
536+
`◀ left (${chipGeom.outArrowLeft.toFixed(2)}) must be ≈ 0 (clamped at the page edge). ` +
537+
`Guards the G1 left-spill clamp for a shallow indent gutter.`,
538+
).toBeLessThanOrEqual(TOL);
539+
expect(chipGeom.outArrowLeft).toBeGreaterThanOrEqual(-TOL);
534540

535-
// (b) leftmost .q2-crumb left ≈ colLeft (±3px): crumbs never enter the
536-
// outer margin. Guards the crumb band starting at colLeft.
541+
// (b) the crumbs spill LEFT into the outer margin (crumbLeft < colLeft) and
542+
// start right at ◀'s right edge — the band is pushed left of the column.
537543
expect(
538544
chipGeom.crumbLeft,
539-
`leftmost crumb left (${chipGeom.crumbLeft.toFixed(2)}) must be colLeft (${colLeft.toFixed(2)}) ±${TOL_COL}px. ` +
540-
`Guards crumbs staying out of the outer margin.`,
541-
).toBeGreaterThanOrEqual(colLeft - TOL_COL);
545+
`leftmost crumb left (${chipGeom.crumbLeft.toFixed(2)}) must be < colLeft (${colLeft.toFixed(2)}): ` +
546+
`the shallow-gutter band spills into the outer margin (G1).`,
547+
).toBeLessThan(colLeft - TOL);
542548
expect(
543549
chipGeom.crumbLeft,
544-
`leftmost crumb left (${chipGeom.crumbLeft.toFixed(2)}) must be ≈ colLeft (${colLeft.toFixed(2)}) ±${TOL_COL}px. ` +
545-
`Guards crumbs staying out of the outer margin.`,
546-
).toBeLessThanOrEqual(colLeft + TOL_COL);
547-
548-
// (c) rightmost .q2-crumb right ≈ surfaceLeft (±10px): the crumb band meets
549-
// the textarea anchor for indented blocks. Generous tolerance —
550-
// "close in common cases", not pixel-perfect.
551-
// Guards the textarea-anchor: reverting to the region-wrapper anchor →
552-
// surfaceLeft = colLeft (no indent) → crumb right no longer meets textarea.
550+
`leftmost crumb left (${chipGeom.crumbLeft.toFixed(2)}) must meet ◀ right (${chipGeom.outArrowRight.toFixed(2)}).`,
551+
).toBeGreaterThanOrEqual(chipGeom.outArrowRight - TOL);
552+
553+
// (c) the band's right edge still reaches the pivot (surfaceLeft) for this
554+
// shallow-but-nonzero gutter — the band width equals the comfortable
555+
// natural width, which here ≈ the gutter, so it lands at the surface.
553556
const TOL_SURF = 10;
554557
expect(
555558
chipGeom.crumbRight,
556559
`rightmost crumb right (${chipGeom.crumbRight.toFixed(2)}) must be ≈ surfaceLeft (${surfaceLeft.toFixed(2)}) ±${TOL_SURF}px. ` +
557-
`Guards textarea-anchor + gutter band meeting the indented surface.`,
560+
`Guards the band meeting the indented surface (textarea anchor).`,
558561
).toBeGreaterThanOrEqual(surfaceLeft - TOL_SURF);
559-
expect(
560-
chipGeom.crumbRight,
561-
`rightmost crumb right (${chipGeom.crumbRight.toFixed(2)}) must be ≈ surfaceLeft (${surfaceLeft.toFixed(2)}) ±${TOL_SURF}px. ` +
562-
`Guards textarea-anchor + gutter band meeting the indented surface.`,
563-
).toBeLessThanOrEqual(surfaceLeft + TOL_SURF);
562+
expect(chipGeom.crumbRight).toBeLessThanOrEqual(surfaceLeft + TOL_SURF);
564563

565564
await iframe.locator('textarea').first().press('Escape');
566565
});
567566

568567
// -------------------------------------------------------------------------
569-
// 4d — Zero-indent Div: ◀ anchored at colLeft, crumbs stay out of margin.
570-
//
571-
// Fixture: a fenced-Div-wrapped paragraph (zero indent).
572-
// Non-indenting containers (Div) get 0-width indent gutter → the crumb
573-
// band overshoots right into the content (never into the margin).
568+
// 4d — Zero-indent Div under the G1 left-spill model.
574569
//
575-
// New design contract (all coords iframe-relative via .evaluate()):
576-
// - ◀ right edge ≈ colLeft (±3px): ◀ sits in the outer margin, flush at
577-
// the text-column left.
578-
// - leftmost .q2-crumb left ≥ colLeft − 3: crumbs never enter the outer
579-
// margin even for zero-indent blocks.
570+
// Fixture: a fenced-Div-wrapped paragraph (zero indent gutter).
580571
//
581-
// For zero-indent blocks the crumb band's right edge OVERSHOOTS into the
582-
// content (rightmost crumb right > surfaceLeft) — that is correct and
583-
// intentional; do NOT assert meets-surface here.
572+
// G1 design contract (all coords iframe-relative via .evaluate()):
573+
// - ◀ clamps at the page edge: outArrowLeft ≈ 0 (chipLeft = 0). The chip
574+
// cannot fit its comfortable band in a zero gutter, so it is pushed all
575+
// the way left until ◀ hits x=0.
576+
// - crumbs spill LEFT into the outer margin: crumbLeft < colLeft. (A zero-
577+
// indent block's crumbs DO enter the margin — the "where you came from"
578+
// direction; see BreadcrumbChip's layout-model doc.)
579+
// - the band overshoots RIGHT past the pivot into content: crumbRight >
580+
// surfaceLeft (geometry unit-test case (c)).
584581
//
585-
// Confirmed sample numbers (run 2026-06-16):
586-
// colLeft=25.5, ◀=[9.5,25.5], crumbs=[25.5,41.5], surfaceLeft≈25.5 (no indent).
582+
// Real browser sample (run 2026-06-19):
583+
// colLeft=25.5, ◀=[0,16], crumbs=[16,60], surfaceLeft≈25.5 (no indent).
587584
//
588-
// Guards: the ◀-at-colLeft anchor and the crumbs-stay-out-of-margin invariant.
585+
// This REPLACES the pre-G1 "3b" expectations (◀ at colLeft, crumbs out of the
586+
// margin) that the old assertions encoded — superseded in the G1 commit and
587+
// never browser-validated before now.
589588
// -------------------------------------------------------------------------
590589

591-
test('4d — zero-indent Div: ◀ anchored at colLeft, crumbs stay out of margin (guards ◀-anchor + no-margin-spill for crumbs)', async ({ page }) => {
590+
test('4d — zero-indent Div: ◀ clamps at the page edge, crumbs spill left into margin and band overshoots right (G1 left-spill)', async ({ page }) => {
592591
await page.addInitScript(() => {
593592
localStorage.setItem('quarto-hub:preferences', JSON.stringify({
594593
version: 1,
@@ -643,6 +642,7 @@ test.describe('Phase 4 — Breadcrumb chip geometry (real browser)', () => {
643642
const outRect = outArrow.getBoundingClientRect();
644643
const crumbRects = crumbs.map((el) => el.getBoundingClientRect());
645644
return {
645+
outArrowLeft: outRect.left,
646646
outArrowRight: outRect.right,
647647
crumbLeft: Math.min(...crumbRects.map((r) => r.left)),
648648
crumbRight: Math.max(...crumbRects.map((r) => r.right)),
@@ -652,41 +652,52 @@ test.describe('Phase 4 — Breadcrumb chip geometry (real browser)', () => {
652652
expect(chipGeom, 'chip geometry must be non-null').not.toBeNull();
653653
if (!chipGeom) throw new Error('impossible — asserted above');
654654

655-
// surfaceLeft for logging only (zero-indent → ≈ colLeft).
655+
// surfaceLeft (zero-indent Div → ≈ colLeft; the band overshoots right of it).
656656
const surfaceLeft = await iframe.locator('textarea').first().evaluate((el) => {
657657
return el.getBoundingClientRect().left;
658658
});
659659

660660
console.log(
661661
`4d: colLeft=${colLeft.toFixed(2)}, surfaceLeft=${surfaceLeft.toFixed(2)}, ` +
662-
`outArrowRight=${chipGeom.outArrowRight.toFixed(2)}, ` +
662+
`outArrowLeft=${chipGeom.outArrowLeft.toFixed(2)}, outArrowRight=${chipGeom.outArrowRight.toFixed(2)}, ` +
663663
`crumbLeft=${chipGeom.crumbLeft.toFixed(2)}, crumbRight=${chipGeom.crumbRight.toFixed(2)}`,
664664
);
665665

666-
// (i) ◀ right edge ≈ colLeft (±3px): ◀ sits in the outer margin, flush
667-
// at the text-column left. Guards the ◀-at-colLeft anchor logic.
668-
const TOL_COL = 3;
666+
// G1 left-spill model for a ZERO-indent Div (gutter = 0). The comfortable
667+
// band (2·CRUMB_W) cannot fit a zero gutter, so the chip is pushed left
668+
// until ◀ clamps at the page edge (chipLeft = 0) and the band spills RIGHT
669+
// past the pivot into the content (BreadcrumbChip.geometry.test.ts case (c)).
670+
// NB: this REPLACES the pre-G1 "3b" expectations (◀ at colLeft, crumbs
671+
// out of the margin) — those were superseded in the same commit and never
672+
// browser-validated. Under G1, a zero-indent block's crumbs DO enter the
673+
// margin (the "where you came from" direction).
674+
const TOL = 3;
675+
676+
// (i) ◀ is clamped at the page edge (chipLeft ≈ 0), NOT anchored at colLeft.
677+
// Binding assertion for the clamp: the ◀-at-colLeft revert moves
678+
// outArrowLeft to ≈ colLeft − MIN_GLYPH_W (~9.5px), failing this.
669679
expect(
670-
chipGeom.outArrowRight,
671-
`◀ right (${chipGeom.outArrowRight.toFixed(2)}) must be ≈ colLeft (${colLeft.toFixed(2)}) ±${TOL_COL}px. ` +
672-
`Guards ◀ anchored flush at the text-column margin.`,
673-
).toBeGreaterThanOrEqual(colLeft - TOL_COL);
674-
expect(
675-
chipGeom.outArrowRight,
676-
`◀ right (${chipGeom.outArrowRight.toFixed(2)}) must be ≈ colLeft (${colLeft.toFixed(2)}) ±${TOL_COL}px. ` +
677-
`Guards ◀ anchored flush at the text-column margin.`,
678-
).toBeLessThanOrEqual(colLeft + TOL_COL);
679-
680-
// (ii) leftmost .q2-crumb left ≥ colLeft − 3: crumbs do NOT enter the
681-
// outer margin even for zero-indent blocks. The gutter is zero so the
682-
// band overshoots right (into content), never left.
683-
// Guards: crumbs-stay-out-of-margin invariant.
680+
chipGeom.outArrowLeft,
681+
`◀ left (${chipGeom.outArrowLeft.toFixed(2)}) must be ≈ 0 (clamped at the page edge) for a zero-indent block.`,
682+
).toBeLessThanOrEqual(TOL);
683+
expect(chipGeom.outArrowLeft).toBeGreaterThanOrEqual(-TOL);
684+
685+
// (ii) the crumbs spill LEFT into the outer margin (crumbLeft < colLeft) —
686+
// the zero gutter forces the band left of the column.
684687
expect(
685688
chipGeom.crumbLeft,
686-
`leftmost crumb left (${chipGeom.crumbLeft.toFixed(2)}) must be ≥ colLeft (${colLeft.toFixed(2)}) − 3. ` +
687-
`Crumbs must never enter the outer page margin. ` +
688-
`For zero-indent blocks the gutter overshoots right (into content), not left.`,
689-
).toBeGreaterThanOrEqual(colLeft - 3);
689+
`leftmost crumb left (${chipGeom.crumbLeft.toFixed(2)}) must be < colLeft (${colLeft.toFixed(2)}): ` +
690+
`a zero-indent block's band spills into the outer margin (G1).`,
691+
).toBeLessThan(colLeft - TOL);
692+
693+
// (iii) the band overshoots RIGHT past the pivot into the content
694+
// (crumbRight > surfaceLeft) — the comfortable width has nowhere to go
695+
// but right once ◀ is pinned at x=0.
696+
expect(
697+
chipGeom.crumbRight,
698+
`rightmost crumb right (${chipGeom.crumbRight.toFixed(2)}) must overshoot surfaceLeft (${surfaceLeft.toFixed(2)}): ` +
699+
`the comfortable band spills right past the pivot for a zero gutter (G1 case c).`,
700+
).toBeGreaterThan(surfaceLeft + TOL);
690701

691702
await iframe.locator('textarea').first().press('Escape');
692703
});

0 commit comments

Comments
 (0)