Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions .context/standards/Architecture-Decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4199,7 +4199,12 @@ and the rename lands with the `ProjectSelector` migration (PT-4549). Both names
store keyed by web view id. Content zoom is the first feature written to the rule: the pane's own
levels are `platform.contentZoomLevels` in the definition state, the default and the per-project
memory are `platform.webViewContentZoom` and `platform.webViewContentZoomMemory`
(`src/renderer/services/web-view-content-zoom.service.ts`).
(`src/renderer/services/web-view-content-zoom.service.ts`). Definition state that belongs to a
project carries the identity it belongs to: the levels are stamped with
`platform.contentZoomIdentity` (`kind:identity`), written and removed with them, because a pane
re-pointed at another project keeps its web view id and the view spreads its own saved state onto
the new definition — so without the stamp the previous project's levels are indistinguishable from
levels chosen for the new one.
- **Alternatives:** (a) **A per-view service with its own hidden `Record` store** — the April
content-zoom prototype, PR #2211 — rejected: two stores for one value, and the one that is not the
definition has to be taught by hand about every lifecycle event the definition gets for free.
Expand Down Expand Up @@ -4576,6 +4581,33 @@ and the rename lands with the `ProjectSelector` migration (PT-4549). Both names
- **Source:** PT-4464; lead dev's review of PR #2670 (2026-08-25), item 11. Surface inventory
measured against the top of the multi-window stack.

## adr-pop-ups-follow-their-content-zoom-area: A pop-up takes the zoom of the area it opens from

- **Date:** 2026-09-17
- **Status:** Accepted
- **Context:** pop-ups portal to `document.body`, outside the zoom area, so they rendered at
interface scale next to zoomed content, and a hand-wrapped editor popover was misplaced at
200 %.
- **Decision:** a pop-up takes the zoom of the area it opens from. `ContentZoomRoot` publishes
its area through React context, and the library's popover, dropdown menu and tooltip mark
their portaled content with the area and a pop-up flag and cap their size by the zoom factor.
The platform bootstrap never counts flagged content as a pane or anchors the indicator on it.
`ContentZoomAreaProvider` covers pop-ups rendered outside the area element.
- **Alternatives:**
- per-call-site `ContentZoomRoot` wraps with a `zoomArea` prop threaded through the comment
list (repeated at every site, easy to forget);
- the bootstrap detecting Radix pop-ups and copying the trigger's area (depends on Radix
internals);
- keeping pop-ups at interface scale (rejected by the product owner).
- **Consequences:**
- six shadcn files carry `CUSTOM` changes - three functional (popover, dropdown menu, tooltip)
and three recording the components that do not follow an area yet;
- the library's `Select`, `ContextMenu`, `Menubar` and dropdown sub-menu
(`DropdownMenuSubContent`) content do not follow an area yet; each needs the same small change
when first opened from zoomed content;
- a pop-up portaled into a container inside another area inherits that container's zoom.
- **Source:** PT-4634.

## adr-primary-window-owns-app-lifetime: The primary window's close decides whether the app quits; the role stays a role

- **Date:** 2026-08-27
Expand Down Expand Up @@ -6995,13 +7027,13 @@ and the rename lands with the `ProjectSelector` migration (PT-4549). Both names
not even register its wheel listener while a pane has no areas — and the platform scales such a view
whole at the Settings default instead. On Windows and Linux this is what a user notices first:
Ctrl+`+`, Ctrl+`-` and Ctrl+`0` now do nothing anywhere except a view that answers them itself —
today the Scripture editor, through the zoom areas it marks, and Enhanced Resources, through the
keydown handler it has always had for its own scripture-pane zoom
today the Scripture editor and the comment list, through the zoom areas they mark, and Enhanced
Resources, through the keydown handler it has always had for its own scripture-pane zoom
(`extensions/src/platform-enhanced-resources/src/web-views/enhanced-resource.web-view.tsx`). Main
no longer claims those chords, nothing replaces them, and a pane with no marked area deliberately
leaves the keystroke to whoever else may want it rather than swallowing it for no effect. So a user
on Notes, or on the Text Collection — whose own pane zoom is wheel and menu only — presses Ctrl+0
and nothing happens. That is the intended cost of scoping zoom to a pane rather than to the window,
on the Text Collection — whose own pane zoom is wheel and menu only — or on any view that marks no
area, such as Home or an inventory, presses Ctrl+0 and nothing happens. That is the intended cost of scoping zoom to a pane rather than to the window,
and it shrinks as views adopt the mechanism (the Text Collection grid in PT-4582, Enhanced
Resources in PT-4583). The bootstrap listens in the **bubble** phase on purpose, so
a view that owns Ctrl+wheel for a sub-region keeps precedence by stopping propagation in the capture
Expand Down
16 changes: 16 additions & 0 deletions .context/standards/Component-Builder-Patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,22 @@ The platform then scales that element on Ctrl/⌘+`+`/`-`/`0`, Ctrl/⌘+wheel an

First reference implementation: the Scripture editor's two areas, `main` for the text and `footnotes` for the footnotes pane (PT-4581).

**Pop-ups follow their area.** Popovers, dropdown menus and tooltips from `platform-bible-react`
that open from inside a `ContentZoomRoot` take that area's zoom level. Popovers and dropdown
menus also cap their own width and height to the pane's available space and scroll their content
if it doesn't fit; tooltips cap only width, so a tooltip taller than the available space is
clipped at the pane's edge. A pop-up your view renders outside the area element — beside the
content and anchored to a position in it — joins the area only when wrapped in
`ContentZoomAreaProvider` (pass the same `area` as the root; omit it for the main area). Toolbar
pop-ups outside every area stay at interface scale. `SelectContent`, `ContextMenuContent`,
`MenubarContent` and `DropdownMenuSubContent` do not follow an area yet either — they render at
interface scale even when opened from inside one. `ContentZoomRoot` and `ContentZoomAreaProvider`
are experimental. A
Comment thread
lyonsil marked this conversation as resolved.
pop-up you build without these components can opt in by putting
`data-platform-content-zoom-root="<area>"` and `data-platform-content-zoom-popup` on its portaled
content. Those attributes only scale it: such a pop-up gets none of the library's size caps, so it
must keep itself inside the pane.

---

## Async Hook State Shape
Expand Down
16 changes: 16 additions & 0 deletions .context/standards/Extension-Development-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,22 @@ That is the whole opt-in. The platform then scales the marked element on Ctrl/

`ContentZoomRoot`, `CONTENT_ZOOM_ROOT_ATTRIBUTE` and the `data-platform-content-zoom-root` contract are **experimental** and may change without notice.

**Pop-ups follow their area.** Popovers, dropdown menus and tooltips from `platform-bible-react`
Comment thread
lyonsil marked this conversation as resolved.
that open from inside a `ContentZoomRoot` take that area's zoom level. Popovers and dropdown
menus also cap their own width and height to the pane's available space and scroll their content
if it doesn't fit; tooltips cap only width, so a tooltip taller than the available space is
clipped at the pane's edge. A pop-up your view renders outside the area element — beside the
content and anchored to a position in it — joins the area only when wrapped in
`ContentZoomAreaProvider` (pass the same `area` as the root; omit it for the main area). Toolbar
pop-ups outside every area stay at interface scale. `SelectContent`, `ContextMenuContent`,
`MenubarContent` and `DropdownMenuSubContent` do not follow an area yet either — they render at
interface scale even when opened from inside one. `ContentZoomRoot` and `ContentZoomAreaProvider`
are experimental. A
Comment thread
lyonsil marked this conversation as resolved.
pop-up you build without these components can opt in by putting
`data-platform-content-zoom-root="<area>"` and `data-platform-content-zoom-popup` on its portaled
content. Those attributes only scale it: such a pop-up gets none of the library's size caps, so it
must keep itself inside the pane.

---

## Contributions
Expand Down
7 changes: 7 additions & 0 deletions .context/standards/Paranext-Core-Patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,13 @@ user controls, a hidden one for the memory.
copy of the definition's state, and it has to be taught by hand about every open, move, reload and
close the definition already handles.

State kept in a definition needs to say **what it belongs to** whenever the pane can be re-pointed at
another project: a re-point keeps the web view id and the view rebuilds its definition by spreading
its own saved state, so the old project's state arrives looking like state chosen for the new one.
Store the identity beside the value and check it. Content zoom stamps
`platform.contentZoomIdentity` (`kind:identity`) alongside its levels and re-seeds the pane from
memory when the two disagree.

Reference: content zoom keeps the pane's own levels under `platform.contentZoomLevels` in the
definition state, and the default and per-project memory in the `platform.webViewContentZoom` and
`platform.webViewContentZoomMemory` settings
Expand Down
118 changes: 115 additions & 3 deletions e2e-tests/fixtures/comment-test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,16 @@ import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { expect, type FrameLocator, type Page } from '@playwright/test';
import { expect, type Frame, type FrameLocator, type Page } from '@playwright/test';
import {
addUsersToProject,
DEFAULT_WEBSOCKET_PORT,
escapeXml,
PAPI_METHOD_REGISTRATION_TIMEOUT_MS,
sendPapiRequestOnce,
waitForPapiMethodRegistered,
} from './helpers';
import { getEditorFrame } from './scripture-editor-helpers';

// ─────────────────────────────────────────────────────────────────────────────
// Constants
Expand All @@ -65,8 +67,6 @@ const PARATEXT_PROJECTS_ROOT = path.join(
/** Network object name for the Paratext project data provider factory */
const PARATEXT_PDPF_METHOD = 'object:platform.Paratext-pdpf.getProjectDataProviderId';

const DEFAULT_WEBSOCKET_PORT = 8876;

/**
* Paratext app-data directories are named `Paratext<major><minor>` — `Paratext80` is 8.0,
* `Paratext94` is 9.4, `Paratext100` is 10.0 — so the suffix read as a number orders the versions
Expand Down Expand Up @@ -635,6 +635,118 @@ export async function openCommentList(mainPage: Page, project: CommentTestProjec
throw new Error(`Failed to open comment list after 5 attempts for project ${project.shortName}`);
}

/**
* Clicks a project-scoped comment-list tab — the Column 3 "Comments" tab in Simple mode
* (`comments-tab.spec.ts`) or the per-project Comments panel tab it shares a layout with
* (`comments-panel-content-zoom.spec.ts`) — handling the rc-tabs overflow case where the tab is
* attached but clipped by the scrollable tab bar: rc-tabs renders every tab node at all times but
* clips those outside the visible portion, so `toBeAttached()` succeeds for a clipped tab while a
* direct click would miss it.
*
* @param webViewId The tab's web view id (`data-web-view-id` on `.platform-tab-title`)
* @param actionTimeoutMs Bounds the click/hover actions — pass a short value when calling inside a
* retry loop so a blocked click (e.g. the workspace-updating overlay intercepting pointer events)
* fails fast and the loop can retry, instead of burning the default 30 s action timeout
*/
export async function clickCommentsTab(
mainPage: Page,
webViewId: string,
actionTimeoutMs = 30_000,
): Promise<void> {
const tabTitle = mainPage.locator(`.platform-tab-title[data-web-view-id="${webViewId}"]`);
if (await tabTitle.isVisible()) {
await tabTitle.click({ timeout: actionTimeoutMs });
return;
}
// Tab is outside the visible scroll area — open the overflow dropdown and activate it.
const dockBar = mainPage.locator('.dock-bar').filter({ has: tabTitle });
await dockBar.locator('.dock-nav-more').hover({ timeout: actionTimeoutMs });
// rc-tabs re-renders PlatformTabTitle (including our data-web-view-id) in the overflow popup.
await mainPage
.locator('[role="listbox"] [role="option"]')
.filter({ has: mainPage.locator(`[data-web-view-id="${webViewId}"]`) })
.click({ timeout: 5_000 });
}

/**
* Points the (worker-scoped, singleton) Comment List Panel — the Column 3 "Comments" tab in Simple
* mode — at `projectId`, via the `legacyCommentManager.openCommentListPanel` command. Shared by
Comment thread
lyonsil marked this conversation as resolved.
* `comments-tab.spec.ts` (called inline for each of its test projects) and
* `comments-panel-content-zoom.spec.ts` (its own Simple-mode Column 3 panel tab).
*/
export async function openCommentListPanel(
projectId: string,
port = DEFAULT_WEBSOCKET_PORT,
registrationTimeoutMs = 60_000,
sendTimeoutMs = 150_000,
): Promise<void> {
await waitForPapiMethodRegistered(
'command:legacyCommentManager.openCommentListPanel',
port,
registrationTimeoutMs,
);
await sendPapiRequestOnce(
'command:legacyCommentManager.openCommentListPanel',
[projectId],
port,
sendTimeoutMs,
);
}

/**
* Calls {@link openCommentListPanel}, brings its tab to front (which is also what mounts the panel's
* iframe the first time — Column 3's tabs render their content lazily, on first activation), and
* retries the whole sequence, bounded, until the iframe attaches and `expectedText` appears inside
* it — ARRANGEMENT only, for seeding the (fixed, non-closable Simple-mode) singleton Comments panel
* with content before a test acts on it. Returns the resolved content frame so the caller doesn't
* need a separate {@link getEditorFrame} call.
*
* TODO(PT-4745): every `openCommentListPanel` call re-points an already-mounted panel rather than
* creating a fresh instance (Simple mode's Comments panel is a singleton mounted before any test
* code runs), and the re-point sometimes never reaches the mounted component's props — the panel
* then keeps showing the previous project (or nothing), with no error. The command is idempotent,
* so reissuing it here is safe. Do NOT reach for this to retry an assertion that is itself testing
* the re-point path — see `comments-panel-content-zoom.spec.ts`'s "re-pointed panel" step, which is
* `test.step.skip`ped for the same underlying bug instead of retried, because retrying there would
* retry the very behavior under test.
*
* @param mainPage The Electron main window page the panel's iframe attaches in
* @param panelId The Comment List Panel's web view id (`data-web-view-id`)
* @param projectId The project id to point the panel at
* @param expectedText Text expected to appear in the panel body once it shows `projectId`'s content
* @param attempts Bounded retry count; the call is cheap and idempotent, so a few attempts absorb
* the intermittent re-point failure without masking a persistent one
*/
export async function openCommentListPanelUntilVisible(
mainPage: Page,
panelId: string,
projectId: string,
expectedText: string,
attempts = 3,
): Promise<Frame> {
let lastError: unknown;
// Sequential retry loop: each attempt must open the panel, activate its tab, wait for its iframe,
// and poll for its content before deciding whether to retry.
/* eslint-disable no-await-in-loop */
for (let attempt = 0; attempt < attempts; attempt++) {
Comment thread
lyonsil marked this conversation as resolved.
await openCommentListPanel(projectId);
await clickCommentsTab(mainPage, panelId);
const timeout = attempt < attempts - 1 ? 20_000 : 90_000;
try {
await mainPage
.locator(`iframe[data-web-view-id="${panelId}"]`)
.waitFor({ state: 'attached', timeout });
const frame = await getEditorFrame(mainPage, panelId);
await expect(frame.locator('body')).toContainText(expectedText, { timeout });
return frame;
} catch (e) {
lastError = e;
}
}
/* eslint-enable no-await-in-loop */
throw lastError;
}

/** Returns the frame locator for the comment list web view iframe. */
export function getCommentListFrame(mainPage: Page): FrameLocator {
// The comment list iframe has title "Comments: {shortName}"; the scripture editor iframe has
Expand Down
Loading
Loading