Skip to content

PT-4184: Add the verse-aligned Grid view to the Text Collection - #2781

Merged
katherinejensen00 merged 1 commit into
mainfrom
pt-4184-grid
Sep 25, 2026
Merged

katherinejensen00 merged 1 commit into
mainfrom
pt-4184-grid

Conversation

@katherinejensen00

@katherinejensen00 katherinejensen00 commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Adds a third VIEW mode to the Text Collection — Grid — where verse N of every resource sits on
one row, so translations can be read in parallel down a passage. Verse and Chapter are unchanged.

        GRK              NRSV             CPB
      ┌───────────────┬────────────────┬───────────────┐
   1  │ ¹When there…  │ ¹Now when…     │ ¹Jesus knew…  │   row height = tallest cell
   2  │ ²(although…   │ ²— although…   │ ²His disci…   │
   3  │ ³he left Ju…  │ ³⁻⁴he left Ju… │               │   bridge spans 2 rows;
   4  │ ⁴And he had…  │       ↕        │ ⁴He had to…   │   CPB has no verse 3
      └───────────────┴────────────────┴───────────────┘

The one idea

Rows are CSS grid tracks on the grid root, shared by every column. A display: contents /
subgrid chain carries that row axis down through each column to the verse-block elements the
editor produces upstream (PT-4304), and each
block is placed on an explicit grid-row derived from its own data-verse-start/-end.

Two consequences worth holding onto while reading:

  • Nothing measures anything. A row is as tall as its tallest cell natively, which is also why
    per-resource zoom needs no special handling.
  • Every level between the root and a verse block must stop generating a box — including three
    elements owned by @eten-tech-foundation/platform-editor. That coupling is the main thing to
    scrutinize; it exists because the view keeps one editor per column so a reader can select and
    copy a passage down it.

Where to look, in review order

File What it does
aligned-grid.styles.ts Start here. The whole layout: the flatten-the-chain rules and the generated per-verse row rules.
aligned-grid.component.tsx The grid root — the view's only scroll port; owns the stylesheet and column tracks.
use-aligned-reference-scroll.hook.ts Scroll-to-reference. Three rules keep it from fighting the reader (below).
resource-column.component.tsx One column, now shared by the chapter row and the grid.
scripture-text-grid.component.tsx The viewMode seam — a third branch, not a variant of the flex ones.
resource-cell{,-view}.component.tsx Asks the editor for block verses, hands scrolling to the grid, and renders the "no verses" state.
upstream-editor-contract.test.ts Fails when a class or attribute the layout depends on disappears from the installed editor.
aligned-grid.component.stories.tsx The visual reference: bridged verse, missing verse, poetry, RTL, six-column overflow.
adr-aligned-grid-flattens-the-editor-dom Why the coupling, and the alternatives rejected to get there.

What to scrutinize

  • The upstream DOM coupling. Three class names this repo doesn't own are flattened. A rename
    breaks alignment silently — the grid still renders, just wrong — so the guard is a contract test
    that reads the installed editor bundle. Is that the right guard?
  • The drag source moved to the header band, because a column-wide draggable hijacks click-drag
    text selection in Chromium. This changes the chapter view too — intentional, worth confirming.
  • The reference scroll is three interacting rules: a verse already on screen is left alone
    (clicking a verse reports it as the new reference), it re-checks as slower columns arrive, and it
    stands down once the reader scrolls. The "did the reader move it?" test compares scrollTop to
    what the hook last wrote — a heuristic, with overflow-anchor: none to stop the browser tripping it.
  • Nothing here has been run in the app yet. Mixed per-column zoom, the sticky header under
    horizontal scroll, and a chapter with no verses all deserve eyes.

Known limitations

  • Sub-verse markers overlap. \v 3a and \v 3b both resolve to row 3 and paint on top of each
    other. No core-side fix exists — filed as
    PT-4559 for an upstream change.
  • Section headings are hidden in this view, and rows are visual, not announced — both
    deliberate, both recorded in the ADR with the reasoning and the path to revisiting.

Testing

npm run typecheck (4/4), npm run lint, and npm test all pass — 88 files / 1461 tests in this
extension. Note the root lint run ignores extensions/, so these files were also linted with the
extensions/ config directly; that is what caught two type-assertion errors.

Not run: e2e (needs a running app plus E2E_TEST_RESOURCE_IDS) and Chromatic.

Full review summary — findings, decisions, and suggested agenda

Findings

Critical — Must address before merge

None.

Important — Should address before merge

  • The grid scrolled out from under the reader. Every reference change scrolled the target
    verse to the top, including changes the grid itself originates — clicking or selecting text in
    a column reports a new reference, so a click moved the passage under the cursor. (fixed during
    review: a verse already on screen is left alone, the rule useBcvSyncScroll already implements
    for the comment list)
  • The scroll latched on the first verse block to render, so a column arriving later pushed
    the target back off screen with no correction. (fixed during review: re-checked as columns
    arrive; stops once the reader moves the port themselves, until the reference changes)
  • Chrome's scroll anchoring rewrites scrollTop on the grid root, which the "did the reader
    scroll?" check read as the reader — firing exactly in the late-column case it exists for.
    (fixed during review: overflow-anchor: none on the port)
  • The whole column was an HTML5 drag source, which in Chromium hijacks click-drag text
    selection — the capability one-editor-per-column exists to protect. (fixed during review: the
    header band is the drag source; the column stays the drop target. Applies to chapter view too,
    by decision)
  • npm run lint did not cover these files. The root ESLint run ignores extensions/ by
    default, so two no-type-assertion errors in new test code went unreported. (fixed during
    review: real DOMRect instances instead of as DOMRect literals; verified with the
    extensions-scoped config)
  • Footnote caller counters leaked across columns. .editor-input carries
    counter-reset: caller crossref, and display: contents destroys that scope. (fixed during
    review: re-homed onto the per-column content wrapper)
  • Verse 0 and verse 1 produced the same scroll key, so moving between them never re-armed —
    and the call violated resolveDisplayVerseNum's documented "chapter surfaces must not call
    this". (fixed during review: the raw verse is used, plus versification in the key)
  • A malformed verse corrupted alignment. Upstream removes data-verse-start for a reversed
    bridge (3-1); such a block matched no row rule and was auto-placed into the first free row of
    the shared grid. (fixed during review: those blocks are dropped, which keeps the rest of the
    column aligned; the verse is still readable in Verse and Chapter view)
  • Escape was swallowed in Grid view by the capture-phase chapter-context handler for a split
    the reader cannot see. (fixed during review: gated on the verse view)
  • Aligned-view reorder was asserted in three places and tested in none. (fixed during
    review: keyboard-move, header-drag, and drop-target tests in the aligned block)
  • The verse view still hand-rolled the drag wiring this PR centralized, leaving two
    implementations of one behavior. (fixed during review: it consumes buildReorder)
  • The zero state was hand-rolled markup where EmptyState exists, and so lacked the
    role="status" a zero state should have. (fixed during review)
  • The "Coming soon" hint labeled the wrong toggle. It renders after the whole group, and Grid
    was appended after the disabled Chapter item — the component default, and what the stories
    show. (fixed during review: the hint names the mode it describes)
  • Only the e2e geometry test validates against the real editor DOM, and it is CI-skipped, so
    an upstream rename would leave the other guards green.
    (fixed during review: a new
    upstream-editor-contract.test.ts reads the installed editor bundle and fails when a class or
    attribute the layout depends on is gone — and asserts our stylesheet still names it)
  • The editor pin does not express what the code needs. (moot since the rebase onto
    main: both manifests depend on the staged file:dev-packages/staging/platform-editor
    build (0.8.16), so there is no registry pin to bump — see
    adr-dev-packages-staged-file-deps)

Minor — Consider

  • repeat(0, …) is invalid CSS and the web view briefly passes an empty resource list (fixed
    during review: the track count floors at one and is counted from the columns rather than taken
    as a prop that could disagree with them)
  • Column floor was 15rem against the chapter row's 16rem (fixed during review: matched, with a
    comment tying the two spellings together)
  • No overflow-wrap, so a long unbreakable run could paint over the next column (fixed during
    review)
  • scrollPortToBlock dropped the clientTop subtraction that getTopWithinScrollContainer
    makes, which matters because this view allows an external border (fixed during review)
  • The frame-budget e2e test could hang until Playwright's global timeout instead of failing on
    its assertion (fixed during review: a bounded wait, and the observer disconnects on every
    path)
  • emptyMessage conflated "should this be empty?" with "did the string resolve?" (fixed during
    review: falls back to the key)
  • "No verses to align" used internal vocabulary; the view is called Grid (fixed during
    review: reworded, and it now says what to do next)
  • stg-aligned coined an abbreviation used nowhere else in the repo, in the DOM and three test
    files (fixed during review: spelled out)
  • The View Options key list was not in the en/es parity test (fixed during review)
  • hasAlignableVerse's table/sidebar claim was untested (fixed during review)
  • Story fixtures were named after translations (greek, cpb) rather than what each
    demonstrates (fixed during review)
  • The shortcut catalog's locations pointed at a file with no keyboard handler (fixed during
    review)
  • Per-column zoom in a view whose purpose is alignment (measured at 1.5× in Chromium during
    design: rows stayed aligned, because the row is sized by its tallest cell. Kept — an
    original-language column often needs a larger font. Worth confirming in the app.)
  • ResourceColumn has no story (matches this folder's split: presentational views get
    stories, components that render the PAPI-connected ResourceCell do not)
  • Grid view always scrolls sideways below a 300px pane (inherent to a column-per-resource
    layout, and the same floor the chapter view already uses; the root is still the single scroll
    port)
  • "Grid" names a layout while Verse and Chapter name a unit of scripture (author chose Grid
    over Aligned and Parallel; internal value stays aligned)
  • .styles.ts is a new file-name suffix in this directory (kept: the file is a stylesheet,
    and .const.ts/.utils.ts would describe it less well)
  • Sub-verse markers collide. \v 3a and \v 3b both resolve to data-verse-start="3", so
    both blocks land in one grid area and paint over each other. No CSS-only fix exists — stacking
    would need a per-row wrapper the editor does not emit. Filed as PT-4559 (upstream fix);
    Grid view renders overlapping text for those verses until then.
  • Section headings are hidden in Grid view with no indication content was dropped (it is
    reachable in Verse and Chapter view). Deliberate — recorded in the ADR with the path to
    spanning them across a full-width row later.
  • Rows are not announced. The grid is a group of labeled column regions; ARIA table semantics
    would need a row-major DOM, which one-editor-per-column rules out. Verse numbers at the start
    of each block are what let a screen-reader user correlate columns. Flagged for AT validation.

Template Propagation

Shared Regions Modified

None. No changed file contains a #region shared with marker.

Extension Config Changes

None. No package.json, tsconfig*.json, .eslintrc*, webpack.config.*, .erb/configs/, or
.github/workflows/ file was touched.

Positive Observations

  • The hidden-tab rule (.claude/rules/cross-view-sync-hidden-views.md) is satisfied end to end —
    useViewVisibility + useRunWhenVisible, a comment at the sync site, and the catch-up test the
    rule asks for (mount hidden, change the reference, flip visibility).
  • ResourceColumn is a genuine deduplication: the chapter row's inline region/drag markup and the
    new aligned column are now one component, with the layout difference isolated to two props.
  • The upstream DOM coupling is defended where it is fragile: a contract test against the installed
    editor bundle, a story that reproduces the markup for Chromatic, and an e2e test that measures real
    block geometry with a positive control so it cannot pass vacuously.
  • Generated CSS is unit-testable by construction, and the tests assert the invariant that matters —
    declared row count ≥ emitted rules ≥ Psalm 119's 176 verses.
  • Localization is complete: both new keys in en and es, registered in the frozen key lists, and
    now covered by the parity test.
  • The architecture decision is recorded with its alternatives and its residual risks, inserted in
    slug byte order, and referenced from the code by slug rather than restated.

Interview Notes

Stated purpose: add the verse-aligned Grid view to the Text Collection so verse N of every
resource lines up on one row.

Decisions the author made during review:

  • Sub-verse collision: file it upstream rather than hack around it in core — there is no CSS-only
    fix, and a JS pass would fight the editor's DOM.
  • Degrade-to-unaligned: first fixed with a :has() gate, then the gate was removed — an empty column
    is the recoverable failure (see the ADR).
  • Column-wide drag source: fix in this PR, accepting that it changes chapter view too.
  • Upstream-rename guard: build the real one.
  • Escape handling: fix now.
  • Earlier in the branch: verse numbers inline (matching what upstream renders), "Grid" as the visible
    label with aligned as the internal value, and one PR rather than staged ones.
  • Section headings and non-announced rows: the author asked for a recommendation rather than a
    question, and both are now recorded as settled decisions in the ADR.

Verification note for the reviewer: the grid component test mocks useStylesheet — the
stylesheet is inert under jsdom (no layout) — and its content is asserted directly in
aligned-grid.styles.test.ts.

Verified outside the app (2026-09-23, @captaincrazybro in review): the generated stylesheet was
rendered in headless Chromium and measured — row alignment through the display: contents/subgrid
chain, bridged and verse-200 placement, hidden unplaceable blocks, the sticky header (including under
horizontal scroll), mixed-zoom alignment, placeholder columns, and scrollTop surviving
display: none.

Not verified in the running app: none of this has been exercised in Platform.Bible itself. The e2e tests that
measure real geometry are local-only and need E2E_TEST_RESOURCE_IDS; the Storybook stories have not
been rendered. The Chromatic snapshots are unconfirmed.

In-Review Quality Check

  • npm run typecheck — all four projects clean.
  • npm run lint — clean. Also ran ESLint with the extensions/ config, which the root run
    ignores; that is what surfaced the two type-assertion errors, now fixed.
  • npm test — 245 core files and every workspace pass; the extension's own suite is 88 files /
    1461 tests. Exit 0.
  • Not run: e2e (needs a running app and downloaded resources) and Storybook/Chromatic.

Suggested Review Focus

  • The upstream DOM coupling. Three class names this repo does not own are flattened with
    display: contents. Is the contract test the right guard, and is anything else lost by
    flattening beyond the padding and counter scope already accounted for?
  • Moving the drag source to the header changes chapter view too. Confirm that is the wanted
    behavior there, not just in Grid.
  • The reference-scroll rules. Three interacting rules (leave a visible verse alone, re-check
    as columns arrive, stand down once the reader scrolls) — the "did the reader move it?" check is
    a heuristic comparing scrollTop to what the hook last wrote.
  • Run it. Nothing here has been seen in the app: mixed per-column zoom, the sticky header
    under horizontal scroll, and a chapter with no verses are all worth a look.
  • The pin bump to ~0.8.16 before or at merge.
  • Sub-verse overlap (PT-4559): agree the upstream fix is the right call, and that shipping with
    the limitation is acceptable meanwhile.

AI-assisted — session


This change is Reviewable

@jolierabideau jolierabideau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI-assisted review summary

Ran a seven-perspective AI review panel (security, scope, contracts, architecture, tests, UX, clarity) against 3a993c5, with PT-4184's acceptance criteria and named requirements (F2/F3/F4, R3/R6/R7/R8, and the F6 banding note) given to each reviewer. Findings below are filtered and reconciled by hand, not pasted raw — where two reviewers disagreed I read the code and adjudicated. Flagging it as AI-assisted so you can weight it accordingly.

Short version: the architecture delivers approach D and the engineering discipline is genuinely strong. Two things block merge and neither is about code quality. Your PR description had already disclosed most of what the panel found, which made this much easier to review.

Blocking

1. The branch doesn't build from the committed manifests.

You flag the pin in "Known limitations", but I think it's worse than "bump both manifests once it publishes":

  • npm's latest published platform-editor is 0.8.15. 0.8.16 isn't on the registry at all.
  • The PR bumps nothing — no diff to the root manifest, the extension manifest, or package-lock.json, which still resolves platform-editor-0.8.15.tgz.

So npm ci + npm run typecheck fails on the BLOCK_VERSE_VIEW_MODE import (TS2305), and three of upstream-editor-contract.test.ts's six cases go red. Extension tests do run in CI (npm test covers --workspaces --if-present).

One thing I could not determine and am not asserting: whether 0.8.16 actually contains the block-verse work. The only 0.8.16 I can see is a local yalc link built from a sibling scripture-editors checkout that's well behind its remote, and it has neither the constant nor verse-block/data-verse-start/data-verse-end. That's equally explained by a stale local build. Which published version should this pin to?

2. The AC 7 frame-budget check can't fail — scripture-text-grid.spec.ts:791-822.

openAlignedGridWithResources awaits getByTestId('scripture-text-grid-aligned') toBeVisible before returning (scripture-text-grid.page.ts:288). The five [role="region"] columns are AlignedGrid's own children from the same React pass, so the synchronous pre-check at :812 already sees ≥5 and resolves immediately — elapsedMs reads ~0 regardless of real cost. The chapter original works because switchToChapterView() returns before the render.

The pattern was reused structurally; the measurement window was lost. Two safeguards also didn't come across: the 15s give-up setTimeout (:478-483), and the pre-check resolves without observer.disconnect().

Worth a look before merge

  • Per-column zoom may defeat alignment. Zoom lands as inline CSS zoom on [data-cell-content] (resource-cell-view.component.tsx:325), which aligned-grid.styles.ts:82-90 makes a grid-template-rows: subgrid box. zoom scales used layout values, so a zoomed column measures against scaled row tracks while its neighbours don't. The ticket's "native subgrid handles this" reasoning holds for differing font sizes — I'm not confident it holds for zoom. You list this as unverified; this seems like the mechanism that makes it a real risk.
  • Verse-0 on the placement side. AC 3 names it separately. buildVerseRowRules starts at 1, and the guard at aligned-grid.styles.ts:111 only catches blocks missing data-verse-start — so a block with data-verse-start="0" (or >200) passes the :not() filter, gets no grid-row, and is auto-placed into the shared grid's first free row: the exact silent misalignment that rule's own comment describes. In practice :127 suppresses it wholesale instead, so alignment holds because the content is gone — chapter view keeps that front matter. Inverting the guard to park anything the rules didn't place would close both cases.
  • Localization immutability. %webView_scriptureTextGrid_viewOptions_comingSoon% is edited in place in en (:182) and es (:370). The Localization Guide marks modifying existing key/value pairs as CRITICAL — new key + fallbackKey in the metadata section (already at :386). Simplest fix is probably reverting the reword, especially since the string is currently unreachable (isChapterEnabled is passed unconditionally at scripture-text-grid.web-view.tsx:549).
  • Chapter-view drag. You called this out, so just confirming the coverage consequence: cell-reorder.spec.ts still passes only because it runs in the default verse view, where the listitem is still draggable. Chapter-mode column drag now has no coverage. The data-testid="scripture-text-grid-cell-draggable" also now names an element that's a drop target but not a drag source — worth renaming.
  • Keyboard operability of the scroll port. aligned-grid.component.tsx:54 has no tabIndex, and the read-only editors aren't focusable, so nothing inside the single port takes focus — Arrow/PageDown can't scroll it, and content below the fold is pointer-only. In chapter mode this was per-column; here it's the whole view.
  • Placeholder cells render off-screen. grid-row: 1/-1 plus h-full/justify-center centres a spinner or "Download failed" at the midpoint of the full chapter height — so one late resource among five looks like a blank column.
  • The reference-scroll observer watches the root with subtree: true, calls requestScroll() per mutation batch unthrottled, and never stands down — a forced sync layout (querySelectorAll over ~176×6 blocks + three rect reads) per batch, across six mutating Lexical editors. Squarely inside what AC 7 is meant to measure. Coalescing into one rAF and disconnecting once applied would help.
  • viewMode is re-spelled as an inline literal union in three places beyond the exported ResourceCollectionViewMode, and persisted viewMode isn't coerced on read — once a user picks Grid, 'aligned' is in their saved layout permanently, so a revert or downgrade renders the verse list while handing viewMode="aligned" to ResourceCell.

On the coupling question you asked about

You asked whether the contract test is the right guard. I think yes in design, incomplete in coverage: it resolves via createRequire and reads the real installed dist/index.js, and the bundle guard means a missing dist fails loudly rather than vacuously — that's the right shape. But CONTRACT omits the two upstream API symbols the code actually imports (BLOCK_VERSE_VIEW_MODE, getViewOptions), which are the highest-risk part, and resource-cell.component.test.tsx:57-61 mocks the whole module so nothing observes whether they exist. It also omits .editor-placeholder, which :132 selects — an upstream rename there would silently make a read-only grid invite edits. Adding those three would close the gap the docblock claims to close. (Caveat: toContain matches anywhere in the bundle, so a rename that leaves the old string in a comment still passes. Fine for a tripwire.)

Things that are notably well done

  • The hidden-view rule is fully honoured — both sanctioned helpers, deferral documented at the sync site with a pointer to the rule file, repeats collapsed, consumed instantly, and backed by a real test that mounts hidden, changes the reference, flips visibility, and asserts the catch-up fires. That's the rule most often skipped.
  • The ADR is properly done — correct LC_ALL=C slug position, all required fields, alternatives honestly recorded. One inaccuracy: Consequences say an out-of-range verse "would fall outside the explicit grid", but your own stylesheet comment at :106-110 says the more accurate and worse thing.
  • The e2e helper move is byte-for-byte faithful — checked symbol by symbol; additions only, all four consuming specs repointed, no assertion weakened.
  • Comment discipline is clean across ~50 new comments — no change narration, no review-finding IDs, no stage tags. One in-PR ticket banner (scripture-text-grid.spec.ts:703) is the only exception.
  • Security: zero findings. The generated CSS interpolates only integer-coerced verse numbers, so no selector injection.
  • useStylesheet is established here (four existing consumers), contentOverflow matches the ticket's specified shape exactly, and ResourceColumn is a net reduction in duplicated drag wiring.

AC status

Cleanly satisfied: 2, 5, 8/R8, 9, F2, F3/R7, F4, R6, scroll-ownership, third-branch. Partial: 3 (verse-0), 4 (locale reversal implicit — no story or assertion; the existing RTL e2e asserts flexDirection, which the grid branch doesn't use), 7 (above). Satisfied but unverifiable without the app: 1, 6. F6 is vacuous — no banding implemented, so nothing can violate the row-index rule yet. R3 still needs Alex's sign-off, and the rule is broader than \s headings — it hides every non-verse-block child, so \d titles and intro material go too.

Also: the ticket's open question about verse-number placement is no longer free — the accessibility story leans on the numbers being inline, so a once-per-row gutter would take it with it.


Generated with Claude Code (Opus 5) and edited by hand before posting. Full report available on request; happy to be wrong on any of these, particularly the zoom/subgrid interaction, which I reasoned about but did not run.

@katherinejensen00 katherinejensen00 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this was a genuinely useful review, and the two blocking items were both real. Everything below is pushed as 51a0127. I also ran a separate multi-agent review pass on top of yours, which found several more things in the same areas; where that changed an answer you gave me, I've said so.

Blocking

  1. "The branch doesn't build from the committed manifests… Which published version should this pin to?"

None — the pin isn't where the answer lives. Platform.Bible deliberately builds the editor from scripture-editors' platform-yalc branch rather than from npm releases; that branch's own README says so ("to avoid the overhead of making releases for every little change we need to make to the editor"). dev-packages.json names revision: platform-yalc, and npm ci → postinstall → link-dev-packages checks it out, builds it, and yalc-links it over node_modules. That branch is at ddd55c4, which contains PT-4304 "Add read-only block-verse view mode" (#538) and is versioned 0.8.16 — so the installed editor does have BLOCK_VERSE_VIEW_MODE, verse-block, and both range attributes. CI is green on all three platforms, npm test included. Bumping the manifests to ~0.8.16 would break npm install outright, since that version isn't on the registry: the ~0.8.15 pins are the floor npm resolves before the link replaces it.

Your local 0.8.16 was a stale build — so was mine, which is a good catch in itself; npm run link-dev-packages rebuilt it and the symbols appeared. What I have changed is the failure mode: upstream-editor-contract.test.ts now checks BLOCK_VERSE_VIEW_MODE and getViewOptions in the installed bundle, so a stale or unlinked editor fails with a test that names the missing symbol instead of a TS2305 deep in a build. The ADR now records where the requirement actually comes from, and I'll fix the "bump both manifests once it publishes" line in the PR description, which was wrong.

  1. "The AC 7 frame-budget check can't fail… the measurement window was lost."

Correct, and worse than you diagnosed: switchToChapterView also ends with an awaited press('Escape'), so the chapter "original" had the same defect. Both tests now arm the measurement before the view switch and read it after (armColumnRenderMeasure / readColumnRenderMs) — the only ordering that actually brackets the render. The 15s give-up is back, every exit path disconnects and clears it, and a give-up now throws rather than returning 15000 as though it were a measurement.

Worth a look

"Per-column zoom may defeat alignment." You identified the mechanism correctly, and the ticket's "native subgrid handles this" reasoning doesn't cover it — differing font sizes and a scaled box aren't the same thing. Rather than argue it, I moved the zoom off that box: ResourceCellView takes a zoomTarget, and in the grid it publishes the factor as --aligned-zoom instead of setting zoom, with the stylesheet zooming the verse blocks. Tracks are never inside a zoomed box, and a row still takes the height of its tallest — now larger — cell. New MixedZoom story so Chromatic sees it, plus a test asserting [data-cell-content] carries no zoom. Still worth your eyes in the app.

"Verse-0 on the placement side… Inverting the guard to park anything the rules didn't place would close both cases." Done that way. Verse blocks are display: none by default and the generated start rule adds display: block alongside grid-row-start, so being placed is what makes a block visible; the old :not([data-verse-start]) rule is gone as a special case. hasAlignableVerse now applies the same test (and searches the whole paragraph subtree), so a chapter of reversed bridges or verses past 200 reports as empty instead of rendering a silent blank column.

"Localization immutability." Straight violation, thank you. Both values restored; the hint reads a new %…_viewOptions_chapterComingSoon%. I did not add the fallbackKey redirect: comingSoon isn't being replaced — its value and meaning are unchanged and still correct as a generic hint — so a fallback pointing at a chapter-specific string would be inert (the old key still resolves) and misleading. It's now unreferenced, which the immutability rule means we keep. Happy to add the metadata entry if you'd rather follow the recipe literally.

"Chapter-view drag… the testid names an element that's a drop target but not a drag source." Renamed: the column is scripture-text-grid-column-drop-target (now present only when it is one) and the header band is scripture-text-grid-column-drag-source; both on the page object. On coverage — chapter-mode column drag is covered at the unit level (the "chapter view reorder" block drags header-r-b onto a column, plus ring, self-hover, clear-on-drop, keyboard). Your underlying point still landed, though: those tests mock ResourceCell and hand-roll the header, so deleting draggable from the real component would leave them green. Added ResourceCellView header drag source tests against the real component.

"Keyboard operability of the scroll port." Added tabIndex={0} plus a focus-visible ring, since it's now the one focusable thing in the view with no control of its own to show focus on. My first justification comment was wrong — there are tab stops inside (grip, zoom kebab); the real reason is that both sit in the sticky header and neither scrolls the port. Comment and test rationale corrected.

"Placeholder cells render off-screen." Fixed — pinned to the top of its column rather than centred over a chapter's height. New ColumnWithNothingToShow story and a stylesheet test.

"The reference-scroll observer… never stands down." Both halves. Batches coalesce into at most one check per animation frame, and once the reader takes the port over the callback returns on a boolean and reads no layout until the reference changes. findVerseBlockForVerse also tries a direct querySelector first, so the common case never collects every block. I kept the observer connected rather than disconnecting, so re-arming is a ref reset rather than a re-subscribe. Related, from my own pass: the "did the reader move it?" heuristic had a real bug — the browser clamps scrollTop when content shrinks (unchecking a resource, zooming a column out), and that was read as the reader, standing the scroll down permanently and killing the late-column correction in exactly the case it exists for. Now compared against the position we wrote, clamped into the currently valid range, with a test.

"viewMode is re-spelled… and persisted viewMode isn't coerced on read." ResourceCollectionViewMode is the single spelling now — ScriptureTextGrid, ResourceCell, ResourceColumn (as Exclude<…, 'verse'>) — derived from a frozen RESOURCE_COLLECTION_VIEW_MODES that also backs a new isResourceCollectionViewMode. The toggle uses the guard instead of its own literal check, and the web view coerces the persisted value through it, falling back to 'verse'. Guard tests added.

The coupling question

"Yes in design, incomplete in coverage… CONTRACT omits the two upstream API symbols… It also omits .editor-placeholder." All three added. The contract is split into a DOM group (checked in the bundle and in our stylesheet, now including editor-placeholder) and an API group (bundle only, since those never appear in CSS). The docblock states your toContain caveat so nobody reads it as stronger than a tripwire. I did not build the structural jsdom test my own pass suggested — mounting the real editor in jsdom to walk the display: contents chain is a bigger investment than this PR should carry, and the e2e geometry test already assertsinto a DOM group (checked in the bundle and in our stylesheet, now including editor-placeholder) and an API group (bundle only, since those never appear in CSS). The docblock states your toContain caveat so nobody reads it as stronger than a tripwire. I did not build the structural jsdom test my own pass suggested — mounting the real editor in jsdom to walk the display: contents chain is a bigger investment than this PR should carry, and the e2e geometry test already asserts that invariant where there's layout. Say the word and I'll file it.

Separately, my own pass found the :has(.verse-block) gate's documented consequence was wrong in a way that matters: a column whose editor produced no verse blocks doesn't degrade to "unaligned" — its paragraphs are grid items of the shared subgrid with an auto row, so they auto-place and stretch rows for every other column too. One misconfigured resource collapsed the whole grid. The gate is gone; an empty column is the recoverable failure, and the contract test names its cause.

Docs and claims

"One inaccuracy: Consequences say an out-of-range verse 'would fall outside the explicit grid'." Corrected — and the placement fix changed the answer anyway: such a verse is now dropped rather than auto-placed, so the paragraph says that, notes the verse stays readable in Verse and Chapter view, and records the zoom placement too.

"R3… the rule is broader than \s headings." True, and neither the comment nor the ADR said so. Both now describe the rule by what it does. R3 sign-off from Alex still outstanding — mine to chase.

"Partial: 4 (locale reversal implicit — no story or assertion)." Added a grid-mode RTL e2e that measures where the first column lands under dir=ltr vs dir=rtl; the chapter test's flexDirection assertion has nothing to say about grid tracks. Still local-only, like the rest of that suite.

"One in-PR ticket banner is the only exception." Dropped the ticket; the description of what the block tests stays.

Two things I'd flag back

  • Verse-view keyboard reorder never worked. The grip renders only in ResourceCellView's header-band layout, and verse view uses the inline-name layout — so showDragHandle/onReorderKeyDown are accepted and discarded there. Pre-existing, but this PR asserted the opposite in three places, and the component test's mock renders its own grip regardless of nameDisplay, which is why it looked covered. I corrected the claims (component TSDoc, shortcuts catalog, the mock) rather than adding a visible control to the default view without UX review. Wants a ticket.
  • useStylesheet injects from a passive effect, so the grid paints one frame before the layout rules land. Fixing it means touching a shared hook with four existing consumers, so I've left it and am noting it rather than burying it.

Also fixed in passing: two e2e specs (cell-reorder, scripture-text-grid-zoom) were selecting a gridcell role removed in PT-4157, so their assertions ran over an empty list; the per-cell empty message is plain text again, because EmptyState's role="status" gave every cell its own live region and N announcements per reference change; and a genuinely empty chapter is now told it's empty rather than sent to a view with nothing to show either.

Verification: format:check, typecheck (4/4), lint, extension suite 1492/1492, dotnet test 1716 — all clean. Root suite 3538/3540; the two failures are in web-view.component.test.tsx and web-view.service-shard.test.ts, which this branch doesn't touch and which pass in isolation. Still not run: e2e (needs a running app plus E2E_TEST_RESOURCE_IDS) and Chromatic.

@katherinejensen00 made 1 comment.
Reviewable status: 0 of 35 files reviewed, all discussions resolved.

@captaincrazybro

Copy link
Copy Markdown
Contributor

Suggestion: have useAlignedReferenceScroll take isViewVisible rather than calling useViewVisibility() itself

Context: PT-4543 needs the Text Collection's chapter surfaces to follow the scroll group's verse too (the chapter-context pane opens at the top of the chapter and never moves to the verse). Rather than write a second scroll loop, I've branched off pt-4184-grid and parameterized yours by the verse-target lookup — the rules all transfer, only the anchor differs (.verse-block[data-verse-start] under BLOCK_VERSE_VIEW_MODE vs. the inline layout's span[data-marker="v"][data-number]). Branch: pt-4543-chapter-verse-scroll. Your control loop is unchanged; the three edits are an injected finder, an isEnabled flag, and one extra observer dependency.

That surfaced one thing worth changing at the source, so it doesn't have to be undone downstream.

As it stands on this PR, this is a non-issue — AlignedGrid is the only consumer, so useViewVisibility() at use-aligned-reference-scroll.hook.ts:51 builds exactly one IntersectionObserver. It stops being one as soon as a per-cell consumer exists. PT-4543 calls the hook from ResourceCell, which renders once per resource, so a Text Collection showing N resources builds N observers, all observing the same iframe's document.body — and in verse and aligned mode the hook is disabled and does nothing with the answer anyway.

Nothing breaks: each observer is cheap and browser-native, and the visibility answer is identical for all of them (they share one iframe). It's just N times more machinery than the one question needs, scaling with resource count.

Suggested shape:

export function useAlignedReferenceScroll(
  portRef: RefObject<HTMLElement | null>,
  scrRef: SerializedVerseRef,
  isViewVisible: boolean,
): void

…dropping the internal call and passing the parameter straight to useRunWhenVisible. AlignedGrid would call useViewVisibility() once; a per-cell consumer takes it as a prop from the web-view root, which is where the grid's scrRef already comes from.

Two side benefits:

  • It matches how the rest of the repo does this. useBcvSyncScroll and useFocusSearchOnInvoke both take isViewVisible as a parameter and leave useViewVisibility() to the consumer that owns the view.
  • It makes the hook testable with a boolean. Right now a test has to vi.mock('platform-bible-react') to stub useViewVisibility, because jsdom has no IntersectionObserver — that's what use-reference-scroll.hook.test.tsx on my branch does, and use-bcv-sync-scroll.hook.test.ts avoids it entirely by taking the prop.

Entirely your call on whether it belongs in this PR or a follow-up — it's a small change but it does touch AlignedGrid and the hook's signature, and I don't want to push churn into a PR that's already been through review. If you'd rather not, say so and I'll carry the per-cell cost downstream instead; I'd just rather not fork the hook's design by fixing it only on my branch.

One heads-up either way: my branch renames use-aligned-reference-scroll.hook.ts → use-reference-scroll.hook.ts and aligned-scroll.utils.ts → reference-scroll.utils.ts, since both now serve chapter surfaces as well. If that's awkward for you mid-review I can keep the old names and rename later.

@katherinejensen00

Copy link
Copy Markdown
Contributor Author

@captaincrazybro Good call, and better to fix it here than fork the hook's design. Done in 5f789e0: useAlignedReferenceScroll(portRef, scrRef, isViewVisible) now has the shape you suggested. The internal useViewVisibility() call is gone and the parameter goes straight to useRunWhenVisible. AlignedGrid calls useViewVisibility() once. The TSDoc says why it's a parameter, so a later consumer doesn't move the call back inside.

Behavior is unchanged: the hidden-then-activated catch-up test in scripture-text-grid.component.test.tsx still passes through AlignedGrid, and all 333 tests in scripture-text-grid/ pass. I also rebased the branch onto current main, so you'll need to rebase pt-4543-chapter-verse-scroll onto the new head.

On the renames: go ahead on your branch. I'd rather not rename files in this PR after it has been through review.

@captaincrazybro

Copy link
Copy Markdown
Contributor

Question, not a defect: the grid lands a verse flush at the top, the editor leaves 80px of context above it

Thanks for taking the isViewVisible change — 5f789e0b293 is exactly the shape. I'll rebase PT-4543 onto the new head and keep the renames on my branch as you asked.

One more thing came out of reviewing PT-4543, and this one I genuinely don't know the answer to, so I'd rather ask than assume.

scrollPortToBlock puts the target at the first visible pixel of the port:

port.scrollTop += block.getBoundingClientRect().top - getFirstVisibleY(port);

The Scripture editor's scrollToVerse deliberately does not, subtracting VERSE_NUMBER_SCROLL_OFFSET = 80 (editor-dom.util.ts:6, applied at :244) — its comment says "scroll a bit above the verse so you can see a bit of context." scrollToAnnotation uses the same constant at :301/:304.

So after the same action — a BCV move in the toolbar — the verse lands at the very top of a Text Collection surface and 80px down in the editor. In Simple mode those are side by side in one window, and PT-4543 extends the grid's behavior to a third surface (the chapter-context pane and chapter columns), so whichever is right, it's about to be more visible.

I am not proposing a change here, for two reasons:

  • It's a product/feel call, not a correctness one, and you've been closer to this view than I have.
  • 80px is a sensible slice of a full-height editor and a quarter of a ~300px chapter cell, so copying the editor's constant across might well be the wrong answer even if matching the editor is the right instinct. A proportional offset, a smaller constant, or deliberately keeping flush-to-top are all defensible.

What I'd like is for it to be a decision rather than an artifact of the two surfaces having been written separately — mostly so the next person doesn't "fix" one to match the other in either direction. If you're happy with flush-to-top, a line in the ADR saying so would settle it permanently, and I'll point the Text Collection's chapter surfaces at the same answer.

@katherinejensen00

Copy link
Copy Markdown
Contributor Author

@captaincrazybro
I am leaning towards keeping it flush at the top to help with alignement and maximizing space. Here are some pros and cons. Feel free to push back.

A. Keep flush-to-top

  • Pros:
    • The whole aligned row is visible, and every column starts at the same verse boundary. That's the point of a grid.
    • It uses the least of a short port, which matters most for ~300px chapter cells in PT-4543.
    • It's the simplest to implement.
    • The "scroll only when out of view" rule stays easy to reason about: when it scrolls, the verse is where you'd expect.
  • Cons:
    • You get no preceding context. In poetry or a continued sentence, the verse starts mid-thought with nothing above it.
    • In Simple mode it looks different from the editor sitting right beside it. Someone will see that and "fix" it unless the reason is written down.

B. Share the editor's 80px constant

  • Pros:
    • Both surfaces act the same after a toolbar move, so nobody files it as a bug.
    • It's one exporte

@rolfheij-sil

Copy link
Copy Markdown
Contributor

🤖 Claude: Heads-up for this PR's next rebase. Rolf decided on 2026-09-23, in review of #2844, that the Text Collection's own per-column zoom goes: its level multiplied with the pane's content zoom, so a column could render anywhere from 25 % to 900 % with no control showing the combined size. #2849, which merges after #2825 and #2844, removes it in 141eb15 (feat(zoom): drop the Text Collection's per-column zoom; content zoom alone sizes its text). After that, only the platform's content zoom sizes the grid's text, through the ContentZoomRoot area="text-collection" marker around each cell's text.

What #2849 removes or changes in files this PR also touches:

  • Deleted: scripture-text-grid/scripture-text-grid.zoom-integration.test.tsx (this PR edits it), plus resource-zoom.utils.ts, use-resource-zoom.hook.ts, use-resource-zoom-input.hook.ts and their tests.
  • resource-cell-view.component.tsx: no zoom / zoomMenuLabels props, no ZoomMenuLabels type, no zoom kebab, no contentStyle zoom wrapper. The right-click menu keeps Copy only and shows whenever the state is not unavailable. The marker wraps the cell text directly.
  • resource-cell.component.tsx, resource-cell.const.ts (the four ZOOM_* keys are gone from the constants and RESOURCE_CELL_STRING_KEYS), scripture-text-grid.component.tsx (no zoom / zoomMenuLabels props, no prune effect, no useResourceZoomInput), scripture-text-grid.web-view.tsx (no useResourceZoom, no zoom labels), and the tests and stories of each.
  • contributions/localizedStrings.json: the four %webView_scriptureTextGrid_cell_zoom…% / …_resetZoom% keys keep their values but are marked deprecated (2026-09-23).
  • e2e-tests/tests/enhanced-resources/scripture-text-grid-zoom.spec.ts is retargeted to content zoom only.
  • The keyboard-shortcut catalog loses its scripture-text-grid-zoom-wheel entry.
  • createContentZoomWheelReader is removed from platform-bible-utils.

What that means for this PR's own additions: the aligned view's per-column zoom has nothing to carry any more. That covers ALIGNED_ZOOM_PROPERTY / AlignedZoomStyle, ZoomTarget / zoomTarget, the MixedZoom story, the zoom kebab in the sticky header, and the per-resource-zoom wording in your ADR entry and in aligned-grid.styles.ts, so they can go. One thing to check rather than assume: your note that zoom on the subgrid box scales the row tracks it inherits may also apply to the content-zoom marker. Content zoom applies zoom to the element that <ContentZoomRoot area="text-collection"> renders (it carries data-platform-content-zoom-root="text-collection"), so in the aligned view that marker probably belongs on the verse blocks rather than on the column's subgrid wrapper.

captaincrazybro added a commit that referenced this pull request Sep 23, 2026
PT-4543. Records the approved design before implementation: parameterize
#2781's reference-scroll hook by verse-target finder rather than extracting
the older settle loop from resource-text-panel.component.tsx, and call it from
ResourceCell for viewMode === 'chapter'.

Documents why the settle loop was rejected (no reader stand-down, no re-check
as content arrives, no shrink-clamp discrimination, a smooth rather than
instant hidden-tab catch-up, and success recorded on scrollToVerse's return
value even when findScrollContainer found no overflowing container), and why
the shared port math needs no per-layout branch: getFirstVisibleY looks up
[data-cell-header] inside the port, which is correctly empty in chapter mode
because the header band is the port's sibling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
captaincrazybro added a commit that referenced this pull request Sep 23, 2026
Nine fixes from a review pass over this branch, plus the tests and records that
pin them.

Correctness:

- CSS `zoom` sat on `[data-cell-content]`, which this branch makes the scroll
  port, so `scrollPortToBlock`'s viewport-space delta was applied to a
  zoom-scaled `scrollTop`. Measured in Chromium: at 2x the port overshot by
  ~7800px and oscillated without converging; at 0.5x it landed a pane short.
  The factor moves to `[data-cell-pad]`, leaving the port unzoomed. Rendering
  measures identical at every factor.
- `isBlockInPortView` had been forked on target height, which silently gave the
  aligned Grid view full-containment semantics: a partly visible verse block a
  reader clicked was scrolled to the top of the port. The overlap rule is
  restored and the containment rule moves to `isMarkerFullyInPortView`, injected
  as `isTargetVisible`. `aligned-grid.component.tsx` is untouched, so the Grid
  view keeps exactly the behavior PT-4184/#2781 reviewed.
- A chapter cell now skips the scroll-group echo of a reference it published
  itself, reusing `isEchoOfPublishedScrRef`. The docstring's claim that a block
  anchor needs no latch holds; a one-line marker anchor does need one, because a
  click deep inside a long verse publishes a verse whose marker is above the
  fold.
- Chapter cells scroll with `VERSE_NUMBER_SCROLL_OFFSET`, the lead-in the
  editor, the model text panel and the reference panels already share, instead
  of parking the verse flush against the pane's top border.
- The single-resource cell is width-filling again in its non-ready states; it
  had become a shrink-to-fit flex item, so a short placeholder rendered at the
  message's width rather than the pane's.

CI and contracts:

- `isViewVisible` was a required prop with 71 call sites never updated, so
  `npm run typecheck` failed with 5 errors from the stories alone.
- `AlignedGrid` took the prop instead of opening a second `IntersectionObserver`
  for an answer its parent already held.
- `data-marker` and `data-number` are registered in the upstream editor DOM
  contract; the marker selector is composed from `VERSE_MARKER_SELECTOR` rather
  than respelled; `VerseTargetFinder`'s port narrows to `HTMLElement`.
- The `main.ts` comment no longer claims pinning would overwrite a power-mode
  choice, which the two pinners it cites cannot do.

Tests:

- `ResourceCell`'s chapter wiring had no coverage at all. Five cases pin the
  port identity, the view-mode gate, an end-to-end scroll, and the echo latch.
- A parity table pins both finders against each other so Grid and Chapter cannot
  drift on which verse a reference resolves to.
- `[data-cell-header]` staying outside `[data-cell-content]` is now asserted on
  the rendered component; the ADR had claimed this was pinned when only the
  arithmetic was.
- Every new test was checked by mutation: each fails when the behavior it pins
  is removed.

PT-4543.

Session-URL: https://claude.ai/code/54702569-a224-47cb-a68c-76051ebac749
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@captaincrazybro

Copy link
Copy Markdown
Contributor

@captaincrazybro I am leaning towards keeping it flush at the top to help with alignement and maximizing space. Here are some pros and cons. Feel free to push back.

A. Keep flush-to-top

  • Pros:

    • The whole aligned row is visible, and every column starts at the same verse boundary. That's the point of a grid.
    • It uses the least of a short port, which matters most for ~300px chapter cells in PT-4543.
    • It's the simplest to implement.
    • The "scroll only when out of view" rule stays easy to reason about: when it scrolls, the verse is where you'd expect.
  • Cons:

    • You get no preceding context. In poetry or a continued sentence, the verse starts mid-thought with nothing above it.
    • In Simple mode it looks different from the editor sitting right beside it. Someone will see that and "fix" it unless the reason is written down.

B. Share the editor's 80px constant

  • Pros:

    • Both surfaces act the same after a toolbar move, so nobody files it as a bug.
    • It's one exporte

I say this is fine. Go ahead and keep flush-to-top.

@captaincrazybro

Copy link
Copy Markdown
Contributor

Found these while reviewing PT-4543 (#2856), which is stacked on this branch and reuses the scroll
controller — so I had the geometry loaded and ran a pass over this PR too. Two functional findings,
plus some verification you may find useful given the PR body notes nothing has been run in the app.

1. A verse-0 reference scrolls to the wrong row when columns start at different verses

scripture-text-grid/aligned-scroll.utils.ts, findVerseBlockForVerse

The "reference above every block" fallback returns blocks[0]. querySelectorAll returns
document order, so with several columns that is the first column's first block — not the block
with the lowest data-verse-start.

const blocks = [...port.querySelectorAll<HTMLElement>('.verse-block[data-verse-start]')].filter(isPlacedBlock);
const [firstBlock] = blocks;
if (!firstBlock) return undefined;
if (!Number.isFinite(verseNum)) return firstBlock;   // ← same element
...
return best ?? firstBlock;                            // ← here

Repro: a commentary covering only MAT 5:10-12 in the left column, a full text starting at verse 1
in the right. Navigate to MAT 5:0 (chapter intro). The grid scrolls to row 10 and verses 1-9 are
above the fold, rather than landing at the top of the chapter.

The single-column fixture in aligned-scroll.utils.test.ts cannot catch this — document order and
verse order coincide there. A two-column case with different starting verses would.

Reproduced against the shipped function in jsdom. Lowest-data-verse-start rather than
first-in-document-order looks like the intended element for both sites.

2. The scroll-clamp guard reads inverted, and can stand the sync down permanently

scripture-text-grid/use-aligned-reference-scroll.hook.ts

const expected = maxScrollTop > 0 ? Math.min(applied, maxScrollTop) : applied;

The comment says the unclamped branch exists so a port with nothing to scroll does not read this
hook's own write as the reader's. But when the port reports nothing to scroll, scrollTop is
necessarily 0, so a non-zero applied always mismatches — which sets hasStoodDownRef and stops
the sync for the current reference until it changes. Clamping to 0 in that branch would match.

Flagging this as plausible rather than confirmed: in a real browser applied is read back from a
position the port actually held, so the window may be narrow. But the new test
("does not read a content shrink as the reader scrolling") only covers maxScrollTop = 300 > 0, so
the other branch is unexercised either way.

Verification that came out clean

Since the PR body says this has not been run in the app, I rendered the generated
ALIGNED_GRID_STYLESHEET in headless Chromium and measured real geometry. All of the following
work, and several were things I expected to break:

  • Rows align across columns through the whole display: contents / subgrid chain.
  • A bridged verse 3-4 spans two rows at the right height, and verse 200 — the last generated rule —
    places correctly, so the grid-row: 2/-1 local-line math holds.
  • A reversed-bridge block (no range attributes) and section headings are hidden rather than
    auto-placed, so they cannot corrupt alignment.
  • The sticky header sticks, including under horizontal scroll with 6 columns. I expected the
    grid-area containing block to defeat this; it does not.
  • Per-column zoom keeps rows aligned — the shared row grows to the zoomed cell and both columns'
    blocks keep the same top.
  • A [data-cell-placeholder] column stretches full height, and a column whose editor emits no verse
    blocks does not collapse the shared grid.
  • Chromium preserves scrollTop across display: none, so the hidden-tab catch-up path is sound.
  • The _editor-overrides.scss rules are all out-specified by the grid rules.
  • Extension unit tests on this branch: 25 files / 641 tests, all passing.

Two stale items in the PR body

  • The checklist item about bumping ~0.8.15 in both manifests — the editor is already 0.8.16,
    pinned via file:dev-packages/staging/platform-editor in both manifests, same as main. There is
    no semver pin to bump.
  • The verification note about :has() in the injected stylesheet — the final aligned-grid.styles.ts
    contains no :has().

Non-blocking observation: %webView_scriptureTextGrid_viewOptions_chapterComingSoon% looks dead now
that the web view passes isChapterEnabled unconditionally.


AI-assisted review (Claude Code). Findings above were reproduced by running the code rather than
read off the diff; the "verification that came out clean" list is measured in headless Chromium
against the real generated stylesheet, not inferred. Worth your own check before acting on any of it.

@katherinejensen00

Copy link
Copy Markdown
Contributor Author

@captaincrazybro Thanks, both findings were real. Fixed in 88f31c6, which sits on a fresh rebase onto main, so pt-4543-chapter-verse-scroll will need to rebase again.

1. Verse 0 with columns that start at different verses. Fixed. When the reference is above every block, or is non-finite, findVerseBlockForVerse now returns the block with the lowest data-verse-start instead of blocks[0]. There are two new tests in aligned-scroll.utils.test.ts. One uses the two-column case you described: commentary on 10–12 on the left, a full text on the right, MAT 5:0 lands on the right column's verse 1. The other covers the non-finite path.

2. The clamp guard. You were right that it can happen outside jsdom. Uncheck resources until what's left fits the port, and the browser clamps scrollTop to 0. The guard skipped clamping whenever maxScrollTop <= 0, so it read that clamp as the reader scrolling and stood the sync down. The unclamped branch was only ever there for jsdom, which lays nothing out and keeps whatever scrollTop is written. So the guard now checks for layout directly: a port with clientHeight > 0 clamps into [0, maxScrollTop], and only a port with no height is left unclamped. There's a new component test for it (shrink until nothing overflows, then a late column pushes the target off screen, and it must still be corrected). I checked that it fails without the fix.

Flush-to-top. Thanks for settling it. I added it to adr-aligned-grid-flattens-the-editor-dom as a third row-model decision. It names VERSE_NUMBER_SCROLL_OFFSET, says the difference is deliberate, and notes that PT-4543's chapter surfaces follow the same answer. scrollPortToBlock's TSDoc points to the ADR so nobody "fixes" it.

PR body. Both stale items are fixed: the pin checklist item is struck, and the :has() note is gone. While doing that I found the ADR had the same staleness: its yalc / ~0.8.15 paragraph dated from before #2745. I rewrote it to describe the staged file: dependency and point to adr-dev-packages-staged-file-deps, and fixed two matching comments in upstream-editor-contract.test.ts. I also added your headless-Chromium results to the body's verification section, credited to you.

chapterComingSoon. It is unused by the app, but not dead code. isChapterEnabled and its hint were already on main, and the web view passed isChapterEnabled unconditionally there too. This PR only changed the hint's text: with a third toggle item, a bare "Coming soon" no longer says which mode it means. The component still defaults to false, and the stories use that. Removing the prop and the hint together is a reasonable cleanup, but it belongs in its own PR.

@rolfheij-sil Thanks for the heads-up. #2849 hasn't merged yet (it's stacked on #2844 → #2825), and the per-column zoom it removes is still on main. So I've left ALIGNED_ZOOM_PROPERTY / zoomTarget / MixedZoom and the zoom wording in place for now. Whichever PR lands second takes them out. If this lands first, #2849 deletes them. If #2849 lands first, I'll remove them on the rebase. On the ContentZoomRoot area="text-collection" marker: agreed. It applies zoom, so in the aligned view it has to go on the verse blocks (or inside them), not on the column's subgrid wrapper. Otherwise it scales the shared row tracks, which is the problem the per-column zoom already avoids. I'll carry that over whichever PR lands second.

Verification on the rebased head: npm run typecheck clean, ESLint clean on the changed files (run with the extensions/ config), and the platform-scripture-editor suite passes 100 files / 1796 tests. upstream-editor-contract.test.ts passes against the staged editor. e2e and Chromatic were not run.

@captaincrazybro

Copy link
Copy Markdown
Contributor

Re-reviewed at 88f31c6b92e after the rebase and the fix commit. Both earlier findings are
properly fixed
— I checked the reasoning, not just the diff:

  • Lowest-verse fallback. findVerseBlockForVerse now scans for the lowest data-verse-start
    instead of taking blocks[0], and it's applied at both sites (the non-finite branch and
    best ?? topBlock). blocks is already filtered by isPlacedBlock, so the Number() comparison
    can't see a NaN. The new test uses the same two-column commentary/full-text shape as the repro,
    and there's a non-finite case alongside it.
  • Clamp guard. hasLayout = port.clientHeight > 0 plus Math.max(0, …) is the right reading of
    what the original comment was reaching for: a real browser always clamps, and only a no-layout
    environment stays unclamped. The rewritten comment now describes what the code does.

One new finding: cell-reorder.spec.ts's label assertions can't pass

e2e-tests/tests/enhanced-resources/cell-reorder.spec.ts — seven toBe('Resource A') /
toBe('Resource B') assertions (lines 269, 270, 287, 288, 303, 322, 323).

getCellAriaLabels reads aria-label from [data-testid="scripture-text-grid-cell-draggable"].
That testid is on the verse listitem at scripture-text-grid.component.tsx:397, and the very next
line sets:

aria-label={verseItemName(resource.label)}

verseItemName returns formatReplacementString(cellAccessibleNameTemplate, { resourceName, reference })
— the {resourceName}, {reference} template — so the label is "Resource A, MAT 1:1", not
"Resource A". These are exact-equality assertions, so they fail whenever the template resolves.
The only way they pass is the : label fallback when cellAccessibleNameTemplate is undefined,
which isn't the case in a real app run.

Worth noting how this got here, because the diff reads as verified: the spec's own comment explains
that the previous selector ([role="gridcell"]) matched nothing after PT-4157 removed that role, so
the assertions "would pass over an empty list". Repointing the selector at the real element was
correct — but that element carries the two-part accessible name, so the assertions moved from
vacuously passing to failing.

Not a CI break — playwright.config.ts documents the enhanced-resources project as
"Local-only — NOT wired into CI's test:e2e:smoke" since it needs real Marble resources. So nothing
goes red on the PR. The cost is that the drag-reorder ordering assertions don't work for anyone who
runs them locally either.

Smallest fixes: assert against the template, use toContain('Resource A'), or read the name from
data-resource-id rather than the accessible name.

Two things I checked and deliberately did not file

  1. %webView_scriptureTextGrid_cell_noVersesToShow% advises "Switch to Verse or Chapter view", but
    for a chapter with prose and no verse markers, Verse view also shows nothing ("No text for this
    verse") — only Chapter view helps. Copy, not behaviour.
  2. columnDropTarget / columnDragSource are added to the page object but used by no spec, so the
    column drag — whose source this PR moved from the whole column to the header band — has unit
    coverage only.

Checked and sound

The scroll hook's stand-down / clamp / catch-up state machine and the stable requestScroll
identity; findVerseBlockForVerse across bridge, gap, verse-0, non-finite and multi-column cases;
row-rule generation against the grid-row-end off-by-one and the 200-row ceiling vs
MAX_PLACEABLE_VERSE_FOR_EMPTY_STATE; the subgrid chain selectors against the DOM the cell and the
editor actually emit, including Tailwind-vs-stylesheet specificity; zoomTarget='blocks' custom
property inheritance through the display:contents levels; the reorder extraction (buildReorder
wired identically in all three views, reorderHandleId still supplied, focus restore still resolves
under gridRef); view-mode branch reconciliation (all four branches return a root
<div ref={gridRef}>, so the zoom wheel listener survives a mode switch while ResourceCell
correctly remounts); the persisted-viewMode guard; the Escape gating change; and en/es coverage for
all 11 new View Options keys.


AI-assisted review (Claude Code). The finding above was confirmed by reading the rendered element's
actual aria-label source, and the CI-scope claim by reading playwright.config.ts and the
test:e2e:smoke script.

rolfheij-sil added a commit that referenced this pull request Sep 24, 2026
…zoom scope and area label

- ADR adr-text-collection-resources-are-zoom-areas: one content zoom area
  per resource (resource-<id>, text-collection fallback), the zoom scope
  and area label attributes, the restored menus on the platform commands,
  and the consequences (memory per project x resource, keys on one
  resource, no migration of the per-column levels, the #2781 path).
- Amendments to adr-resource-panes-name-their-zoom-areas (the grid zooms
  per resource again, as areas) and adr-zoom-areas-mark-project-text (a
  zoom scope decides for an unmarked target inside it).
- Component-Builder-Patterns 1.7.8 and Extension-Development-Guide 1.1.7:
  when to label an area and when to put a zoom scope on a container.

Part of PT-4585 (Text Collection per-resource zoom areas).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTCXDyYLo9NMf8wGoDdWSc
captaincrazybro added a commit that referenced this pull request Sep 24, 2026
PT-4543. Records the approved design before implementation: parameterize
#2781's reference-scroll hook by verse-target finder rather than extracting
the older settle loop from resource-text-panel.component.tsx, and call it from
ResourceCell for viewMode === 'chapter'.

Documents why the settle loop was rejected (no reader stand-down, no re-check
as content arrives, no shrink-clamp discrimination, a smooth rather than
instant hidden-tab catch-up, and success recorded on scrollToVerse's return
value even when findScrollContainer found no overflowing container), and why
the shared port math needs no per-layout branch: getFirstVisibleY looks up
[data-cell-header] inside the port, which is correctly empty in chapter mode
because the header band is the port's sibling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
captaincrazybro added a commit that referenced this pull request Sep 24, 2026
Nine fixes from a review pass over this branch, plus the tests and records that
pin them.

Correctness:

- CSS `zoom` sat on `[data-cell-content]`, which this branch makes the scroll
  port, so `scrollPortToBlock`'s viewport-space delta was applied to a
  zoom-scaled `scrollTop`. Measured in Chromium: at 2x the port overshot by
  ~7800px and oscillated without converging; at 0.5x it landed a pane short.
  The factor moves to `[data-cell-pad]`, leaving the port unzoomed. Rendering
  measures identical at every factor.
- `isBlockInPortView` had been forked on target height, which silently gave the
  aligned Grid view full-containment semantics: a partly visible verse block a
  reader clicked was scrolled to the top of the port. The overlap rule is
  restored and the containment rule moves to `isMarkerFullyInPortView`, injected
  as `isTargetVisible`. `aligned-grid.component.tsx` is untouched, so the Grid
  view keeps exactly the behavior PT-4184/#2781 reviewed.
- A chapter cell now skips the scroll-group echo of a reference it published
  itself, reusing `isEchoOfPublishedScrRef`. The docstring's claim that a block
  anchor needs no latch holds; a one-line marker anchor does need one, because a
  click deep inside a long verse publishes a verse whose marker is above the
  fold.
- Chapter cells scroll with `VERSE_NUMBER_SCROLL_OFFSET`, the lead-in the
  editor, the model text panel and the reference panels already share, instead
  of parking the verse flush against the pane's top border.
- The single-resource cell is width-filling again in its non-ready states; it
  had become a shrink-to-fit flex item, so a short placeholder rendered at the
  message's width rather than the pane's.

CI and contracts:

- `isViewVisible` was a required prop with 71 call sites never updated, so
  `npm run typecheck` failed with 5 errors from the stories alone.
- `AlignedGrid` took the prop instead of opening a second `IntersectionObserver`
  for an answer its parent already held.
- `data-marker` and `data-number` are registered in the upstream editor DOM
  contract; the marker selector is composed from `VERSE_MARKER_SELECTOR` rather
  than respelled; `VerseTargetFinder`'s port narrows to `HTMLElement`.
- The `main.ts` comment no longer claims pinning would overwrite a power-mode
  choice, which the two pinners it cites cannot do.

Tests:

- `ResourceCell`'s chapter wiring had no coverage at all. Five cases pin the
  port identity, the view-mode gate, an end-to-end scroll, and the echo latch.
- A parity table pins both finders against each other so Grid and Chapter cannot
  drift on which verse a reference resolves to.
- `[data-cell-header]` staying outside `[data-cell-content]` is now asserted on
  the rendered component; the ADR had claimed this was pinned when only the
  arithmetic was.
- Every new test was checked by mutation: each fails when the behavior it pins
  is removed.

PT-4543.

Session-URL: https://claude.ai/code/54702569-a224-47cb-a68c-76051ebac749
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rolfheij-sil added a commit that referenced this pull request Sep 24, 2026
…zoom scope and area label

- ADR adr-text-collection-resources-are-zoom-areas: one content zoom area
  per resource (resource-<id>, text-collection fallback), the zoom scope
  and area label attributes, the restored menus on the platform commands,
  and the consequences (memory per project x resource, keys on one
  resource, no migration of the per-column levels, the #2781 path).
- Amendments to adr-resource-panes-name-their-zoom-areas (the grid zooms
  per resource again, as areas) and adr-zoom-areas-mark-project-text (a
  zoom scope decides for an unmarked target inside it).
- Component-Builder-Patterns 1.7.8 and Extension-Development-Guide 1.1.7:
  when to label an area and when to put a zoom scope on a container.

Part of PT-4585 (Text Collection per-resource zoom areas).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTCXDyYLo9NMf8wGoDdWSc
@katherinejensen00

Copy link
Copy Markdown
Contributor Author

@captaincrazybro Thanks. The cell-reorder.spec.ts finding was real, and it's fixed in 3544fac, which sits on a fresh rebase onto main. That means pt-4543-chapter-verse-scroll will need to rebase again.

Label assertions. I took your third option. getCellResourceIds (renamed from getCellAriaLabels) now reads data-resource-id from each draggable listitem. The seven exact checks compare against idA / idB, the ids the test flags. I didn't use toContain('Resource A') because the accessible name is a localized template. Matching a substring of it would still depend on the {resourceName} placeholder surviving translation. The id is exactly what the reorder works on, so the order assertions no longer depend on the display name at all. The two relative tests (swap, and persist across reload) now compare ids as well. The helper's comment now explains why it reads the id and not the name. I dropped its history paragraph about the old gridcell selector.

The two you left unfiled. I agree with leaving both as they are:

  • noVersesToShow copy: fair. For prose with no verse markers, only Chapter view helps. Changing the string means a new key under the immutability rule, so I'll raise it with UX rather than guess the wording here.
  • columnDropTarget / columnDragSource unused by any spec: correct, column drag has unit coverage only. I added them to the page object for a chapter-mode drag spec that doesn't exist yet.

Rebase notes. There were two conflicts, both in scripture-text-grid-zoom.spec.ts. One was imports. In the other, main had already swapped the dead gridcell selector for stg.cellDraggable, so I took main's version. #2844 is now in the base. It puts ContentZoomRoot area="text-collection" around the whole grid body, so every column scales together and row alignment is unaffected. The per-cell marker placement @rolfheij-sil raised only arrives with #2849, which hasn't merged yet, so that plan hasn't changed.

Verification on the rebased head:

  • Typecheck: core, e2e and workspaces are clean. typecheck:erb fails only on spdx-license-list, a dependency from feat(notices): let a downstream product generate its notices with a policy overlay #2798 that isn't installed in my worktree, not in this branch's code.
  • ESLint (with the extensions/ config) and Prettier are clean on every changed file.
  • The platform-scripture-editor suite passes: 102 files, 1836 tests.
  • I haven't run the updated cell-reorder.spec.ts. It's local-only and needs a running app plus E2E_TEST_RESOURCE_IDS.

AI-assisted (Claude Code).

captaincrazybro added a commit that referenced this pull request Sep 24, 2026
PT-4543. Records the approved design before implementation: parameterize
#2781's reference-scroll hook by verse-target finder rather than extracting
the older settle loop from resource-text-panel.component.tsx, and call it from
ResourceCell for viewMode === 'chapter'.

Documents why the settle loop was rejected (no reader stand-down, no re-check
as content arrives, no shrink-clamp discrimination, a smooth rather than
instant hidden-tab catch-up, and success recorded on scrollToVerse's return
value even when findScrollContainer found no overflowing container), and why
the shared port math needs no per-layout branch: getFirstVisibleY looks up
[data-cell-header] inside the port, which is correctly empty in chapter mode
because the header band is the port's sibling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
captaincrazybro added a commit that referenced this pull request Sep 24, 2026
Nine fixes from a review pass over this branch, plus the tests and records that
pin them.

Correctness:

- CSS `zoom` sat on `[data-cell-content]`, which this branch makes the scroll
  port, so `scrollPortToBlock`'s viewport-space delta was applied to a
  zoom-scaled `scrollTop`. Measured in Chromium: at 2x the port overshot by
  ~7800px and oscillated without converging; at 0.5x it landed a pane short.
  The factor moves to `[data-cell-pad]`, leaving the port unzoomed. Rendering
  measures identical at every factor.
- `isBlockInPortView` had been forked on target height, which silently gave the
  aligned Grid view full-containment semantics: a partly visible verse block a
  reader clicked was scrolled to the top of the port. The overlap rule is
  restored and the containment rule moves to `isMarkerFullyInPortView`, injected
  as `isTargetVisible`. `aligned-grid.component.tsx` is untouched, so the Grid
  view keeps exactly the behavior PT-4184/#2781 reviewed.
- A chapter cell now skips the scroll-group echo of a reference it published
  itself, reusing `isEchoOfPublishedScrRef`. The docstring's claim that a block
  anchor needs no latch holds; a one-line marker anchor does need one, because a
  click deep inside a long verse publishes a verse whose marker is above the
  fold.
- Chapter cells scroll with `VERSE_NUMBER_SCROLL_OFFSET`, the lead-in the
  editor, the model text panel and the reference panels already share, instead
  of parking the verse flush against the pane's top border.
- The single-resource cell is width-filling again in its non-ready states; it
  had become a shrink-to-fit flex item, so a short placeholder rendered at the
  message's width rather than the pane's.

CI and contracts:

- `isViewVisible` was a required prop with 71 call sites never updated, so
  `npm run typecheck` failed with 5 errors from the stories alone.
- `AlignedGrid` took the prop instead of opening a second `IntersectionObserver`
  for an answer its parent already held.
- `data-marker` and `data-number` are registered in the upstream editor DOM
  contract; the marker selector is composed from `VERSE_MARKER_SELECTOR` rather
  than respelled; `VerseTargetFinder`'s port narrows to `HTMLElement`.
- The `main.ts` comment no longer claims pinning would overwrite a power-mode
  choice, which the two pinners it cites cannot do.

Tests:

- `ResourceCell`'s chapter wiring had no coverage at all. Five cases pin the
  port identity, the view-mode gate, an end-to-end scroll, and the echo latch.
- A parity table pins both finders against each other so Grid and Chapter cannot
  drift on which verse a reference resolves to.
- `[data-cell-header]` staying outside `[data-cell-content]` is now asserted on
  the rendered component; the ADR had claimed this was pinned when only the
  arithmetic was.
- Every new test was checked by mutation: each fails when the behavior it pins
  is removed.

PT-4543.

Session-URL: https://claude.ai/code/54702569-a224-47cb-a68c-76051ebac749
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@captaincrazybro

Copy link
Copy Markdown
Contributor

@katherinejensen00 — the grid's reference scroll misses once the Text Collection pane is zoomed. #2844 (now on main and in this branch) caused it together with scrollPortToBlock; neither is wrong alone. cc @rolfheij-sil

Cause. #2844 wraps the grid body in <ContentZoomRoot area="text-collection">, so the platform sets CSS zoom on an element above the grid's scroll port. scrollPortToBlock adds a getBoundingClientRect() distance to scrollTop:

port.scrollTop += block.getBoundingClientRect().top - getFirstVisibleY(port) - leadInPx;

Rects are in screen pixels and include every ancestor's zoom. scrollTop is in the port's own unzoomed layout pixels. At zoom Z the scroll goes Z× too far, and each retry repeats the mistake the other way, so the error is multiplied by −(Z − 1) every attempt.

Measured in Chromium 145 with the same DOM shape and the code's formula. The number is the gap between the verse and the top of the port after each attempt (0 = correct):

Zoom placement 1× 1.5× 2×
Below the port (per-cell zoom on [data-cell-pad]) 0 0 0
Above the port (#2844's wrapper) 0 −2445 → 1222 → −611 −3260 → 3260 → −3260 (never settles)
Both, as the code now stacks them 0 −4019 → 2009 → −1005 −7459 → 7459 → −7459

At 1.5× the scroll overshoots and slowly settles. At 2× it bounces forever. Above 2× it grows until it hits the end of the content. At 100% nothing shows, so normal testing misses it. isBlockInPortView and getFirstVisibleY are unaffected, since they compare rects with rects; only the conversion into scrollTop is wrong.

Suggested fix, in the one shared function. Measuring the scale directly covers zoom from any ancestor without reading the custom property:

const scale = port.getBoundingClientRect().height / port.offsetHeight || 1;
port.scrollTop += (block.getBoundingClientRect().top - getFirstVisibleY(port)) / scale - leadInPx;

(leadInPx is in layout pixels, so it is subtracted after dividing.) A unit test can stub getBoundingClientRect at 2× the offsetHeight.

#2856 (stacked here) calls the same function for the chapter cells, so one fix here covers both. Caveat: this was measured on a synthetic page, not the running app. To confirm in the app, zoom the Text Collection pane to 200% in Grid view and change the verse.

@katherinejensen00

Copy link
Copy Markdown
Contributor Author

@captaincrazybro Thanks, this one was real. Fixed in cf62a6f. main was already in the branch, so I didn't rebase this time, and pt-4543-chapter-verse-scroll doesn't need to rebase again. It only needs to pick up the new commit.

The fix is in the one shared function, so #2856's chapter cells get it too. I took your suggestion and measure the scale off the port itself:

function getViewportPxPerLayoutPx(port: HTMLElement): number {
  return port.offsetHeight > 0 ? port.getBoundingClientRect().height / port.offsetHeight : 1;
}

scrollPortToBlock divides the rect distance by that ratio before adding it to scrollTop. Two differences from your snippet:

  • Explicit guard instead of || 1. jsdom (and any port with no layout height) has offsetHeight === 0. When the rect is stubbed with a height, the ratio comes out as Infinity, not NaN, so || 1 wouldn't catch it and the scroll would become 0.
  • clientTop now gets scaled too. getFirstVisibleY added port.clientTop (layout pixels) to a rect top (viewport pixels). That's the same unit mix, just smaller: the border width times (Z − 1). It now scales clientTop into viewport pixels first, so both isBlockInPortView and scrollPortToBlock measure from the same corrected origin. On your branch, leadInPx is in layout pixels, so subtract it after the division, as you said.

Tests. There's a new scrollPortToBlock block in aligned-scroll.utils.test.ts. Its rects are scaled by the zoom, and offsetHeight, clientTop and scrollTop stay in layout pixels. It checks 1×, 1.5×, 2× and 3×, plus a bordered port at 2×. The four zoomed cases fail against the previous code and pass now. The hook's stand-down and clamp guard compares scrollTop against scrollHeight and clientHeight, which are all layout pixels, so it was unaffected.

Verification: the platform-scripture-editor suite passes (102 files, 1841 tests), typecheck:workspaces is clean, and ESLint is clean on both files. I haven't run it in the app yet. Your 200% Grid-view repro is still the check to do before merge.

AI-assisted (Claude Code).

@katherinejensen00
katherinejensen00 enabled auto-merge (squash) September 24, 2026 21:17
rolfheij-sil added a commit that referenced this pull request Sep 25, 2026
…zoom scope and area label

- ADR adr-text-collection-resources-are-zoom-areas: one content zoom area
  per resource (resource-<id>, text-collection fallback), the zoom scope
  and area label attributes, the restored menus on the platform commands,
  and the consequences (memory per project x resource, keys on one
  resource, no migration of the per-column levels, the #2781 path).
- Amendments to adr-resource-panes-name-their-zoom-areas (the grid zooms
  per resource again, as areas) and adr-zoom-areas-mark-project-text (a
  zoom scope decides for an unmarked target inside it).
- Component-Builder-Patterns 1.7.8 and Extension-Development-Guide 1.1.7:
  when to label an area and when to put a zoom scope on a container.

Part of PT-4585 (Text Collection per-resource zoom areas).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTCXDyYLo9NMf8wGoDdWSc
rolfheij-sil added a commit that referenced this pull request Sep 25, 2026
…zoom scope and area label

- ADR adr-text-collection-resources-are-zoom-areas: one content zoom area
  per resource (resource-<id>, text-collection fallback), the zoom scope
  and area label attributes, the restored menus on the platform commands,
  and the consequences (memory per project x resource, keys on one
  resource, no migration of the per-column levels, the #2781 path).
- Amendments to adr-resource-panes-name-their-zoom-areas (the grid zooms
  per resource again, as areas) and adr-zoom-areas-mark-project-text (a
  zoom scope decides for an unmarked target inside it).
- Component-Builder-Patterns 1.7.8 and Extension-Development-Guide 1.1.7:
  when to label an area and when to put a zoom scope on a container.

Part of PT-4585 (Text Collection per-resource zoom areas).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTCXDyYLo9NMf8wGoDdWSc
rolfheij-sil added a commit that referenced this pull request Sep 25, 2026
Content zoom now scales only project text. Following the UX review on
the content-zoom epic (PT-4575), the feature is narrowed to do one
job: make small project text easier to read. Views that show no
project data (Home, New Tab, Get resources, Manage books,
Registration, Marketplace, the Send/Receive dialog, URL views) are no
longer zoomed and no longer offer zoom. In the views that do zoom —
Scripture editor, Comments, Text Collection, Enhanced Resources, Find,
the four inventories, Checks, the Markers Checklist and the Lexical
Tools dictionary — only the text grows; buttons, inputs, filters,
headers, card frames and every pop-up keep interface scale.

- Framework: each first-party zoomable web-view type is declared with
  a kind and default zoom area; a pane is zoomable only when declared
  or actively reporting an area (the old whole-iframe fallback is
  gone). Project text is marked directly (ContentZoomRoot,
  ContentZoomTextProvider) instead of by region. A new
  data-platform-content-zoom-scope attribute lets an unscaled
  container (a row, column or card) route clicks/wheel to its area
  without itself scaling, and a label prop names the area in the
  on-screen zoom-level badge.
- Text Collection: each resource is its own zoom area again —
  Ctrl+wheel, pinch, the right-click menu and the chapter view's "⋮"
  zoom one resource at a time; the shipped menu strings are restored
  (no longer deprecated). A resource's level persists per project and
  follows "Tab content default zoom" until zoomed individually.
- Resource panes: only project text is marked inside each — the
  Scripture editor's text tree (character-marker bar and empty-state
  views excluded), Comments' snippets/bodies/diffs, Text Collection's
  per-resource cells inside the scroll box, Enhanced Resources' entry
  text, Find's results/previews, the inventories' item columns,
  Checks' checked text, and the Markers Checklist's marker tokens.
- Settings: "Tab content default zoom" and "Interface scaling" both
  use a shared percentage stepper (50-300%) that wraps to two rows
  instead of clipping in a narrow pane, and accepts a typed percentage
  (Enter/blur commits, Escape abandons); the stored values (factor,
  level) are unchanged.
- Pop-ups fixed at interface scale: context menus, popovers, tooltips,
  the marker/footnote palettes and the inline footnote/comment editors
  no longer scale with content zoom; the area-following pop-up code
  and the whole-iframe fallback are removed.
- Docs/ADRs: new entries record that content zoom applies only to
  zoomable panes, that pop-ups stay at interface scale, that zoom
  areas mark project text, and that Text Collection resources are
  their own zoom areas; superseded/withdrawn entries are marked, not
  deleted. The Extension Development Guide and Component Builder
  Patterns document the opt-in for third-party views.

Deferred: Word List and Compare Versions are not yet declared
zoomable — their text markers ship in follow-up PRs in
paratext-bible-extensions and paratext-bible-internal-extensions.
Eight pre-existing, zoom-unrelated Text Collection grid e2e failures
are tracked as PT-4779. PR #2781 (verse-aligned Grid view) needs a
rebase onto this change.

Known, not caused by this PR and not fixed here: Ctrl+wheel over the
Scripture editor's character-marker bar or the blank space below a
short chapter, and Enhanced Resources' entries area while its text is
unmounted, target the last-used zoom area rather than the one under
the pointer (accepted as documented behavior). In Text Collection,
focus returning to the View Options button after Escape can leave its
tooltip covering a neighboring control until focus moves elsewhere;
poetry lines collapse at high zoom in a narrow column (a pre-existing
viewport-unit issue in the editor stylesheet, not specific to Text
Collection); and a zoom menu's enabled/disabled state can trail the
visible level by up to ~250ms.

Hidden tabs: no catch-up logic was needed. Zoomability and zoom levels
are pushed as CSS variables and a MutationObserver-driven event, both
of which need no layout, so an inactive tab is already correct by the
time it's shown; only the on-screen level badge needs geometry, and it
only appears for a direct user action on a visible pane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTCXDyYLo9NMf8wGoDdWSc
Claude-Session: https://claude.ai/code/session_01MddYES5CZ1wg3CjnyakeXw
Claude-Session: https://claude.ai/code/session_017NQ6fkAtScn8yARdcQr6ez

@captaincrazybro captaincrazybro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Everything raised in review is fixed or deliberately deferred, and I re-checked the fixes
rather than just the replies.

  • Verse-0 / non-finite fallback and the clamp guard — both correct, both tested, and the
    rewritten comments now describe what the code does.
  • cell-reorder.spec.ts — getCellResourceIds reads data-resource-id and all seven
    assertions compare ids; no name-based toBe('Resource …') remain. Reading the id rather than a
    substring of a localized template is the right call.
  • Zoom conversion — I ran the shipped implementation against the same harness that found the
    bug, with a bordered port so the clientTop scaling is exercised. Gap is 0 at 1.5×, 2× and 3×
    with zoom above the port, below it, and both stacked; the "both stacked" case previously read
    −7459 and never settled.

Two improvements over what was suggested, worth noting: the offsetHeight > 0 guard instead of
|| 1 is genuinely better, since a stubbed rect over a zero offsetHeight yields Infinity rather
than NaN and would have collapsed the scroll to 0; and scaling clientTop inside
getFirstVisibleY caught a unit mix that wasn't in the report.

Deferrals I agree with: the noVersesToShow wording belongs with UX under the string-immutability
rule, and columnDropTarget / columnDragSource being ahead of a chapter-drag spec is fine.

What this approval does not cover: neither of us has run the Grid view in the app. Your own
"200% Grid-view repro" is still outstanding, and the updated cell-reorder.spec.ts hasn't been run
(local-only, needs a running app and E2E_TEST_RESOURCE_IDS). Both are worth doing before merge —
the zoom class of bug is invisible at 100%, which is how it survived this long.

AI-assisted review (Claude Code); the measurements above are from headless Chromium, not inferred.

A third VIEW mode where verse N of every resource shares a row, so several
translations can be read in parallel down a passage. Verse and Chapter are
unchanged.

Layout. Alignment is native: the grid root owns a single scroll port and the
row axis, and a `display: contents` / subgrid chain carries that axis down to
the editor's block-verse layout. Nothing measures anything, so a row is as tall
as its tallest cell even when per-resource zoom puts columns at different
sizes. The chain flattens three elements this repo does not own
(`.editor-container`, `.editor-inner`, `.editor-input`), because one editor per
column is what keeps selecting and copying a passage down a column working;
adr-aligned-grid-flattens-the-editor-dom records why. A contract test reads the
installed editor bundle and fails by name if a class or export the layout
depends on disappears, since a rename would otherwise break alignment silently.

Placement is opt-in. Each verse block is placed by an explicit `grid-row` from
its own `data-verse-start`/`-end` (so a bridge spans its rows and a missing
verse cannot shift the rows below it), and a block the rules cannot place is
hidden rather than auto-placed into the shared grid. Row lines are local to the
content subgrid below the sticky header. Everything between verse blocks —
section headings, chapter descriptions — is hidden in this view; rows are a
visual relationship, not ARIA table semantics. Both are recorded in the ADR.

Zoom. Each resource stays its own content-zoom area (PT-4585's model): its text
is marked with `ContentZoomRoot`, and the column carries the zoom scope. In this
view the marker sits inside the subgrid chain, so the aligned stylesheet makes
it `display: contents` and pins its own zoom to 1, and the verse blocks take the
area's level from the platform's `--platform-content-zoom-<area>` variable
instead. Zooming the marker or the content wrapper would scale the shared row
tracks with the text. Checked in Chromium: rows stay aligned with one column
at 150 %, and the level applies once, not squared.

Reference sync scrolls explicitly (Lexical skips the scroll-into-view for a
read-only editor) and flush under the sticky header. It leaves a verse already
on screen alone, re-checks as late columns arrive, stands down once the reader
scrolls until the reference changes, tells a browser clamp after content
shrinks from the reader, and converts rect distances into the port's layout
pixels under an ancestor zoom. It defers while the tab is hidden and catches up
on activation, per .claude/rules/cross-view-sync-hidden-views.md.

Cells: a column whose chapter has no verses to align says so instead of
rendering blank, placeholders span the column, the header band (not the whole
column) is the reorder drag source so text selection keeps working, and the
reorder wiring is shared between the chapter row and the Grid view through
`ResourceColumn`. The keyboard shortcut catalog notes reorder applies here too.

Sub-verse markers (\v 3a, \v 3b) collapse to one row; filed upstream as
PT-4559.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KHpKaxZxC4sBAAXQFVKEmp
Claude-Session: https://claude.ai/code/session_01STzLD6CiarMZUP37MVxYc5
@katherinejensen00
katherinejensen00 merged commit a121e14 into main Sep 25, 2026
6 of 7 checks passed
@katherinejensen00
katherinejensen00 deleted the pt-4184-grid branch September 25, 2026 22:55
katherinejensen00 added a commit that referenced this pull request Sep 29, 2026
PT-4184's verse-aligned Grid view (#2781) added resource-column.component.tsx,
importing GridResource from resource-cell.component. This branch moved that
type to grid-resources.utils, so the merge failed typecheck:workspaces with
TS2614 even though neither side did alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants