mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
fix(web): make the master/pane freeze invisible to the user (BUG-2263) (#987)
On the full-page item host, opening a detail pane froze the non-active
side by DEGRADING its DOM — fields became plaintext, buttons vanished,
the title turned read-only. Under the focus-follows-editing model
(PLAN-2179) the freeze is transient and one-click-reversible, so that
degradation was pure user-visible friction: you'd click a plaintext
field, the click would flip activePane, the field would re-render into a
live control, and you'd have to click again.
The freeze exists ONLY 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,
whose collab state is fully itemID-keyed / instance-local, and most REST
surfaces (fields, title, assign/role, tags, move, delete, share,
relationships, children, comments, reactions, archived restore, star) are
single-item, server-gated, side-independent writes.
So drop the `!peeking` term from those REST surfaces — gate them on
`canEdit` alone (their pre-freeze contract) — and keep `peeking` ONLY on
the content editor and its chrome (rich + raw editors, bubble/link
popover, provider-lifecycle mode toggle + retry). The content editor is
already invisible: the host's pointerdown-capture flips activePane before
the click's caret placement (TASK-2180 no-remount reactive editable), so
one gesture activates the side and lands the edit. Now the whole side is:
click anywhere -> edit it, no visible mode.
Two surfaces are NOT side-independent and stay confined to the active side
(the two documented exceptions, found by Codex review):
- Version restore REST-writes this item's `items.content` directly, which
collides with the retained Y.Doc on a peeking side. Kept frozen via a
new ItemTimeline `restoreFrozen={peeking}` prop; comments/reactions
(separate REST entities) stay live.
- The quick-actions "Manage/New" controls rewrite the whole collection
`settings` from a per-item snapshot (last-write-wins across two items in
one collection), so they gate on `isOwner && !peeking` and recheck
canEdit at dispatch; the read-only prompt-copy actions stay visible on
both sides.
Scope: full-page host only. The collection route never passes peeking, so
`mutationsEnabled === canEdit`, `frozen={peeking}` is inert, and
`restoreFrozen` defaults false there — every change is byte-identical on
that route. `mutationsEnabled` survives but now scopes to content-editor
chrome only.
Tests: rewrote the masterFreeze unit probe + both full-page e2e specs
(host + capstone) to assert the new contract — the frozen side keeps its
editable title/fields/buttons; only its content editor flips
contenteditable=false. The freeze signal moved from `h1.title-readonly`
to the ProseMirror `contenteditable` attribute. Added a runtime-mutation
e2e assertion (a field edit on the frozen master PATCHes the correct item),
a real-QuickActionsMenu integration test, and unit coverage for the two
exceptions.
Two PRE-EXISTING concurrency issues were surfaced by the review (version-
restore gating + collection-settings write-exposure are byte-identical to
main, so this PR neither introduces nor worsens them); filed as BUG-2264
(restore <-> Y.Doc reconciliation) and BUG-2265 (collection-settings
optimistic concurrency).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
This commit is contained in:
@@ -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<string, unknown> = { 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 <h1>.
|
||||
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 <h1>).
|
||||
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<string, unknown> };
|
||||
const fields = typeof it.fields === 'string' ? JSON.parse(it.fields) : (it.fields ?? {});
|
||||
return (fields as Record<string, unknown>).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.
|
||||
|
||||
@@ -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 <h1>, 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 <h1>); 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 <h1> title, a NON-focusable,
|
||||
// NON-navigable element that drops focus to <body>. 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=<a different 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=<the master by its ref-shaped slug>` 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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<string, TagSaver>();
|
||||
|
||||
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}
|
||||
<span class="archived-hint">It's read-only until restored.</span>
|
||||
</div>
|
||||
{#if canRestore && !peeking}
|
||||
{#if canRestore}
|
||||
<button class="archived-restore-btn" onclick={handleRestore} disabled={restoring}>
|
||||
{restoring ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
@@ -3799,13 +3800,16 @@
|
||||
onkeydown={handleTitleKeydown}
|
||||
oninput={(e) => autoResizeTitle(e.currentTarget)}
|
||||
></textarea>
|
||||
{:else if mutationsEnabled}
|
||||
{:else if canEdit}
|
||||
<!-- Editable on BOTH master and pane, even while peeking (BUG-2263):
|
||||
the freeze is invisible. Clicking flips activePane (pointerdown)
|
||||
and startEditTitle opens the editor; the actual PATCH is
|
||||
permission-gated (canEdit), not freeze-gated. -->
|
||||
<button class="title" onclick={startEditTitle}>
|
||||
{item.title}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Read-only title (PLAN-1100 / TASK-1105; frozen while peeking,
|
||||
TASK-2172) — no click-to-edit. -->
|
||||
<!-- Read-only title for true viewers (no canEdit; PLAN-1100 / TASK-1105). -->
|
||||
<h1 class="title title-readonly">{item.title}</h1>
|
||||
{/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}
|
||||
<!-- Present on both master and pane even while peeking (BUG-2263);
|
||||
the chip's click flips activePane before refreshFromSource runs,
|
||||
whose own `mutationsEnabled` + `isEditable` guards keep the
|
||||
content REPLACE on the now-active editor. -->
|
||||
<button
|
||||
type="button"
|
||||
class="source-chip"
|
||||
@@ -3886,24 +3894,26 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="meta-actions">
|
||||
<!-- Star toggles a REST mutation (starredStore.toggle) and is otherwise
|
||||
available to viewers too, so it gates on `peeking` ONLY — NOT
|
||||
`mutationsEnabled` (which folds in canEdit and would wrongly disable
|
||||
a non-peeking viewer's star, breaking byte-identity). TASK-2172. -->
|
||||
<!-- Star is a per-user, itemId-keyed REST toggle available to viewers too,
|
||||
and cannot collide across sides — so it is NOT frozen while peeking
|
||||
(BUG-2263). No canEdit or peeking gate. -->
|
||||
<button
|
||||
class="action-btn star-btn"
|
||||
class:starred={starredStore.isStarred(item.id)}
|
||||
disabled={peeking}
|
||||
onclick={() => { if (peeking || !item) return; starredStore.toggle(wsSlug, item.slug, item.id); }}
|
||||
onclick={() => { if (!item) return; starredStore.toggle(wsSlug, item.slug, item.id); }}
|
||||
title={starredStore.isStarred(item.id) ? 'Unstar' : 'Star'}
|
||||
>
|
||||
{starredStore.isStarred(item.id) ? '★' : '☆'}
|
||||
</button>
|
||||
<!-- Quick-actions trigger gated on `!peeking` (TASK-2172): unmounts the
|
||||
whole menu while peeking, dismissing any open dropdown/create-form
|
||||
and blocking the owner create/manage collection-schema mutations.
|
||||
`&& !peeking` → byte-identical when not peeking. -->
|
||||
{#if collection && (quickActions.length > 0 || isOwner) && !peeking}
|
||||
<!-- Quick-actions menu: the prompt-copy actions are read-only and stay
|
||||
visible on the peeking side (invisible freeze, BUG-2263). But the
|
||||
owner "New / Manage actions" controls WRITE the whole collection
|
||||
`settings` JSON from this instance's local snapshot with no merge —
|
||||
and master + pane can be DIFFERENT items in the SAME collection, so
|
||||
two concurrent saves are last-write-wins. So `canEdit` (which gates
|
||||
ONLY those write controls) folds in `!peeking`: the collection-config
|
||||
writes are confined to the active side, the prompts stay on both. -->
|
||||
{#if collection && (quickActions.length > 0 || isOwner)}
|
||||
<!-- {#key itemSlug}: structural containment (PLAN-2105 / TASK-2112).
|
||||
Remount this item-scoped menu on every item switch so any
|
||||
in-flight quick-action continuation is discarded. Keyed on
|
||||
@@ -3923,7 +3933,7 @@
|
||||
{collection}
|
||||
scope="item"
|
||||
{wsSlug}
|
||||
canEdit={isOwner}
|
||||
canEdit={isOwner && !peeking}
|
||||
onmanage={() => {
|
||||
editCollectionSection = 'actions';
|
||||
editCollectionOpen = true;
|
||||
@@ -3989,7 +3999,7 @@
|
||||
📎 {backlinksCount}
|
||||
</button>
|
||||
{/if}
|
||||
{#if mutationsEnabled}
|
||||
{#if canEdit}
|
||||
<div class="move-wrapper">
|
||||
<button class="action-btn" onclick={() => { showMoveMenu = !showMoveMenu; }} disabled={moving}>
|
||||
{moving ? 'Moving...' : 'Move to...'}
|
||||
@@ -4024,17 +4034,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Share opens a dialog that dispatches access-grant / share-link REST
|
||||
mutations; hide the trigger while peeking. `isOwner && !peeking`
|
||||
(not mutationsEnabled) stays byte-identical for an archived-item
|
||||
owner when not peeking. Any already-open dialog is dismissed by the
|
||||
peeking-begin transition (shareDialogOpen=false). TASK-2172. -->
|
||||
{#if isOwner && !peeking}
|
||||
<!-- Share opens a dialog that dispatches owner-only, item-scoped grant /
|
||||
share-link REST mutations — side-independent, so the trigger stays
|
||||
available on the peeking side too (BUG-2263). Any open dialog is still
|
||||
dismissed by the peek-begin transition when this side goes passive. -->
|
||||
{#if isOwner}
|
||||
<button class="action-btn" onclick={() => { shareDialogOpen = true; }}>
|
||||
Share
|
||||
</button>
|
||||
{/if}
|
||||
{#if mutationsEnabled}
|
||||
{#if canEdit}
|
||||
{#if confirmDelete}
|
||||
<span class="delete-confirm">
|
||||
Delete this item?
|
||||
@@ -4131,7 +4140,7 @@
|
||||
{field}
|
||||
value={rawFieldValue}
|
||||
onchange={(v) => updateField(field.key, v)}
|
||||
readonly={!mutationsEnabled}
|
||||
readonly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4146,7 +4155,7 @@
|
||||
{tags}
|
||||
suggestions={tagSuggestions}
|
||||
onchange={updateTags}
|
||||
readonly={!mutationsEnabled}
|
||||
readonly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4159,7 +4168,7 @@
|
||||
<div class="field-row">
|
||||
<span class="field-label">Assigned to</span>
|
||||
<div class="field-value">
|
||||
{#if mutationsEnabled}
|
||||
{#if canEdit}
|
||||
<select
|
||||
class="assignment-select"
|
||||
value={item.assigned_user_id ?? ''}
|
||||
@@ -4192,7 +4201,7 @@
|
||||
<div class="field-row">
|
||||
<span class="field-label">Role</span>
|
||||
<div class="field-value">
|
||||
{#if mutationsEnabled}
|
||||
{#if canEdit}
|
||||
<select
|
||||
class="assignment-select"
|
||||
value={item.agent_role_id ?? ''}
|
||||
@@ -4591,7 +4600,7 @@
|
||||
{#if entry.status}
|
||||
<span class="link-status">{formatFieldDisplay(entry.status)}</span>
|
||||
{/if}
|
||||
{#if entry.linkId && mutationsEnabled}
|
||||
{#if entry.linkId && canEdit}
|
||||
<button class="link-delete-btn" title="Remove relationship" onclick={() => handleDeleteLink(entry.linkId)}>×</button>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -4604,9 +4613,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add Relationship — gated on the master-freeze predicate (PLAN-1100 /
|
||||
TASK-1105; frozen while peeking, TASK-2172). -->
|
||||
{#if item && mutationsEnabled}
|
||||
<!-- Add Relationship — a source-item-scoped REST link create, side-independent,
|
||||
so it stays available on the peeking side too (BUG-2263). Permission-gated
|
||||
on canEdit; the add-link box is dismissed by the peek-begin transition when
|
||||
this side goes passive. -->
|
||||
{#if item && canEdit}
|
||||
<div class="add-relationship-section">
|
||||
{#if !showAddLink}
|
||||
<button class="add-relationship-btn" onclick={() => { showAddLink = true; }}>
|
||||
@@ -4663,13 +4674,12 @@
|
||||
always-mounted SSE guarantee below. -->
|
||||
{#if item}
|
||||
<div id="item-children" class="children-anchor">
|
||||
<!-- ChildItems takes the REAL `canEdit` (reorder authorizes off
|
||||
parent edit) plus a SEPARATE `frozen={peeking}` (TASK-2172): the
|
||||
freeze stops add-child + reorder while child-row navigation stays
|
||||
live, WITHOUT routing add-child's independent capability logic
|
||||
through the parent's canEdit (that would change non-peeking
|
||||
behavior — the byte-identity regression the orchestrator flagged). -->
|
||||
<ChildItems {wsSlug} {username} {itemSlug} itemId={item.id} parentFields={fields} terminalStatuses={childTerminalStatuses} onChildrenChange={(children) => { if (keyedSlug !== itemSlug) return; handleChildrenChange(children); }} {canEdit} frozen={peeking} selfDirty={localDirty} selfLastSaveTime={localLastSaveTime} onOpenTarget={paneOpenTarget} />
|
||||
<!-- ChildItems takes the REAL `canEdit` and is NOT frozen while peeking
|
||||
(BUG-2263): add-child (create/link) and reorder are side-independent
|
||||
REST ops, so they stay live on the peeking side. `frozen={false}`
|
||||
also stops the dndzone from re-initing on activePane flips — removing
|
||||
a source of drill-click swallowing. -->
|
||||
<ChildItems {wsSlug} {username} {itemSlug} itemId={item.id} parentFields={fields} terminalStatuses={childTerminalStatuses} onChildrenChange={(children) => { if (keyedSlug !== itemSlug) return; handleChildrenChange(children); }} {canEdit} frozen={false} selfDirty={localDirty} selfLastSaveTime={localLastSaveTime} onOpenTarget={paneOpenTarget} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -4697,10 +4707,13 @@
|
||||
|
||||
<!-- Unified Timeline (comments + activity + versions) -->
|
||||
<div id="item-timeline" class="timeline-section">
|
||||
<!-- Timeline freeze (TASK-2172 / R12): `frozen={peeking}` hides the
|
||||
comment composer, disables reply/reaction/delete, unmounts any
|
||||
already-open comment/reply edit form (and its CommentEditor direct
|
||||
upload), and hides version restore on a peeking master. -->
|
||||
<!-- Timeline COMMENTS/REACTIONS are per-item / per-user REST entities,
|
||||
side-independent, so they are NOT frozen while peeking (BUG-2263) —
|
||||
composer + controls stay live on the passive side. VERSION RESTORE is
|
||||
different: it 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 a
|
||||
later collab flush could overwrite it — a same-item collision. So it
|
||||
stays frozen: `restoreFrozen={peeking}` (Codex P1). -->
|
||||
<ItemTimeline
|
||||
{wsSlug}
|
||||
{username}
|
||||
@@ -4710,7 +4723,8 @@
|
||||
onRestore={handleVersionRestore}
|
||||
itemId={item.id}
|
||||
collectionId={item.collection_id}
|
||||
frozen={peeking}
|
||||
frozen={false}
|
||||
restoreFrozen={peeking}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
<script lang="ts">
|
||||
// PLAN-2154 Phase 2 / HT-2176 Option A (TASK-2172) — pre-pane field-save probe.
|
||||
// BUG-2263 — INVISIBLE-freeze field probe.
|
||||
//
|
||||
// Mounts the REAL `FieldEditor` to prove the Option A invariant for FIELDS: a
|
||||
// value the user typed BEFORE the pane opened still SAVES (its 500ms debounce
|
||||
// fires onchange even after the field flips read-only on peeking-begin), while
|
||||
// a NEW field edit cannot be started once read-only. `peeking` drives the
|
||||
// FieldEditor's `readonly` exactly as ItemDetail does (`readonly={!mutationsEnabled}`,
|
||||
// with canEdit=true here). A `<button>` flips peeking so the test can simulate
|
||||
// the pane opening mid-edit without prop-rerender gymnastics.
|
||||
// Mounts the REAL `FieldEditor` to prove that a field stays INTERACTIVE across
|
||||
// a peeking flip: ItemDetail now drives `readonly={!canEdit}` (NOT
|
||||
// `!mutationsEnabled`), so opening the pane does NOT flip the field read-only —
|
||||
// the input stays mounted and a typed value's 500ms debounce still fires
|
||||
// onchange (→ updateField, which is side-independent). A `<button>` flips
|
||||
// peeking so the test can simulate the pane opening mid-edit; canEdit is fixed
|
||||
// true here, so `readonly` is constant false regardless of peeking.
|
||||
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
|
||||
import type { FieldDef } from '$lib/types';
|
||||
|
||||
let { onchange }: { onchange: (v: any) => void } = $props();
|
||||
|
||||
// `peeking` is flipped in-test to simulate the pane opening. It is surfaced
|
||||
// (below) so the test can confirm the flip happened — but it is DELIBERATELY
|
||||
// NOT wired into the field's `readonly`, which tracks `!canEdit` alone. That is
|
||||
// the whole point: peeking must NOT change the field's interactivity.
|
||||
let peeking = $state(false);
|
||||
const canEdit = true;
|
||||
const field: FieldDef = { key: 'component', label: 'Component', type: 'text' };
|
||||
</script>
|
||||
|
||||
<button data-testid="begin-peek" onclick={() => (peeking = true)}>peek</button>
|
||||
<span data-testid="probe-peeking">{peeking}</span>
|
||||
|
||||
<FieldEditor {field} value="" {onchange} readonly={peeking} />
|
||||
<FieldEditor {field} value="" {onchange} readonly={!canEdit} />
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<script lang="ts">
|
||||
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172) — master-freeze wiring probe.
|
||||
// BUG-2263 — the master/pane freeze is INVISIBLE to the user.
|
||||
//
|
||||
// HT-2176 Option A: the freeze blocks the INITIATION of NEW edits while the
|
||||
// master peeks; a save the user debounced BEFORE the pane opened completes
|
||||
// normally (no suppression, no data loss, no provider teardown). So this probe
|
||||
// asserts the NEW-EDIT GATES only — every new-edit surface is disabled/gated
|
||||
// while peeking, and byte-identical to the canEdit-only baseline when not.
|
||||
// (That a pre-pane in-flight save is NOT suppressed is a runtime property of
|
||||
// ItemDetail's saver/flush paths — no recheck blocks them — not assertable in
|
||||
// this static gate probe.)
|
||||
// The freeze exists ONLY to keep exactly one TYPEABLE collab content editor
|
||||
// (so the three singleton UX scalars — editorStore.dirty/lastSaveTime,
|
||||
// collectionStore.activeItem, tab title — stay single-owner). It is NOT a
|
||||
// data-collision barrier: master and pane are always DIFFERENT items, whose
|
||||
// collab state is fully itemID-keyed / instance-local. So only the CONTENT
|
||||
// surfaces stay frozen while peeking; every REST surface (fields, buttons,
|
||||
// title, children, timeline, star, share, …) stays interactive on both sides
|
||||
// and is gated on `canEdit` (permission) alone. A click flips activePane first
|
||||
// (host pointerdown-capture), so the interaction lands in one gesture.
|
||||
//
|
||||
// The running-app assertion is deferred to TASK-2175 (F) — no host passes
|
||||
// `peeking={true}` until TASK-2174 (E). The probe imports the SAME
|
||||
// `computeMutationsEnabled` helper `ItemDetail` uses and renders the CANONICAL
|
||||
// gate EXPRESSIONS from ItemDetail.svelte, so the two can't drift — same
|
||||
// pattern as GuardProbe.svelte for the localDirty shadow.
|
||||
// This probe renders the CANONICAL gate EXPRESSIONS from ItemDetail.svelte so
|
||||
// the two can't drift — same pattern as GuardProbe.svelte for the localDirty
|
||||
// shadow. `mutationsEnabled` (= canEdit && !peeking) survives, but now gates
|
||||
// ONLY the content-editor chrome (bubble/link popover); the running-app
|
||||
// assertions live in the pane-full-page e2e specs.
|
||||
import { computeMutationsEnabled } from '../mutationGate';
|
||||
|
||||
let {
|
||||
@@ -31,71 +32,94 @@
|
||||
quickActionsPresent?: boolean;
|
||||
} = $props();
|
||||
|
||||
// The exact derived from ItemDetail.svelte.
|
||||
// The exact derived from ItemDetail.svelte — now scopes to content chrome only.
|
||||
let mutationsEnabled = $derived(computeMutationsEnabled(canEdit, peeking));
|
||||
</script>
|
||||
|
||||
<!-- Scalar gate props threaded to child components (mirror the exact
|
||||
ItemDetail expressions). -->
|
||||
<div data-testid="mutationsEnabled">{mutationsEnabled}</div>
|
||||
|
||||
<!-- CONTENT bucket — stays frozen while peeking (the one collision surface).
|
||||
The editor stays visually identical (same live view, editable flipped in
|
||||
place, no remount); a click activates the side and lands the caret. -->
|
||||
<div data-testid="editor-editable">{!peeking}</div>
|
||||
<div data-testid="raw-readonly">{!canEdit || peeking}</div>
|
||||
<!-- FieldEditor input is readonly while peeking → NO new field edit can be
|
||||
started; a value typed BEFORE the pane opened still saves (updateField has
|
||||
no peeking recheck — its debounce completes). -->
|
||||
<div data-testid="field-readonly">{!mutationsEnabled}</div>
|
||||
<!-- ChildItems receives the REAL canEdit (reorder authorizes off parent edit)
|
||||
plus a SEPARATE frozen — NOT mutationsEnabled — so add-child's independent
|
||||
capability logic is untouched when not peeking. Timeline mirrors this. -->
|
||||
|
||||
<!-- REST bucket — INVISIBLE freeze: interactive on both sides, gated on canEdit. -->
|
||||
<div data-testid="field-readonly">{!canEdit}</div>
|
||||
<div data-testid="child-canEdit">{canEdit}</div>
|
||||
<div data-testid="child-frozen">{peeking}</div>
|
||||
<div data-testid="timeline-frozen">{peeking}</div>
|
||||
<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 hides on `!peeking`
|
||||
— NOT on `mutationsEnabled` (a genuine read-only viewer keeps the toggle;
|
||||
only a peeking master must not tear the provider down). -->
|
||||
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}
|
||||
|
||||
<!-- Mutation UI gated on `mutationsEnabled` — unmounted while peeking. -->
|
||||
{#if mutationsEnabled}
|
||||
<!-- REST mutation UI — gated on `canEdit` alone, present on both sides. -->
|
||||
{#if canEdit}
|
||||
<button data-testid="delete-btn">Delete</button>
|
||||
{/if}
|
||||
{#if mutationsEnabled}
|
||||
{#if canEdit}
|
||||
<button data-testid="move-btn">Move to…</button>
|
||||
{/if}
|
||||
{#if mutationsEnabled}
|
||||
{#if canEdit}
|
||||
<button data-testid="add-relationship-btn">+ Add relationship</button>
|
||||
{/if}
|
||||
|
||||
<!-- Editor bubble/link popover is CONTENT chrome — it only appears on an editor
|
||||
interaction (selection), which requires the side to be active, so it stays
|
||||
gated on `mutationsEnabled` (invisible: never shows on the passive side). -->
|
||||
{#if mutationsEnabled}
|
||||
<button data-testid="editor-mutation-ui">bubble/link popover</button>
|
||||
{/if}
|
||||
|
||||
<!-- Title: editable click-to-edit vs read-only heading. -->
|
||||
{#if mutationsEnabled}
|
||||
<!-- Title: editable click-to-edit on both sides (canEdit); read-only heading is
|
||||
for TRUE viewers (no canEdit) only. -->
|
||||
{#if canEdit}
|
||||
<button data-testid="title-editable">Edit title</button>
|
||||
{:else}
|
||||
<h1 data-testid="title-readonly">Title</h1>
|
||||
{/if}
|
||||
|
||||
<!-- Archived restore uses the `canRestore && !peeking` split (canEdit is forced
|
||||
false for archived items, so it can't ride `mutationsEnabled`). -->
|
||||
{#if canRestore && !peeking}
|
||||
<!-- Archived restore rides `canRestore` (canEdit is forced false for archived
|
||||
items); NOT frozen while peeking — restore is a side-independent REST op. -->
|
||||
{#if canRestore}
|
||||
<button data-testid="archived-restore-btn">Restore</button>
|
||||
{/if}
|
||||
|
||||
<!-- Star gates on `peeking` ONLY (viewers can star; mutationsEnabled would
|
||||
wrongly disable a non-peeking viewer). -->
|
||||
<button data-testid="star-btn" disabled={peeking}>Star</button>
|
||||
<!-- Star: per-user, itemId-keyed REST toggle — no canEdit or peeking gate. -->
|
||||
<button data-testid="star-btn">Star</button>
|
||||
|
||||
<!-- Share + the whole quick-actions menu gate on `!peeking` (the menu unmounts
|
||||
while peeking, dismissing any open dropdown/create-form). NOT mutationsEnabled
|
||||
— byte-identical for an archived-item owner. -->
|
||||
{#if isOwner && !peeking}
|
||||
<!-- Share: side-independent owner-only REST op — present on both sides. -->
|
||||
{#if isOwner}
|
||||
<button data-testid="share-btn">Share</button>
|
||||
{/if}
|
||||
{#if (quickActionsPresent || isOwner) && !peeking}
|
||||
<button data-testid="quickactions-menu">Quick actions</button>
|
||||
|
||||
<!-- Quick-actions menu: the prompt-copy actions are read-only and present on both
|
||||
sides; the owner "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 `isOwner && !peeking` — the write is confined to the active
|
||||
side (BUG-2263 / Codex P1). ItemDetail forwards `canEdit={isOwner && !peeking}`. -->
|
||||
{#if quickActionsPresent || (isOwner && !peeking)}
|
||||
<div data-testid="quickactions-menu">
|
||||
{#if quickActionsPresent}
|
||||
<button data-testid="quickactions-prompt">Copy a prompt</button>
|
||||
{/if}
|
||||
{#if isOwner && !peeking}
|
||||
<button data-testid="quickactions-manage">Manage actions</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Version restore REST-writes this item's `items.content` directly, colliding
|
||||
with the retained Y.Doc on a peeking side — a SAME-ITEM collision. So it stays
|
||||
FROZEN while peeking (`restoreFrozen={peeking}`), unlike comments/reactions
|
||||
which are side-independent and stay live (BUG-2263 / Codex P1). -->
|
||||
{#if !peeking}
|
||||
<button data-testid="version-restore-btn">Restore this version</button>
|
||||
{/if}
|
||||
|
||||
@@ -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<typeof mount> | 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<typeof mount> | 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<HTMLInputElement>('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<HTMLButtonElement>('[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');
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
@@ -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<typeof mount> | 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user