+ Student context: No "ANALYSIS" link on this page, so annotations use sessionStorage.
+ They persist across page reloads within the same browser session, but are cleared when the browser closes.
+
+ Trainer context: The "ANALYSIS" link above triggers localStorage persistence.
+ Annotations survive browser restarts. A "Clear gram" button is available to reset.
+
+
+
+
+
+
+
+
time-start
0
+
time-end
10
+
freq-start
0
+
freq-end
50
+
+
+
+
+ Test steps:
+
+
Add some markers in Cross Cursor mode
+
Reload the page — markers should still be there
+
Close and reopen the browser — markers should still be there
+
Click "Clear gram" button — markers should be removed
+
Reload — markers should stay gone
+
+
+
+
+
+
diff --git a/specs/155-browser-storage/tasks.md b/specs/155-browser-storage/tasks.md
index 3396f12..aa3ec9b 100644
--- a/specs/155-browser-storage/tasks.md
+++ b/specs/155-browser-storage/tasks.md
@@ -19,8 +19,8 @@
**Purpose**: No new project initialisation needed — GramFrame is an existing project. This phase creates the shared storage module.
-- [ ] T001 Create storage adapter module with context detection, key generation, save/load/clear operations, schema versioning, and graceful degradation (try/catch around all Web Storage calls) in `src/core/storage.js`
-- [ ] T002 Add JSDoc type definitions for StoredAnnotations, AnalysisData, HarmonicsData, DopplerData, and DataCoordinates in `src/types.js`
+- [x] T001 Create storage adapter module with context detection, key generation, save/load/clear operations, schema versioning, and graceful degradation (try/catch around all Web Storage calls) in `src/core/storage.js`
+- [x] T002 Add JSDoc type definitions for StoredAnnotations, AnalysisData, HarmonicsData, DopplerData, and DataCoordinates in `src/types.js`
---
@@ -30,10 +30,10 @@
**CRITICAL**: No user story work can begin until this phase is complete
-- [ ] T003 Add state listener in GramFrame constructor that calls `saveAnnotations()` on every state change (filtering to only save when annotation-relevant state changes) in `src/main.js`
-- [ ] T004 Add restore logic in GramFrame constructor — after mode infrastructure is initialised but before first `notifyStateListeners()` call — to load saved annotations and merge into `this.state` in `src/main.js`
-- [ ] T005 Pass instance index to storage functions to support multiple GramFrame instances on the same page in `src/main.js`
-- [ ] T006 Run `yarn typecheck` to validate all new JSDoc types and storage module signatures
+- [x] T003 Add state listener in GramFrame constructor that calls `saveAnnotations()` on every state change (filtering to only save when annotation-relevant state changes) in `src/main.js`
+- [x] T004 Add restore logic in GramFrame constructor — after mode infrastructure is initialised but before first `notifyStateListeners()` call — to load saved annotations and merge into `this.state` in `src/main.js`
+- [x] T005 Pass instance index to storage functions to support multiple GramFrame instances on the same page in `src/main.js`
+- [x] T006 Run `yarn typecheck` to validate all new JSDoc types and storage module signatures
**Checkpoint**: Foundation ready — storage saves and restores automatically. User story implementation can now begin.
@@ -47,17 +47,17 @@
### Tests for User Story 1
-- [ ] T007 [P] [US1] Write Playwright test: add analysis markers on a trainer page, reload, verify markers are restored with correct positions and colours in `tests/storage.spec.ts`
-- [ ] T008 [P] [US1] Write Playwright test: add harmonic sets on a trainer page, reload, verify harmonic sets are restored with correct spacing and anchor positions in `tests/storage.spec.ts`
-- [ ] T009 [P] [US1] Write Playwright test: add doppler curve on a trainer page, reload, verify fPlus/fMinus/fZero markers and curve are restored in `tests/storage.spec.ts`
-- [ ] T010 [P] [US1] Write Playwright test: verify annotations are silently restored without any prompt or confirmation dialog in `tests/storage.spec.ts`
+- [x] T007 [P] [US1] Write Playwright test: add analysis markers on a trainer page, reload, verify markers are restored with correct positions and colours in `tests/storage.spec.ts`
+- [x] T008 [P] [US1] Write Playwright test: add harmonic sets on a trainer page, reload, verify harmonic sets are restored with correct spacing and anchor positions in `tests/storage.spec.ts`
+- [x] T009 [P] [US1] Write Playwright test: add doppler curve on a trainer page, reload, verify fPlus/fMinus/fZero markers and curve are restored in `tests/storage.spec.ts`
+- [x] T010 [P] [US1] Write Playwright test: verify annotations are silently restored without any prompt or confirmation dialog in `tests/storage.spec.ts`
### Implementation for User Story 1
-- [ ] T011 [US1] Create a test HTML page that includes an "ANALYSIS" link to simulate a trainer page for Playwright testing in `tests/fixtures/trainer-page.html`
-- [ ] T012 [US1] Ensure `detectUserContext()` in `src/core/storage.js` correctly returns trainer context when an anchor with exact text "ANALYSIS" is present on the page
-- [ ] T013 [US1] Verify that trainer context uses `localStorage` so annotations survive browser restarts — validate by running T007–T010 tests
-- [ ] T014 [US1] Add GramFramePage helper methods for storage testing (e.g., `clearStorage()`, `getStorageEntry()`, `setStorageEntry()`) in `tests/helpers/gram-frame-page.js`
+- [x] T011 [US1] Create a test HTML page that includes an "ANALYSIS" link to simulate a trainer page for Playwright testing in `tests/fixtures/trainer-page.html`
+- [x] T012 [US1] Ensure `detectUserContext()` in `src/core/storage.js` correctly returns trainer context when an anchor with exact text "ANALYSIS" is present on the page
+- [x] T013 [US1] Verify that trainer context uses `localStorage` so annotations survive browser restarts — validate by running T007–T010 tests
+- [x] T014 [US1] Add GramFramePage helper methods for storage testing (e.g., `clearStorage()`, `getStorageEntry()`, `setStorageEntry()`) in `tests/helpers/gram-frame-page.js`
**Checkpoint**: Trainer persistence is fully functional and independently testable.
@@ -71,14 +71,14 @@
### Tests for User Story 2
-- [ ] T015 [P] [US2] Write Playwright test: add annotations on a student page (no "ANALYSIS" link), reload, verify annotations persist within the session in `tests/storage.spec.ts`
-- [ ] T016 [P] [US2] Write Playwright test: add annotations on a student page, close browser context and open a new one, verify annotations are gone (clean slate) in `tests/storage.spec.ts`
+- [x] T015 [P] [US2] Write Playwright test: add annotations on a student page (no "ANALYSIS" link), reload, verify annotations persist within the session in `tests/storage.spec.ts`
+- [x] T016 [P] [US2] Write Playwright test: add annotations on a student page, close browser context and open a new one, verify annotations are gone (clean slate) in `tests/storage.spec.ts`
### Implementation for User Story 2
-- [ ] T017 [US2] Create a test HTML page without an "ANALYSIS" link to simulate a student page for Playwright testing in `tests/fixtures/student-page.html`
-- [ ] T018 [US2] Ensure `detectUserContext()` in `src/core/storage.js` correctly returns student context when no "ANALYSIS" link is present, selecting `sessionStorage`
-- [ ] T019 [US2] Verify student context behaviour by running T015–T016 tests
+- [x] T017 [US2] Create a test HTML page without an "ANALYSIS" link to simulate a student page for Playwright testing in `tests/fixtures/student-page.html`
+- [x] T018 [US2] Ensure `detectUserContext()` in `src/core/storage.js` correctly returns student context when no "ANALYSIS" link is present, selecting `sessionStorage`
+- [x] T019 [US2] Verify student context behaviour by running T015–T016 tests
**Checkpoint**: Student session-scoped persistence is fully functional and independently testable.
@@ -92,16 +92,16 @@
### Tests for User Story 3
-- [ ] T020 [P] [US3] Write Playwright test: on a trainer page with stored annotations, click "Clear gram" button, verify annotations are removed from display and storage in `tests/storage.spec.ts`
-- [ ] T021 [P] [US3] Write Playwright test: after clearing a gram, reload the page and verify no annotations are restored in `tests/storage.spec.ts`
-- [ ] T022 [P] [US3] Write Playwright test: on a student page, verify no "Clear gram" button is visible in `tests/storage.spec.ts`
+- [x] T020 [P] [US3] Write Playwright test: on a trainer page with stored annotations, click "Clear gram" button, verify annotations are removed from display and storage in `tests/storage.spec.ts`
+- [x] T021 [P] [US3] Write Playwright test: after clearing a gram, reload the page and verify no annotations are restored in `tests/storage.spec.ts`
+- [x] T022 [P] [US3] Write Playwright test: on a student page, verify no "Clear gram" button is visible in `tests/storage.spec.ts`
### Implementation for User Story 3
-- [ ] T023 [US3] Add "Clear gram" button rendering (trainer pages only) in the existing controls area in `src/components/UIComponents.js`
-- [ ] T024 [US3] Wire "Clear gram" button click handler to call `clearAnnotations()` from storage module and reset component state (analysis markers, harmonic sets, doppler data) in `src/main.js`
-- [ ] T025 [US3] Style "Clear gram" button to match existing UI controls in `src/gramframe.css`
-- [ ] T026 [US3] Verify "Clear gram" behaviour by running T020–T022 tests
+- [x] T023 [US3] Add "Clear gram" button rendering (trainer pages only) in the existing controls area in `src/components/UIComponents.js`
+- [x] T024 [US3] Wire "Clear gram" button click handler to call `clearAnnotations()` from storage module and reset component state (analysis markers, harmonic sets, doppler data) in `src/main.js`
+- [x] T025 [US3] Style "Clear gram" button to match existing UI controls in `src/gramframe.css`
+- [x] T026 [US3] Verify "Clear gram" behaviour by running T020–T022 tests
**Checkpoint**: Clear gram functionality is complete and independently testable.
@@ -111,12 +111,12 @@
**Purpose**: Edge cases, degradation, and final validation
-- [ ] T027 [P] Write Playwright test: verify graceful degradation when storage is unavailable (annotations work but are not persisted, no errors shown) in `tests/storage.spec.ts`
-- [ ] T028 [P] Write Playwright test: verify stored data with unrecognised schema version is discarded with console warning on page load in `tests/storage.spec.ts`
-- [ ] T029 [P] Write Playwright test: verify no storage entry is created until the user makes their first annotation in `tests/storage.spec.ts`
-- [ ] T030 Run `yarn typecheck` — zero errors
-- [ ] T031 Run `yarn test` — all Playwright tests green (existing + new storage tests)
-- [ ] T032 Run `yarn build` — clean production build
+- [x] T027 [P] Write Playwright test: verify graceful degradation when storage is unavailable (annotations work but are not persisted, no errors shown) in `tests/storage.spec.ts`
+- [x] T028 [P] Write Playwright test: verify stored data with unrecognised schema version is discarded with console warning on page load in `tests/storage.spec.ts`
+- [x] T029 [P] Write Playwright test: verify no storage entry is created until the user makes their first annotation in `tests/storage.spec.ts`
+- [x] T030 Run `yarn typecheck` — zero errors
+- [x] T031 Run `yarn test` — all Playwright tests green (existing + new storage tests)
+- [x] T032 Run `yarn build` — clean production build
---
diff --git a/src/core/storage.js b/src/core/storage.js
new file mode 100644
index 0000000..9543b28
--- /dev/null
+++ b/src/core/storage.js
@@ -0,0 +1,175 @@
+/**
+ * Browser Storage Adapter for GramFrame
+ *
+ * Persists user annotations (analysis markers, harmonic sets, doppler curves)
+ * in browser storage. Trainers get localStorage (permanent); students get
+ * sessionStorage (cleared on browser close).
+ *
+ * Context detection: pages with an anchor containing exact text "ANALYSIS"
+ * are trainer pages; all others are student pages.
+ */
+
+///
+
+/** @type {number} */
+const SCHEMA_VERSION = 1
+
+/** @type {string} */
+const KEY_PREFIX = 'gramframe::'
+
+/**
+ * Detect whether the current page is a trainer or student context.
+ * Trainer pages contain an anchor element with exact text "ANALYSIS".
+ * @returns {'trainer' | 'student'}
+ */
+export function detectUserContext() {
+ const anchors = document.querySelectorAll('a')
+ for (let i = 0; i < anchors.length; i++) {
+ if (anchors[i].textContent && anchors[i].textContent.trim() === 'ANALYSIS') {
+ return 'trainer'
+ }
+ }
+ return 'student'
+}
+
+/**
+ * Get the appropriate Storage object for the detected context.
+ * Returns null if storage is unavailable.
+ * @param {'trainer' | 'student'} context
+ * @returns {Storage | null}
+ */
+export function getStorage(context) {
+ try {
+ const storage = context === 'trainer' ? localStorage : sessionStorage
+ // Probe write/read to confirm availability
+ const testKey = '__gramframe_test__'
+ storage.setItem(testKey, '1')
+ storage.removeItem(testKey)
+ return storage
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Build a namespaced storage key from the current page path.
+ * @param {number} [instanceIndex] - Zero-based index when multiple instances exist on the same page
+ * @returns {string}
+ */
+export function buildStorageKey(instanceIndex) {
+ const pathname = window.location.pathname
+ if (instanceIndex != null && instanceIndex > 0) {
+ return `${KEY_PREFIX}${pathname}::${instanceIndex}`
+ }
+ return `${KEY_PREFIX}${pathname}`
+}
+
+/**
+ * Extract annotation data from GramFrame state and save to storage.
+ * Only writes when there is at least one annotation present.
+ * @param {GramFrameState} state - Current component state
+ * @param {number} [instanceIndex] - Instance index for multi-instance pages
+ * @returns {boolean} True if saved successfully
+ */
+export function saveAnnotations(state, instanceIndex) {
+ try {
+ const context = detectUserContext()
+ const storage = getStorage(context)
+ if (!storage) return false
+
+ const hasMarkers = state.analysis && state.analysis.markers && state.analysis.markers.length > 0
+ const hasHarmonics = state.harmonics && state.harmonics.harmonicSets && state.harmonics.harmonicSets.length > 0
+ const hasDoppler = state.doppler && (state.doppler.fPlus !== null || state.doppler.fMinus !== null)
+
+ if (!hasMarkers && !hasHarmonics && !hasDoppler) {
+ // No annotations — remove any existing entry rather than storing empty data
+ const key = buildStorageKey(instanceIndex)
+ storage.removeItem(key)
+ return true
+ }
+
+ /** @type {StoredAnnotations} */
+ const data = {
+ version: SCHEMA_VERSION,
+ savedAt: new Date().toISOString(),
+ analysis: {
+ markers: (state.analysis && state.analysis.markers || []).map(m => ({
+ id: m.id,
+ color: m.color,
+ time: m.time,
+ freq: m.freq
+ }))
+ },
+ harmonics: {
+ harmonicSets: (state.harmonics && state.harmonics.harmonicSets || []).map(hs => ({
+ id: hs.id,
+ color: hs.color,
+ anchorTime: hs.anchorTime,
+ spacing: hs.spacing
+ }))
+ },
+ doppler: {
+ fPlus: state.doppler && state.doppler.fPlus ? { time: state.doppler.fPlus.time, freq: state.doppler.fPlus.freq } : null,
+ fMinus: state.doppler && state.doppler.fMinus ? { time: state.doppler.fMinus.time, freq: state.doppler.fMinus.freq } : null,
+ fZero: state.doppler && state.doppler.fZero ? { time: state.doppler.fZero.time, freq: state.doppler.fZero.freq } : null,
+ color: state.doppler && state.doppler.color || null
+ }
+ }
+
+ const key = buildStorageKey(instanceIndex)
+ storage.setItem(key, JSON.stringify(data))
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Load and validate stored annotations from browser storage.
+ * Returns null if no data exists, parsing fails, or version is unrecognised.
+ * @param {number} [instanceIndex] - Instance index for multi-instance pages
+ * @returns {StoredAnnotations | null}
+ */
+export function loadAnnotations(instanceIndex) {
+ try {
+ const context = detectUserContext()
+ const storage = getStorage(context)
+ if (!storage) return null
+
+ const key = buildStorageKey(instanceIndex)
+ const raw = storage.getItem(key)
+ if (!raw) return null
+
+ const data = JSON.parse(raw)
+
+ if (!data || data.version !== SCHEMA_VERSION) {
+ console.warn('GramFrame: Discarding stored annotations — unrecognised schema version:', data && data.version)
+ storage.removeItem(key)
+ return null
+ }
+
+ return /** @type {StoredAnnotations} */ (data)
+ } catch {
+ console.warn('GramFrame: Failed to load stored annotations — data discarded')
+ return null
+ }
+}
+
+/**
+ * Remove stored annotations for the current page.
+ * @param {number} [instanceIndex] - Instance index for multi-instance pages
+ * @returns {boolean} True if cleared successfully
+ */
+export function clearAnnotations(instanceIndex) {
+ try {
+ const context = detectUserContext()
+ const storage = getStorage(context)
+ if (!storage) return false
+
+ const key = buildStorageKey(instanceIndex)
+ storage.removeItem(key)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/src/gramframe.css b/src/gramframe.css
index 16f64f8..8801696 100644
--- a/src/gramframe.css
+++ b/src/gramframe.css
@@ -658,6 +658,43 @@
0 1px 2px rgba(0,0,0,0.1);
}
+/* Clear gram button — trainer pages only */
+.gram-frame-clear-btn {
+ margin-top: 8px;
+ padding: 6px 10px;
+ background: linear-gradient(180deg, #6a4a4a 0%, #4a2a2a 50%, #2a1a1a 100%);
+ color: #ddd;
+ border: 2px solid #6a3a3a;
+ border-radius: 4px;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ cursor: pointer;
+ text-transform: uppercase;
+ box-shadow:
+ inset 0 1px 2px rgba(255,255,255,0.15),
+ inset 0 -1px 2px rgba(0,0,0,0.3),
+ 0 2px 4px rgba(0,0,0,0.3);
+ transition: all 0.1s ease;
+ width: 100%;
+}
+
+.gram-frame-clear-btn:hover {
+ background: linear-gradient(180deg, #8a5a5a 0%, #6a3a3a 50%, #4a2a2a 100%);
+ box-shadow:
+ inset 0 1px 2px rgba(255,255,255,0.25),
+ inset 0 -1px 2px rgba(0,0,0,0.4),
+ 0 3px 6px rgba(0,0,0,0.4);
+}
+
+.gram-frame-clear-btn:active {
+ transform: translateY(1px);
+ box-shadow:
+ inset 0 2px 4px rgba(0,0,0,0.4),
+ 0 1px 2px rgba(0,0,0,0.2);
+}
+
/* Rate input UI styles removed - backend functionality preserved */
/* SVG cursor styles removed - using CSS cursor only */
diff --git a/src/main.js b/src/main.js
index 46a6176..5bb7494 100644
--- a/src/main.js
+++ b/src/main.js
@@ -48,6 +48,13 @@ import {
cleanupEventListeners
} from './core/events.js'
+import {
+ saveAnnotations,
+ loadAnnotations,
+ clearAnnotations,
+ detectUserContext
+} from './core/storage.js'
+
import {
cleanupKeyboardControl
} from './core/keyboardControl.js'
@@ -114,7 +121,13 @@ export class GramFrame {
// Bound event handlers
_boundHandleResize;
-
+
+ // Storage instance index for multi-instance pages
+ _storageInstanceIndex;
+
+ // Whether this instance is a trainer context
+ _isTrainerContext;
+
/**
* Creates a new GramFrame instance
* @param {HTMLTableElement} configTable - Configuration table element to replace
@@ -125,7 +138,13 @@ export class GramFrame {
this.configTable = configTable
this.stateListeners = []
this.instanceId = ''
-
+
+ // Determine storage instance index (count existing containers)
+ this._storageInstanceIndex = document.querySelectorAll('.gram-frame-container').length
+
+ // Detect trainer vs student context
+ this._isTrainerContext = detectUserContext() === 'trainer'
+
// Delegate to initialization modules
initializeDOMProperties(this)
setupSpectrogramComponents(this)
@@ -137,7 +156,18 @@ export class GramFrame {
updateModeUIWithCommands(this)
setupAllEventListeners(this)
setupStateListeners(this)
-
+
+ // Add "Clear gram" button for trainer pages
+ if (this._isTrainerContext) {
+ this._addClearGramButton()
+ }
+
+ // Restore saved annotations before first render
+ this._restoreAnnotations()
+
+ // Register storage save listener
+ this._setupStorageSaveListener()
+
// Final state notification
notifyStateListeners(this.state, this.stateListeners)
}
@@ -210,6 +240,117 @@ export class GramFrame {
handleResize(this)
}
+ /**
+ * Add a "Clear gram" button to the controls area (trainer pages only)
+ */
+ _addClearGramButton() {
+ const btn = document.createElement('button')
+ btn.className = 'gram-frame-clear-btn'
+ btn.textContent = 'Clear gram'
+ btn.title = 'Remove all annotations for this gram'
+ btn.addEventListener('click', (e) => {
+ e.preventDefault()
+ this._clearGram()
+ })
+
+ // Append to the mode column alongside the mode buttons
+ if (this.modeColumn) {
+ this.modeColumn.appendChild(btn)
+ }
+ }
+
+ /**
+ * Clear all annotations from state and storage
+ */
+ _clearGram() {
+ // Reset analysis markers
+ this.state.analysis.markers = []
+ this.state.analysis.isDragging = false
+ this.state.analysis.draggedMarkerId = null
+
+ // Reset harmonic sets
+ this.state.harmonics.harmonicSets = []
+
+ // Reset doppler
+ this.state.doppler.fPlus = null
+ this.state.doppler.fMinus = null
+ this.state.doppler.fZero = null
+ this.state.doppler.speed = null
+ this.state.doppler.color = null
+
+ // Clear selection
+ this.state.selection.selectedType = null
+ this.state.selection.selectedId = null
+ this.state.selection.selectedIndex = null
+
+ // Remove from storage
+ clearAnnotations(this._storageInstanceIndex)
+
+ // Re-render
+ if (this.featureRenderer) {
+ this.featureRenderer.renderAllPersistentFeatures()
+ }
+ if (this.currentMode && typeof this.currentMode.activate === 'function') {
+ this.currentMode.cleanup()
+ this.currentMode.activate()
+ }
+
+ notifyStateListeners(this.state, this.stateListeners)
+ }
+
+ /**
+ * Restore saved annotations from browser storage into state
+ */
+ _restoreAnnotations() {
+ const saved = loadAnnotations(this._storageInstanceIndex)
+ if (!saved) return
+
+ // Merge analysis markers
+ if (saved.analysis && Array.isArray(saved.analysis.markers)) {
+ this.state.analysis.markers = saved.analysis.markers
+ }
+
+ // Merge harmonic sets
+ if (saved.harmonics && Array.isArray(saved.harmonics.harmonicSets)) {
+ this.state.harmonics.harmonicSets = saved.harmonics.harmonicSets
+ }
+
+ // Merge doppler state
+ if (saved.doppler) {
+ this.state.doppler.fPlus = saved.doppler.fPlus || null
+ this.state.doppler.fMinus = saved.doppler.fMinus || null
+ this.state.doppler.fZero = saved.doppler.fZero || null
+ if (saved.doppler.color) {
+ this.state.doppler.color = saved.doppler.color
+ }
+ }
+ }
+
+ /**
+ * Set up a state listener that saves annotations on relevant state changes
+ */
+ _setupStorageSaveListener() {
+ /** @type {string} */
+ let lastSerialised = ''
+
+ this.stateListeners.push((state) => {
+ // Build a minimal representation of annotation-relevant state
+ const annotationSnapshot = JSON.stringify({
+ markers: state.analysis && state.analysis.markers,
+ harmonicSets: state.harmonics && state.harmonics.harmonicSets,
+ fPlus: state.doppler && state.doppler.fPlus,
+ fMinus: state.doppler && state.doppler.fMinus,
+ fZero: state.doppler && state.doppler.fZero,
+ dopplerColor: state.doppler && state.doppler.color
+ })
+
+ if (annotationSnapshot !== lastSerialised) {
+ lastSerialised = annotationSnapshot
+ saveAnnotations(this.state, this._storageInstanceIndex)
+ }
+ })
+ }
+
/**
* Destroy the component and clean up resources
*/
diff --git a/src/types.js b/src/types.js
index 0a7ad5a..3cba98b 100644
--- a/src/types.js
+++ b/src/types.js
@@ -206,6 +206,55 @@
* @property {number} y - Screen y coordinate
*/
+/**
+ * Stored annotation set persisted in browser storage for a single GramFrame instance
+ * @typedef {Object} StoredAnnotations
+ * @property {number} version - Schema version (currently 1)
+ * @property {string} savedAt - ISO 8601 timestamp of last save
+ * @property {StoredAnalysisData} analysis - Stored analysis mode annotations
+ * @property {StoredHarmonicsData} harmonics - Stored harmonics mode annotations
+ * @property {StoredDopplerData} doppler - Stored doppler mode annotations
+ */
+
+/**
+ * Stored analysis data
+ * @typedef {Object} StoredAnalysisData
+ * @property {Array} markers - All analysis markers
+ */
+
+/**
+ * Stored marker (persisted subset of AnalysisMarker)
+ * @typedef {Object} StoredMarker
+ * @property {string} id - Unique marker identifier
+ * @property {string} color - Marker colour (hex)
+ * @property {number} time - Time position in seconds
+ * @property {number} freq - Frequency position in Hz
+ */
+
+/**
+ * Stored harmonics data
+ * @typedef {Object} StoredHarmonicsData
+ * @property {Array} harmonicSets - All harmonic sets
+ */
+
+/**
+ * Stored harmonic set (persisted subset of HarmonicSet)
+ * @typedef {Object} StoredHarmonicSet
+ * @property {string} id - Unique identifier
+ * @property {string} color - Display colour (hex)
+ * @property {number} anchorTime - Y-axis position in seconds
+ * @property {number} spacing - Frequency spacing between harmonics in Hz
+ */
+
+/**
+ * Stored doppler data
+ * @typedef {Object} StoredDopplerData
+ * @property {DataCoordinates|null} fPlus - Upper frequency marker position
+ * @property {DataCoordinates|null} fMinus - Lower frequency marker position
+ * @property {DataCoordinates|null} fZero - Centre frequency marker position
+ * @property {string|null} color - Curve colour (hex)
+ */
+
/**
* State listener callback function
* @typedef {function(GramFrameState): void} StateListener
diff --git a/tests/fixtures/student-page.html b/tests/fixtures/student-page.html
new file mode 100644
index 0000000..65251a7
--- /dev/null
+++ b/tests/fixtures/student-page.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Student Page - Storage Test
+
+
+
+
+