diff --git a/web/e2e/pane-full-page-capstone.spec.ts b/web/e2e/pane-full-page-capstone.spec.ts index 34ec3dcb..aac9095f 100644 --- a/web/e2e/pane-full-page-capstone.spec.ts +++ b/web/e2e/pane-full-page-capstone.spec.ts @@ -15,16 +15,16 @@ import type { SuiteFixture } from './fixtures'; * FULL-PAGE host specifically, the three properties that host alone * introduces or that only it can now demonstrate: * - * 1. Option-A mutation-SILENCE (the D2 / HT-2176 freeze acceptance). While - * a pane is peeking beside the retain-alive master, NO NEW user edit can - * be INITIATED on the master — the title click-to-edit is gone, field - * inputs are read-only, the comment composer / compose surfaces are - * unmounted, the rich editor is contenteditable=false, and the star / - * Share / Quick-actions affordances are gated. This is Option A — "no NEW - * edit can be INITIATED while peeking", NOT "zero network writes": a - * pre-pane pending save legitimately completes and remote collab sync is - * expected, so we assert the INITIATION surfaces are disabled/absent, not - * the absence of REST/WS traffic. Un-peeking (close) restores every one. + * 1. INVISIBLE freeze (BUG-2263). While a pane is peeking beside the + * retain-alive master, the ONLY thing that changes on the master is that + * its collab CONTENT editor stops being typeable (contenteditable=false) — + * the single collision surface the freeze exists to protect. EVERY other + * surface stays live and editable: the title is still a click-to-edit + * button, field inputs stay editable, the comment composer is present, and + * the star / Share / Quick-actions / Move / Delete / Add-relationship + * affordances are all present. They are side-independent, server-gated REST + * mutations, so the freeze must be transparent to the user there. Un-peeking + * (close) restores the content editor's typeability; nothing else moved. * * 2. The bounded TWO-WS cost while peeking (D2's "cost model" note). Opening * the pane yields at most the master's provider + the pane's provider = 2 @@ -138,12 +138,21 @@ async function seedNoteCollection( request: APIRequestContext, namePrefix: string, itemPrefix: string, + quickActions?: Array<{ label: string; prompt: string }>, ): Promise<{ id: string; slug: string }> { const name = `${namePrefix} ${Date.now()}`; const schema = JSON.stringify({ fields: [{ key: 'note', label: 'Note', type: 'text' }] }); + const data: Record = { name, prefix: itemPrefix, schema }; + if (quickActions?.length) { + // Seed read-only prompt-copy actions so the quick-actions trigger stays + // visible on the peeking side (the write controls gate separately). + data.settings = JSON.stringify({ + quick_actions: quickActions.map((a) => ({ ...a, scope: 'item' })), + }); + } const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections`, { headers: authHeaders(fixture), - data: { name, prefix: itemPrefix, schema }, + data, }); if (!resp.ok()) throw new Error(`collection create failed (${resp.status()}): ${await resp.text()}`); return (await resp.json()) as { id: string; slug: string }; @@ -242,16 +251,16 @@ test.describe('full-page pane host CAPSTONE (PLAN-2154 Phase 2 / TASK-2175)', () ); }); - // ── 1. Option-A mutation-SILENCE (the D2 / HT-2176 freeze acceptance) ──── - // While a pane peeks beside the retain-alive master, NO NEW user edit can be - // INITIATED on the master. We assert the KEY initiation surfaces are - // disabled/absent — NOT the absence of network writes (Option A explicitly - // permits a pre-pane pending save to complete + remote collab sync). This is - // the RUNTIME smoke of the freeze; the exhaustive per-mutation-path audit - // (raw mode, tags, assignment, timeline reply/reaction/version, drag-reorder, - // etc.) is unit-tested in masterFreeze.svelte.test.ts + mutationGate.test.ts. - // Then un-peek (close) and assert the surfaces are restored. - test('peeking freezes the master NEW-edit-initiation surfaces; closing the pane restores them', async ({ + // ── 1. INVISIBLE freeze (BUG-2263) ────────────────────────────────────── + // While a pane peeks beside the retain-alive master, the ONLY master surface + // that changes is its content editor's typeability (contenteditable=false) — + // the single collision surface. We assert every OTHER surface (title, field, + // composer, star, Share, Quick-actions, Delete, Move, Add-relationship) stays + // live and editable, i.e. the freeze is transparent to the user. This is the + // RUNTIME smoke of the invisible freeze; the exhaustive per-surface gate audit + // is unit-tested in masterFreeze.svelte.test.ts + mutationGate.test.ts. Then + // un-peek (close) and assert the content editor is typeable again. + test('peeking freezes ONLY the master content editor; every other surface stays live and editable (BUG-2263 invisible freeze)', async ({ page, fixture, request, @@ -316,48 +325,127 @@ test.describe('full-page pane host CAPSTONE (PLAN-2154 Phase 2 / TASK-2175)', () // Activate the pane (click its title) → the master becomes the frozen side. await pane.locator('.title', { hasText: 'FP freeze target' }).click(); - // ── Peeking: every NEW-edit-initiation surface is gone/disabled. ── - // Title: click-to-edit button replaced by a non-editable

. - await expect(col.locator('h1.title.title-readonly', { hasText: 'FP freeze master' })).toBeVisible(); - await expect(col.locator('button.title', { hasText: 'FP freeze master' })).toHaveCount(0); - // Field: the editable input is gone — rendered as a readonly-display. - await expect(col.locator('input.field-input')).toHaveCount(0); - await expect(col.locator('.field-row', { hasText: 'Note' }).locator('.readonly-display')).toBeVisible(); - // Comment composer: unmounted entirely. - await expect(col.locator('.compose')).toHaveCount(0); - // Star: disabled (gated on `peeking`, not unmounted — it stays visible). - await expect(col.locator('button.star-btn')).toBeDisabled(); - // Share + Quick-actions triggers: unmounted. - await expect(col.locator('button.action-btn', { hasText: 'Share' })).toHaveCount(0); - await expect(col.locator('button.trigger-btn[title="Quick actions"]')).toHaveCount(0); - // Move-to + Delete: unmounted (gated on mutationsEnabled). - await expect(col.locator('button.action-btn', { hasText: 'Move to' })).toHaveCount(0); - await expect(col.locator('button.delete-btn')).toHaveCount(0); - // Relationship mutation surfaces: the per-link remove + Add opener are gone. - await expect(col.locator('button.link-delete-btn')).toHaveCount(0); - await expect(col.locator('button.add-relationship-btn')).toHaveCount(0); - // Rich editor: retained (still visible — no teardown), but read-only. - await expect(masterEditor).toBeVisible(); + // ── Peeking (BUG-2263): the freeze is INVISIBLE. The ONLY thing that + // changes on the master is that its content editor stops being typeable + // (contenteditable=false). Every other surface stays EXACTLY as pre-peek — + // the master/pane freeze exists solely to keep one typeable collab editor, + // and must be transparent to the user everywhere else. ── await expect(masterEditor).toHaveAttribute('contenteditable', 'false'); - - // ── Close (un-peek) → every surface is EDITABLE again. ── - await pane.locator('button[aria-label="Close pane"]').click(); - await expect(pane).toBeHidden(); + // Title: STILL a click-to-edit button (no degraded

). await expect(col.locator('button.title', { hasText: 'FP freeze master' })).toBeVisible(); await expect(col.locator('h1.title.title-readonly')).toHaveCount(0); - await expect(col.locator('.field-row', { hasText: 'Note' }).locator('input.field-input')).toBeVisible(); + // Field: STILL an editable input (no readonly-display swap). + await expect(noteInput).toBeVisible(); + await expect(noteInput).toBeEnabled(); + await expect(col.locator('.field-row', { hasText: 'Note' }).locator('.readonly-display')).toHaveCount(0); + // Comment composer: still present. await expect(col.locator('.compose')).toBeVisible(); + // Star: still enabled (never gated on peeking anymore). await expect(col.locator('button.star-btn')).toBeEnabled(); + // Share + Delete + Move + Add-relationship + per-link remove: all still + // present (side-independent single-item REST — no freeze). await expect(col.locator('button.action-btn', { hasText: 'Share' })).toBeVisible(); - await expect(col.locator('button.trigger-btn[title="Quick actions"]')).toBeVisible(); await expect(col.locator('button.delete-btn')).toBeVisible(); await expect(col.locator('button.action-btn', { hasText: 'Move to' })).toBeVisible(); await expect(col.locator('button.add-relationship-btn')).toBeVisible(); - // The per-link remove is back in the DOM (display:none until row-hover). await expect(col.locator('button.link-delete-btn')).toHaveCount(1); + // EXCEPTION (Codex P1): the owner quick-actions menu's "Manage/New" controls + // WRITE the whole collection settings from a per-item snapshot (last-write- + // wins across two items in one collection), so they gate on `!peeking`. This + // note collection seeds NO read-only prompt actions, so with the write + // controls gated the trigger has nothing to show and is hidden on the peeking + // side (prompt actions, when present, would keep it visible — unit-tested). + await expect(col.locator('button.trigger-btn[title="Quick actions"]')).toHaveCount(0); + // Rich editor: retained + visible (no teardown) — only not typeable. + await expect(masterEditor).toBeVisible(); + + // ── RUNTIME MUTATION (Codex P2): the invisible freeze is not merely visual — + // a field edit typed on the FROZEN master must actually PATCH the CORRECT + // item. Type into the note field while peeking and assert it persists to + // THIS master's fields (server-side), proving updateField fired for the + // right item id from the frozen side. ── + await noteInput.fill('edited-while-peeking'); + await expect + .poll( + async () => { + const resp = await request.get( + `/api/v1/workspaces/${fixture.workspaceSlug}/items/${master.slug}`, + { headers: authHeaders(fixture) }, + ); + if (!resp.ok()) return null; + const it = (await resp.json()) as { fields?: string | Record }; + const fields = typeof it.fields === 'string' ? JSON.parse(it.fields) : (it.fields ?? {}); + return (fields as Record).note; + }, + { timeout: 6000 }, + ) + .toBe('edited-while-peeking'); + + // ── Close (un-peek) → the content editor is typeable again; nothing else was + // ever frozen, so it is unchanged. ── + await pane.locator('button[aria-label="Close pane"]').click(); + await expect(pane).toBeHidden(); + await expect(col.locator('button.title', { hasText: 'FP freeze master' })).toBeVisible(); + await expect(noteInput).toBeVisible(); + await expect(col.locator('.compose')).toBeVisible(); await expect(masterEditor).toHaveAttribute('contenteditable', 'true', { timeout: SYNC_TIMEOUT }); }); + // ── 1b. Quick-actions EXCEPTION (Codex P1): the owner "New/Manage" controls + // WRITE the whole collection settings from a per-item snapshot (last-write-wins + // across two items in one collection), so they gate on `!peeking` — confined to + // the active side. The read-only prompt-copy actions stay visible on both sides. + // This mounts the REAL QuickActionsMenu (seeded with a prompt action) to prove + // the gate wiring, not just the empty-trigger-hides case. + test('quick-actions: read-only prompt actions stay visible on the peeking side; the collection-settings write controls (New/Manage) are confined to the active side (Codex P1)', async ({ + page, + fixture, + request, + }) => { + test.setTimeout(60_000); + await page.setViewportSize(DESKTOP); + await browserLogin(page); + + // Seed a collection WITH a read-only prompt action so the trigger stays + // visible on the peeking side even once the write controls are gated. + const coll = await seedNoteCollection(fixture, request, 'FP qa', 'FPQA', [ + { label: 'Summarize', prompt: 'Summarize this item' }, + ]); + const master = await seedNoteItem(fixture, request, coll.slug, `FP qa master ${Date.now()}`, 'm', ''); + const target = await seedNoteItem(fixture, request, coll.slug, `FP qa target ${Date.now()}`, 't', ''); + await seedRelatedLink(fixture, request, master.slug, target.id); + + await page.goto(fullPageUrl(fixture, coll.slug, master.slug)); + const col = masterCol(page); + const trigger = col.locator('button.trigger-btn[title="Quick actions"]'); + + // Pre-peek (master active, owner): open the menu → the prompt action AND the + // owner write controls are all present. + await expect(trigger).toBeVisible(); + await trigger.click(); + await expect(col.locator('.action-label', { hasText: 'Summarize' })).toBeVisible(); + await expect(col.locator('.action-label', { hasText: 'New quick action' })).toBeVisible(); + await expect(col.locator('.action-label', { hasText: 'Manage actions' })).toBeVisible(); + await trigger.click(); // close + + // Open the pane and activate it → the master becomes the peeking side. + await openPaneViaRelated(page, 'FP qa target'); + const pane = page.locator('.item-pane'); + await expect(pane).toBeVisible(); + await pane.locator(EDITOR_SELECTOR).click(); + await expect(col.locator(EDITOR_SELECTOR)).toHaveAttribute('contenteditable', 'false'); + + // Peeking side: the trigger stays VISIBLE — the read-only prompt affordance + // keeps it (contrast the freeze test, where an EMPTY collection's trigger + // HIDES on the peeking side once the owner write controls gate off). We do NOT + // open it here: clicking the trigger re-activates the master (click-to- + // activate is the focus-follows model), so the write controls are only ever + // reached from the ACTIVE side — which is exactly the safety property. The + // render gate that drops New/Manage while `peeking` is asserted at the unit + // level (masterFreeze) where the state can be held without a click. + await expect(trigger).toBeVisible(); + }); + // ── 2. Bounded TWO-WS cost while peeking (D2 cost model / the guard core) ─ // Opening the pane yields master-provider + pane-provider = 2 collab WS to // DISTINCT rooms, and the master's OWN room is NEVER given a second provider. diff --git a/web/e2e/pane-full-page-host.spec.ts b/web/e2e/pane-full-page-host.spec.ts index c21f4e17..0f690b8b 100644 --- a/web/e2e/pane-full-page-host.spec.ts +++ b/web/e2e/pane-full-page-host.spec.ts @@ -109,7 +109,7 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { ); }); - test('focus follows editing: open keeps the master editable (pane = read-only preview); clicking a side activates it and freezes the other; drill/back keep the pane active (PLAN-2179 DR-2/DR-3 / TASK-2181)', async ({ + test('focus follows editing is INVISIBLE (BUG-2263): open keeps the master editable (pane = preview); clicking a side activates it and freezes the other; the frozen side keeps its editable title — only the content editor stops being typeable (PLAN-2179 DR-2/DR-3 / TASK-2181)', async ({ page, fixture, request, @@ -126,17 +126,18 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { const relatedRef = await itemRef(fixture, request, related.slug); const grandchildRef = await itemRef(fixture, request, grandchild.slug); - // Editable/frozen probes: a `button.title` is the click-to-edit title - // (editable side); an `h1.title.title-readonly` is the frozen side. - const masterEditable = page.locator('button.title', { hasText: 'FP host master' }); - const masterFrozen = page.locator('h1.title.title-readonly', { hasText: 'FP host master' }); + // BUG-2263: the freeze is INVISIBLE — the title stays a click-to-edit + // `button.title` on BOTH sides, so it is NO LONGER a peeking probe. Which + // side is EDITABLE is signalled by its CONTENT editor's `contenteditable`. + const masterTitleBtn = page.locator('.item-page-host > .item-page button.title', { hasText: 'FP host master' }); + const masterEditor = page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror'); // Land on the MASTER full page. No pane yet: the flex-row host is present, - // the master title is EDITABLE (a click-to-edit button — not peeking), and - // there's no `?item=`. + // the master title is an editable button, its editor is typeable, no `?item=`. await page.goto(fullPageUrl(fixture, master.slug)); await expect(page.locator('.item-page-host')).toBeVisible(); - await expect(masterEditable).toBeVisible(); + await expect(masterTitleBtn).toBeVisible(); + await expect(masterEditor).toHaveAttribute('contenteditable', 'true'); await expect(page.locator('.item-pane')).toHaveCount(0); expect(openItemParam(page)).toBeNull(); @@ -149,33 +150,34 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { const pane = page.locator('.item-pane'); await expect(pane).toBeVisible(); await expect.poll(() => openItemParam(page)).toBe(relatedRef); + const paneEditor = pane.locator('.editor-wrapper .ProseMirror'); - // DR-2: opening does NOT freeze the master. Focus-follows-editing means the - // MASTER stays the active/editable side (openItemPaneByRef never moves focus - // into the pane), and the pane opens as a READ-ONLY PREVIEW — pane title is a - // non-editable

, no click-to-edit button. - await expect(masterEditable).toBeVisible(); - await expect(masterFrozen).toHaveCount(0); - await expect(pane.locator('h1.title.title-readonly', { hasText: 'FP host related' })).toBeVisible(); - await expect(pane.locator('button.title')).toHaveCount(0); + // DR-2: opening does NOT freeze the master — it stays the active/editable + // side. The pane opens as a PREVIEW, but INVISIBLY (BUG-2263): its title is + // STILL an editable button (not a degraded

); only its content editor is + // not typeable (contenteditable=false). + await expect(masterEditor).toHaveAttribute('contenteditable', 'true'); + await expect(pane.locator('button.title', { hasText: 'FP host related' })).toBeVisible(); + await expect(pane.locator('h1.title.title-readonly')).toHaveCount(0); + await expect(paneEditor).toHaveAttribute('contenteditable', 'false'); // Depth 0: the pane's Back chevron is hidden. await expect(pane.locator('button.pane-back-btn')).toHaveCount(0); - // Drill a CHILD row directly from the FROZEN preview — on the FIRST click. - // Content-link / child-row navigation stays live while the master is active - // (the mini-browser preview), so the pointerdown activator EXCLUDES navigable - // targets: the freeze-flip can't re-init ChildItems' dndzone and swallow the - // click. The drill is pane-internal, so it ALSO activates the pane — pane - // editable, master frozen (PLAN-2179 DR-2 / TASK-2181). No pre-activation click. + // Drill a CHILD row directly from the preview — on the FIRST click. The drill + // is pane-internal, so it ALSO activates the pane — pane editable, master + // frozen (PLAN-2179 DR-2 / TASK-2181). No pre-activation click. const masterPathname = new URL(page.url()).pathname; await pane.locator('.child-row', { hasText: 'FP host grandchild' }).click(); await expect.poll(() => openItemParam(page)).toBe(grandchildRef); expect(new URL(page.url()).pathname).toBe(masterPathname); await expect(pane.locator('button.pane-back-btn')).toBeVisible(); await expect(pane.locator('button.title', { hasText: 'FP host grandchild' })).toBeVisible(); - await expect(masterFrozen).toBeVisible(); - await expect(masterEditable).toHaveCount(0); + // Pane active, master frozen — but the master's freeze is INVISIBLE: its + // title is still an editable button; only its editor is not typeable. + await expect(paneEditor).toHaveAttribute('contenteditable', 'true'); + await expect(masterEditor).toHaveAttribute('contenteditable', 'false'); + await expect(masterTitleBtn).toBeVisible(); // Browser BACK → pops one drill level back to B in the pane. A drill-pop is // still pane-internal, so `activePane` stays 'pane' — pane editable, master @@ -184,33 +186,31 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { await expect.poll(() => openItemParam(page)).toBe(relatedRef); await expect(pane.locator('button.title', { hasText: 'FP host related' })).toBeVisible(); await expect(pane.locator('button.pane-back-btn')).toHaveCount(0); - await expect(masterFrozen).toBeVisible(); - await expect(masterEditable).toHaveCount(0); + await expect(paneEditor).toHaveAttribute('contenteditable', 'true'); + await expect(masterEditor).toHaveAttribute('contenteditable', 'false'); - // Click BACK into the MASTER — on its now-read-only

title, a NON-focusable, - // NON-navigable element that drops focus to . The pointerdown activator - // re-activates the master, and the desktop backstop must NOT yank focus back to - // the pane. Master editable again; pane freezes. Exactly one side editable. - await masterFrozen.click(); - await expect(masterEditable).toBeVisible(); - await expect(masterFrozen).toHaveCount(0); - await expect(pane.locator('h1.title.title-readonly', { hasText: 'FP host related' })).toBeVisible(); - await expect(pane.locator('button.title')).toHaveCount(0); - - // Click into the PANE on its read-only title (a NON-navigable target) → the - // pointerdown activator makes the pane the active side; the master freezes. - await pane.locator('.title', { hasText: 'FP host related' }).click(); + // Click BACK into the MASTER content editor → the pointerdown activator + // re-activates the master (and the same click lands the caret in the now- + // editable view — one gesture), and the desktop backstop must NOT yank focus + // back to the pane. Master editable again; pane freezes. Exactly one side. + await masterEditor.click(); + await expect(masterEditor).toHaveAttribute('contenteditable', 'true'); + await expect(paneEditor).toHaveAttribute('contenteditable', 'false'); + // The frozen pane's title stays an editable button (invisible freeze). await expect(pane.locator('button.title', { hasText: 'FP host related' })).toBeVisible(); - await expect(masterFrozen).toBeVisible(); - await expect(masterEditable).toHaveCount(0); - // Close (✕) → the pane unmounts cleanly, `?item=` drops, and the master is - // EDITABLE again (no longer peeking → click-to-edit button returns). + // Click into the PANE content editor → the pointerdown activator makes the + // pane the active side; the master freezes. + await paneEditor.click(); + await expect(paneEditor).toHaveAttribute('contenteditable', 'true'); + await expect(masterEditor).toHaveAttribute('contenteditable', 'false'); + + // Close (✕) → the pane unmounts cleanly, `?item=` drops, and the master's + // editor is typeable again. await pane.locator('button[title="Close pane"]').click(); await expect(page.locator('.item-pane')).toHaveCount(0); await expect.poll(() => openItemParam(page)).toBeNull(); - await expect(masterEditable).toBeVisible(); - await expect(page.locator('h1.title.title-readonly')).toHaveCount(0); + await expect(masterEditor).toHaveAttribute('contenteditable', 'true'); }); test('opening AND closing the pane freezes/thaws the master WITHOUT remounting its editor (PLAN-2179 DR-1 / TASK-2180 — reactive freeze)', async ({ @@ -255,17 +255,18 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { expect(await editorNode!.evaluate((el) => el.isConnected)).toBe(true); await expect(masterMainEditor).toHaveAttribute('contenteditable', 'true'); - // Click INTO the pane → the master FREEZES. The freeze is REACTIVE: the SAME - // editor DOM node is still connected (a `{#key}`-driven remount — the OLD - // peeking-in-the-key behavior — would have detached this handle, isConnected → - // false), and it merely flipped contenteditable=false in place. This is the - // whole point of PLAN-2179 DR-1: freeze without destroying/recreating the editor. - await pane.locator('.title', { hasText: 'FP host reactive-freeze related' }).click(); - await expect( - page.locator('h1.title.title-readonly', { hasText: 'FP host reactive-freeze master' }), - ).toBeVisible(); + // Click INTO the pane's content editor → the master FREEZES. The freeze is + // REACTIVE: the SAME editor DOM node is still connected (a `{#key}`-driven + // remount — the OLD peeking-in-the-key behavior — would have detached this + // handle, isConnected → false), and it merely flipped contenteditable=false in + // place. This is the whole point of PLAN-2179 DR-1: freeze without + // destroying/recreating the editor. + await pane.locator('.editor-wrapper .ProseMirror').click(); expect(await editorNode!.evaluate((el) => el.isConnected)).toBe(true); await expect(masterMainEditor).toHaveAttribute('contenteditable', 'false'); + // BUG-2263 invisibility: the frozen master's title is STILL an editable + // button — the freeze degrades nothing but the content editor's typeability. + await expect(page.locator('.item-page-host > .item-page button.title', { hasText: 'FP host reactive-freeze master' })).toBeVisible(); // Close the pane → the master thaws back to editable. The editor node // survives the un-freeze too (no remount on either edge), flipping @@ -322,7 +323,7 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { .click(); await expect(pane).toBeVisible(); await expect.poll(() => openItemParam(page)).toBe(relatedRef); - await pane.locator('.title', { hasText: 'FP host drag-handle related' }).click(); + await pane.locator('.editor-wrapper .ProseMirror').click(); await expect(masterMain).toHaveAttribute('contenteditable', 'false'); await page.mouse.move(5, 5); // leave the editor first await masterMain.locator('p').first().hover({ force: true }); @@ -359,7 +360,7 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { await expect(page.locator('.item-pane')).toHaveCount(0); await expect.poll(() => openItemParam(page)).toBeNull(); // The master stays fully EDITABLE (never went peeking). - await expect(page.locator('h1.title.title-readonly')).toHaveCount(0); + await expect(page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'true'); }); test('a cold-loaded shared `?item=` still mounts the pane (the self-collision mount-gate does not suppress legitimate cross-item cold loads)', async ({ @@ -385,10 +386,12 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { await expect.poll(() => openItemParam(page)).toBe(otherRef); // DR-2 cold-load initializer (desktop): no focusin fires on a `?item=` deep // load, so `activePane` seeds to the MASTER — it's EDITABLE beside the pane, - // which opens as a read-only PREVIEW (frozen title). - await expect(page.locator('button.title', { hasText: 'FP host cold master' })).toBeVisible(); - await expect(page.locator('h1.title.title-readonly', { hasText: 'FP host cold master' })).toHaveCount(0); - await expect(pane.locator('h1.title.title-readonly', { hasText: 'FP host cold other' })).toBeVisible(); + // which opens as a PREVIEW. INVISIBLE freeze (BUG-2263): the pane's title is + // an editable button; only its content editor is not typeable. + await expect(page.locator('.item-page-host > .item-page button.title', { hasText: 'FP host cold master' })).toBeVisible(); + await expect(page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'true'); + await expect(pane.locator('button.title', { hasText: 'FP host cold other' })).toBeVisible(); + await expect(pane.locator('.editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'false'); }); test('a cold-loaded `?item=` is stripped (server slug-fallback self-collision)', async ({ @@ -416,7 +419,7 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { // Must be stripped — never mount a second provider on the master's own room. await expect(page.locator('.item-pane')).toHaveCount(0); await expect.poll(() => openItemParam(page)).toBeNull(); - await expect(page.locator('h1.title.title-readonly')).toHaveCount(0); + await expect(page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'true'); }); test('Expand a pane item to full page, then browser Back, restores the pane (no stale-master strip)', async ({ @@ -460,8 +463,9 @@ test.describe('full-page pane host (PLAN-2154 Phase 2 / TASK-2174)', () => { // latched to 'pane' and PERSISTS across the expand + Back (this route // component is REUSED, never remounted). The restored pane is therefore the // ACTIVE/editable side and the master A is frozen — the pane "stays active" - // across browser Back (PLAN-2179 DR-2). + // across browser Back (PLAN-2179 DR-2). The freeze is INVISIBLE (BUG-2263): + // the master's frozen state shows only on its content editor, not its title. await expect(pane.locator('button.title', { hasText: 'FP host expand related' })).toBeVisible(); - await expect(page.locator('h1.title.title-readonly', { hasText: 'FP host expand master' })).toBeVisible(); + await expect(page.locator('.item-page-host > .item-page .editor-wrapper .ProseMirror')).toHaveAttribute('contenteditable', 'false'); }); }); diff --git a/web/src/lib/components/common/QuickActionsMenu.svelte b/web/src/lib/components/common/QuickActionsMenu.svelte index bc18fb59..73c3fcf8 100644 --- a/web/src/lib/components/common/QuickActionsMenu.svelte +++ b/web/src/lib/components/common/QuickActionsMenu.svelte @@ -117,6 +117,12 @@ } function handleManage() { + // Recheck `canEdit` at dispatch time (mirrors handleSaveNewAction): if it + // flips false while the menu is open — e.g. the master-freeze passing + // canEdit=false once this side becomes the peeking preview (BUG-2263) — refuse + // to open the collection-settings editor. The trigger itself unmounts on the + // same flip (`{#if canEdit}`); this guards the render→click race. + if (!canEdit) return; open = false; resetCreateForm(); onmanage?.(); diff --git a/web/src/lib/components/items/ItemDetail.svelte b/web/src/lib/components/items/ItemDetail.svelte index a4bb128f..b617ef1a 100644 --- a/web/src/lib/components/items/ItemDetail.svelte +++ b/web/src/lib/components/items/ItemDetail.svelte @@ -2051,7 +2051,7 @@ }); async function startEditTitle() { - if (!item || !mutationsEnabled) return; + if (!item || !canEdit) return; titleDraft = item.title; editingTitle = true; // Wait for the DOM to render the textarea, then focus + select all @@ -2098,9 +2098,10 @@ async function saveTitle() { editingTitle = false; - // Master-freeze guard (TASK-2172): a blur can fire saveTitle after the - // freeze began; drop the write so a peeking master never PATCHes a title. - if (!mutationsEnabled) return; + // Title edits are a single-item REST PATCH — side-independent, server-gated, + // no shared-content collision — so they are NOT frozen while peeking (BUG-2263): + // the freeze is invisible and gates only the content editor. Permission alone. + if (!canEdit) return; if (!item || titleDraft.trim() === item.title) return; // Capture the target item + generation BEFORE the await. A blur-fired // saveTitle can resolve AFTER the pane switched to another item (click @@ -2134,13 +2135,13 @@ } async function updateField(key: string, value: any) { - // HT-2176 Option A (TASK-2172): NO `mutationsEnabled` recheck. NEW field - // input is blocked at the UI (`FieldEditor readonly={!mutationsEnabled}`), - // so this only ever runs for a value the user typed BEFORE the pane opened - // — FieldEditor's 500ms debounce fires the pending onchange after peeking - // began (peeking doesn't re-prop `value`, so its cancel-on-external-change - // $effect leaves the timer armed). That pre-pane save must COMPLETE, not be - // suppressed (mirrors the tag/raw pre-pane saves). + // NO freeze recheck (BUG-2263): field editing is invisible to the freeze — + // FieldEditor stays interactive on the peeking side (`readonly={!canEdit}`), + // and a field write is a single-item, server-gated REST PATCH with a + // field-level MERGE, so it is side-independent and cannot collide with the + // active editor. Permission is enforced at the UI (readonly) and server-side. + // (This also lets a pre-flip pending onchange from FieldEditor's 500ms + // debounce complete rather than being suppressed.) if (!item) return; const updated = { ...fields, [key]: value }; const payload = JSON.stringify(updated); @@ -2249,7 +2250,7 @@ const tagSavers = new Map(); function updateTags(newTags: string[]) { - if (!item || !mutationsEnabled) return; + if (!item || !canEdit) return; const targetItem = item; const targetWs = wsSlug; // Optimistic so chips react instantly. @@ -2281,10 +2282,10 @@ saveStatus = 'saving'; try { while (saver.pending !== null) { - // HT-2176 Option A (TASK-2172): NO peeking recheck in the drain. NEW - // tag input is blocked at the UI (`TagInput readonly={!mutationsEnabled}`) - // + the `updateTags` guard, so this only drains a tag edit the user - // made BEFORE the pane opened — allowed to complete. + // NO freeze recheck in the drain (BUG-2263): tag editing is invisible + // to the freeze (TagInput stays interactive via `readonly={!canEdit}`) + // and a tag write is a side-independent single-item REST PATCH, so it + // runs from either side; `updateTags` gates on canEdit (permission). const toSave = saver.pending; saver.pending = null; const fresh = await api.items.update(saver.ws, saver.itemId, { @@ -2533,7 +2534,7 @@ } async function updateAssignedUser(userId: string | null) { - if (!item || !mutationsEnabled) return; + if (!item || !canEdit) return; // Capture target + generation before the await; drop the post-await // write if the pane switched items mid-request (PLAN-2105 / TASK-2112; // coordinator P1). @@ -2559,7 +2560,7 @@ } async function updateAgentRole(roleId: string | null) { - if (!item || !mutationsEnabled) return; + if (!item || !canEdit) return; // Capture target + generation before the await; drop the post-await // write if the pane switched items mid-request (PLAN-2105 / TASK-2112; // coordinator P1). @@ -3237,7 +3238,7 @@ } async function handleDelete() { - if (!item || !mutationsEnabled) return; + if (!item || !canEdit) return; // Capture identity before the await. The DELETE targets `targetItem.id` // (= A), but the post-await feedback must be fenced: if the pane // switched to B while A's delete was in flight, calling handleGone() @@ -3268,9 +3269,9 @@ // archived — surface that message the same way other handlers do. TASK-1829. async function handleRestore() { // `canEdit` is forced false for an archived item, so restore gates on - // `canRestore` — freeze it with `!peeking` (not `mutationsEnabled`), the - // same split the render gate uses (TASK-2172). - if (!item || restoring || peeking) return; + // `canRestore` (checked at the render site). Restore is a single-item REST + // mutation — side-independent — so it is NOT frozen while peeking (BUG-2263). + if (!item || restoring) return; // Capture target + generation before the awaits; drop the post-await // write if the pane switched items mid-request (PLAN-2105 / TASK-2112; // coordinator "gate all post-await writes"). @@ -3352,7 +3353,7 @@ } async function handleDeleteLink(linkId?: string) { - if (!linkId || !item || !mutationsEnabled) return; + if (!linkId || !item || !canEdit) return; // Capture identity BEFORE the awaits. The refresh GET must use the // captured slug (not the live `itemSlug`, which an A→B→C switch would // have advanced) and the result must be dropped if we switched away — @@ -3442,7 +3443,7 @@ } async function handleCreateLink(target: Item) { - if (!item || !mutationsEnabled) return; + if (!item || !canEdit) return; // Capture the SOURCE item (the one being edited) + generation before the // awaits. `target` is the link target chosen from search; `sourceItem` // is the current item. Use the captured slug for the refresh GET and @@ -3477,7 +3478,7 @@ } async function handleMove(targetSlug: string) { - if (!item || moving || !mutationsEnabled) return; + if (!item || moving || !canEdit) return; moving = true; showMoveMenu = false; // Capture FULL route identity (workspace, username, source @@ -3776,7 +3777,7 @@ {/if} It's read-only until restored. - {#if canRestore && !peeking} + {#if canRestore} @@ -3799,13 +3800,16 @@ onkeydown={handleTitleKeydown} oninput={(e) => autoResizeTitle(e.currentTarget)} > - {:else if mutationsEnabled} + {:else if canEdit} + {:else} - +

{item.title}

{/if} {#if typeof fields.pad_source_url === 'string' && fields.pad_source_url} @@ -3829,7 +3833,11 @@ Refresh button so the import history is still discoverable. (Per Codex review round 1.) --> - {#if mutationsEnabled && !rawMode} + {#if canEdit && !rawMode} + - - {#if collection && (quickActions.length > 0 || isOwner) && !peeking} + + {#if collection && (quickActions.length > 0 || isOwner)} - {#if isOwner && !peeking} + + {#if isOwner} {/if} - {#if mutationsEnabled} + {#if canEdit} {#if confirmDelete} Delete this item? @@ -4131,7 +4140,7 @@ {field} value={rawFieldValue} onchange={(v) => updateField(field.key, v)} - readonly={!mutationsEnabled} + readonly={!canEdit} /> @@ -4146,7 +4155,7 @@ {tags} suggestions={tagSuggestions} onchange={updateTags} - readonly={!mutationsEnabled} + readonly={!canEdit} /> @@ -4159,7 +4168,7 @@
Assigned to
- {#if mutationsEnabled} + {#if canEdit} {formatFieldDisplay(entry.status)} {/if} - {#if entry.linkId && mutationsEnabled} + {#if entry.linkId && canEdit} {/if} @@ -4604,9 +4613,11 @@
{/if} - - {#if item && mutationsEnabled} + + {#if item && canEdit}
{#if !showAddLink} +{peeking} - + diff --git a/web/src/lib/components/items/masterFreeze/FreezeProbe.svelte b/web/src/lib/components/items/masterFreeze/FreezeProbe.svelte index 587ee034..f7b72575 100644 --- a/web/src/lib/components/items/masterFreeze/FreezeProbe.svelte +++ b/web/src/lib/components/items/masterFreeze/FreezeProbe.svelte @@ -1,20 +1,21 @@
{mutationsEnabled}
+ +
{!peeking}
{!canEdit || peeking}
- -
{!mutationsEnabled}
- + + +
{!canEdit}
{canEdit}
-
{peeking}
-
{peeking}
+
{false}
+
{false}
+ 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} {/if} - -{#if mutationsEnabled} + +{#if canEdit} {/if} -{#if mutationsEnabled} +{#if canEdit} {/if} -{#if mutationsEnabled} +{#if canEdit} {/if} + + {#if mutationsEnabled} {/if} - -{#if mutationsEnabled} + +{#if canEdit} {:else}

Title

{/if} - -{#if canRestore && !peeking} + +{#if canRestore} {/if} - - + + - -{#if isOwner && !peeking} + +{#if isOwner} {/if} -{#if (quickActionsPresent || isOwner) && !peeking} - + + +{#if quickActionsPresent || (isOwner && !peeking)} +
+ {#if quickActionsPresent} + + {/if} + {#if isOwner && !peeking} + + {/if} +
+{/if} + + +{#if !peeking} + {/if} 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 223aa96f..df73df07 100644 --- a/web/src/lib/components/items/masterFreeze/masterFreeze.svelte.test.ts +++ b/web/src/lib/components/items/masterFreeze/masterFreeze.svelte.test.ts @@ -3,19 +3,22 @@ import { flushSync, mount, unmount } from 'svelte'; import FreezeProbe from './FreezeProbe.svelte'; import FieldSaveProbe from './FieldSaveProbe.svelte'; -// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172) — retain-alive master freeze. +// BUG-2263 — the master/pane freeze is INVISIBLE to the user. // -// HT-2176 Option A: the freeze blocks the INITIATION of NEW edits while peeking; -// a pre-pane in-flight/debounced save completes on its own (not suppressed, not -// re-flushed on un-peek). These tests therefore assert the NEW-EDIT GATES only — -// (a) every new-edit surface is disabled/gated while peeking, and byte-identical -// to the canEdit-only baseline when not. There are deliberately NO suspend / -// resume / re-flush assertions: nothing is suspended under Option A. +// The freeze's only job is to keep exactly one TYPEABLE collab content editor +// (single-owner of the editorStore/activeItem/tab-title singletons). It is NOT a +// data-collision barrier — master and pane are always different items. So while +// peeking, ONLY the content surfaces stay frozen (the rich editor's editable bit, +// the raw editor, the editor bubble/link chrome, and the provider-lifecycle mode +// toggle); EVERY REST surface — fields, title, delete/move/add-relationship, +// children, timeline, star, share, quick-actions, archived restore — stays +// interactive on both sides, gated on `canEdit` (permission) alone. A click flips +// activePane first, so the interaction lands in one gesture. // -// FreezeProbe mounts the CANONICAL freeze gate expressions from ItemDetail, -// backed by the shared `computeMutationsEnabled` helper, so the gate predicate + -// wiring can't drift. The running-app assertion is TASK-2175's — no host passes -// `peeking={true}` until TASK-2174. +// FreezeProbe renders the CANONICAL gate expressions from ItemDetail, backed by +// the shared `computeMutationsEnabled` helper (which now scopes to content chrome +// only), so the gate predicates can't drift. The running-app assertions live in +// the pane-full-page e2e specs. function target(): HTMLElement { return document.body.appendChild(document.createElement('div')); @@ -33,20 +36,25 @@ function disabled(root: HTMLElement, testid: string): boolean { return (root.querySelector(`[data-testid="${testid}"]`) as HTMLButtonElement | null)?.disabled ?? false; } -// Every mutation surface the master-freeze gates behind `mutationsEnabled`. -const MUTATION_SURFACES = [ +// REST surfaces that stay LIVE on the peeking side (invisible freeze). +const REST_LIVE_SURFACES = [ 'delete-btn', 'move-btn', 'add-relationship-btn', - 'editor-mutation-ui', 'title-editable', ]; -describe('retain-alive master freeze wiring (TASK-2172)', () => { +describe('invisible master/pane freeze wiring (BUG-2263)', () => { let root: HTMLElement | null = null; let instance: ReturnType | null = null; - function render(props: { canEdit?: boolean; peeking?: boolean; canRestore?: boolean }) { + function render(props: { + canEdit?: boolean; + peeking?: boolean; + canRestore?: boolean; + isOwner?: boolean; + quickActionsPresent?: boolean; + }) { root = target(); instance = mount(FreezeProbe, { target: root, props }); flushSync(); @@ -60,117 +68,138 @@ describe('retain-alive master freeze wiring (TASK-2172)', () => { root = null; }); - it('peeking=true freezes EVERY mutation surface even for an editor (canEdit=true)', () => { + it('peeking=true freezes ONLY the content surfaces; every REST surface stays live', () => { const r = render({ canEdit: true, peeking: true }); + // mutationsEnabled still collapses to false while peeking, but now gates + // only the content-editor chrome. expect(text(r, 'mutationsEnabled')).toBe('false'); - // Editor is read-only and the raw editor is read-only; the field input - // is read-only too (no NEW field edit can be started while peeking). + + // CONTENT bucket — frozen: the editor is not typeable, the raw editor is + // read-only, the bubble/link chrome + provider-lifecycle mode toggle hide. expect(text(r, 'editor-editable')).toBe('false'); expect(text(r, 'raw-readonly')).toBe('true'); - expect(text(r, 'field-readonly')).toBe('true'); - // Child + timeline receive the frozen signal (child keeps the REAL - // canEdit — the freeze rides the separate `frozen` prop, not canEdit). - expect(text(r, 'child-canEdit')).toBe('true'); - expect(text(r, 'child-frozen')).toBe('true'); - expect(text(r, 'timeline-frozen')).toBe('true'); - // Every gated mutation control is unmounted. - for (const surface of MUTATION_SURFACES) { - expect(present(r, surface), `${surface} must be frozen`).toBe(false); - } - // Title falls through to the read-only heading, and archived restore hides. - expect(present(r, 'title-readonly')).toBe(true); - expect(present(r, 'archived-restore-btn')).toBe(false); - // The mode toggle (provider-teardown control) is hidden — retain-alive. + expect(present(r, 'editor-mutation-ui')).toBe(false); expect(present(r, 'mode-toggle')).toBe(false); - // Star disabled; Share + the whole quick-actions menu hidden/dismissed. - expect(disabled(r, 'star-btn')).toBe(true); - expect(present(r, 'share-btn')).toBe(false); - expect(present(r, 'quickactions-menu')).toBe(false); - }); - it('peeking=false, canEdit=true keeps every mutation surface live (byte-identical baseline)', () => { - const r = render({ canEdit: true, peeking: false }); - - expect(text(r, 'mutationsEnabled')).toBe('true'); - expect(text(r, 'editor-editable')).toBe('true'); - expect(text(r, 'raw-readonly')).toBe('false'); + // REST bucket — INVISIBLE: fields interactive, children/timeline not frozen. expect(text(r, 'field-readonly')).toBe('false'); expect(text(r, 'child-canEdit')).toBe('true'); expect(text(r, 'child-frozen')).toBe('false'); expect(text(r, 'timeline-frozen')).toBe('false'); - for (const surface of MUTATION_SURFACES) { + + // Every REST mutation control stays mounted while peeking. + for (const surface of REST_LIVE_SURFACES) { + expect(present(r, surface), `${surface} must stay live while peeking`).toBe(true); + } + // Title shows its editable affordance (not the viewer heading). + expect(present(r, 'title-readonly')).toBe(false); + // Archived restore, star (enabled), Share stay live. + expect(present(r, 'archived-restore-btn')).toBe(true); + expect(disabled(r, 'star-btn')).toBe(false); + expect(present(r, 'share-btn')).toBe(true); + // TWO documented exceptions (Codex P1) — same-item / same-collection WRITES + // stay confined to the ACTIVE side. On the peeking side: version restore + // (writes items.content, collides with the retained Y.Doc) is hidden; and the + // owner collection-management menu is hidden here because no prompt actions + // are seeded — see the dedicated exceptions test for the prompts-still-visible + // case where only the Manage control is gated. + expect(present(r, 'version-restore-btn')).toBe(false); + expect(present(r, 'quickactions-menu')).toBe(false); + }); + + it('peeking=false, canEdit=true is identical to peeking=true for every REST surface (invisibility)', () => { + const r = render({ canEdit: true, peeking: false }); + + expect(text(r, 'mutationsEnabled')).toBe('true'); + // Content surfaces are the ONLY difference from the peeking case. + expect(text(r, 'editor-editable')).toBe('true'); + expect(text(r, 'raw-readonly')).toBe('false'); + expect(present(r, 'editor-mutation-ui')).toBe(true); + expect(present(r, 'mode-toggle')).toBe(true); + + // REST surfaces are byte-identical to the peeking case above. + expect(text(r, 'field-readonly')).toBe('false'); + expect(text(r, 'child-canEdit')).toBe('true'); + expect(text(r, 'child-frozen')).toBe('false'); + expect(text(r, 'timeline-frozen')).toBe('false'); + for (const surface of REST_LIVE_SURFACES) { expect(present(r, surface), `${surface} must be live`).toBe(true); } expect(present(r, 'title-readonly')).toBe(false); expect(present(r, 'archived-restore-btn')).toBe(true); - // A non-peeking master keeps its mode toggle (editor or viewer alike). - expect(present(r, 'mode-toggle')).toBe(true); - // Star enabled; Share + quick-actions menu live. expect(disabled(r, 'star-btn')).toBe(false); expect(present(r, 'share-btn')).toBe(true); - expect(present(r, 'quickactions-menu')).toBe(true); + // (quick-actions + version restore — the two exceptions — are covered in + // their own test below.) }); - it('star stays enabled for a non-peeking VIEWER, and share/quick-actions for a non-peeking archived owner (byte-identity)', () => { - // A viewer (canEdit=false, mutationsEnabled=false) can still star when not - // peeking — star gates on peeking, not mutationsEnabled. - let r = render({ canEdit: false, peeking: false, isOwner: false }); - expect(disabled(r, 'star-btn')).toBe(false); - unmount(instance!); - root!.remove(); - - // An archived-item owner (isOwner=true, canEdit=false) keeps Share + the - // quick-actions menu when not peeking — they gate on `!peeking`, not - // mutationsEnabled (which would fold in the archived canEdit=false). - r = render({ canEdit: false, peeking: false, isOwner: true }); - expect(present(r, 'share-btn')).toBe(true); + it('the two same-item/same-collection WRITE surfaces are confined to the active side; prompt actions stay visible (Codex P1)', () => { + // Seed prompt actions so the quick-actions menu trigger has read-only content + // to keep it visible on both sides. + // Active side (peeking=false): prompts + Manage + version restore all present. + let r = render({ canEdit: true, peeking: false, isOwner: true, quickActionsPresent: true }); expect(present(r, 'quickactions-menu')).toBe(true); - - // Peeking freezes both regardless. + expect(present(r, 'quickactions-prompt')).toBe(true); + expect(present(r, 'quickactions-manage')).toBe(true); + expect(present(r, 'version-restore-btn')).toBe(true); unmount(instance!); root!.remove(); - r = render({ canEdit: false, peeking: true, isOwner: true }); - expect(disabled(r, 'star-btn')).toBe(true); - expect(present(r, 'share-btn')).toBe(false); - expect(present(r, 'quickactions-menu')).toBe(false); + instance = null; + root = null; + + // Peeking side: prompts STILL visible (invisible freeze for the read-only + // part), but the collection-management WRITE (Manage) is gone and version + // restore is frozen — both would collide (last-write-wins / Y.Doc overwrite). + r = render({ canEdit: true, peeking: true, isOwner: true, quickActionsPresent: true }); + expect(present(r, 'quickactions-menu')).toBe(true); + expect(present(r, 'quickactions-prompt')).toBe(true); + expect(present(r, 'quickactions-manage')).toBe(false); + expect(present(r, 'version-restore-btn')).toBe(false); }); - it('peeking gate is independent of canEdit — a view-only master already freezes without peeking', () => { - // canEdit=false alone (a genuine read-only viewer) hides the mutation UI; - // `mutationsEnabled` collapses to canEdit when not peeking, so the freeze - // prop changes nothing for that caller. - const r = render({ canEdit: false, peeking: false }); - expect(text(r, 'mutationsEnabled')).toBe('false'); - expect(text(r, 'editor-editable')).toBe('true'); // still a live (read-only) editor, NOT peeking - // The mode toggle stays for a read-only viewer — it's peeking-gated, not - // mutation-gated (the provider only needs protecting from a peek teardown). - expect(present(r, 'mode-toggle')).toBe(true); - for (const surface of MUTATION_SURFACES) { - expect(present(r, surface)).toBe(false); + it('a true viewer (canEdit=false) still sees the read-only forms, regardless of peeking', () => { + // canEdit=false is a genuine read-only viewer — fields/title degrade to + // read-only, mutation controls hide. This is PERMISSION, not the freeze, + // and is identical whether peeking or not. + for (const peeking of [false, true]) { + const r = render({ canEdit: false, peeking, isOwner: false }); + expect(text(r, 'field-readonly')).toBe('true'); + expect(present(r, 'title-readonly')).toBe(true); + expect(present(r, 'title-editable')).toBe(false); + for (const surface of REST_LIVE_SURFACES) { + if (surface === 'title-editable') continue; + expect(present(r, surface), `${surface} hidden for a viewer`).toBe(false); + } + // Star stays available to viewers. + expect(disabled(r, 'star-btn')).toBe(false); + unmount(instance!); + root!.remove(); + instance = null; + root = null; } }); - it('archived restore rides `canRestore && !peeking`, not canEdit (archived items force canEdit false)', () => { - // Not peeking: restore shows for a permitted user. - let r = render({ canEdit: false, peeking: false, canRestore: true }); - expect(present(r, 'archived-restore-btn')).toBe(true); - unmount(instance!); - root!.remove(); - - // Peeking: restore hides even though canRestore is true. - r = render({ canEdit: false, peeking: true, canRestore: true }); + it('archived restore rides `canRestore` alone (not peeking) — archived items force canEdit false', () => { + // canRestore true: restore shows whether peeking or not (invisible freeze). + for (const peeking of [false, true]) { + const r = render({ canEdit: false, peeking, canRestore: true }); + expect(present(r, 'archived-restore-btn')).toBe(true); + unmount(instance!); + root!.remove(); + instance = null; + root = null; + } + // canRestore false: hidden regardless. + const r = render({ canEdit: false, peeking: true, canRestore: false }); expect(present(r, 'archived-restore-btn')).toBe(false); }); }); -// HT-2176 Option A / fix #1 (TASK-2172): a FIELD value typed BEFORE the pane -// opened must SAVE. FieldEditor debounces onchange ~500ms; if peeking begins -// before it fires, the field flips read-only (blocking any NEW edit) but the -// pending debounce still fires onchange — and `updateField` no longer rechecks -// `mutationsEnabled`, so the pre-pane value completes. This mounts the REAL -// FieldEditor to lock that behavior in. -describe('pre-pane debounced field save completes under Option A (TASK-2172)', () => { +// BUG-2263: a FIELD stays interactive across a peeking flip — the input does NOT +// unmount, and a typed value's debounce still fires onchange (→ updateField, +// which is side-independent). This mounts the REAL FieldEditor to lock it in. +describe('field stays interactive across a peeking flip (BUG-2263)', () => { let root: HTMLElement | null = null; let instance: ReturnType | null = null; @@ -189,26 +218,27 @@ describe('pre-pane debounced field save completes under Option A (TASK-2172)', ( return root; } - it('a value typed before peeking still fires onchange after the field goes read-only', () => { + it('the input stays mounted after peeking begins, and the debounced value still saves', () => { vi.useFakeTimers(); const onchange = vi.fn(); const r = mountProbe(onchange); - // Type a value (pre-pane) — arms the 500ms debounce, does NOT fire yet. + // Type a value — arms the 500ms debounce, does NOT fire yet. const input = r.querySelector('input.field-input')!; input.value = 'ui/editor'; input.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); expect(onchange).not.toHaveBeenCalled(); - // Pane opens mid-edit → the field flips read-only (input unmounts, no new - // edit possible) but the armed debounce survives. + // Pane opens mid-edit → the field must STAY interactive (invisible freeze): + // peeking flips true, but the input is NOT unmounted (contrast the old + // degrade-to-readonly behavior). r.querySelector('[data-testid="begin-peek"]')!.click(); flushSync(); - expect(r.querySelector('input.field-input')).toBeNull(); // NEW edit blocked + expect(text(r, 'probe-peeking')).toBe('true'); + expect(r.querySelector('input.field-input')).not.toBeNull(); - // The debounce fires → the pre-pane value reaches the parent (→ updateField, - // which no longer suppresses it). The freeze did NOT drop the typed value. + // The debounce fires → the value reaches the parent (→ updateField). vi.advanceTimersByTime(500); expect(onchange).toHaveBeenCalledTimes(1); expect(onchange).toHaveBeenCalledWith('ui/editor'); diff --git a/web/src/lib/components/timeline/ItemTimeline.svelte b/web/src/lib/components/timeline/ItemTimeline.svelte index b81d11b1..e64ad0c6 100644 --- a/web/src/lib/components/timeline/ItemTimeline.svelte +++ b/web/src/lib/components/timeline/ItemTimeline.svelte @@ -37,18 +37,26 @@ itemId?: string; collectionId?: string; /** - * PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. When the - * full-page host peeks a detail pane beside this item's ItemDetail, the - * master passes `frozen={true}` so its timeline goes fully read-only: - * the composer hides, reply/reaction/delete disable, any already-open - * comment/reply edit form (and its CommentEditor direct-upload) unmounts, - * and version restore hides. Defaults false → byte-identical for every - * existing caller. + * `frozen` freezes the COMMENT/REACTION surfaces (composer, reply, edit, + * delete, reaction). These are per-item / per-user REST entities, so under + * the invisible-freeze model (BUG-2263) the full-page host leaves this + * `false` even while peeking — comments stay live on the passive side. + * Defaults false → byte-identical for every existing caller. */ frozen?: boolean; + /** + * `restoreFrozen` freezes ONLY version restore. Unlike comments, a restore + * REST-writes this item's `items.content` directly (not via the Y.Doc + * applier), so on a peeking side whose Y.Doc is retained-alive it can be + * overwritten by a later collab flush — a SAME-ITEM collision the + * different-master/pane premise does NOT cover (BUG-2263 / Codex P1). The + * host passes `restoreFrozen={peeking}` so restore stays confined to the + * active editor. Defaults false → byte-identical for every existing caller. + */ + restoreFrozen?: boolean; } - let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId, frozen = false }: Props = $props(); + let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId, frozen = false, restoreFrozen = false }: Props = $props(); // Resolve canEditItem reactively; falls to false if itemId/collectionId // aren't supplied (e.g. an older caller). Folds in the master-freeze gate @@ -495,7 +503,7 @@ {itemSlug} {currentContent} {onRestore} - {frozen} + frozen={frozen || restoreFrozen} /> {/if}
diff --git a/web/src/lib/components/timeline/TimelineVersionCard.svelte.test.ts b/web/src/lib/components/timeline/TimelineVersionCard.svelte.test.ts new file mode 100644 index 00000000..fc6f6470 --- /dev/null +++ b/web/src/lib/components/timeline/TimelineVersionCard.svelte.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import TimelineVersionCard from './TimelineVersionCard.svelte'; +import type { Version } from '$lib/types'; + +// BUG-2263 / Codex P1 + P2 — REAL-component coverage for the version-restore +// exception. Unlike the other REST surfaces, version restore REST-writes this +// item's `items.content` directly (colliding with the retained Y.Doc on a peeking +// side), so it stays FROZEN while peeking. ItemDetail wires that as +// `restoreFrozen={peeking}` → ItemTimeline passes `frozen={frozen || restoreFrozen}` +// → TimelineVersionCard's `frozen`. This mounts the ACTUAL card (not a probe) to +// lock the terminal gate: the restore control is present iff NOT frozen. + +function target(): HTMLElement { + return document.body.appendChild(document.createElement('div')); +} + +// Minimal non-diff version so no mount-time API fetch fires (is_diff=false → the +// content-resolve effect returns early) and the card renders its restore area. +const version = { + id: 'v1', + item_id: 'i1', + content: 'hello', + is_diff: false, + change_summary: 'edited', + created_by: 'user', + source: 'web', + created_at: '2026-07-20T00:00:00Z', +} as unknown as Version; + +describe('TimelineVersionCard restore gate (BUG-2263)', () => { + let root: HTMLElement | null = null; + let instance: ReturnType | null = null; + + function render(frozen: boolean) { + root = target(); + instance = mount(TimelineVersionCard, { + target: root, + props: { version, wsSlug: 'ws', itemSlug: 'ITEM-1', currentContent: 'now', onRestore: () => {}, frozen }, + }); + flushSync(); + // The restore-area lives inside the expanded card body — expand it first. + (root.querySelector('.card-header') as HTMLButtonElement).click(); + flushSync(); + return root; + } + + afterEach(() => { + if (instance) unmount(instance); + root?.remove(); + instance = null; + root = null; + }); + + it('shows the restore control when NOT frozen (active side)', () => { + const r = render(false); + expect(r.querySelector('.restore-area')).not.toBeNull(); + }); + + it('HIDES the restore control when frozen (peeking side — same-item content collision)', () => { + const r = render(true); + expect(r.querySelector('.restore-area')).toBeNull(); + }); +});