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:
-
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.
-
Eager change detection. Every keystroke triggers validation across the entire form tree because the form uses
UntypedFormGroupwith no scoped change propagation. -
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.
-
No type safety.
UntypedFormGroupprovides no compile-time guarantees. Mismatched types between form controls and question definitions cause runtime errors. -
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()andField/[field]from@angular/forms/signalsfor 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
pendingAnswerfield on the Annotation document (D46), eliminating full-form serialisation. - Records
AQVersionIdandQSVIdon every AnnotationVersion for full audit traceability. - Creates immutable SessionVersions on submit, with an explicit
AnnotationAVMappinning 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
pendingAnsweron 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¶
- 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.
- For each question, the form renders the appropriate control type (boolean, select, checklist, text, numeric, or autocomplete) based on the question's
DataType. - As the annotator answers each question, the answer auto-saves to the server as a
pendingAnsweron the corresponding Annotation document. This is a debounced, per-question write --- not a full-form serialisation. - The annotator can navigate away and return later. The
pendingAnsweris server-side (D46), so it survives browser crashes, machine changes, and session timeouts. - When the annotator clicks Submit, the system:
- Creates an AnnotationVersion (AV) for each annotation from its
pendingAnswer, recording theAQVersionIdandQSVIdthat were active - Clears
pendingAnsweron each annotation - Creates an immutable SessionVersion with an
AnnotationAVMapthat pins each annotation to its specific AV - Sets the session status to Complete
- 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:
- 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.
- 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.
- 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).
- On submit, the system creates AnnotationVersions on the reconciliation Annotation (which has
annotatorId: null, D45), withcommittedBytracking the reconciler's identity andstageIdtracking 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 mappingquestionIdtoAnnotationAnswer(which can bestring | 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 useapplyWhen(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:
- Annotator changes an answer --- the form model signal updates via
[field]two-way binding. - The signal store marks the question as dirty (
dirtyAnswerIdsset). - 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
pendingAnsweron the affected Annotation documents. - 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 eachannotationIdto the specificAnnotationVersionIdthat was current at save time. This is an explicit snapshot, not a computed filter (D3). - The
QSVIdthat was active in the stage when the submission occurred. - A
createdByActionfield 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/compatprovides 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
ReactiveFormsModuleandFormsModulein 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¶
- Phase 1: Build v2 alongside v1. New component tree at
annotation-form-v2/. The v1 form is unchanged. - Phase 2: Feature flag. Project-level
useAnnotationFormV2setting, default off. Both forms must produce compatible data during coexistence. - Phase 3: Opt-in beta. Selected projects switch to v2. Gather feedback on performance and usability.
- Phase 4: Default on. All new projects use v2. Existing projects can opt out.
- 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 oldFormGrouppatterns 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-labeloraria-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-motionmedia 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. |
Related Documents¶
| 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 |