narrarium-astro-reader 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,8 @@ npx narrarium-astro-reader reader --book-root .. --package-name my-book-reader
28
28
  - live search across canon, chapters, and scenes
29
29
  - character, location, faction, item, secret, and timeline indexes
30
30
  - canon mention popups, backlinks, and asset rendering for book, entity, chapter, and scene art
31
+ - a Full read chapter mode that opens an immersive browser-fullscreen reading view with continuous prose, auto-hiding minimal previous/jump/next navigation, a small ghost hint for rediscovering controls, and canon popups still available
32
+ - dismissible Read Aloud controls that can be reopened from a subtle masthead speaker button and stay out of the way during Full read mode
31
33
  - automatic EPUB export to `public/downloads/book.epub`
32
34
  - live watcher for book markdown, canon, and assets during `npm run dev`
33
35
  - `npm run doctor` for broken references, spoiler thresholds, asset metadata, and stale `plot.md`, `resumes/`, or `state/`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "narrarium-astro-reader",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "description": "Astro reader and scaffolding CLI for Narrarium book repositories.",
6
6
  "license": "MIT",
@@ -50,7 +50,7 @@
50
50
  "test": "npm run build:cli && node --test test/**/*.test.mjs"
51
51
  },
52
52
  "dependencies": {
53
- "narrarium": "^0.1.22",
53
+ "narrarium": "^0.1.23",
54
54
  "astro": "^5.14.1",
55
55
  "chokidar": "^4.0.3",
56
56
  "marked": "^16.3.0"
@@ -121,7 +121,10 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
121
121
  <p class="eyebrow">Read Aloud</p>
122
122
  <p class="tts-player__title" data-tts-title>Ready</p>
123
123
  </div>
124
- <span class="chip" data-tts-badge>Browser voice</span>
124
+ <div class="tts-player__top-actions">
125
+ <span class="chip" data-tts-badge>Browser voice</span>
126
+ <button type="button" class="icon-btn" data-tts-close aria-label="Hide read aloud controls">x</button>
127
+ </div>
125
128
  </div>
126
129
  <p class="tts-player__label" data-tts-label>Choose a chapter or scene to start reading aloud.</p>
127
130
  <div class="tts-player__controls">
@@ -156,6 +159,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
156
159
  const ttsVoiceStorageKey = "narrarium-reader-tts-voice";
157
160
  const ttsRateStorageKey = "narrarium-reader-tts-rate";
158
161
  const ttsProgressStorageKey = "narrarium-reader-tts-progress";
162
+ const ttsPlayerDismissedStorageKey = "narrarium-reader-tts-player-dismissed";
159
163
  const glossaryDataElement = document.getElementById("canon-glossary-data");
160
164
  const chapterDataElement = document.getElementById("reader-chapter-data");
161
165
  const glossaryEntries = glossaryDataElement ? JSON.parse(glossaryDataElement.textContent || "[]") : [];
@@ -657,12 +661,12 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
657
661
  const player = document.querySelector("[data-tts-player]");
658
662
  const roots = [...document.querySelectorAll("[data-tts-root]")];
659
663
  const triggers = [...document.querySelectorAll("[data-tts-trigger]")];
664
+ const panelToggle = document.querySelector("[data-tts-panel-toggle]");
660
665
  if (!(player instanceof HTMLElement) || roots.length === 0) {
661
666
  return;
662
667
  }
663
668
 
664
669
  document.body.classList.add("tts-enabled");
665
- player.hidden = false;
666
670
  triggers.forEach((trigger) => {
667
671
  if (trigger instanceof HTMLElement) {
668
672
  trigger.hidden = false;
@@ -674,12 +678,17 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
674
678
  return;
675
679
  }
676
680
 
681
+ if (panelToggle instanceof HTMLButtonElement) {
682
+ panelToggle.hidden = false;
683
+ }
684
+
677
685
  const titleElement = player.querySelector("[data-tts-title]");
678
686
  const labelElement = player.querySelector("[data-tts-label]");
679
687
  const badgeElement = player.querySelector("[data-tts-badge]");
680
688
  const statusElement = player.querySelector("[data-tts-status]");
681
689
  const playToggle = player.querySelector("[data-tts-play-toggle]");
682
690
  const stopButton = player.querySelector("[data-tts-stop]");
691
+ const closeButton = player.querySelector("[data-tts-close]");
683
692
  const voiceSelect = player.querySelector("[data-tts-voice]");
684
693
  const rateInput = player.querySelector("[data-tts-rate]");
685
694
 
@@ -690,6 +699,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
690
699
  !(statusElement instanceof HTMLElement) ||
691
700
  !(playToggle instanceof HTMLButtonElement) ||
692
701
  !(stopButton instanceof HTMLButtonElement) ||
702
+ !(closeButton instanceof HTMLButtonElement) ||
693
703
  !(voiceSelect instanceof HTMLSelectElement) ||
694
704
  !(rateInput instanceof HTMLInputElement)
695
705
  ) {
@@ -709,14 +719,19 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
709
719
  runId: 0,
710
720
  speaking: false,
711
721
  paused: false,
722
+ focusMode: document.body.classList.contains("is-focus-mode"),
723
+ dismissed: readStorage(ttsPlayerDismissedStorageKey) === "true",
712
724
  titleElement,
713
725
  labelElement,
714
726
  badgeElement,
715
727
  statusElement,
716
728
  playToggle,
717
729
  stopButton,
730
+ closeButton,
718
731
  voiceSelect,
719
732
  rateInput,
733
+ player,
734
+ panelToggle,
720
735
  };
721
736
 
722
737
  const savedProgress = readJson(ttsProgressStorageKey);
@@ -739,10 +754,24 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
739
754
  window.speechSynthesis.onvoiceschanged = () => populateVoiceOptions(state);
740
755
  }
741
756
 
757
+ if (panelToggle instanceof HTMLButtonElement) {
758
+ panelToggle.addEventListener("click", () => {
759
+ if (state.player.hidden) {
760
+ showTtsPlayer(state, { persistDismissed: false });
761
+ updateTtsUi(state);
762
+ return;
763
+ }
764
+
765
+ hideTtsPlayer(state, { persistDismissed: true });
766
+ });
767
+ }
768
+
742
769
  window.addEventListener("beforeunload", () => {
743
770
  state.runId += 1;
744
771
  state.speech.cancel();
745
772
  });
773
+
774
+ hideTtsPlayer(state, { persistDismissed: false });
746
775
  }
747
776
 
748
777
  function buildTtsScopes(roots) {
@@ -883,6 +912,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
883
912
 
884
913
  trigger.dataset.defaultLabel = trigger.textContent || "Read aloud";
885
914
  trigger.addEventListener("click", () => {
915
+ showTtsPlayer(state, { persistDismissed: false });
886
916
  const scopeId = trigger.dataset.ttsTrigger || "chapter";
887
917
  startTtsScope(state, scopeId);
888
918
  updateTtsTriggerButtons(triggers, state);
@@ -892,6 +922,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
892
922
 
893
923
  function bindTtsPlayerControls(state) {
894
924
  state.playToggle.addEventListener("click", () => {
925
+ showTtsPlayer(state, { persistDismissed: false });
895
926
  if (state.speech.speaking && !state.speech.paused) {
896
927
  state.speech.pause();
897
928
  state.paused = true;
@@ -914,6 +945,10 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
914
945
  updateTtsUi(state);
915
946
  });
916
947
 
948
+ state.closeButton.addEventListener("click", () => {
949
+ hideTtsPlayer(state, { persistDismissed: true });
950
+ });
951
+
917
952
  state.voiceSelect.addEventListener("change", () => {
918
953
  state.voiceUri = state.voiceSelect.value;
919
954
  writeStorage(ttsVoiceStorageKey, state.voiceUri);
@@ -937,6 +972,7 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
937
972
  window.addEventListener("narrarium:tts-start", (event) => {
938
973
  const detail = event instanceof CustomEvent ? event.detail || {} : {};
939
974
  const scopeId = typeof detail.scopeId === "string" ? detail.scopeId : "chapter";
975
+ showTtsPlayer(state, { persistDismissed: false });
940
976
  startTtsScope(state, scopeId, { fromStart: Boolean(detail.fromStart) });
941
977
  updateTtsTriggerButtons(state.triggers, state);
942
978
  });
@@ -946,6 +982,15 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
946
982
  stopTts(state, detail.keepProgress !== false);
947
983
  updateTtsUi(state);
948
984
  });
985
+
986
+ window.addEventListener("narrarium:focus-mode-change", (event) => {
987
+ const detail = event instanceof CustomEvent ? event.detail || {} : {};
988
+ state.focusMode = Boolean(detail.enabled);
989
+ if (state.focusMode) {
990
+ hideTtsPlayer(state, { persistDismissed: false });
991
+ }
992
+ syncTtsPanelToggle(state);
993
+ });
949
994
  }
950
995
 
951
996
  function populateVoiceOptions(state) {
@@ -1156,6 +1201,41 @@ const chaptersJson = JSON.stringify(chapters).replace(/</g, "\\u003c");
1156
1201
  updateTtsTriggerButtons(state.triggers, state);
1157
1202
  }
1158
1203
 
1204
+ function showTtsPlayer(state, options) {
1205
+ if (state.focusMode) {
1206
+ return;
1207
+ }
1208
+
1209
+ state.dismissed = false;
1210
+ state.player.hidden = false;
1211
+ syncTtsPanelToggle(state);
1212
+ if (options?.persistDismissed === false) {
1213
+ removeStorage(ttsPlayerDismissedStorageKey);
1214
+ } else {
1215
+ writeStorage(ttsPlayerDismissedStorageKey, "false");
1216
+ }
1217
+ }
1218
+
1219
+ function hideTtsPlayer(state, options) {
1220
+ state.player.hidden = true;
1221
+ state.dismissed = true;
1222
+ syncTtsPanelToggle(state);
1223
+ if (options?.persistDismissed) {
1224
+ writeStorage(ttsPlayerDismissedStorageKey, "true");
1225
+ }
1226
+ }
1227
+
1228
+ function syncTtsPanelToggle(state) {
1229
+ if (!(state.panelToggle instanceof HTMLButtonElement)) {
1230
+ return;
1231
+ }
1232
+
1233
+ const expanded = !state.player.hidden && !state.focusMode;
1234
+ state.panelToggle.setAttribute("aria-pressed", expanded ? "true" : "false");
1235
+ state.panelToggle.setAttribute("aria-label", expanded ? "Hide read aloud controls" : "Open read aloud controls");
1236
+ state.panelToggle.textContent = expanded ? "🔈" : "🔊";
1237
+ }
1238
+
1159
1239
  function highlightTtsSegment(segment) {
1160
1240
  clearTtsHighlight();
1161
1241
  segment.classList.add("is-speaking");
@@ -86,6 +86,7 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
86
86
  </nav>
87
87
  <div class="masthead-actions">
88
88
  <a class="chip reader-status" data-continue-link href="./" hidden>Continue reading</a>
89
+ <button type="button" class="masthead-btn" data-tts-panel-toggle aria-label="Open read aloud controls" aria-pressed="false" hidden>🔊</button>
89
90
  <button type="button" class="masthead-btn" data-search-toggle aria-label="Search">🔍</button>
90
91
  <button type="button" class="masthead-btn" data-reader-settings-toggle aria-label="Open reading settings">⚙</button>
91
92
  </div>
@@ -31,10 +31,10 @@ const currentIndex = allChapters.findIndex((entry) => entry.slug === chapterSlug
31
31
  const previousChapter = currentIndex > 0 ? allChapters[currentIndex - 1] : null;
32
32
  const nextChapter = currentIndex >= 0 && currentIndex < allChapters.length - 1 ? allChapters[currentIndex + 1] : null;
33
33
  const chapterHtml = await marked.parse(chapter.body);
34
- const chapterMetaEntries = [
35
- ["Point of view", chapter.metadata.pov],
36
- ["Timeline", chapter.metadata.timeline_ref],
37
- ["Tags", chapter.metadata.tags],
34
+ const chapterMetaEntries: Array<[string, unknown]> = [
35
+ ["Point of view", chapter.metadata.pov] as [string, unknown],
36
+ ["Timeline", chapter.metadata.timeline_ref] as [string, unknown],
37
+ ["Tags", chapter.metadata.tags] as [string, unknown],
38
38
  ].filter(([, value]) => value !== undefined && value !== null && (!Array.isArray(value) || value.length > 0));
39
39
  const chapterId = String(chapter.metadata.id);
40
40
  const chapterRelatedLinks = await loadRelatedCanonLinks(chapterId, [chapter.metadata.pov, chapter.metadata.timeline_ref]);
@@ -73,7 +73,7 @@ const renderedParagraphs = await Promise.all(
73
73
  </div>
74
74
 
75
75
  <section class="section chapter-reading-frame">
76
- <div class="chapter-focus-banner">
76
+ <div class="chapter-focus-banner focus-hidden">
77
77
  <p class="eyebrow">Full read</p>
78
78
  <h2>{chapter.metadata.title}</h2>
79
79
  <p class="chapter-meta" data-reader-current-scene>Chapter opening</p>
@@ -195,42 +195,81 @@ const renderedParagraphs = await Promise.all(
195
195
  <p class="eyebrow">Full read</p>
196
196
  <div class="focus-rail__title">{chapter.metadata.title}</div>
197
197
  </div>
198
- <div class="focus-rail__status" data-reader-current-scene>Chapter opening</div>
199
198
  </div>
200
199
  <div class="focus-rail__controls">
201
200
  <a class:list={["focus-rail__link", !previousChapter && "is-disabled"]} href={previousChapter ? `chapters/${previousChapter.slug}/` : undefined} aria-disabled={!previousChapter}>Previous</a>
202
- <button type="button" class="focus-rail__button is-primary" data-reader-focus-tts="chapter">From start</button>
203
- <button type="button" class="focus-rail__button" data-reader-marker-play disabled>From mark</button>
204
- <button type="button" class="focus-rail__button" data-reader-focus-stop>Stop voice</button>
201
+ <label class="focus-rail__jump">
202
+ <span class="sr-only">Jump to chapter</span>
203
+ <select data-reader-focus-jump aria-label="Jump to chapter">
204
+ {allChapters.map((entry) => (
205
+ <option value={entry.slug} selected={entry.slug === chapterSlug}>
206
+ {`Chapter ${String(entry.metadata.number).padStart(3, "0")} - ${entry.metadata.title}`}
207
+ </option>
208
+ ))}
209
+ </select>
210
+ </label>
205
211
  <a class:list={["focus-rail__link", !nextChapter && "is-disabled"]} href={nextChapter ? `chapters/${nextChapter.slug}/` : undefined} aria-disabled={!nextChapter}>Next</a>
206
212
  <button type="button" class="focus-rail__button" data-reader-focus-toggle>Exit</button>
207
213
  </div>
208
214
  </aside>
209
215
 
216
+ <div class="focus-hint" data-reader-focus-hint aria-hidden="true">
217
+ Move the mouse, tap, or press a key to show controls.
218
+ </div>
219
+
210
220
  <script is:inline>
211
221
  (() => {
212
222
  const focusModeKey = "narrarium-reader-focus-mode";
213
223
  const voiceMarkerKey = "narrarium-reader-voice-marker";
224
+ const focusRailHideDelayMs = 1800;
225
+ const focusHintHideDelayMs = 2600;
214
226
  const root = document.body;
215
227
  const chapterPath = window.location.pathname;
228
+ const chapterSegments = chapterPath.split("/").filter(Boolean);
229
+ const currentChapterSlug = chapterSegments[chapterSegments.length - 1] || "";
230
+ const fullscreenTarget = document.documentElement;
216
231
  const toggleButtons = [...document.querySelectorAll("[data-reader-focus-toggle]")];
217
232
  const markerButtons = [...document.querySelectorAll("[data-reader-marker-set]")];
218
- const markerPlayButton = document.querySelector("[data-reader-marker-play]");
219
- const chapterPlayButton = document.querySelector("[data-reader-focus-tts='chapter']");
220
- const stopVoiceButton = document.querySelector("[data-reader-focus-stop]");
233
+ const chapterJumpSelect = document.querySelector("[data-reader-focus-jump]");
234
+ const focusRail = document.querySelector(".focus-rail");
235
+ const focusHint = document.querySelector("[data-reader-focus-hint]");
221
236
  const currentSceneLabels = [...document.querySelectorAll("[data-reader-current-scene]")];
222
237
  const sceneElements = [...document.querySelectorAll("[data-reader-scene]")];
238
+ let focusRailHideTimer = null;
239
+ let focusHintHideTimer = null;
240
+ let railPointerInside = false;
241
+ let focusHintShownForSession = false;
223
242
 
224
- applyFocusMode(readStorage(focusModeKey) === "true", false);
243
+ applyFocusMode(readStorage(focusModeKey) === "true", false, { allowFullscreenRequest: false });
225
244
  updateMarkerUi(readMarker());
226
245
  updateActiveScene();
227
246
 
228
247
  window.addEventListener("scroll", scheduleSceneRefresh, { passive: true });
229
248
  window.addEventListener("resize", scheduleSceneRefresh);
249
+ document.addEventListener("pointermove", handleFocusRailWake, { passive: true });
250
+ document.addEventListener("touchstart", handleFocusRailWake, { passive: true });
251
+ document.addEventListener("fullscreenchange", handleFullscreenChange);
252
+
253
+ focusRail?.addEventListener("pointerenter", () => {
254
+ railPointerInside = true;
255
+ showFocusRail();
256
+ });
257
+ focusRail?.addEventListener("pointerleave", () => {
258
+ railPointerInside = false;
259
+ scheduleFocusRailHide();
260
+ });
261
+ focusRail?.addEventListener("focusin", () => {
262
+ showFocusRail();
263
+ });
264
+ focusRail?.addEventListener("focusout", () => {
265
+ window.requestAnimationFrame(() => {
266
+ scheduleFocusRailHide();
267
+ });
268
+ });
230
269
 
231
270
  toggleButtons.forEach((button) => {
232
271
  button.addEventListener("click", () => {
233
- applyFocusMode(!root.classList.contains("is-focus-mode"), true);
272
+ applyFocusMode(!root.classList.contains("is-focus-mode"), true, { allowFullscreenRequest: true });
234
273
  });
235
274
  });
236
275
 
@@ -246,20 +285,13 @@ const renderedParagraphs = await Promise.all(
246
285
  });
247
286
  });
248
287
 
249
- chapterPlayButton?.addEventListener("click", () => {
250
- startTtsFromScope("chapter");
251
- });
252
-
253
- markerPlayButton?.addEventListener("click", () => {
254
- const marker = readMarker();
255
- if (!marker || marker.path !== chapterPath || !marker.scopeId) {
288
+ chapterJumpSelect?.addEventListener("change", () => {
289
+ const nextSlug = chapterJumpSelect.value;
290
+ if (!nextSlug || nextSlug === currentChapterSlug) {
256
291
  return;
257
292
  }
258
- startTtsFromScope(marker.scopeId);
259
- });
260
293
 
261
- stopVoiceButton?.addEventListener("click", () => {
262
- window.dispatchEvent(new CustomEvent("narrarium:tts-stop", { detail: { keepProgress: true } }));
294
+ window.location.href = `chapters/${nextSlug}/`;
263
295
  });
264
296
 
265
297
  document.addEventListener("keydown", (event) => {
@@ -268,13 +300,17 @@ const renderedParagraphs = await Promise.all(
268
300
  }
269
301
 
270
302
  if (event.key === "Escape" && root.classList.contains("is-focus-mode")) {
271
- applyFocusMode(false, true);
303
+ applyFocusMode(false, true, { allowFullscreenRequest: true });
304
+ return;
272
305
  }
273
306
 
274
307
  if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "f") {
275
308
  event.preventDefault();
276
- applyFocusMode(!root.classList.contains("is-focus-mode"), true);
309
+ applyFocusMode(!root.classList.contains("is-focus-mode"), true, { allowFullscreenRequest: true });
310
+ return;
277
311
  }
312
+
313
+ handleFocusRailWake();
278
314
  });
279
315
 
280
316
  let refreshScheduled = false;
@@ -291,28 +327,130 @@ const renderedParagraphs = await Promise.all(
291
327
  });
292
328
  }
293
329
 
294
- function applyFocusMode(enabled, persist) {
330
+ function applyFocusMode(enabled, persist, options = { allowFullscreenRequest: false }) {
295
331
  root.classList.toggle("is-focus-mode", enabled);
332
+ window.dispatchEvent(new CustomEvent("narrarium:focus-mode-change", { detail: { enabled } }));
296
333
  toggleButtons.forEach((button) => {
297
334
  button.textContent = enabled ? "Exit full read" : "Full read";
298
335
  button.setAttribute("aria-pressed", enabled ? "true" : "false");
299
336
  });
337
+ if (enabled) {
338
+ resetFocusHintSession();
339
+ showFocusRailTemporarily();
340
+ } else {
341
+ clearFocusRailHideTimer();
342
+ clearFocusHintHideTimer();
343
+ focusRail?.classList.remove("is-hidden");
344
+ focusHint?.classList.remove("is-visible");
345
+ }
300
346
  scheduleSceneRefresh();
301
347
 
302
348
  if (persist) {
303
349
  writeStorage(focusModeKey, enabled ? "true" : "false");
304
350
  }
351
+
352
+ if (options.allowFullscreenRequest) {
353
+ void syncFullscreenForFocusMode(enabled);
354
+ }
355
+ }
356
+
357
+ function handleFocusRailWake() {
358
+ if (!root.classList.contains("is-focus-mode")) {
359
+ return;
360
+ }
361
+
362
+ hideFocusHint();
363
+ showFocusRailTemporarily();
364
+ }
365
+
366
+ function showFocusRailTemporarily() {
367
+ showFocusRail();
368
+ scheduleFocusRailHide();
369
+ }
370
+
371
+ function showFocusRail() {
372
+ clearFocusRailHideTimer();
373
+ focusRail?.classList.remove("is-hidden");
374
+ }
375
+
376
+ function scheduleFocusRailHide() {
377
+ clearFocusRailHideTimer();
378
+
379
+ if (!root.classList.contains("is-focus-mode") || railPointerInside || focusRail?.matches(":focus-within")) {
380
+ return;
381
+ }
382
+
383
+ focusRailHideTimer = window.setTimeout(() => {
384
+ if (!railPointerInside && !focusRail?.matches(":focus-within") && root.classList.contains("is-focus-mode")) {
385
+ focusRail?.classList.add("is-hidden");
386
+ showFocusHintOnce();
387
+ }
388
+ }, focusRailHideDelayMs);
389
+ }
390
+
391
+ function clearFocusRailHideTimer() {
392
+ if (focusRailHideTimer !== null) {
393
+ window.clearTimeout(focusRailHideTimer);
394
+ focusRailHideTimer = null;
395
+ }
396
+ }
397
+
398
+ function showFocusHintOnce() {
399
+ if (focusHintShownForSession || !root.classList.contains("is-focus-mode")) {
400
+ return;
401
+ }
402
+
403
+ focusHintShownForSession = true;
404
+ focusHint?.classList.add("is-visible");
405
+ clearFocusHintHideTimer();
406
+ focusHintHideTimer = window.setTimeout(() => {
407
+ focusHint?.classList.remove("is-visible");
408
+ focusHintHideTimer = null;
409
+ }, focusHintHideDelayMs);
410
+ }
411
+
412
+ function hideFocusHint() {
413
+ clearFocusHintHideTimer();
414
+ focusHint?.classList.remove("is-visible");
415
+ }
416
+
417
+ function resetFocusHintSession() {
418
+ focusHintShownForSession = false;
419
+ hideFocusHint();
305
420
  }
306
421
 
307
- function startTtsFromScope(scopeId) {
308
- window.dispatchEvent(
309
- new CustomEvent("narrarium:tts-start", {
310
- detail: {
311
- scopeId,
312
- fromStart: true,
313
- },
314
- }),
315
- );
422
+ function clearFocusHintHideTimer() {
423
+ if (focusHintHideTimer !== null) {
424
+ window.clearTimeout(focusHintHideTimer);
425
+ focusHintHideTimer = null;
426
+ }
427
+ }
428
+
429
+ async function syncFullscreenForFocusMode(enabled) {
430
+ if (typeof fullscreenTarget.requestFullscreen !== "function" || typeof document.exitFullscreen !== "function") {
431
+ return;
432
+ }
433
+
434
+ try {
435
+ if (enabled) {
436
+ if (!document.fullscreenElement) {
437
+ await fullscreenTarget.requestFullscreen();
438
+ }
439
+ } else if (document.fullscreenElement) {
440
+ await document.exitFullscreen();
441
+ }
442
+ } catch {
443
+ // Ignore browsers that block or do not support fullscreen transitions.
444
+ }
445
+ }
446
+
447
+ function handleFullscreenChange() {
448
+ root.classList.toggle("is-reader-fullscreen", Boolean(document.fullscreenElement));
449
+
450
+ if (root.classList.contains("is-focus-mode")) {
451
+ hideFocusHint();
452
+ showFocusRailTemporarily();
453
+ }
316
454
  }
317
455
 
318
456
  function updateMarkerUi(marker) {
@@ -328,10 +466,6 @@ const renderedParagraphs = await Promise.all(
328
466
  document.querySelectorAll("[data-reader-marker-article]").forEach((article) => {
329
467
  article.classList.toggle("has-voice-marker", activeScopeId !== null && article.dataset.readerMarkerScope === activeScopeId);
330
468
  });
331
-
332
- if (markerPlayButton instanceof HTMLButtonElement) {
333
- markerPlayButton.disabled = !activeScopeId;
334
- }
335
469
  }
336
470
 
337
471
  function updateActiveScene() {
@@ -124,7 +124,6 @@ body.is-focus-mode {
124
124
  body.is-focus-mode .masthead,
125
125
  body.is-focus-mode .focus-hidden,
126
126
  body.is-focus-mode .reader-overlay,
127
- body.is-focus-mode .canon-overlay,
128
127
  body.is-focus-mode .tts-player,
129
128
  body.is-focus-mode .scene-tools,
130
129
  body.is-focus-mode .scene-media {
@@ -144,6 +143,10 @@ body.is-focus-mode .chapter-reading-frame {
144
143
  box-shadow: none;
145
144
  }
146
145
 
146
+ body.is-focus-mode .chapter-reading-frame > .section {
147
+ margin-top: 0;
148
+ }
149
+
147
150
  body.is-focus-mode .chapter-focus-banner {
148
151
  display: block;
149
152
  border: 0;
@@ -159,7 +162,11 @@ body.is-focus-mode .chapter-focus-banner {
159
162
  body.is-focus-mode .scene {
160
163
  max-width: min(100%, calc(var(--reader-measure) + 8ch));
161
164
  margin-inline: auto;
162
- padding: 2rem 0;
165
+ padding: 1.5rem 0;
166
+ }
167
+
168
+ body.is-focus-mode .scene-header {
169
+ display: none;
163
170
  }
164
171
 
165
172
  body.is-focus-mode .scene-stack .scene::after {
@@ -174,6 +181,16 @@ body.is-focus-mode .focus-rail {
174
181
  display: block;
175
182
  }
176
183
 
184
+ body.is-focus-mode .focus-rail.is-hidden {
185
+ opacity: 0;
186
+ pointer-events: none;
187
+ transform: translate(-50%, calc(100% + 1.5rem));
188
+ }
189
+
190
+ body.is-focus-mode .focus-hint {
191
+ display: inline-flex;
192
+ }
193
+
177
194
  body.tts-enabled .shell {
178
195
  padding-bottom: 8rem;
179
196
  }
@@ -308,6 +325,12 @@ body.tts-enabled .shell {
308
325
  border-color: color-mix(in srgb, var(--accent) 25%, var(--line));
309
326
  }
310
327
 
328
+ .masthead-btn[aria-pressed="true"] {
329
+ background: var(--accent-soft);
330
+ color: var(--text);
331
+ border-color: color-mix(in srgb, var(--accent) 30%, var(--line));
332
+ }
333
+
311
334
  /* Primary TTS play button */
312
335
  .tts-button.is-primary {
313
336
  background: var(--accent);
@@ -359,6 +382,18 @@ body.tts-enabled .shell {
359
382
  border-color: color-mix(in srgb, var(--accent) 22%, var(--line));
360
383
  }
361
384
 
385
+ .sr-only {
386
+ position: absolute;
387
+ width: 1px;
388
+ height: 1px;
389
+ padding: 0;
390
+ margin: -1px;
391
+ overflow: hidden;
392
+ clip: rect(0, 0, 0, 0);
393
+ white-space: nowrap;
394
+ border: 0;
395
+ }
396
+
362
397
  .chip-row {
363
398
  display: flex;
364
399
  flex-wrap: wrap;
@@ -1126,6 +1161,12 @@ mark {
1126
1161
  gap: 0.75rem;
1127
1162
  }
1128
1163
 
1164
+ .tts-player__top-actions {
1165
+ display: inline-flex;
1166
+ align-items: center;
1167
+ gap: 0.5rem;
1168
+ }
1169
+
1129
1170
  .tts-player__title {
1130
1171
  margin: 0.15rem 0 0;
1131
1172
  font-weight: 600;
@@ -1139,7 +1180,7 @@ mark {
1139
1180
  position: fixed;
1140
1181
  left: 50%;
1141
1182
  bottom: 1rem;
1142
- transform: translateX(-50%);
1183
+ transform: translate(-50%, 0);
1143
1184
  z-index: 46;
1144
1185
  width: min(760px, calc(100vw - 1rem));
1145
1186
  padding: 1rem 1.25rem;
@@ -1147,6 +1188,8 @@ mark {
1147
1188
  border: 1px solid var(--line);
1148
1189
  border-radius: 16px;
1149
1190
  box-shadow: var(--shadow);
1191
+ opacity: 1;
1192
+ transition: opacity 180ms ease, transform 180ms ease;
1150
1193
  }
1151
1194
 
1152
1195
  .focus-rail__meta {
@@ -1163,6 +1206,54 @@ mark {
1163
1206
  color: var(--text);
1164
1207
  }
1165
1208
 
1209
+ .focus-rail__jump {
1210
+ min-width: min(320px, 100%);
1211
+ }
1212
+
1213
+ .focus-rail__jump select {
1214
+ min-width: min(320px, 100%);
1215
+ }
1216
+
1217
+ .focus-hint {
1218
+ display: none;
1219
+ position: fixed;
1220
+ left: 50%;
1221
+ bottom: 6.25rem;
1222
+ transform: translate(-50%, 0.8rem);
1223
+ z-index: 45;
1224
+ max-width: min(520px, calc(100vw - 2rem));
1225
+ padding: 0.7rem 1rem;
1226
+ border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--line));
1227
+ border-radius: 999px;
1228
+ background: color-mix(in srgb, var(--surface) 92%, var(--app-bg));
1229
+ color: var(--text-muted);
1230
+ font-size: 0.82rem;
1231
+ line-height: 1.45;
1232
+ text-align: center;
1233
+ box-shadow: var(--shadow);
1234
+ opacity: 0;
1235
+ pointer-events: none;
1236
+ transition: opacity 220ms ease, transform 220ms ease;
1237
+ }
1238
+
1239
+ .focus-hint.is-visible {
1240
+ opacity: 1;
1241
+ transform: translate(-50%, 0);
1242
+ animation: focus-hint-pulse 1.8s ease-in-out 2;
1243
+ }
1244
+
1245
+ @keyframes focus-hint-pulse {
1246
+ 0% {
1247
+ box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.16);
1248
+ }
1249
+ 70% {
1250
+ box-shadow: 0 0 0 10px rgba(139, 92, 246, 0);
1251
+ }
1252
+ 100% {
1253
+ box-shadow: var(--shadow);
1254
+ }
1255
+ }
1256
+
1166
1257
  /* ─── Related links section ──────────────────────────────────── */
1167
1258
  .scene > h2 {
1168
1259
  margin-bottom: 0.9rem;
@@ -1343,6 +1434,22 @@ mark {
1343
1434
  transform: none;
1344
1435
  }
1345
1436
 
1437
+ .focus-hint {
1438
+ left: 0.75rem;
1439
+ right: 0.75rem;
1440
+ bottom: 6.75rem;
1441
+ max-width: none;
1442
+ transform: translateY(0.8rem);
1443
+ }
1444
+
1445
+ .focus-hint.is-visible {
1446
+ transform: translateY(0);
1447
+ }
1448
+
1449
+ body.is-focus-mode .focus-rail.is-hidden {
1450
+ transform: translateY(calc(100% + 1.5rem));
1451
+ }
1452
+
1346
1453
  body.is-focus-mode .shell {
1347
1454
  width: calc(100vw - 1rem);
1348
1455
  padding-bottom: 13rem;