Skip to content

Annotation Form V2

Rebuilt annotation form using Angular 21 signal-based forms with per-question auto-save, virtual scroll for large question sets, session versioning, and candidate blinding.

Overview

The Annotation Form V2 replaces the current annotation form with a ground-up rebuild using Angular 21's experimental signal-based forms API (@angular/forms/signals). The new form addresses a known performance problem (#1125) where projects with many questions experience slow rendering and frame drops, and introduces per-question auto-save so annotators never lose work.

For annotators, the experience changes in four ways: the form scrolls smoothly even with 100+ questions (virtual scroll), each answer saves automatically as they work (per-question auto-save via server-side pendingAnswer), navigating away from unsaved work triggers a warning, and each submitted session creates an immutable snapshot linking answers to the exact question versions that were active at the time.

For the reconciliation workflow, the form supports a side-by-side view where reconcilers see anonymised candidate answers ("Annotator A", "Annotator B") alongside their own form. Candidate blinding is a system invariant enforced at the API level --- annotators never see each other's answers.

This feature ships behind a project-level feature flag (useAnnotationFormV2) and reads from the existing versioned question model (AQ/QSV). No data migration is required.

Problem Statement

The current annotation form has five structural limitations:

  1. All questions render at once. A stage with 50 questions and 20 units produces 1,000 form controls in the DOM simultaneously, causing slow initial render and high memory usage.

  2. Eager change detection. Every keystroke triggers validation across the entire form tree because the form uses UntypedFormGroup with no scoped change propagation.

  3. Full-form save model. The entire form is serialised on every auto-save. There is no per-question save --- if the browser crashes mid-session, all unsaved answers are lost.

  4. No type safety. UntypedFormGroup provides no compile-time guarantees. Mismatched types between form controls and question definitions cause runtime errors.

  5. No version tracking. Answers are not linked to the specific question version or question set version that was active when they were collected, making audit trails incomplete.

Solution

A signal-forms-based annotation form that:

  • Uses form() and Field / [field] from @angular/forms/signals for all form state --- fully typed, signal-native, and zoneless-compatible.
  • Renders only visible questions via CDK virtual scroll, keeping DOM size constant regardless of total question count.
  • Saves each answer independently to the server via the pendingAnswer field on the Annotation document (D46), eliminating full-form serialisation.
  • Records AQVersionId and QSVId on every AnnotationVersion for full audit traceability.
  • Creates immutable SessionVersions on submit, with an explicit AnnotationAVMap pinning each annotation to a specific AnnotationVersion (D3, D11).

Scope

In Scope

  • Signal-forms-based form component tree (new annotation-form-v2/ directory)
  • All six question control types: boolean (toggle/radio), select (dropdown), checklist (multi-select), text (free text), numeric (number input), autocomplete (typeahead)
  • CDK virtual scroll per category section
  • Per-question auto-save via pendingAnswer on the Annotation document
  • Session submission creating immutable SessionVersion with explicit AnnotationVersionIds
  • Unsaved-changes navigation guard
  • Reconciliation view with anonymised candidate answers side-by-side
  • Signal store (ngrx/signals) for application state (session, questions, dirty tracking)
  • Feature flag rollout (useAnnotationFormV2)
  • compatForm() bridge for incremental migration during v1/v2 coexistence
  • Accessibility: keyboard navigation, ARIA labels, screen reader support, focus management with virtual scroll

Out of Scope / Future

  • Question management UI (version history, diffs, badges) --- covered by the Question Management feature
  • Reconciliation dashboard, pool management, bulk approve --- covered by the Reconciliation feature
  • Cross-project question sharing and community catalogue --- future v2 capability
  • AQV update impact handling wizard (Options A/B/C for affected annotations) --- deferred
  • Removal of v1 form --- happens after migration period, not part of this spec
  • Benchmark suite implementation details --- covered during implementation planning

User Workflows

Annotating a Study

  1. Annotator opens a study for annotation. The form loads questions from the stage's current Question Set Version (QSV), which defines the ordered list of specific question versions to render.
  2. For each question, the form renders the appropriate control type (boolean, select, checklist, text, numeric, or autocomplete) based on the question's DataType.
  3. As the annotator answers each question, the answer auto-saves to the server as a pendingAnswer on the corresponding Annotation document. This is a debounced, per-question write --- not a full-form serialisation.
  4. The annotator can navigate away and return later. The pendingAnswer is server-side (D46), so it survives browser crashes, machine changes, and session timeouts.
  5. When the annotator clicks Submit, the system:
  6. Creates an AnnotationVersion (AV) for each annotation from its pendingAnswer, recording the AQVersionId and QSVId that were active
  7. Clears pendingAnswer on each annotation
  8. Creates an immutable SessionVersion with an AnnotationAVMap that pins each annotation to its specific AV
  9. Sets the session status to Complete
  10. The Save operation (without Complete) follows the same AV-creation pattern but leaves the session status as Incomplete, allowing the annotator to continue later.

Unsaved Changes Guard

When the annotator has dirty (unsaved) answers and attempts to navigate away from the form, a confirmation dialog warns that unsaved changes will be lost. The guard tracks dirty state via the signal store's dirtyAnswerIds set, which is populated when the form model signal changes and cleared when auto-save completes.

Reconciliation View

When a reconciler opens a study for reconciliation:

  1. The system presents the full Project Question Set. Questions from the current stage's QSV are required; other project questions are optional and show previously reconciled answers as read-only context.
  2. Candidate answers are displayed anonymously ("Annotator A", "Annotator B") in a side-by-side comparison. The reconciler does not know which annotator provided which answer. This blinding is enforced at the API level --- it is a system invariant (D10), not a UI preference.
  3. The reconciler creates their own answer for each question using the same form controls. Even when they agree with a candidate, they record their own answer (D7).
  4. On submit, the system creates AnnotationVersions on the reconciliation Annotation (which has annotatorId: null, D45), with committedBy tracking the reconciler's identity and stageId tracking the originating stage.

Revert

If the annotator wants to discard in-progress changes, the Revert action clears pendingAnswer on all session annotations and restores the last committed state from the most recent SessionVersion's AnnotationAVMap.

Question Types

All six annotation question types are supported, each rendering as a specific control:

Type Control HTML Element Signal Forms Integration
Boolean Toggle or radio group <mat-radio-group> [field] directive
Select Dropdown <mat-select> [field] directive
Checklist Multi-select checkboxes Custom <fieldset> with <mat-checkbox> FormValueControl<string[]>
Text Free text input <input matInput> [field] directive
Numeric Number input <input matInput type="number"> [field] directive
Autocomplete Typeahead combo box Custom combo with <mat-autocomplete> FormValueControl<string>

Native HTML inputs use the [field] directive directly for two-way binding with the signal form tree. Complex controls (checklist, autocomplete) implement the FormValueControl<T> interface, which replaces the legacy ControlValueAccessor pattern.

Conditional visibility (child questions shown only when parent answer matches a trigger value) is handled via schema-level applyWhen() rules and CDK virtual scroll item visibility. The form's conditional rendering logic implements the same Parent Annotation Condition (PAC) used by the QSV transition algorithm -- a question is rendered if and only if its entire ancestor chain satisfies PAC given the current answers. This shared logic ensures that the form's visible question set matches the ResolvedAQVersionIds materialized on each ASV at save time.

Architecture

Signal Forms Foundation

The form is built on Angular 21's experimental signal-based forms (@angular/forms/signals). The core primitive is form(model, schemaCallback), which creates a FieldTree<T> from a WritableSignal<T> with schema-based validation.

Key architectural choices:

  • The form model is a WritableSignal<AnnotationFormModel> --- a flat dictionary mapping questionId to AnnotationAnswer (which can be string | number | boolean | string[] | null).
  • The signal form tree is the single source of truth for answer values. The [field] directive provides two-way binding between the template and the model signal.
  • Validation rules are built dynamically from the question definitions in the schema callback. Required questions use required(path); conditional rules use applyWhen(path, logicFn, schemaFn).
  • Application-level state (session metadata, questions list, dirty tracking, save status) lives in an ngrx signalStore, separate from the form model.

Why signal forms over ReactiveFormsModule:

  • Signal-scoped change detection eliminates the eager re-validation problem --- only the affected field's signal graph updates on change, not the entire form tree.
  • Zoneless-compatible: no dependency on Zone.js change detection.
  • Type-safe: the FieldTree<T> provides compile-time type checking for all form values.
  • Two-way model binding: the WritableSignal<T> model is always in sync with the form UI, simplifying auto-save (read directly from the model signal).

Virtual Scroll

Each category section uses cdk-virtual-scroll-viewport to virtualise its question list. Only questions visible in the viewport are rendered in the DOM.

Two virtualisation strategies based on category type:

  • Unit-based categories: Virtualise at the unit level. Each unit contains its questions. The viewport item size is the estimated unit height.
  • Non-unit categories: Virtualise at the question level. Each question is an individual viewport item.

This approach keeps DOM size constant regardless of total question count. A stage with 50 questions and 20 units renders only the visible subset (typically 5--10 items) at any time.

Focus management: When virtual scroll brings new items into view (e.g., via keyboard Tab navigation), focus is preserved on the correct element. This requires coordination between the virtual scroll viewport and the form's focus management logic.

Per-Question Auto-Save

Each question answer saves independently to the server via the pendingAnswer field on the Annotation document (D46). The auto-save flow:

  1. Annotator changes an answer --- the form model signal updates via [field] two-way binding.
  2. The signal store marks the question as dirty (dirtyAnswerIds set).
  3. An auto-save service watches the dirty set. After a debounce interval, it reads current values from the form model signal and issues PATCH requests to update pendingAnswer on the affected Annotation documents.
  4. On success, the question is removed from the dirty set. On failure, the question remains dirty for retry.

Key design decision (D46): Auto-save writes to pendingAnswer on the server, not to client-side storage (IndexedDB). This ensures answers survive browser crashes, machine changes, and session timeouts. pendingAnswer is a mutable field --- updating it is a single-document atomic write with no transaction overhead.

Distinction from Save/Complete: Auto-save writes to pendingAnswer (mutable buffer). Save/Complete creates immutable AnnotationVersions from pendingAnswer, clears the buffer, and creates a SessionVersion. Auto-save is frequent and lightweight; Save/Complete is an explicit user action that uses a multi-document transaction (D47).

Session Versioning

Submitting the form (Save or Complete) creates an immutable SessionVersion containing:

  • An AnnotationAVMap --- a dictionary mapping each annotationId to the specific AnnotationVersionId that was current at save time. This is an explicit snapshot, not a computed filter (D3).
  • The QSVId that was active in the stage when the submission occurred.
  • A createdByAction field distinguishing "save" from "complete".

This provides a full audit trail: given any SessionVersion, the system can reconstruct exactly which question versions were active, what the annotator's answers were, and when the submission occurred. There are no "latest" lookups or predicate-based resolution --- the session knows exactly which annotation versions it contains (D11).

Consuming Versioned Questions

The form loads questions from the stage's embedded Question Set Version (QSV), not directly from the project's mutable question list. The QSV provides an ordered list of specific AQVersionIds with their resolved question definitions.

This decouples the form from question editing: even if an admin creates a new question version while an annotator is working, the annotator's session continues against the QSV that was active when they started. Each annotation records the AQVersionId and QSVId for full traceability.

State Management Separation

Application state is split between two concerns:

Concern Mechanism Manages
Answer values WritableSignal<AnnotationFormModel> + signal form tree Current answer for each question
Everything else ngrx signalStore Questions list, session metadata, dirty tracking, save status, reconciliation mode, error state

The form model signal is the single source of truth for answer values. The signal store reads from it (e.g., for completion percentage) but does not duplicate answer data.

Performance Requirements

Metric Current (v1) Target (v2) Approach
Initial render (50 questions) ~800ms < 200ms Virtual scroll, OnPush change detection
Initial render (1k units) ~5s+ < 300ms Virtual scroll, lazy initialisation
Keystroke latency ~150ms < 16ms (60fps) Signal-scoped change detection (no full-tree re-validation)
Auto-save latency ~500ms (full session) < 100ms (single answer) Per-question PATCH, no full serialisation
Memory (1k units) ~50MB DOM < 15MB DOM Virtual scroll (only visible items in DOM)

A benchmark suite should be created that loads a stage with 50 questions across 20 units (1,000 controls total), measuring render time, interaction latency, and memory usage. This suite runs in CI as a Playwright test with performance assertions.

Data Flow

Stage QSV (versioned question config)
  |
  v
Form loads: resolve AQVersionIds -> question definitions with types, options, help text
  |
  v
Signal form tree: form(model, schemaCallback) -> FieldTree<AnnotationFormModel>
  |
  v
Virtual scroll renders visible questions with [field] directive or FormValueControl<T>
  |
  v
Annotator changes answer -> model signal updates -> dirty set updated
  |
  v
Auto-save service (debounced) -> PATCH pendingAnswer on Annotation documents (server)
  |
  v
Submit (Save/Complete):
  -> Create AV per annotation from pendingAnswer (records AQVersionId, QSVId)
  -> Clear pendingAnswer
  -> Create immutable SessionVersion with AnnotationAVMap
  -> Multi-document transaction (D47)

Signal Forms API Notes

The Angular 21 signal forms API (@angular/forms/signals) was verified at @angular/forms@21.0.0 during Phase 1. All prescribed APIs compile successfully, with four discrepancies between the original specification and the actual API:

Discrepancy 1: Field Directive --- [field], not [formField]

The directive is exported as Field with the template selector [field]. Earlier documentation incorrectly referred to it as FormField with [formField]. This is a naming difference only --- the functionality is identical. All template references must use [field]; all TypeScript type references must use Field<T> or FieldTree<T>.

Discrepancy 2: compatForm() Import Path

compatForm() is imported from @angular/forms/signals/compat, not from @angular/forms/signals. This is a separate sub-entry point. compatForm() is available in Angular 21.0.0 (the original spec's concern about needing 21.2+ was incorrect).

Discrepancy 3: Validators Use Path-First Signatures

Validators take (path, config?) as arguments directly --- e.g., required(fields.name). The schema() function creates a reusable Schema<T> object from a callback, it does not bind validators to individual fields. The correct pattern within a schema callback is required(path), not schema(path, required()).

Discrepancy 4: applyWhen() Signature Is Path-First

The correct signature is applyWhen(path, logicFn, schemaOrSchemaFn) --- the field path is the first argument, the condition function second, and the schema (or schema callback) third. The original spec had the condition function first and the path second.

Experimental Status

The entire @angular/forms/signals API is marked experimental in Angular 21.0.0. Every exported function and type includes @experimental 21.0.0 in its JSDoc. This is an accepted risk, mitigated by:

  • The compatForm() bridge from @angular/forms/signals/compat provides a migration path if APIs change.
  • The form rebuild is behind a feature flag (useAnnotationFormV2) for progressive rollout.
  • Signal forms are the designated successor to ReactiveFormsModule and FormsModule in Angular's roadmap.

Migration Strategy

No Data Migration Required

The form v2 reads from the existing versioned question model (AQ/QSV/Annotation). No new data structures are introduced by the form itself --- versioning infrastructure is established by the annotation-versioning feature (Phase 2--3) before the form ships (Phase 4).

Phased Rollout

  1. Phase 1: Build v2 alongside v1. New component tree at annotation-form-v2/. The v1 form is unchanged.
  2. Phase 2: Feature flag. Project-level useAnnotationFormV2 setting, default off. Both forms must produce compatible data during coexistence.
  3. Phase 3: Opt-in beta. Selected projects switch to v2. Gather feedback on performance and usability.
  4. Phase 4: Default on. All new projects use v2. Existing projects can opt out.
  5. Phase 5: Remove v1. Delete old form components after the migration period.

Backward Compatibility During Coexistence

During the rollout period, both forms must produce compatible data:

  • v1 writes SessionSubmissionWebDto (current format).
  • v2 writes individual answer patches (auto-save) plus session submission (Save/Complete).
  • The backend accepts both formats.
  • compatForm() bridges old FormGroup patterns to the signal form tree during incremental control migration.

Accessibility Requirements

All v2 components must maintain or improve accessibility:

  • Keyboard navigation: Tab between questions, Enter to submit, Escape to cancel.
  • ARIA labels: All form controls labelled with question text via aria-label or aria-labelledby.
  • Screen reader support: Category headings announced as landmarks; question help text linked via aria-describedby.
  • Focus management: When virtual scroll brings new items into view, focus is preserved on the correct element. Scrolling to a question programmatically (e.g., from a validation error summary) sets focus on that question's control.
  • High contrast: Material theme tokens support high-contrast mode.
  • Reduced motion: Animations respect prefers-reduced-motion media query.

Success Criteria

# Requirement Criterion
FORM-01 Signal forms adoption All form state managed via form() + FieldTree<T>. No ReactiveFormsModule or FormsModule imports in v2 components.
FORM-02 Template directive All native inputs use the [field] directive. Complex controls implement FormValueControl<T>.
FORM-03 Virtual scroll Form renders < 200ms for 50 questions; < 300ms for 1k units. Memory < 15MB DOM for 1k units.
FORM-04 Keystroke latency < 16ms (60fps) --- no frame drops during typing.
FORM-05 Per-question auto-save Each answer auto-saves to the server via pendingAnswer. Debounced, batched. Survives browser crash and machine change.
FORM-06 Session versioning Submit creates immutable SessionVersion with explicit AnnotationAVMap. Each AV records AQVersionId and QSVId.
FORM-07 Question type coverage All six question types supported: boolean, select, checklist, text, numeric, autocomplete.
FORM-08 Reconciliation view Reconciler sees anonymised candidate answers side-by-side. Candidate blinding enforced at API level. Reconciler always creates own answer.
FORM-09 Unsaved changes guard Navigation away from dirty form triggers confirmation dialog.
FORM-10 Feature flag v2 form enabled per project via useAnnotationFormV2. v1 and v2 produce compatible data.
FORM-11 Accessibility Keyboard navigation, ARIA labels, screen reader support, focus management with virtual scroll, high contrast, reduced motion.
FORM-12 Versioned question consumption Form loads questions from the stage's QSV, not the mutable project question list.
Document Relevance
Annotation Management & Reconciliation Parent feature initiative
Design Decisions Authoritative design reference (D1--D50). Key decisions: D3 (explicit AVMap), D10 (candidate blinding), D11 (QSV immutability), D46 (pendingAnswer auto-save)
Annotation Versioning Design D37--D50 refinements. Supersedes design-decisions.md where they conflict
Product Overview PO-facing description of all four capabilities
Question Management Question versioning and QSV composition that determines form content
Reconciliation Reconciliation comparison view that reuses the annotation form