fix(web): render the Rich/Markdown mode toggle on the peeking side too (BUG-2263 follow-up) (#988)

The invisible-freeze work (PR #987) left ONE surface still gated on
`!peeking`: the editor's Rich⇄Markdown mode toggle. It was hidden on the
passive preview because it's a provider-LIFECYCLE control — switching to
Markdown nulls collabKey and DESTROYS the retained collab provider, which
retain-alive (D2) forbids WHILE peeking.

But under focus-follows-editing a click on the toggle fires the host's
pointerdown-capture activator FIRST, flipping activePane to that side
(peeking=false) before the click's onclick runs. So by the time the flip
executes, the side is already ACTIVE and tearing down its own provider is
normal active-side behavior — the "teardown while peeking" the gate feared
can't happen via a click. The onclick's existing `if (peeking) return`
guards (plus the `|| peeking` mid-flush rechecks) remain as the backstop
for a re-peek DURING the async flush (e.g. the user clicks the other side
mid-flip).

So drop the `{#if !peeking}` render gate — the toggle now renders on both
sides like every other invisible-freeze surface. Verified in the browser:
opening the pane shows the toggle on the peeking preview; clicking the
peeking master's "Markdown" button activates it and flips to raw mode in
ONE gesture, ProseMirror unmounts cleanly, exactly one typeable editor
throughout, zero console errors.

Tests: FreezeProbe renders mode-toggle unconditionally; masterFreeze
asserts it present while peeking; new host e2e opens the pane, confirms the
toggle on the peeking side, and asserts the one-gesture flip (a successful
flip proves activation preceded the guarded onclick).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
This commit is contained in:
xarmian
2026-07-20 20:06:13 -04:00
committed by GitHub
parent 29e49e4c63
commit d14bceb3e2
4 changed files with 73 additions and 17 deletions
+50
View File
@@ -339,6 +339,56 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => {
await expect.poll(handleDisplay, { timeout: 3000 }).not.toBe('none');
});
test('the Rich/Markdown mode toggle renders on the peeking side and flips in ONE gesture (BUG-2263 follow-up)', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await browserLogin(page);
const master = await seedDocWithContent(fixture, request, 'FP mode-toggle master', 'Master body paragraph.');
const related = await seedDoc(fixture, request, 'FP mode-toggle related');
await seedRelatedLink(fixture, request, master.slug, related.id);
const relatedRef = await itemRef(fixture, request, related.slug);
await page.goto(fullPageUrl(fixture, master.slug));
const masterCol = page.locator('.item-page-host > .item-page');
const masterEditor = masterCol.locator('.editor-wrapper .ProseMirror');
const masterToggle = masterCol.locator('.editor-mode-toggle');
await expect(masterEditor).toHaveAttribute('contenteditable', 'true');
await expect(masterToggle).toBeVisible();
// Open the pane → the master stays active (DR-2); the pane is a read-only
// preview. NEW (BUG-2263 follow-up): the peeking PANE shows the mode toggle —
// previously it was hidden on the peeking side.
await page
.locator('.relationship-group', { hasText: 'Related' })
.locator('a.link-target', { hasText: 'FP mode-toggle related' })
.click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
await expect.poll(() => openItemParam(page)).toBe(relatedRef);
const paneEditor = pane.locator('.editor-wrapper .ProseMirror');
await expect(pane.locator('.editor-mode-toggle')).toBeVisible();
await expect(paneEditor).toHaveAttribute('contenteditable', 'false');
// Click into the pane → the MASTER becomes the peeking side; its toggle stays.
await paneEditor.click();
await expect(masterEditor).toHaveAttribute('contenteditable', 'false');
await expect(masterToggle).toBeVisible();
// ONE GESTURE: click the peeking master's "Markdown" button. Its onclick bails
// on `if (peeking) return`, so a successful flip PROVES the pointerdown
// activator un-peeked the master FIRST, in the same gesture.
await masterCol.locator('.editor-mode-toggle .mode-btn', { hasText: 'Markdown' }).click();
await expect(masterCol.locator('.editor-mode-toggle .mode-btn', { hasText: 'Markdown' })).toHaveClass(/active/);
// Master is now in raw markdown mode: the ProseMirror unmounted, and the pane
// is the frozen side — exactly one typeable editor throughout.
await expect(masterEditor).toHaveCount(0);
await expect(paneEditor).toHaveAttribute('contenteditable', 'false');
});
test('a cold-loaded `?item=<the master itself>` is stripped — never mounts a pane on the master', async ({
page,
fixture,
@@ -4235,12 +4235,14 @@
Editor, EditorBubbleMenu, provider, collabKey and SSE stay
persistent across an A→B item switch (the no-{#key} perf premise). -->
<div class="content-panel">
<!-- Master-freeze (TASK-2172): the Rich⇄Markdown toggle is a
provider-LIFECYCLE control — switching to Markdown flushes,
sets rawMode, nulls collabKey and DESTROYS the retained
provider (violating retain-alive). Hide it entirely while
peeking; the onclick guards below are belt-and-suspenders. -->
{#if !peeking}
<!-- BUG-2263 invisible freeze: the Rich⇄Markdown toggle renders on
BOTH sides. It IS a provider-LIFECYCLE control (Markdown flushes,
nulls collabKey, DESTROYS the retained provider), but a click
activates THIS side first (pointerdown → activePane flip →
peeking=false) before the flip runs, so the teardown only ever
happens on the now-ACTIVE side — never while peeking. The onclick
`peeking` guards below stay as the backstop for a re-peek DURING the
async flush (e.g. the user clicks the other side mid-flip). -->
<div class="editor-mode-toggle">
<button
class="mode-btn"
@@ -4435,7 +4437,6 @@
title="Raw markdown editor"
>Markdown</button>
</div>
{/if}
{#if rawMode}
{#key item.id}
<!-- Master-freeze (TASK-2172, HT-2176 Option A): a peeking
@@ -52,13 +52,13 @@
<div data-testid="child-frozen">{false}</div>
<div data-testid="timeline-frozen">{false}</div>
<!-- The Rich⇄Markdown mode toggle is a provider-LIFECYCLE control (switching to
Markdown destroys the retained collab provider), so it stays hidden on the
passive preview (`!peeking`); it reappears the instant you click in (which
you must do to edit content anyway). The one accepted visible exception. -->
{#if !peeking}
<button data-testid="mode-toggle">Rich / Markdown</button>
{/if}
<!-- The Rich⇄Markdown mode toggle now renders on BOTH sides (BUG-2263 follow-up).
It IS a provider-LIFECYCLE control (Markdown destroys the retained provider),
but a click activates THIS side first (pointerdown → peeking=false) before the
guarded flip runs, so the teardown only ever happens on the now-active side. So
it renders unconditionally like the other invisible-freeze surfaces; the flip's
`if (peeking) return` onclick guards are the backstop for a re-peek mid-flush. -->
<button data-testid="mode-toggle">Rich / Markdown</button>
<!-- REST mutation UI — gated on `canEdit` alone, present on both sides. -->
{#if canEdit}
@@ -76,11 +76,14 @@ describe('invisible master/pane freeze wiring (BUG-2263)', () => {
expect(text(r, 'mutationsEnabled')).toBe('false');
// CONTENT bucket — frozen: the editor is not typeable, the raw editor is
// read-only, the bubble/link chrome + provider-lifecycle mode toggle hide.
// read-only, and the selection bubble/link chrome is gone (it only appears on
// a selection, which requires activation).
expect(text(r, 'editor-editable')).toBe('false');
expect(text(r, 'raw-readonly')).toBe('true');
expect(present(r, 'editor-mutation-ui')).toBe(false);
expect(present(r, 'mode-toggle')).toBe(false);
// The Rich/Markdown toggle now renders on the peeking side too (BUG-2263
// follow-up): a click activates the side before the guarded provider flip.
expect(present(r, 'mode-toggle')).toBe(true);
// REST bucket — INVISIBLE: fields interactive, children/timeline not frozen.
expect(text(r, 'field-readonly')).toBe('false');
@@ -112,7 +115,9 @@ describe('invisible master/pane freeze wiring (BUG-2263)', () => {
const r = render({ canEdit: true, peeking: false });
expect(text(r, 'mutationsEnabled')).toBe('true');
// Content surfaces are the ONLY difference from the peeking case.
// The editor's own state (typeable, raw not read-only) + its selection chrome
// are the ONLY differences from the peeking case. The mode toggle now renders
// in BOTH states (asserted true here and in the peeking case above).
expect(text(r, 'editor-editable')).toBe('true');
expect(text(r, 'raw-readonly')).toBe('false');
expect(present(r, 'editor-mutation-ui')).toBe(true);