Skip to content

Commit b08f320

Browse files
committed
feat(storage): detect trainer context via class/data-attribute (DITA-friendly)
detectUserContext() previously only recognised id="gf-persistent" (plus the fragile "ANALYSIS" anchor heuristic). DITA-OT / Oxygen WebHelp topic-scope and uniquify every @id in their HTML output, so an authored id="gf-persistent" is rewritten per-page and never matches — instructor pages silently fall back to ephemeral sessionStorage. Accept a class (.gf-persistent) and a data-attribute ([data-gf-persistent]) as trainer flags too, keeping the id for backward compatibility. @outputclass is passed through to @Class verbatim and un-mangled (exactly how table.gram-config is already detected) and classes aren't uniquified, so .gf-persistent is reliably emittable from DITA and stable on every page. - Export TRAINER_FLAG_SELECTOR ('#gf-persistent, .gf-persistent, [data-gf-persistent]') and use document.querySelector for detection. - Add unit tests (tests/detect-user-context.spec.js) covering id, class, data-attribute, legacy ANALYSIS anchor, and the negative student case, plus the exported selector. - Add an e2e test + fixture confirming class="gf-persistent" drives the full localStorage pipeline. - Document the class/data-attribute forms and the DITA-OT id-mangling rationale in docs/HTML-Integration-Guide.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q1PucHHFUDRRo2g7zC6aLq
1 parent 3f5d7bc commit b08f320

6 files changed

Lines changed: 228 additions & 7 deletions

File tree

docs/HTML-Integration-Guide.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,64 @@ GramFrame.removeStateListener(listener)
114114

115115
State is deep-copied before being passed to listeners, so you cannot accidentally mutate internal state.
116116

117+
## Annotation Persistence (Trainer vs. Student)
118+
119+
GramFrame can persist annotations (analysis markers, harmonic sets, doppler
120+
curves) in browser storage. The storage backend depends on whether the page is
121+
detected as a **trainer** page or a **student** page:
122+
123+
- **Trainer pages** use `localStorage` — annotations persist permanently, so an
124+
instructor can author them once and have them survive browser restarts.
125+
- **Student pages** use `sessionStorage` — annotations are ephemeral and cleared
126+
when the browser tab/session closes.
127+
128+
A page is treated as a trainer page if **any** of the following explicit flags is
129+
present anywhere in the page, in order of preference:
130+
131+
| Form | Example | Notes |
132+
|------|---------|-------|
133+
| Class | `<span class="gf-persistent"></span>` | **Recommended** — DITA-friendly |
134+
| Data attribute | `<span data-gf-persistent></span>` | DITA-friendly |
135+
| Id | `<span id="gf-persistent"></span>` | Legacy; kept for backward compatibility |
136+
137+
The flag element can be hidden and placed anywhere on the page — detection runs
138+
over the whole document with no ordering constraints.
139+
140+
```html
141+
<!-- Mark this page as a trainer/instructor page -->
142+
<span class="gf-persistent" hidden></span>
143+
```
144+
145+
A legacy heuristic also treats a page as trainer context if it contains an
146+
anchor whose exact text is `ANALYSIS`. This is fragile (it false-positives on
147+
any page with such a link) and is retained only for backward compatibility —
148+
prefer an explicit flag above.
149+
150+
### Why a class and data-attribute, not just an id
151+
152+
The AAAC training material is produced through a DITA-OT / Oxygen WebHelp
153+
publishing pipeline. **DITA-OT topic-scopes and uniquifies every `@id`** in its
154+
HTML output, so an authored `id="gf-persistent"` is rewritten to something
155+
page-specific (e.g. `id="ariaid-title1_gf-persistent"`) and
156+
`getElementById('gf-persistent')` never matches — instructor pages would
157+
silently fall back to ephemeral `sessionStorage`.
158+
159+
A DITA `@outputclass`, by contrast, is passed straight through to the HTML
160+
`@class` **verbatim and un-mangled** (this is exactly how `table.gram-config`
161+
itself is detected), and classes are not uniquified. So `.gf-persistent` is
162+
reliably emittable from DITA and stable on every page.
163+
164+
DITA integrators add the class to an instructor-only marker they already emit
165+
(profiled out of the student build via DITAVAL), so no extra authoring is
166+
required — students get no flag and stay ephemeral:
167+
168+
```xml
169+
<p outputclass="gf-persistent" audience="instructor">…</p>
170+
```
171+
172+
→ renders to `class="p gf-persistent"` on instructor pages only. No id, no
173+
post-processing, no client-side shim.
174+
117175
## File Protocol Compatibility
118176

119177
GramFrame supports `file://` protocol for offline use. The standalone IIFE build (`gramframe.bundle.js`) bundles all CSS inline, avoiding cross-origin restrictions. See [ADR-013](ADRs/ADR-013-File-Protocol-Compatibility.md).

src/core/storage.js

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,22 @@
55
* in browser storage. Trainers get localStorage (permanent); students get
66
* sessionStorage (cleared on browser close).
77
*
8-
* Context detection: a page is treated as a trainer page if EITHER an anchor
9-
* with exact text "ANALYSIS" is present, OR an element with id "gf-persistent"
10-
* exists anywhere on the page. All other pages are student pages.
8+
* Context detection: a page is treated as a trainer page if ANY of the
9+
* following is present anywhere on the page:
10+
* - an element with id "gf-persistent",
11+
* - an element with class "gf-persistent",
12+
* - an element carrying the data-gf-persistent attribute, OR
13+
* - (legacy) an anchor whose exact text is "ANALYSIS".
14+
* All other pages are student pages.
15+
*
16+
* The class and data-attribute forms exist so the flag can be emitted from a
17+
* DITA-OT / Oxygen WebHelp publishing pipeline. DITA-OT topic-scopes and
18+
* uniquifies every @id in its HTML output, so an authored id="gf-persistent"
19+
* is rewritten to something page-specific and getElementById() never matches.
20+
* @outputclass, by contrast, is passed straight through to the HTML @class
21+
* verbatim and un-mangled (this is exactly how table.gram-config is already
22+
* detected), and classes are not uniquified — so .gf-persistent is reliably
23+
* emittable from DITA and stable on every page.
1124
*/
1225

1326
/// <reference path="../types.js" />
@@ -18,17 +31,25 @@ const SCHEMA_VERSION = 1
1831
/** @type {string} */
1932
const KEY_PREFIX = 'gramframe::'
2033

34+
/**
35+
* CSS selector matching the explicit trainer-persistence flag. Accepts the
36+
* id, class, or data-attribute form. Exported for unit testing.
37+
* @type {string}
38+
*/
39+
export const TRAINER_FLAG_SELECTOR = '#gf-persistent, .gf-persistent, [data-gf-persistent]'
40+
2141
/**
2242
* Detect whether the current page is a trainer or student context.
2343
* A page is treated as trainer context if EITHER condition holds:
24-
* - an anchor element with exact text "ANALYSIS" is present, OR
25-
* - an element with id "gf-persistent" exists anywhere on the page.
44+
* - an explicit persistence flag (id, class, or data-attribute) is present
45+
* anywhere on the page (see TRAINER_FLAG_SELECTOR), OR
46+
* - (legacy) an anchor element with exact text "ANALYSIS" is present.
2647
* All other pages are student context.
2748
* @returns {'trainer' | 'student'}
2849
*/
2950
export function detectUserContext() {
30-
// Explicit persistence flag: an element with id "gf-persistent"
31-
if (document.getElementById('gf-persistent')) {
51+
// Explicit persistence flag: id, class, or data-attribute form.
52+
if (document.querySelector(TRAINER_FLAG_SELECTOR)) {
3253
return 'trainer'
3354
}
3455
// Legacy detection: an anchor whose exact text is "ANALYSIS"

tests/detect-user-context.spec.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
/**
4+
* Unit tests for detectUserContext() and TRAINER_FLAG_SELECTOR in
5+
* src/core/storage.js.
6+
*
7+
* The module touches `document`, so it is imported and exercised in the
8+
* browser via page.evaluate against a controlled DOM. A deliberately empty
9+
* fixture (blank-page.html) is used so no GramFrame instance interferes with
10+
* detection.
11+
*/
12+
13+
/**
14+
* Set document.body.innerHTML to `html`, import the storage module, and return
15+
* the result of detectUserContext().
16+
* @param {import('@playwright/test').Page} page
17+
* @param {string} html - markup to place in the body before detection
18+
* @returns {Promise<'trainer' | 'student'>}
19+
*/
20+
async function detectWith(page, html) {
21+
return page.evaluate(async (markup) => {
22+
document.body.innerHTML = markup
23+
const mod = await import('/src/core/storage.js')
24+
return mod.detectUserContext()
25+
}, html)
26+
}
27+
28+
test.beforeEach(async ({ page }) => {
29+
await page.goto('/tests/fixtures/blank-page.html')
30+
})
31+
32+
test('returns "trainer" for the legacy id="gf-persistent" flag', async ({ page }) => {
33+
const context = await detectWith(page, '<span id="gf-persistent" hidden></span>')
34+
expect(context).toBe('trainer')
35+
})
36+
37+
test('returns "trainer" for the class="gf-persistent" flag (DITA outputclass)', async ({ page }) => {
38+
const context = await detectWith(page, '<span class="p edition-instructor gf-persistent"></span>')
39+
expect(context).toBe('trainer')
40+
})
41+
42+
test('returns "trainer" for the data-gf-persistent attribute flag', async ({ page }) => {
43+
const context = await detectWith(page, '<span data-gf-persistent></span>')
44+
expect(context).toBe('trainer')
45+
})
46+
47+
test('returns "trainer" for the legacy ANALYSIS anchor heuristic', async ({ page }) => {
48+
const context = await detectWith(page, '<a href="#">ANALYSIS</a>')
49+
expect(context).toBe('trainer')
50+
})
51+
52+
test('returns "student" when no flag is present', async ({ page }) => {
53+
const context = await detectWith(page, '<p>Ordinary student page content</p>')
54+
expect(context).toBe('student')
55+
})
56+
57+
test('TRAINER_FLAG_SELECTOR is exported and matches all three flag forms', async ({ page }) => {
58+
const result = await page.evaluate(async () => {
59+
const mod = await import('/src/core/storage.js')
60+
document.body.innerHTML =
61+
'<span id="gf-persistent"></span>' +
62+
'<span class="gf-persistent"></span>' +
63+
'<span data-gf-persistent></span>'
64+
return {
65+
selector: mod.TRAINER_FLAG_SELECTOR,
66+
matches: document.querySelectorAll(mod.TRAINER_FLAG_SELECTOR).length
67+
}
68+
})
69+
expect(typeof result.selector).toBe('string')
70+
expect(result.selector).toContain('#gf-persistent')
71+
expect(result.selector).toContain('.gf-persistent')
72+
expect(result.selector).toContain('[data-gf-persistent]')
73+
expect(result.matches).toBe(3)
74+
})

tests/fixtures/blank-page.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Blank Page - detectUserContext unit tests</title>
7+
</head>
8+
<body>
9+
<!-- Intentionally empty: storage.js is imported and exercised directly via
10+
page.evaluate so detectUserContext() can be unit-tested against a
11+
controlled DOM, with no GramFrame instance interfering. -->
12+
</body>
13+
</html>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Persistent Class Page - Storage Test</title>
7+
<link rel="stylesheet" href="../../src/gramframe.css" />
8+
<script type="module" src="../../src/main.js"></script>
9+
</head>
10+
<body>
11+
<h1>Persistent Class Page</h1>
12+
<!-- No id and no ANALYSIS link: the DITA-friendly class flag forces trainer
13+
persistence. This mirrors how DITA-OT/Oxygen emit @outputclass. -->
14+
<span class="p edition-instructor gf-persistent" hidden></span>
15+
16+
<div class="component-container">
17+
<table class="gram-config">
18+
<tr>
19+
<td colspan="2">
20+
<img src="../../sample/mock-gram.png" alt="Sample Spectrogram">
21+
</td>
22+
</tr>
23+
<tr><td>time-start</td><td>0</td></tr>
24+
<tr><td>time-end</td><td>60</td></tr>
25+
<tr><td>freq-start</td><td>0</td></tr>
26+
<tr><td>freq-end</td><td>100</td></tr>
27+
</table>
28+
</div>
29+
30+
<div class="diagnostics-panel" style="display:none;">
31+
<pre id="state-display">Loading...</pre>
32+
</div>
33+
</body>
34+
</html>

tests/storage.spec.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,27 @@ test.describe('gf-persistent flag forces trainer persistence', () => {
337337
const stateAfter = await getStateFromPage(page)
338338
expect(stateAfter.analysis.markers.length).toBe(stateBefore.analysis.markers.length)
339339
})
340+
341+
// The DITA-friendly class flag must drive the full localStorage pipeline,
342+
// not just detection — DITA-OT id-mangling makes the class form the one the
343+
// AAAC publishing pipeline can actually emit.
344+
test('page with class="gf-persistent" uses localStorage (not sessionStorage)', async ({ page }) => {
345+
await page.goto('/tests/fixtures/persistent-class-page.html')
346+
await page.evaluate(() => {
347+
localStorage.clear()
348+
sessionStorage.clear()
349+
})
350+
const gfp = await gotoFixture(page, '/tests/fixtures/persistent-class-page.html')
351+
352+
await addAnalysisMarker(gfp, 200, 150)
353+
await page.waitForTimeout(300)
354+
355+
const localKeys = await gfp.getStorageKeys('local')
356+
expect(localKeys.length).toBeGreaterThan(0)
357+
358+
const sessionKeys = await gfp.getStorageKeys('session')
359+
expect(sessionKeys.length).toBe(0)
360+
})
340361
})
341362

342363
// ──────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)