testsAndMisc/horatio/docs/superpowers/specs/2026-03-29-annotations-design.md
Krzysztof kuhy Rudnicki fa5ccdaa96 feat: annotations subsystem — core models, drift DB, cubits, and UI
Add the complete annotations feature for marking and annotating script text:

Core models (horatio_core):
- TextMark, LineNote, AnnotationSnapshot, MarkType, NoteCategory
- Script.id field + UUID generation in text_parser

Database layer (horatio_app):
- Drift tables: text_marks, line_notes, annotation_snapshots
- AppDatabase with AnnotationDao (full CRUD + streams + bulk replace)

State management:
- AnnotationCubit: mark/note CRUD, line selection, editing context
- AnnotationHistoryCubit: snapshot save/restore with stream updates

UI components:
- MarkOverlay: colored span rendering for text marks
- NoteIndicator: per-line note count badge
- MarkTypePicker: 6-type ActionChip selector
- NoteEditorSheet: category dropdown + text field bottom sheet
- AnnotationEditorScreen: full editor with long-press marks + note editing
- AnnotationHistoryScreen: snapshot timeline with restore dialog

Wiring:
- main.dart: async DB init with path_provider
- app.dart: RepositoryProvider<AnnotationDao>
- router.dart: /annotations + /annotation-history routes
- role_selection_screen: Annotate Script option
- run.sh: app_codegen step + coverage filtering for generated code

352 tests (105 core + 247 app), 100% branch coverage, zero dead code.
2026-03-29 17:59:26 +02:00

492 lines
15 KiB
Markdown

# Horatio — Annotations Subsystem Design Spec
## Overview
Add two layers of annotations to script lines:
1. **Text marks** — span-based delivery marks on words/syllables (stress, pause,
breath, emphasis, tempo changes)
2. **Line notes** — free-text interpretive notes attached to a whole line (intention,
subtext, blocking, emotion, delivery, general)
Both types include full change history via snapshots, enabling undo and annotation
evolution tracking over time.
## Approach
Drift-backed persistence (dependencies already in `pubspec.yaml`). This establishes
the SQLite persistence layer that Recording and future subsystems will reuse.
- Core models live in `horatio_core` (pure Dart, no Flutter dependency)
- Persistence, state management, and UI live in `horatio_app`
- Annotations bind to `ScriptLine` via `scriptId` (UUID) + `lineIndex`
- This spec also introduces a `scriptId` field on `Script` to provide a stable
unique identifier for annotation binding (titles can collide)
## Data Models (horatio_core)
### MarkType Enum
```dart
enum MarkType {
stress, // Stress/emphasize this word
pause, // Pause before this span
breath, // Take a breath here
emphasis, // General emphasis
slowDown, // Deliver this span slower
speedUp, // Deliver this span faster
}
```
### NoteCategory Enum
```dart
enum NoteCategory {
intention, // "What does the character want here?"
subtext, // "What are they really saying?"
blocking, // "Cross downstage on this line"
emotion, // "Suppressed anger building"
delivery, // "Whisper this line"
general, // Catch-all
}
```
### TextMark
Span-based annotation on text within a line.
```dart
@immutable
final class TextMark {
TextMark({
required this.id,
required this.lineIndex,
required this.startOffset,
required this.endOffset,
required this.type,
required this.createdAt,
}) {
assert(startOffset >= 0, 'startOffset must be non-negative');
assert(endOffset > startOffset, 'endOffset must be greater than startOffset');
}
final String id; // UUID
final int lineIndex; // Which ScriptLine
final int startOffset; // Start character offset in line text
final int endOffset; // End character offset (exclusive)
final MarkType type;
final DateTime createdAt;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TextMark && id == other.id;
@override
int get hashCode => id.hashCode;
Map<String, dynamic> toJson() => {
'id': id,
'lineIndex': lineIndex,
'startOffset': startOffset,
'endOffset': endOffset,
'type': type.name,
'createdAt': createdAt.toUtc().toIso8601String(),
};
factory TextMark.fromJson(Map<String, dynamic> json) => TextMark(
id: json['id'] as String,
lineIndex: json['lineIndex'] as int,
startOffset: json['startOffset'] as int,
endOffset: json['endOffset'] as int,
type: MarkType.values.byName(json['type'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
```
### LineNote
Free-text note attached to a whole line.
```dart
@immutable
final class LineNote {
const LineNote({
required this.id,
required this.lineIndex,
required this.category,
required this.text,
required this.createdAt,
});
final String id; // UUID
final int lineIndex; // Which ScriptLine
final NoteCategory category;
final String text;
final DateTime createdAt;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is LineNote && id == other.id;
@override
int get hashCode => id.hashCode;
Map<String, dynamic> toJson() => {
'id': id,
'lineIndex': lineIndex,
'category': category.name,
'text': text,
'createdAt': createdAt.toUtc().toIso8601String(),
};
factory LineNote.fromJson(Map<String, dynamic> json) => LineNote(
id: json['id'] as String,
lineIndex: json['lineIndex'] as int,
category: NoteCategory.values.byName(json['category'] as String),
text: json['text'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
```
### AnnotationSnapshot
Point-in-time record of all annotations for a script. Enables change history,
undo/redo, and viewing annotation evolution over time.
```dart
@immutable
final class AnnotationSnapshot {
AnnotationSnapshot({
required this.id,
required this.scriptId,
required this.timestamp,
required List<TextMark> marks,
required List<LineNote> notes,
}) : marks = List.unmodifiable(marks),
notes = List.unmodifiable(notes);
final String id; // UUID
final String scriptId;
final DateTime timestamp;
final List<TextMark> marks; // Unmodifiable
final List<LineNote> notes; // Unmodifiable
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AnnotationSnapshot && id == other.id;
@override
int get hashCode => id.hashCode;
Map<String, dynamic> toJson() => {
'id': id,
'scriptId': scriptId,
'timestamp': timestamp.toUtc().toIso8601String(),
'marks': marks.map((m) => m.toJson()).toList(),
'notes': notes.map((n) => n.toJson()).toList(),
};
factory AnnotationSnapshot.fromJson(Map<String, dynamic> json) =>
AnnotationSnapshot(
id: json['id'] as String,
scriptId: json['scriptId'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
marks: (json['marks'] as List<dynamic>)
.map((e) => TextMark.fromJson(e as Map<String, dynamic>))
.toList(),
notes: (json['notes'] as List<dynamic>)
.map((e) => LineNote.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
```
### Serialization
All `DateTime` values use UTC ISO 8601 format. Enums serialize by `name` (string).
Serialization is manual (no `json_serializable` dependency) to keep `horatio_core`
free of codegen. Invalid enum names in `fromJson` throw `ArgumentError` via
`EnumName.byName` — this is intentional; corrupted data should fail loudly.
## Persistence (Drift)
### Database Location
`horatio_app/lib/database/app_database.dart` — central drift database class.
This is the first use of drift in the app. Future subsystems (Recording, SRS
persistence) will add their tables to this same database.
### Tables
**text_marks:**
| Column | Type | Notes |
| ----------- | -------- | ----------------------- |
| id | text PK | UUID |
| scriptId | text | FK-like, UUID of script |
| lineIndex | integer | |
| startOffset | integer | |
| endOffset | integer | |
| markType | text | Enum name string |
| createdAt | dateTime | |
**line_notes:**
| Column | Type | Notes |
| --------- | -------- | ----------------------- |
| id | text PK | UUID |
| scriptId | text | FK-like, UUID of script |
| lineIndex | integer | |
| category | text | Enum name string |
| noteText | text | |
| createdAt | dateTime | |
**annotation_snapshots:**
| Column | Type | Notes |
| ------------ | -------- | ----------------------------- |
| id | text PK | UUID |
| scriptId | text | UUID of script |
| timestamp | dateTime | |
| snapshotJson | text | JSON-serialized marks + notes |
### DAO
`AnnotationDao` providing methods below. Note: `TextMark` and `LineNote` models
do not carry a `scriptId` field — the DAO binds `scriptId` at the persistence
boundary (on insert, and as a filter on queries). Models are always loaded in the
context of a known script.
Methods:
- `watchMarksForScript(scriptId)` → `Stream<List<TextMark>>`
- `watchNotesForScript(scriptId)` → `Stream<List<LineNote>>`
- `watchSnapshotsForScript(scriptId)` → `Stream<List<AnnotationSnapshot>>`
- `insertMark(scriptId, TextMark)`, `deleteMark(id)`
- `insertNote(scriptId, LineNote)`, `updateNoteText(id, text)`, `deleteNote(id)`
- `insertSnapshot(AnnotationSnapshot)` (snapshot carries its own `scriptId`)
- `getMarksForLine(scriptId, lineIndex)` → `Future<List<TextMark>>`
- `getNotesForLine(scriptId, lineIndex)` → `Future<List<LineNote>>`
## State Management
### AnnotationCubit
Handles annotation CRUD. Snapshot management is in a separate cubit (below).
```dart
sealed class AnnotationState extends Equatable {}
final class AnnotationInitial extends AnnotationState {}
final class AnnotationLoaded extends AnnotationState {
final String scriptId;
final List<TextMark> marks;
final List<LineNote> notes;
final int? selectedLineIndex; // Currently focused line
final EditingContext? editing; // Non-null when actively editing
}
@immutable
final class EditingContext {
const EditingContext({
required this.lineIndex,
required this.isAddingMark,
});
final int lineIndex;
final bool isAddingMark; // true = placing mark, false = writing note
}
```
**Methods:**
- `loadAnnotations(scriptId)` — subscribe to drift watch streams
- `selectLine(lineIndex)` — focus a line for annotation
- `startEditing(lineIndex, isAddingMark)` — enter editing mode
- `cancelEditing()` — exit editing mode
- `addMark(lineIndex, startOffset, endOffset, MarkType)` — create TextMark
- `removeMark(id)` — delete a mark
- `addNote(lineIndex, NoteCategory, text)` — create LineNote
- `updateNote(id, text)` — edit note content
- `removeNote(id)` — delete a note
### AnnotationHistoryCubit
Separate cubit for snapshot management (SRP: CRUD vs history are independent
concerns).
```dart
sealed class AnnotationHistoryState extends Equatable {}
final class AnnotationHistoryInitial extends AnnotationHistoryState {}
final class AnnotationHistoryLoaded extends AnnotationHistoryState {
final String scriptId;
final List<AnnotationSnapshot> snapshots;
}
```
**Methods:**
- `loadSnapshots(scriptId)` — subscribe to drift watch stream
- `saveSnapshot()` — capture current marks + notes as a snapshot
- `restoreSnapshot(snapshotId)` — delete all current annotations for the
script and replace with snapshot contents. This is destructive; the UI
must show a confirmation dialog before calling this.
## UI
### Annotation Editor Screen
Accessed from script detail (new route). Shows the full script text with:
- **Mark overlay:** Colored highlights on text spans. Each `MarkType` has a distinct
color (e.g., stress = red underline, pause = blue caret, breath = green dot).
- **Note indicators:** Small icons next to lines that have notes. Tappable to
expand/collapse.
- **Interaction:**
- Tap-and-drag on text to select a span → choose mark type from popup
- Tap a mark to remove it
- Long-press a line → add/edit notes in a bottom sheet with category picker
- **Toolbar:** Mark type filter toggles, snapshot save button, history button
### Rehearsal Screen Enhancement
- During rehearsal, show text marks on the cue/expected lines as colored highlights
- Show note count badge next to lines that have notes
- Optional: tap badge to peek at notes without leaving rehearsal flow
### History View
- Timeline list of `AnnotationSnapshot` entries
- Each entry shows timestamp and diff summary (marks added/removed, notes changed)
- Tap to restore that snapshot (with confirmation)
## Testing Strategy
### horatio_core Tests
- `TextMark`: construction, equality, immutability, offset validation (assert
failures for negative offsets, endOffset <= startOffset)
- `LineNote`: construction, equality, immutability
- `AnnotationSnapshot`: construction with unmodifiable lists, serialization
roundtrip, empty marks/notes roundtrip, invalid enum names in JSON
(expect `ArgumentError`), malformed DateTime strings (expect `FormatException`)
- `MarkType` / `NoteCategory`: all enum values covered in serialization
- `Script.id`: new field present, non-empty
- Target: 100% branch coverage
### horatio_app Tests
- **Drift DAO tests:** In-memory database, CRUD operations, reactive stream
emissions, snapshot save/restore, restore-as-destructive-replace behavior
- **AnnotationCubit tests:** State transitions for load, select, start/cancel
editing, add/remove marks and notes. Mock the DAO.
- **AnnotationHistoryCubit tests:** Load snapshots, save snapshot, restore
snapshot (verify destructive replace). Mock the DAO.
- **Widget tests:** Annotation overlay renders marks correctly, tap interactions
trigger cubit methods, note bottom sheet displays and submits, history timeline
renders snapshots, restore confirmation dialog
- Target: 100% branch coverage
## File Structure
```
horatio_core/lib/src/models/
text_mark.dart
line_note.dart
annotation_snapshot.dart
mark_type.dart
note_category.dart
horatio_app/lib/
database/
app_database.dart
app_database.g.dart (drift codegen)
tables/
text_marks_table.dart
line_notes_table.dart
annotation_snapshots_table.dart
daos/
annotation_dao.dart
annotation_dao.g.dart (drift codegen)
bloc/annotation/
annotation_cubit.dart
annotation_state.dart
screens/
annotation_editor_screen.dart
annotation_history_screen.dart
widgets/
mark_overlay.dart
note_indicator.dart
mark_type_picker.dart
note_editor_sheet.dart
```
## Dependencies
**horatio_core** (new):
- `uuid` — for generating annotation IDs at model creation time
**horatio_app** (already present):
- `drift: ^2.22.0`
- `sqlite3_flutter_libs: ^0.6.0`
- `path_provider: ^2.1.0`
- `equatable: ^2.0.7`
**horatio_app** (new dev dependencies):
- `build_runner` — drift codegen runner
- `drift_dev` — drift code generator
**Build pipeline change:** Add `dart run build_runner build` step to `run.sh`
before the analyze/test steps (with caching so it only regenerates when
drift table definitions change).
## Migration Path
This is the first drift database in the app. Schema version starts at 1. Future
subsystems (Recording, SRS persistence) will add tables via drift schema migrations
(version 2, 3, etc.).
## Script Identity Change
This spec introduces a `scriptId` field (UUID) on the `Script` model in
`horatio_core`. Generated at parse time via `uuid` package. This provides a
stable unique identifier that annotations bind to.
```dart
final class Script {
const Script({
required this.id,
required this.title,
required this.roles,
required this.scenes,
});
final String id; // UUID, generated at parse time
final String title;
final List<Role> roles;
final List<Scene> scenes;
// ... existing methods unchanged
}
```
All existing code that creates `Script` instances (parsers, tests, demo data)
must be updated to supply an `id`.
## Open Decisions
- **Mark rendering:** Exact visual design (colors, underline styles, icons) will be
finalized during implementation based on what looks readable.
- **Snapshot granularity:** Auto-snapshot on every edit vs manual-only. Starting with
manual to keep it simple; auto-snapshot can be added later.