Skip to content

AF2 and stage-review remaining delivery

The minimum usable release completes the existing branch tabs, restores AF1 action/category behaviour, and corrects controls and navigation in the redesigned workspace. It preserves saved answers, incomplete drafts, permissions, and the legacy rollback path. Personalisation follows in independently shipped slices; it remains required programme scope.

Sequence and observable acceptance

Slice Dependencies Acceptance and evidence
Branch tabs, #2810 Merged isolation repair #3207 Current-head AF2/stage-review tests, review and CI pass; switching tabs preserves drafts and control identity; nested errors reveal ancestors; keyboard navigation and deletion preserve focus and selection.
Status cues and action/category/order parity, #3084 #2810 Distinct shapes identify all branch states without colour; invalid forms cannot Complete; dirty partial work can Save subject to permission and saving guards; category icons and visited validity match AF1; adding/deleting/reloading units preserves identity and creation order.
Controls and comments, 3A #3084 Every supported control presents prompt, helper, input and errors in that order; long prompts wrap; accessible names and descriptions stay associated; comment add/edit/discard and quote insertion keep their semantics (the comment is a normal field -- see the comment editing contract below) without hover/focus layout shifts. Unit regressions plus gallery browser screenshots.
Panels and navigation, 3B #3084; PDF-host owner integration Header buttons have unclipped focus surfaces; entity dialog Escape/backdrop/close preserve drafts and restore focus; My Studies design footnote removed while project permission and dirty navigation guards remain; Skip occupies a consistent header location across all stage types with unchanged behaviour.
Release baseline, 4 3A, 3B Annotation-only, screening-only, combined; narrow/wide; light/dark; pristine/dirty/invalid/completed; navigator on/off. Include nested branches, many entities, quote-to-comment and save failure. Required final-head CI and preview/staging journeys pass; deployed versions verified; user guide and rollback recorded.
Entity reordering, 5A Baseline; review-data ordering contract Drag and keyboard move actions persist order without changing entity identity, labels, answers or completion; reload, deletion, selection, failure and read-only paths covered.
Reviewer layouts, 5B Baseline; reviewed interaction and storage contract Per-reviewer versioned layouts for each capability combination; movable Source/Screening/Annotation panels; usable narrow-screen fallback, reset, schema/capability migration and failed persistence recovery.
Source modes, 5C 5B; PDF viewer lifecycle/host integration Embedded, floating and detached modes share authoritative study selection and preserve quotes; keyboard movement, viewport recovery, blocked-popup fallback, window close, study change and sign-out verified.
Backlog and cleanup, 6 Stable accepted default rollout Reconcile every #3067 entry with evidence or explicit follow-up; >8 branches usable; remove only unreferenced legacy components after rollback is no longer needed; preserve the original untracked notes and commit completion evidence.

The shortest critical path is #2810 → #3084 → 3A/3B → baseline acceptance. Shared shell files must be integrated after the active PDF-host changes; independent form work can proceed in parallel. Every implementation uses a sole-writer worktree under the repository’s .worktrees/ directory.

Flags and release gates

Existing annotationFormV2 and stageReviewRedesign gates remain in force. No production activation is implied by merging implementation. Wider/default enablement requires acceptance and a release decision, followed by a stability interval before legacy removal.

Entity order belongs to each reviewer’s review and must persist in that review’s database data. It must preserve entity names, identity and answer ownership. Preserve record-and-stay versus advance behaviour pending its separate decision. Reviewer-layout storage and mode/position persistence need a concrete reviewed contract. Credentials and production activation remain operator-controlled.

Infrastructure hardening, preview race repairs and unrelated colour/selector defects are only baseline blockers when current evidence proves an intrinsic correctness, regression or major security issue. Coordinate existing repair owners rather than duplicating them.

Recovery evidence

On 2026-09-05, #2810 remained open at 7d20971d1 with failed historical Angular checks;

3084 remained stacked on it at 932cba73c3. #3207 and #3174 are merged. Both branches merge

cleanly into the refreshed stack locally. Original pr/ worktrees have untracked status notes and are preserved. The main checkout also has unrelated staged/unstaged work; validation uses committed code in isolated worktrees.

Sources and implementation contracts

The acceptance criteria above govern delivery; prototypes illustrate interactions and do not replace these requirements.

Comment editing contract

Owner-directed deviation from the v4 prototype (2026-10-05)

The v4 review prototype draws a Cancel / Save comment pair under the editor. The owner directed that the AF2 comment behave like a normal field instead, because "Save comment" implied a server save that does not happen: it only ever applied the text to the local review draft, and nothing reaches the server until the form's Save progress or Complete. The pair is removed; the contract below replaces it. Do not restore the buttons to match the prototype.

Saved comment text appears in a compact chip. When the session is editable the whole chip (comment icon, text and a trailing pencil hint) is one native button named "Edit comment for {question}: {text}" with aria-expanded="false", so clicking the comment chip opens the editor; read-only sessions render the same chip as static markup with nothing to press. To copy a comment's text, copy it from the editor. Add comment or the chip opens a local comment transaction held in the shared form store, so pagination and the narrowed Focus view do not lose it. While the editor is open it is the only comment control; there is no separate Hide button.

  • Leaving the field keeps the text. Moving focus out of the editor -- Tab, a click anywhere else, or focus going to the quote toolbar's menu -- applies the text to the review draft (never the server) and collapses the editor back to the chip. Ctrl/Cmd+Enter does the same and returns focus to the chip. A press on the field's own padding, label or hint is inside the editor and does not end it.
  • Esc discards. Escape drops what was typed since the editor opened, collapses it and returns focus to the chip or Add comment. The editor owns the keypress: it does not also close the dialog, panel or Focus mode around it, and an Escape that cancels an input-method composition is left alone. A short hint, "Esc to discard changes", sits under the field and is associated with it through aria-describedby.
  • Clearing the text and leaving removes the comment, and Add comment is offered again.
  • Losing the window is not leaving the field. A focus loss with no new focus target while the document itself has lost focus (alt-tab, developer tools, the detached Study Source window) leaves the editor open and uncommitted; focus returns with the window. The check uses the injected DOCUMENT, which follows a dock panel into its own browser window.
  • A pointer press elsewhere does not collapse the editor until it completes. A mouse press moves focus on mousedown, before its click; collapsing the editor then would shift everything below it and the click would land on whatever moved under the pointer. When focus leaves during a primary-button press (mouse, pen or touch), the commit waits for that press's pointerup / pointercancel (a touch tap's compatibility mouseup) plus one timer tick, so the click dispatches against the unshifted layout first. The press is tracked on the document only while an editor is open, and the listeners are removed when it closes or the row is destroyed. Keyboard moves (Tab, programmatic focus) are not affected and still commit promptly, and a keystroke or context menu ends a tracked press so a lost release cannot delay them. The same guards apply when the deferred commit runs: destroyed row, recycled draft, finished transaction, or focus back in the editor all skip it.
  • A row being torn down is not leaving the field. Virtual-row recycling, branch-tab switches and Focus mode destroy the focused textarea. That neither commits nor discards: the transaction stays in the store's commentEditsByDraftId and the remounted row reopens it. The commit therefore runs a microtask after the focus loss and does nothing if the row was destroyed in the meantime, if focus has returned to the editor, or if the transaction has already been finished.
  • Pending text participates in the existing unsaved-navigation guard. The form counts an open edit whose text differs from the draft as a change while typing.

Whole-session Save progress and Complete apply all pending comment edits before validation and submission. Revert, deletion and replacement by another session clear the relevant local editor state. Quotes inserted while editing are merged into both the open editor and its baseline: discarding with Esc must not erase an independently inserted quote. Selecting study text in the source panel moves focus out of the editor, which commits and collapses it; Quote into comment... then appends to the committed comment and reopens the editor focused. A quote arriving while the editor is still open (for example from the detached source window) is merged with the typing and keeps the editor open. Read-only sessions show comment text without edit controls.

Focus contract (revision 2 §6.7)

Focus is one feature with two entry scopes, and it is a state of the Annotation panel, never a dialog. The panel header stays with a "Focus" hint; everything else collapses to the 48px Focus bar, and the questions take the panel's full width. It works identically when the panel is grouped, maximised, floated, or opened in its own window, because it makes no assumption about its host.

  • Shown-set Focus is entered from the category line's Focus button, offered only while at least one entity is shown. It stacks every shown entity as a sticky-mini-header section. Previous/next scroll between those mini-headers — the current one is determined by scroll position and named beside the chevrons — and never swap entities. A mini-header's filter_center_focus narrows Focus to that entity; its × hides the entity and Focus continues with one fewer. When none remain, an information strip explains how to pick entities; Focus does not close itself. The bar's ⋮ is the "Shown in Focus" menu: a checkbox item per entity that keeps the menu open, Show all, and Add.
  • Single-entity Focus is the shortcut entered from a section mini-header, an entity card's ⋮, or the entity menu. It shows that entity alone, previous/next move between entities, and the ⋮ is the entity menu with "Shown in Focus…" last.
  • Study uses the same Focus state: the same shared draft, the same Save and Complete, the same Back and Escape. Study is a singleton implicit context, so it carries no entity-set controls — no previous/next, no position, no entity menu — and no invented second entity. It does reach the category boundary, because continuing to the next category is navigation rather than an entity-set control: Study's single section is always the end of its questions, so the pill and the end-of-questions footer offer "Continue to ".
  • The bar's contents degrade by its own measured width (a ResizeObserver on the bar, not viewport breakpoints), dropping the position, then the category name, then the Unsaved chip, then the action labels, and finally ellipsising the entity name. Back, the entity icon and name, previous/next and the ⋮ are never removed, and everything dropped stays reachable in the ⋮ header line and the breadcrumb tooltip.
  • At the last entity the right chevron becomes the next-category pill and an end-of-questions footer repeats the move. An empty next category lands on its empty state inside Focus. At the very end of the annotation the pill reads "Finish".
  • Leaving Focus never discards anything. The same question forms stay mounted as the same component instances across entering and leaving, because Focus only changes which units the one workspace projection returns and the class on their wrapper. There is no overlay container, so there is nothing to move and nothing to re-render.
  • Bounded rendering is unchanged: shown-set Focus mounts the shown set intersected with the current card page, so Focus pages through the stack under the same cap the workspace uses.
  • The top annotation action bar is hidden while Focus is on. The Focus bar carries Save and Complete itself, under the same anchors and the same enable rules, and both surfaces call the same commands — there is one authoritative draft and one save path.
  • Escape leaves Focus first in the Escape cascade and claims the event, so the host's Maximise and Rearrange handling does not fire on the same key press. Back and Escape both return keyboard focus to the control Focus was entered from.
  • The panel header stays in Focus and carries a "Focus" hint, but that header belongs to the stage-review Dockview shell, not to the form. AF2 therefore publishes its Focus state through a focusStateChanged output — {scope, category, label}, or null when Focus is off — and the shell renders the hint from it. This is deliberately a description and not a handle: a host can draw a hint and nothing else. No host can enter or leave Focus, because that would make the panel chrome a second owner of what is mounted.

This supersedes the per-entity fullscreen dialog, including the Study fullscreen dialog proposed in #3394.

Branch-answer overflow preferences

The 2026-09-08 reviewer mockup selects wrapping quiet branch tabs (15a) as the default at every nesting level. This first slice changes layout only: existing status shapes, active underline, branch identity, selection and draft state remain shared and unchanged. Long labels fit the available width and retain their full accessible names. Flag decision: this is inside the existing annotationFormV2 feature; no separate flag is needed for the default tab layout correction.

The account-persisted reviewer preference is now delivered. Revision-2 §5's three illustrated radio cards write overflowMode (wrap | scroll | menu, default wrap) to ReviewerWorkspaceSettings; the review page hands it to the annotation form through the ANNOTATION_ROW_OVERFLOW injection token, and both rows the design names honour it — the protocol branch tabs and the §6.3 entity cards. They are render choices over the same branch data and status logic, not project-admin settings and not the Dockview panel tabs.

Mode Branch tabs and entity cards
wrap Every control stays on screen, wrapping onto more rows. The default, and the behaviour of the first slice.
scroll One row, horizontally scrollable, with a chevron button at each end that disables when there is nothing left that way. overflow-y is pinned to hidden so Material's -1px ink-bar margin cannot spawn a vertical scroller.
menu As many controls as fit, then a +N more menu holding the rest. The row is measured with a ResizeObserver plus a MutationObserver, so the count follows both the row's width and the controls in it.

The annotation form's category tab strip has its own account preference, categoryOverflow (scroll | wrap, default scroll; a stored document without the field reads as scroll, and a save that omits it keeps the stored value). It reaches the form through the ANNOTATION_CATEGORY_OVERFLOW injection token, provided by the review page from ReviewPreferencesStore.categoryOverflow; the annotation preview and reconcile view keep the token default. scroll is Material's standard single-row tab nav with its pagination chevrons; wrap adds .category-tabs--wrap, which wraps .mat-mdc-tab-links and disables pagination. Flag decision: inside the existing annotationFormV2 / stageReviewRedesign features; no separate flag.

Switching modes preserves selection, drafts, focus, error reveal and accessibility. Collapsed controls keep their space in the layout and are hidden by visibility, which takes them out of the tab order and the accessibility tree; the +N more menu is what puts them back, with the branch tabs' selection semantics and the entity cards' shown/hidden checkbox semantics and reordering intact. Arrow-key navigation walks the tabs still on the row rather than stepping into a collapsed one that cannot take focus. Wrapping chips (15b) are an unselected alternative.

The mockup references an unspecified 10a selector beyond approximately15 branches. Neither that selector nor an exact cutover is supplied; the older >8 note in repository docs is not a settled threshold. Obtain that design before implementing the cutover. Do not conflate it with the existing ancestry selector.

Status: all three modes implemented for both rows, driven by the persisted preference. Browser coverage checks 375px nested wrapping, visible status indicators, stable tab IDs, keyboard selection and preserved nested drafts; preview acceptance remains pending. Flag decision: no new flag — every mode is inside the existing annotationFormV2 feature, and the preferences dialog that sets the choice is inside the existing default-off stageReviewRedesign flag. Hosts with no reviewer (annotation preview, reconcile) keep the wrap default. The unresolved 10a selector cutover below is still outstanding.

The branch-wrapping MVP keeps Add on the same row when short tabs leave room; longer sibling lists wrap without losing their status markers. The browser contract checks both a short row and actual multiple-row geometry at 375px.

Tab-strip hairline and label clipping (confirmed 2026-09-08)

Two review questions on the wrapping default were settled by measurement in a headless browser rather than by preference:

  • The hairline underlines the tablist, not the whole strip. It sits on .branch-tabs and stops at the tablist's right edge, so it does not run under Add when Add shares the row. The alternatives are worse: the strip wraps, so a border on .branch-tab-strip lands under the last row only -- the Add row once Add drops below the tabs, leaving the tabs unmarked. Forcing a single row with flex-wrap: nowrap restores a full-width hairline but was rejected: below roughly 300px the flex: 1 1 auto tablist collapses to 0px and every branch tab disappears, defeating the point of wrapping.
  • Tab labels clip without an extra min-width: 0. overflow: hidden on .branch-tab-text already zeroes the flex item's automatic minimum size (CSS Sizing: the content-based minimum is 0 when overflow is not visible), so the label shrinks inside the capped tab and ellipsises. Measured at 320px: a 452px label inside a 270px content box, clipped. .branch-add span works by the same rule, not because of min-width: 0 on its parent.

Regression coverage for the tab-label clipping case is tracked as a follow-up; the browser spec currently asserts clipping for the Add label only.

Review navigation waits for the current screening write to settle: Skip/Next and My Studies cannot leave while a decision is in flight, and only the matching intent, study, stage and reviewer outcome releases that guard. Study-keyed cached decisions are accepted only for the current stage and reviewer. Releasing a screened study's reservation uses the annotation-statistics transaction and its durable tracking-mode agreement, keeping fresh projections consistent with the source study.

Validation fixture cleanup

Consolidate the small duplicated valid-layout builders in API/controller and Mongo repository tests as a maintenance follow-up after workspace acceptance. The independently tested serialization boundaries remain covered; sharing those fixtures is not a runtime correctness requirement for the current delivery. The same follow-up should introduce a shared valid/invalid JSON corpus for the client and API validators; current boundary cases have matching focused tests.