From d14bceb3e2a8e255566eecba3a69dbcf14f05bc9 Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 20 Jul 2026 20:06:13 -0400 Subject: [PATCH] fix(web): render the Rich/Markdown mode toggle on the peeking side too (BUG-2263 follow-up) (#988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/e2e/pane-full-page-host.spec.ts | 50 +++++++++++++++++++ .../lib/components/items/ItemDetail.svelte | 15 +++--- .../items/masterFreeze/FreezeProbe.svelte | 14 +++--- .../masterFreeze/masterFreeze.svelte.test.ts | 11 ++-- 4 files changed, 73 insertions(+), 17 deletions(-) diff --git a/web/e2e/pane-full-page-host.spec.ts b/web/e2e/pane-full-page-host.spec.ts index 0f690b8b..02115550 100644 --- a/web/e2e/pane-full-page-host.spec.ts +++ b/web/e2e/pane-full-page-host.spec.ts @@ -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=` is stripped — never mounts a pane on the master', async ({ page, fixture, diff --git a/web/src/lib/components/items/ItemDetail.svelte b/web/src/lib/components/items/ItemDetail.svelte index b617ef1a..79efbfcf 100644 --- a/web/src/lib/components/items/ItemDetail.svelte +++ b/web/src/lib/components/items/ItemDetail.svelte @@ -4235,12 +4235,14 @@ Editor, EditorBubbleMenu, provider, collabKey and SSE stay persistent across an A→B item switch (the no-{#key} perf premise). -->
- - {#if !peeking} +
- {/if} {#if rawMode} {#key item.id} -{#if !peeking} - -{/if} + + {#if canEdit} diff --git a/web/src/lib/components/items/masterFreeze/masterFreeze.svelte.test.ts b/web/src/lib/components/items/masterFreeze/masterFreeze.svelte.test.ts index df73df07..2ea2121c 100644 --- a/web/src/lib/components/items/masterFreeze/masterFreeze.svelte.test.ts +++ b/web/src/lib/components/items/masterFreeze/masterFreeze.svelte.test.ts @@ -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);