explico 0.13.0 → 0.14.0

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.
Files changed (239) hide show
  1. package/CHANGELOG.md +894 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +43 -0
  6. package/dist/compiler/cli-main.js +392 -0
  7. package/dist/compiler/cli.d.ts +424 -9
  8. package/dist/compiler/cli.js +1289 -8
  9. package/dist/compiler/collections.d.ts +88 -1
  10. package/dist/compiler/collections.js +170 -25
  11. package/dist/compiler/course-config.d.ts +1 -1
  12. package/dist/compiler/course-config.js +105 -1
  13. package/dist/compiler/decks.d.ts +161 -0
  14. package/dist/compiler/decks.js +235 -0
  15. package/dist/compiler/exports/combined-html.d.ts +5 -1
  16. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  17. package/dist/compiler/exports/deck-fit.js +90 -0
  18. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  19. package/dist/compiler/exports/export-chapters.js +64 -5
  20. package/dist/compiler/exports/export-pages.d.ts +30 -3
  21. package/dist/compiler/exports/export-pages.js +24 -1
  22. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  23. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  24. package/dist/compiler/exports/latex/cache.js +129 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  26. package/dist/compiler/exports/latex/capture.js +3 -1
  27. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  28. package/dist/compiler/exports/latex/compile.js +12 -1
  29. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  30. package/dist/compiler/exports/latex/deck.js +349 -0
  31. package/dist/compiler/exports/latex/document.d.ts +51 -6
  32. package/dist/compiler/exports/latex/document.js +71 -12
  33. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  34. package/dist/compiler/exports/latex/explico.cls +13 -183
  35. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  36. package/dist/compiler/exports/latex/serialize.js +4 -2
  37. package/dist/compiler/exports/latex-export.d.ts +24 -2
  38. package/dist/compiler/exports/latex-export.js +107 -26
  39. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  40. package/dist/compiler/exports/markdown/serialize.js +85 -8
  41. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  42. package/dist/compiler/exports/markdown-export.js +50 -11
  43. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  44. package/dist/compiler/exports/standalone-html.js +3 -3
  45. package/dist/compiler/exports/static-tree.d.ts +38 -1
  46. package/dist/compiler/exports/static-tree.js +120 -4
  47. package/dist/compiler/knowledge-version.d.ts +26 -2
  48. package/dist/compiler/knowledge-version.js +27 -17
  49. package/dist/compiler/lesson-derivation.d.ts +143 -6
  50. package/dist/compiler/lesson-derivation.js +145 -13
  51. package/dist/compiler/manifest.d.ts +133 -4
  52. package/dist/compiler/manifest.js +359 -58
  53. package/dist/compiler/page-versions.d.ts +160 -0
  54. package/dist/compiler/page-versions.js +198 -0
  55. package/dist/compiler/render-diagnostics.d.ts +5 -1
  56. package/dist/compiler/resolution-report.d.ts +24 -6
  57. package/dist/compiler/resolution-report.js +22 -4
  58. package/dist/compiler/validate-content.d.ts +7 -0
  59. package/dist/compiler/validate-content.js +160 -0
  60. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  61. package/dist/curriculum/competency-graph.d.ts +54 -0
  62. package/dist/curriculum/competency-graph.js +41 -0
  63. package/dist/reference/base-library.d.mts +8 -0
  64. package/dist/reference/base-library.mjs +19 -0
  65. package/dist/reference/consistency.d.ts +8 -4
  66. package/dist/reference/consistency.js +76 -26
  67. package/dist/reference/deck-remark.d.mts +123 -0
  68. package/dist/reference/deck-remark.mjs +600 -0
  69. package/dist/reference/deck-source.d.ts +107 -0
  70. package/dist/reference/deck-source.js +140 -0
  71. package/dist/reference/decks-validate.d.ts +133 -0
  72. package/dist/reference/decks-validate.js +359 -0
  73. package/dist/reference/derived-entries.d.ts +5 -1
  74. package/dist/reference/diagram-fit.d.ts +1 -1
  75. package/dist/reference/figure-symbols.d.ts +23 -6
  76. package/dist/reference/figure-symbols.js +51 -15
  77. package/dist/reference/float-name.d.ts +43 -0
  78. package/dist/reference/float-name.js +58 -0
  79. package/dist/reference/folds-validate.d.ts +1 -1
  80. package/dist/reference/gate-abbreviations.d.ts +1 -1
  81. package/dist/reference/gate-indices.js +5 -4
  82. package/dist/reference/gate-math.d.ts +29 -2
  83. package/dist/reference/gate-math.js +99 -18
  84. package/dist/reference/gate-part-pages.d.ts +1 -1
  85. package/dist/reference/lab-katex-validate.d.ts +10 -6
  86. package/dist/reference/lab-katex-validate.js +17 -9
  87. package/dist/reference/lab-math-scope.js +29 -4
  88. package/dist/reference/math-glyphs.d.mts +25 -0
  89. package/dist/reference/math-glyphs.mjs +189 -25
  90. package/dist/reference/page-notation.d.ts +22 -3
  91. package/dist/reference/page-notation.js +32 -10
  92. package/dist/reference/references.d.ts +14 -1
  93. package/dist/reference/references.js +13 -0
  94. package/dist/reference/registry-scope.d.mts +33 -0
  95. package/dist/reference/registry-scope.mjs +34 -0
  96. package/dist/reference/registry.js +29 -10
  97. package/dist/reference/rehype-deck.d.mts +6 -0
  98. package/dist/reference/rehype-deck.mjs +286 -0
  99. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  100. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  101. package/dist/reference/remark-notation.mjs +249 -40
  102. package/dist/reference/source-lines.d.mts +12 -0
  103. package/dist/reference/source-lines.mjs +65 -0
  104. package/dist/reference/types.d.ts +28 -0
  105. package/dist/rules/catalog.d.ts +21 -4
  106. package/dist/rules/catalog.js +221 -2
  107. package/dist/rules/course-rules.d.ts +5 -1
  108. package/dist/rules/course-rules.js +17 -3
  109. package/dist/rules/course-rules.schema.json +12 -0
  110. package/dist/rules/in-effect.d.ts +13 -3
  111. package/dist/rules/in-effect.js +30 -7
  112. package/dist/rules/rules.schema.json +259 -1
  113. package/dist/rules/settings.d.ts +1 -1
  114. package/dist/session/PreferenceStore.d.ts +6 -0
  115. package/dist/session/PreferenceStore.js +18 -1
  116. package/dist/session/deck-memory.d.ts +116 -0
  117. package/dist/session/deck-memory.js +143 -0
  118. package/dist/session/reading-memory.d.ts +96 -0
  119. package/dist/session/reading-memory.js +133 -0
  120. package/docs/VERSIONING.md +21 -18
  121. package/docs/authoring/README.md +29 -9
  122. package/docs/authoring/agents.md +63 -16
  123. package/docs/authoring/components.md +15 -0
  124. package/docs/authoring/deck.md +588 -0
  125. package/docs/authoring/migrating.md +1087 -14
  126. package/docs/authoring/notation.md +49 -6
  127. package/docs/authoring/structure.md +80 -1
  128. package/docs/authoring/versioning.md +54 -2
  129. package/docs/authoring/visualizations.md +7 -2
  130. package/docs/course-agent/AGENTS.md +37 -0
  131. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  132. package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
  133. package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
  134. package/docs/course-agent/skills/release-content/SKILL.md +24 -0
  135. package/docs/diagram-scaling.md +148 -0
  136. package/docs/privacy.md +59 -0
  137. package/package.json +8 -2
  138. package/src/compiler/astro.ts +60 -21
  139. package/src/compiler/cli-main.ts +469 -0
  140. package/src/compiler/cli.ts +1905 -16
  141. package/src/compiler/collections.ts +284 -25
  142. package/src/compiler/course-config.ts +267 -0
  143. package/src/compiler/decks.ts +455 -0
  144. package/src/compiler/exports/deck-fit.ts +127 -0
  145. package/src/compiler/exports/export-chapters.ts +105 -6
  146. package/src/compiler/exports/export-pages.ts +46 -2
  147. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  148. package/src/compiler/exports/latex/cache.ts +183 -0
  149. package/src/compiler/exports/latex/capture.ts +5 -2
  150. package/src/compiler/exports/latex/compile.ts +18 -0
  151. package/src/compiler/exports/latex/deck.ts +419 -0
  152. package/src/compiler/exports/latex/document.ts +117 -21
  153. package/src/compiler/exports/latex/explico-common.sty +198 -0
  154. package/src/compiler/exports/latex/explico.cls +13 -183
  155. package/src/compiler/exports/latex/serialize.ts +25 -21
  156. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  157. package/src/compiler/exports/latex-export.ts +187 -45
  158. package/src/compiler/exports/markdown/serialize.ts +118 -9
  159. package/src/compiler/exports/markdown-export.ts +56 -11
  160. package/src/compiler/exports/standalone-html.ts +8 -7
  161. package/src/compiler/exports/static-tree.ts +175 -3
  162. package/src/compiler/knowledge-version.ts +56 -21
  163. package/src/compiler/lesson-derivation.ts +282 -16
  164. package/src/compiler/manifest.ts +558 -63
  165. package/src/compiler/page-versions.ts +359 -0
  166. package/src/compiler/resolution-report.ts +38 -7
  167. package/src/compiler/validate-content.ts +212 -0
  168. package/src/components/CourseContents.astro +124 -6
  169. package/src/components/CurriculumMap.astro +49 -1
  170. package/src/components/FloatIndex.astro +1 -1
  171. package/src/components/PartOverview.astro +51 -5
  172. package/src/components/assessments/AssessmentSet.astro +41 -15
  173. package/src/components/block-heading.ts +15 -6
  174. package/src/components/card-carry.ts +7 -4
  175. package/src/components/card-manager.ts +14 -2
  176. package/src/components/card-memory.ts +13 -9
  177. package/src/components/deck/DeckEnhancer.astro +13 -0
  178. package/src/components/deck/deck-context.ts +104 -0
  179. package/src/components/deck/deck-controller.ts +1287 -0
  180. package/src/components/deck/deck-map.ts +91 -0
  181. package/src/components/deck-lane.ts +163 -0
  182. package/src/components/diagram/Diagram.astro +131 -86
  183. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  184. package/src/components/figure/Figure.astro +7 -2
  185. package/src/components/figure/FloatCaption.astro +5 -3
  186. package/src/components/fold/Fold.astro +2 -2
  187. package/src/components/notation/NotationFormula.astro +81 -11
  188. package/src/components/notation/NotationGlossary.astro +132 -1
  189. package/src/components/notation/NotationLayer.astro +29 -0
  190. package/src/components/starlight/AboutPanel.astro +186 -3
  191. package/src/components/starlight/CollapsibleSections.astro +24 -3
  192. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  193. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  194. package/src/components/starlight/LayoutHeader.astro +17 -0
  195. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  196. package/src/components/starlight/LayoutSidebar.astro +807 -21
  197. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  198. package/src/components/starlight/LessonExportActions.astro +27 -9
  199. package/src/components/starlight/LessonFooter.astro +59 -0
  200. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  201. package/src/components/starlight/TextSizeControl.astro +24 -13
  202. package/src/components/starlight/page-marks.ts +139 -0
  203. package/src/components/starlight/section-summary.ts +1 -1
  204. package/src/components/starlight/sidebar-editions.ts +245 -0
  205. package/src/components/table/Table.astro +15 -7
  206. package/src/content-config.ts +87 -1
  207. package/src/curriculum/competency-graph.ts +95 -0
  208. package/src/reference/base-library.mjs +19 -0
  209. package/src/reference/consistency.ts +126 -39
  210. package/src/reference/deck-remark.mjs +600 -0
  211. package/src/reference/deck-source.ts +273 -0
  212. package/src/reference/decks-validate.ts +604 -0
  213. package/src/reference/diagram-layout.ts +3 -1
  214. package/src/reference/figure-symbols.ts +108 -31
  215. package/src/reference/folds-validate.ts +1 -1
  216. package/src/reference/gate-indices.ts +5 -3
  217. package/src/reference/gate-math.ts +169 -25
  218. package/src/reference/lab-katex-validate.ts +30 -15
  219. package/src/reference/lab-math-scope.ts +34 -3
  220. package/src/reference/math-glyphs.mjs +189 -25
  221. package/src/reference/page-notation.ts +69 -15
  222. package/src/reference/references.ts +28 -0
  223. package/src/reference/registry-scope.mjs +34 -0
  224. package/src/reference/registry.ts +41 -11
  225. package/src/reference/rehype-deck.mjs +286 -0
  226. package/src/reference/rehype-heading-anchors.mjs +48 -6
  227. package/src/reference/remark-notation.mjs +249 -40
  228. package/src/reference/source-lines.mjs +65 -0
  229. package/src/reference/types.ts +28 -0
  230. package/src/rules/catalog.ts +257 -4
  231. package/src/rules/course-rules.ts +21 -1
  232. package/src/rules/in-effect.ts +54 -8
  233. package/src/session/PreferenceStore.ts +23 -0
  234. package/src/session/deck-memory.ts +263 -0
  235. package/src/session/reading-memory.ts +243 -0
  236. package/src/styles/deck.css +1150 -0
  237. package/src/styles/global.css +958 -27
  238. package/src/styles/zones.css +277 -0
  239. /package/src/{components → reference}/float-name.ts +0 -0
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import DefaultSidebar from '@astrojs/starlight/components/Sidebar.astro';
3
+ import { SIDEBAR_EDITIONS_DEFAULTS, SIDEBAR_MARKS_DEFAULTS } from '../../compiler/course-config';
3
4
  import { loadManifest } from '../../compiler/manifest';
5
+ import { pageMarkId, pageMarksKey, type PageMarksPayload } from './page-marks';
4
6
 
5
7
  /**
6
8
  * The header control that opens a part's own page. Starlight's sidebar group
@@ -19,6 +21,57 @@ const partLinks = manifest.parts
19
21
  // `<` keeps a label that happens to contain `</script>` from ending the
20
22
  // element early. It is still the same JSON to `JSON.parse`.
21
23
  const partLinkPayload = JSON.stringify(partLinks).replace(/</g, '\\u003c');
24
+
25
+ /**
26
+ * Every chapter and part page the sidebar can mark for a reader
27
+ * (`page-marks.ts`): its link's path and the identity of its content now,
28
+ * from the record rule C5 keeps. Pages outside it (the landing page, the
29
+ * glossary) are never marked.
30
+ */
31
+ const marks = manifest.sidebarMarks ?? SIDEBAR_MARKS_DEFAULTS;
32
+ const pageMarks: PageMarksPayload | undefined =
33
+ (marks.unopened || marks.changed) &&
34
+ (manifest.readingMemory ?? 'ask') === 'ask' &&
35
+ manifest.pageVersions
36
+ ? {
37
+ key: pageMarksKey(import.meta.env.BASE_URL),
38
+ base: import.meta.env.BASE_URL,
39
+ unopened: marks.unopened,
40
+ changed: marks.changed,
41
+ pages: Object.entries(manifest.pageVersions.record.pages).map(
42
+ ([slug, entry]) => ({
43
+ href: `${base}/${slug.replace(/\/index$/u, '')}/`,
44
+ slug,
45
+ id: pageMarkId(entry.pending ?? entry.source),
46
+ }),
47
+ ),
48
+ // Every deck's frames, by number, for the legend's two actions (D9).
49
+ decks: (manifest.decks ?? []).map((deck) => ({
50
+ slug: deck.slug,
51
+ frames: deck.frames.map((frame) => frame.id),
52
+ })),
53
+ }
54
+ : undefined;
55
+ const pageMarksPayload =
56
+ pageMarks && pageMarks.pages.length > 0
57
+ ? JSON.stringify(pageMarks).replace(/</g, '\\u003c')
58
+ : undefined;
59
+
60
+ /**
61
+ * The course's default for a lesson's editions in the sidebar
62
+ * (`sidebar-editions.ts`), for a course with a deck in it: without one,
63
+ * every lesson is a chapter and there is nothing to choose.
64
+ */
65
+ const editions = manifest.sidebarEditions ?? SIDEBAR_EDITIONS_DEFAULTS;
66
+ const hasDeck = manifest.sidebar.some((group) =>
67
+ group.items.some(
68
+ (item) => typeof item === 'object' && 'slug' in item && item.attrs?.['data-edition'] === 'slides',
69
+ ),
70
+ );
71
+ // The site's base, for the address of a chapter's section of its part's deck.
72
+ const editionsPayload = hasDeck
73
+ ? JSON.stringify({ lead: editions.lead, other: editions.other, base: import.meta.env.BASE_URL }).replace(/</g, '\\u003c')
74
+ : undefined;
22
75
  ---
23
76
 
24
77
  <div class="layout-navigation-panel" data-layout-panel="navigation">
@@ -63,6 +116,26 @@ const partLinkPayload = JSON.stringify(partLinks).replace(/</g, '\\u003c');
63
116
  />
64
117
  )
65
118
  }
119
+ {
120
+ pageMarksPayload && (
121
+ <script
122
+ is:inline
123
+ type="application/json"
124
+ data-explico-page-marks
125
+ set:html={pageMarksPayload}
126
+ />
127
+ )
128
+ }
129
+ {
130
+ editionsPayload && (
131
+ <script
132
+ is:inline
133
+ type="application/json"
134
+ data-explico-sidebar-editions
135
+ set:html={editionsPayload}
136
+ />
137
+ )
138
+ }
66
139
  <script>
67
140
  /**
68
141
  * Give each part header a control that opens the part's own page. The
@@ -118,37 +191,745 @@ const partLinkPayload = JSON.stringify(partLinks).replace(/</g, '\\u003c');
118
191
  </script>
119
192
  <script>
120
193
  /**
121
- * Mark every chapter the reader has already passed, so the rail beside
122
- * the chapter list can show how far into the course this page is.
194
+ * A lesson with a chapter and a deck is one row (`sidebar-editions.ts`):
195
+ * the row opens the lead edition under the lesson's title, and the
196
+ * other edition is an icon at the row's end, a second tile joined to
197
+ * the first in a compact part, or is not listed. The compiler lists
198
+ * both editions, each link naming its edition, lesson and title
199
+ * (`data-edition`, `data-lesson`, `data-title`), so without this
200
+ * script the sidebar is what it wrote: the chapter, then its "Slides"
201
+ * row.
202
+ *
203
+ * Two controls in the panel's foot set the state: "Lessons open as"
204
+ * (Chapter or Slides) and "List the other edition too", over the
205
+ * course's default (`sidebarEditions`). The reader's choice is a
206
+ * setting, kept whatever they answered about reading memory. A change
207
+ * rebuilds the rows in place, from the lists as the compiler wrote
208
+ * them, and tells the progress rail and the marks, which run after
209
+ * this script and read the rows it leaves.
210
+ *
211
+ * A chapter its part's deck covers, with no deck of its own, carries
212
+ * its section's address (`data-deck-href`): that section is the
213
+ * chapter's other edition, a link this script adds beside it. While
214
+ * the part's deck is open, the section of the chapter on screen is lit
215
+ * (`aria-current="location"`), as the deck's page tells it
216
+ * (`DECK_FRAME_EVENT`).
217
+ *
218
+ * A part's Overview and its deck (`data-part-edition`) are the part's
219
+ * one row the same way, Overview standing where a chapter stands.
220
+ * Both keep their own glyph in every place.
221
+ */
222
+ import { createBrowserPreferenceStore } from '../../session/PreferenceStore';
223
+ import {
224
+ DECK_CHAPTER_ATTRIBUTE,
225
+ DECK_FRAME_EVENT,
226
+ DECK_SECTION_ATTRIBUTE,
227
+ EDITION_DETAIL_CLASS,
228
+ EDITION_OTHER_CLASS,
229
+ GLYPH_CLASS,
230
+ PART_EDITION_ATTRIBUTE,
231
+ PART_ROW_CLASSES,
232
+ ROW_CLASSES,
233
+ SIDEBAR_EDITIONS_EVENT,
234
+ SIDEBAR_EDITIONS_KEY,
235
+ decodeEditionsChoice,
236
+ deckSectionHref,
237
+ editionLabel,
238
+ encodeEditionsChoice,
239
+ lessonRow,
240
+ partDeckDetail,
241
+ partEditionLabel,
242
+ partRow,
243
+ resolveEditions,
244
+ sectionDetail,
245
+ type Edition,
246
+ type EditionsChoice,
247
+ type EditionsState,
248
+ type PartEdition,
249
+ } from './sidebar-editions';
250
+
251
+ (() => {
252
+ const root = document.querySelector<HTMLElement>(
253
+ '#starlight__navigation-content',
254
+ );
255
+ const payload = root?.querySelector('[data-explico-sidebar-editions]');
256
+ if (!root || !payload?.textContent) return;
257
+ const { lead: courseLead, other: courseOther } = decodeEditionsChoice(
258
+ payload.textContent,
259
+ );
260
+ if (!courseLead || !courseOther) return;
261
+ const course: EditionsState = { lead: courseLead, other: courseOther };
262
+ let base = '/';
263
+ try {
264
+ const parsed: unknown = JSON.parse(payload.textContent);
265
+ const given = (parsed as { base?: unknown } | null)?.base;
266
+ if (typeof given === 'string') base = given;
267
+ } catch {}
268
+
269
+ interface Link {
270
+ readonly a: HTMLAnchorElement;
271
+ readonly li: HTMLLIElement;
272
+ readonly edition: Edition;
273
+ }
274
+ interface Lesson {
275
+ readonly title: string;
276
+ readonly links: Link[];
277
+ }
278
+ interface PartLink {
279
+ readonly a: HTMLAnchorElement;
280
+ readonly li: HTMLLIElement;
281
+ readonly edition: PartEdition;
282
+ }
283
+ interface Part {
284
+ /** The part's directory, `data-part`. */
285
+ readonly dir: string;
286
+ /** The part's label, from its deck's `data-title`. */
287
+ title: string;
288
+ /** Overview's own label, as the compiler wrote it. */
289
+ overviewLabel: string;
290
+ readonly links: PartLink[];
291
+ }
292
+ type Entry = HTMLLIElement | Lesson | Part;
293
+ // Not `'part' in entry`: every element has a `part` of its own.
294
+ const isPart = (entry: Entry): entry is Part =>
295
+ !(entry instanceof HTMLLIElement) && 'overviewLabel' in entry;
296
+ const labelOf = (a: HTMLAnchorElement) =>
297
+ a.querySelector<HTMLElement>(':scope > span');
298
+ /** A link's name, and its second line when it has one (`aria-description`). */
299
+ const setLabel = (a: HTMLAnchorElement, text: string) => {
300
+ const span = labelOf(a);
301
+ if (!span) return;
302
+ span.textContent = text;
303
+ const detail = a.getAttribute('aria-description');
304
+ if (!detail) return;
305
+ const line = document.createElement('small');
306
+ line.className = EDITION_DETAIL_CLASS;
307
+ line.setAttribute('aria-hidden', 'true');
308
+ line.textContent = detail;
309
+ span.append(line);
310
+ };
311
+
312
+ // Every chapter's section link, lit while its section is on screen.
313
+ const sections: HTMLAnchorElement[] = [];
314
+
315
+ // Every list as the compiler wrote it, a lesson where its first
316
+ // edition stood and a part where its Overview stood, so each
317
+ // rebuild starts from the same rows.
318
+ const lists = new Map<HTMLUListElement, Entry[]>();
319
+ for (const list of root.querySelectorAll<HTMLUListElement>('ul')) {
320
+ let entries: Entry[] = [];
321
+ const lessons = new Map<string, Lesson>();
322
+ const parts = new Map<string, Part>();
323
+ for (const li of list.querySelectorAll<HTMLLIElement>(':scope > li')) {
324
+ const own = li.querySelector<HTMLAnchorElement>(`:scope > a[${PART_EDITION_ATTRIBUTE}]`);
325
+ const partEdition = own?.getAttribute(PART_EDITION_ATTRIBUTE);
326
+ if (own && (partEdition === 'overview' || partEdition === 'slides')) {
327
+ const dir = own.dataset.part ?? '';
328
+ let part = parts.get(dir);
329
+ if (!part) {
330
+ part = { dir, title: '', overviewLabel: 'Overview', links: [] };
331
+ parts.set(dir, part);
332
+ entries.push(part);
333
+ }
334
+ part.links.push({ a: own, li, edition: partEdition });
335
+ if (partEdition === 'overview') {
336
+ part.overviewLabel = labelOf(own)?.textContent?.trim() || part.overviewLabel;
337
+ } else {
338
+ part.title = own.dataset.title ?? part.title;
339
+ const frames = Number(own.dataset.frames);
340
+ if (Number.isInteger(frames) && frames > 0) {
341
+ own.setAttribute('aria-description', partDeckDetail(own.dataset.chapters ?? '', frames));
342
+ }
343
+ }
344
+ continue;
345
+ }
346
+ const a = li.querySelector<HTMLAnchorElement>(':scope > a[data-lesson]');
347
+ const edition = a?.dataset.edition;
348
+ if (!a || (edition !== 'chapter' && edition !== 'slides')) {
349
+ entries.push(li);
350
+ continue;
351
+ }
352
+ const slug = a.dataset.lesson ?? '';
353
+ const known = lessons.get(slug);
354
+ if (known) {
355
+ known.links.push({ a, li, edition });
356
+ continue;
357
+ }
358
+ const lesson: Lesson = {
359
+ title: a.dataset.title ?? labelOf(a)?.textContent?.trim() ?? '',
360
+ links: [{ a, li, edition }],
361
+ };
362
+ lessons.set(slug, lesson);
363
+ entries.push(lesson);
364
+ }
365
+ // A chapter with no deck of its own, whose section in its part's
366
+ // deck is its other edition: a link like the chapter's, to it.
367
+ for (const lesson of lessons.values()) {
368
+ const [only] = lesson.links;
369
+ const href = only?.a.dataset.deckHref;
370
+ if (!only || lesson.links.length !== 1 || only.edition !== 'chapter' || !href) continue;
371
+ const section = only.a.cloneNode(false) as HTMLAnchorElement;
372
+ section.href = deckSectionHref(base, href);
373
+ section.removeAttribute('aria-current');
374
+ section.removeAttribute('data-deck-href');
375
+ section.setAttribute(DECK_SECTION_ATTRIBUTE, '');
376
+ section.dataset.edition = 'slides';
377
+ const range = only.a.dataset.deckRange;
378
+ if (range) section.setAttribute('aria-description', sectionDetail(range));
379
+ section.append(document.createElement('span'));
380
+ lesson.links.push({ a: section, li: only.li, edition: 'slides' });
381
+ sections.push(section);
382
+ }
383
+ // A part with no deck keeps its Overview's item as the compiler wrote it.
384
+ entries = entries.map((entry) =>
385
+ isPart(entry) && !entry.links.some((link) => link.edition === 'slides')
386
+ ? entry.links[0]!.li
387
+ : entry,
388
+ );
389
+ if (lessons.size > 0 || entries.some(isPart)) lists.set(list, entries);
390
+ }
391
+ if (lists.size === 0) return;
392
+
393
+ // The other edition's own mark, on its icon's corner: its `::before`
394
+ // and `::after` draw its glyph.
395
+ const badge = () => {
396
+ const mark = document.createElement('i');
397
+ mark.className = 'layout-edition-mark';
398
+ mark.setAttribute('aria-hidden', 'true');
399
+ return mark;
400
+ };
401
+
402
+ /** A part's one row, or none: its deck's own row, unlisted. */
403
+ const partItems = (entry: Part, state: EditionsState): HTMLLIElement[] => {
404
+ const open = entry.links.find(
405
+ (link) => link.a.getAttribute('aria-current') === 'page',
406
+ )?.edition;
407
+ const row = partRow(
408
+ entry.links.some((link) => link.edition === 'overview'),
409
+ state,
410
+ open,
411
+ );
412
+ for (const { a } of entry.links) {
413
+ a.classList.remove(...PART_ROW_CLASSES);
414
+ a.querySelector(':scope > .layout-edition-mark')?.remove();
415
+ }
416
+ if (!row) return [];
417
+ const lead =
418
+ entry.links.find((link) => link.edition === row.lead) ?? entry.links[0]!;
419
+ const other = entry.links.find((link) => link.edition === row.other);
420
+ setLabel(lead.a, partEditionLabel(lead.edition, entry.title, 'lead', entry.overviewLabel));
421
+ lead.li.replaceChildren(lead.a);
422
+ if (other) {
423
+ other.a.classList.add(EDITION_OTHER_CLASS);
424
+ setLabel(other.a, partEditionLabel(other.edition, entry.title, 'other', entry.overviewLabel));
425
+ other.a.append(badge());
426
+ lead.li.append(other.a);
427
+ }
428
+ lead.li.dataset.editions = other ? 'pair' : 'single';
429
+ return [lead.li];
430
+ };
431
+
432
+ const arrange = (state: EditionsState) => {
433
+ for (const [list, entries] of lists) {
434
+ list.replaceChildren(
435
+ ...entries.flatMap((entry) => {
436
+ if (entry instanceof HTMLLIElement) return [entry];
437
+ if (isPart(entry)) return partItems(entry, state);
438
+ const open = entry.links.find(
439
+ (link) => link.a.getAttribute('aria-current') === 'page',
440
+ )?.edition;
441
+ const row = lessonRow(
442
+ entry.links.map((link) => link.edition),
443
+ state,
444
+ open,
445
+ );
446
+ const lead =
447
+ entry.links.find((link) => link.edition === row.lead) ??
448
+ entry.links[0]!;
449
+ const other = entry.links.find((link) => link.edition === row.other);
450
+ for (const { a } of entry.links) {
451
+ a.classList.remove(...ROW_CLASSES);
452
+ a.querySelector(':scope > .layout-edition-mark')?.remove();
453
+ }
454
+ setLabel(lead.a, editionLabel(lead.edition, entry.title, 'lead'));
455
+ if (row.glyph) lead.a.classList.add(GLYPH_CLASS[row.glyph]);
456
+ lead.li.replaceChildren(lead.a);
457
+ if (other) {
458
+ other.a.classList.add(EDITION_OTHER_CLASS);
459
+ setLabel(other.a, editionLabel(other.edition, entry.title, 'other'));
460
+ other.a.append(badge());
461
+ lead.li.append(other.a);
462
+ }
463
+ // Lets the CSS join a pair's tiles without `:has()`.
464
+ lead.li.dataset.editions = other ? 'pair' : 'single';
465
+ return [lead.li];
466
+ }),
467
+ );
468
+ }
469
+ };
470
+
471
+ /** The section of the chapter on screen in the part's deck open here, lit; none elsewhere. */
472
+ const light = () => {
473
+ const chapter = document.documentElement.getAttribute(DECK_CHAPTER_ATTRIBUTE) ?? '';
474
+ for (const section of sections) {
475
+ if (chapter !== '' && section.dataset.lesson === chapter) {
476
+ section.setAttribute('aria-current', 'location');
477
+ } else {
478
+ section.removeAttribute('aria-current');
479
+ }
480
+ }
481
+ };
482
+
483
+ // The controls, first in the panel's foot, above the marks' line.
484
+ const sidebarList = root.querySelector('sl-sidebar-state-persist');
485
+ let foot = root.querySelector<HTMLElement>('.explico-sidebar-foot');
486
+ if (!foot) {
487
+ foot = document.createElement('div');
488
+ foot.className = 'explico-sidebar-foot';
489
+ if (sidebarList) sidebarList.after(foot);
490
+ else root.append(foot);
491
+ }
492
+ const controls = document.createElement('div');
493
+ controls.className = 'explico-sidebar-editions';
494
+ const group = document.createElement('fieldset');
495
+ group.className = 'explico-sidebar-editions__lead';
496
+ const legend = document.createElement('legend');
497
+ legend.textContent = 'Lessons open as';
498
+ const segments = document.createElement('span');
499
+ segments.className = 'explico-sidebar-editions__segments';
500
+ group.append(legend, segments);
501
+ const leads = new Map<Edition, HTMLInputElement>();
502
+ for (const [edition, text] of [
503
+ ['chapter', 'Chapter'],
504
+ ['slides', 'Slides'],
505
+ ] as const) {
506
+ const option = document.createElement('label');
507
+ option.className = 'explico-sidebar-editions__segment';
508
+ const input = document.createElement('input');
509
+ input.type = 'radio';
510
+ input.name = 'explico-sidebar-editions-lead';
511
+ input.value = edition;
512
+ input.addEventListener('change', () => {
513
+ if (input.checked) choose({ lead: edition });
514
+ });
515
+ const name = document.createElement('span');
516
+ name.textContent = text;
517
+ option.append(input, name);
518
+ segments.append(option);
519
+ leads.set(edition, input);
520
+ }
521
+ const listed = document.createElement('input');
522
+ listed.type = 'checkbox';
523
+ listed.addEventListener('change', () =>
524
+ choose({ other: listed.checked ? 'listed' : 'hidden' }),
525
+ );
526
+ const listedOption = document.createElement('label');
527
+ listedOption.className = 'explico-sidebar-editions__other';
528
+ listedOption.append(listed, 'List the other edition too');
529
+ controls.append(group, listedOption);
530
+ foot.prepend(controls);
531
+
532
+ const store = createBrowserPreferenceStore();
533
+ const show = (state: EditionsState) => {
534
+ arrange(state);
535
+ light();
536
+ const lead = leads.get(state.lead);
537
+ if (lead) lead.checked = true;
538
+ listed.checked = state.other === 'listed';
539
+ window.dispatchEvent(
540
+ new CustomEvent<EditionsState>(SIDEBAR_EDITIONS_EVENT, { detail: state }),
541
+ );
542
+ };
543
+ /** The reader's change, kept with the fields they set before: a field never set keeps the course's default. */
544
+ const choose = (change: EditionsChoice) => {
545
+ const choice = {
546
+ ...decodeEditionsChoice(store.get(SIDEBAR_EDITIONS_KEY)),
547
+ ...change,
548
+ };
549
+ store.set(SIDEBAR_EDITIONS_KEY, encodeEditionsChoice(choice));
550
+ show(resolveEditions(course, choice));
551
+ };
552
+ show(resolveEditions(course, decodeEditionsChoice(store.get(SIDEBAR_EDITIONS_KEY))));
553
+ // Before the rail's listener, which counts to the section lit here.
554
+ window.addEventListener(DECK_FRAME_EVENT, light);
555
+ })();
556
+ </script>
557
+ <script>
558
+ /**
559
+ * Mark every row the reader has already passed, so the rail beside the
560
+ * chapter list can show how far into the course this page is.
123
561
  *
124
562
  * The sidebar's document order *is* the reading order — the compiler
125
563
  * built it from the merged track order — so "covered" is simply
126
564
  * "earlier in this list than the current page". A part whose chapters
127
565
  * are all covered is covered itself, which is what makes the rail read
128
566
  * as one run rather than as a mark per group.
567
+ *
568
+ * A row is a list item with a link of its own. A lesson's row holds its
569
+ * editions (`sidebar-editions.ts`) and is the open page's row when any
570
+ * of them is the open page, so a lesson whose deck is open is not
571
+ * behind the reader. The rows are counted again when the reader
572
+ * switches the editions. With a part's deck open, the current row is
573
+ * the one whose section is on screen (`aria-current="location"`),
574
+ * counted again as the deck moves.
129
575
  */
576
+ import { DECK_FRAME_EVENT, SIDEBAR_EDITIONS_EVENT } from './sidebar-editions';
577
+
130
578
  (() => {
131
- const root = document.querySelector('#starlight__navigation-content');
132
- const links = [...(root?.querySelectorAll('a') ?? [])].filter(
133
- (link) => !link.classList.contains('layout-part-open'),
134
- );
135
- const current = links.findIndex(
136
- (link) => link.getAttribute('aria-current') === 'page',
579
+ const root = document.querySelector<HTMLElement>(
580
+ '#starlight__navigation-content',
137
581
  );
138
- if (current < 1) return;
582
+ if (!root) return;
139
583
 
140
- for (const link of links.slice(0, current)) {
141
- link.parentElement?.setAttribute('data-covered', '');
142
- }
143
- for (const group of root?.querySelectorAll('details') ?? []) {
144
- const chapters = [...group.querySelectorAll(':scope > ul > li')];
145
- if (
146
- chapters.length > 0 &&
147
- chapters.every((item) => item.hasAttribute('data-covered'))
148
- ) {
149
- group.setAttribute('data-covered', '');
584
+ const cover = () => {
585
+ for (const item of root.querySelectorAll('[data-covered]')) {
586
+ item.removeAttribute('data-covered');
150
587
  }
588
+ const rows = [...root.querySelectorAll('li')].filter((item) =>
589
+ item.querySelector(':scope > a'),
590
+ );
591
+ const at = (current: string) =>
592
+ rows.findIndex((item) =>
593
+ item.querySelector(`:scope > a[aria-current="${current}"]`),
594
+ );
595
+ const lit = at('location');
596
+ const current = lit === -1 ? at('page') : lit;
597
+ if (current < 1) return;
598
+
599
+ for (const row of rows.slice(0, current)) {
600
+ row.setAttribute('data-covered', '');
601
+ }
602
+ for (const group of root.querySelectorAll('details')) {
603
+ const chapters = [...group.querySelectorAll(':scope > ul > li')];
604
+ if (
605
+ chapters.length > 0 &&
606
+ chapters.every((item) => item.hasAttribute('data-covered'))
607
+ ) {
608
+ group.setAttribute('data-covered', '');
609
+ }
610
+ }
611
+ };
612
+ cover();
613
+ window.addEventListener(SIDEBAR_EDITIONS_EVENT, cover);
614
+ window.addEventListener(DECK_FRAME_EVENT, cover);
615
+ })();
616
+ </script>
617
+ <script>
618
+ /**
619
+ * Mark, for this reader, every chapter and part page they have not
620
+ * opened yet (a quiet ring) and every one whose content changed since
621
+ * they last opened it (an accent dot), then record this page as seen.
622
+ * What a mark means is in `page-marks.ts`; how it looks, in
623
+ * `global.css`: at the end of a labelled row, on the corner of a
624
+ * compact tile, after a closed part's label, and on the collapsed
625
+ * panel's restore control.
626
+ *
627
+ * Each marked link names its mark as its description
628
+ * (`aria-describedby`), and a line at the end of the panel says what
629
+ * the marks on screen mean, with "Mark all as seen" and "Mark all as
630
+ * unseen". The line stays while the record is kept, marks or none, so
631
+ * the reader can always go back. Both actions cover every deck's
632
+ * frames too (D9, `../../session/deck-memory.ts`), whose marks a deck's own
633
+ * page shows. On a page that changed since the reader last opened
634
+ * it, a line under its title says so.
635
+ *
636
+ * A lesson's row (`sidebar-editions.ts`) carries its own link's mark;
637
+ * the other edition's icon carries its own, on its corner. The marks
638
+ * follow the rows when the reader switches the editions. A chapter's
639
+ * section of its part's deck carries none: it is no page, and the
640
+ * part's deck's row carries the deck page's.
641
+ *
642
+ * The pages a reader opened are reading memory
643
+ * (`session/reading-memory.ts`): recorded, and marked, only after they
644
+ * said yes to it. Before an answer, after a no, and without storage,
645
+ * nothing is recorded and nothing is marked. An answer given on this
646
+ * page takes effect at once.
647
+ */
648
+ import { createBrowserPreferenceStore } from '../../session/PreferenceStore';
649
+ import {
650
+ deckPositionsKey,
651
+ READING_MEMORY_EVENT,
652
+ readingMemoryStore,
653
+ rememberReading,
654
+ } from '../../session/reading-memory';
655
+ import {
656
+ DECK_MEMORY_EVENT,
657
+ decodeDeckMemory,
658
+ encodeDeckMemory,
659
+ frameMark,
660
+ withEveryDeckSeen,
661
+ withEveryDeckUnseen,
662
+ type DeckMemory,
663
+ } from '../../session/deck-memory';
664
+ import {
665
+ decodeSeen,
666
+ encodeSeen,
667
+ markOf,
668
+ samePath,
669
+ withAllSeen,
670
+ withNoneSeen,
671
+ withVisit,
672
+ type PageMark,
673
+ type PageMarksPayload,
674
+ } from './page-marks';
675
+ import { DECK_SECTION_ATTRIBUTE, EDITION_OTHER_CLASS, SIDEBAR_EDITIONS_EVENT } from './sidebar-editions';
676
+
677
+ (() => {
678
+ const payloadElement = document.querySelector(
679
+ '[data-explico-page-marks]',
680
+ );
681
+ const root = document.querySelector<HTMLElement>(
682
+ '#starlight__navigation-content',
683
+ );
684
+ if (!payloadElement?.textContent || !root) return;
685
+ let payload: PageMarksPayload;
686
+ try {
687
+ payload = JSON.parse(payloadElement.textContent);
688
+ } catch {
689
+ return;
151
690
  }
691
+
692
+ const store = createBrowserPreferenceStore();
693
+ const pathOf = (href: string) =>
694
+ samePath(new URL(href, location.href).pathname);
695
+ const byPath = new Map(
696
+ payload.pages.map((page) => [samePath(page.href), page]),
697
+ );
698
+ const current = byPath.get(pathOf(location.href));
699
+ const changedNote = document.querySelector<HTMLElement>(
700
+ '[data-explico-page-changed]',
701
+ );
702
+
703
+ const TEXT: Record<PageMark, string> = {
704
+ unopened: 'Not opened yet',
705
+ changed: 'Changed since you last opened it',
706
+ };
707
+ const KINDS: readonly PageMark[] = ['unopened', 'changed'];
708
+ const panel = root.closest<HTMLElement>('.layout-navigation-panel');
709
+ // In the panel's foot, right under the chapter list, after the
710
+ // editions' controls: on a phone, Starlight's own controls follow it
711
+ // in the same menu, and the legend belongs with the marks.
712
+ const list = root.querySelector('sl-sidebar-state-persist');
713
+ const place = (element: HTMLElement) => {
714
+ let foot = root.querySelector<HTMLElement>('.explico-sidebar-foot');
715
+ if (!foot) {
716
+ foot = document.createElement('div');
717
+ foot.className = 'explico-sidebar-foot';
718
+ if (list) list.after(foot);
719
+ else root.append(foot);
720
+ }
721
+ foot.append(element);
722
+ };
723
+
724
+ const setMarks = (element: HTMLElement | null, marks: Set<PageMark>) => {
725
+ if (!element) return;
726
+ if (marks.size > 0) element.dataset.pageMarks = [...marks].sort().join(' ');
727
+ else delete element.dataset.pageMarks;
728
+ };
729
+
730
+ // Every deck's frames as the decks' pages number them (`deck-memory.ts`).
731
+ const decks = (payload.decks ?? []).map((deck) => ({
732
+ slug: deck.slug,
733
+ frames: deck.frames.map((id, index) => ({ number: index + 1, id })),
734
+ }));
735
+ const deckKey = deckPositionsKey(payload.base);
736
+ const readDecks = () => decodeDeckMemory(readingMemoryStore(payload.base).get(deckKey));
737
+
738
+ /**
739
+ * What the decks' frames hold for the two actions: a frame the
740
+ * course would mark, and a frame recorded as seen.
741
+ */
742
+ const deckState = () => {
743
+ const memory = readDecks();
744
+ const marked = decks.some((deck) =>
745
+ deck.frames.some((frame) => {
746
+ const mark = frameMark(frame, memory.get(deck.slug));
747
+ return mark === 'unseen' ? payload.unopened : mark === 'changed' && payload.changed;
748
+ }),
749
+ );
750
+ const seen = decks.some((deck) => (memory.get(deck.slug)?.seen.size ?? 0) > 0);
751
+ return { marked, seen };
752
+ };
753
+
754
+ // The two actions, in this order; each covers every deck's frames
755
+ // too (D9), which the decks' pages mark.
756
+ const ACTIONS = {
757
+ seen: {
758
+ label: 'Mark all as seen',
759
+ done: decks.length > 0 ? 'Every page and slide is marked as seen.' : 'Every page is marked as seen.',
760
+ record: () => withAllSeen(payload.pages),
761
+ decks: (memory: DeckMemory) => withEveryDeckSeen(memory, decks),
762
+ },
763
+ unseen: {
764
+ label: 'Mark all as unseen',
765
+ done:
766
+ decks.length > 0
767
+ ? 'Every page is marked as not opened yet, and every slide as not shown yet.'
768
+ : 'Every page is marked as not opened yet.',
769
+ // The open page is recorded again: it is open.
770
+ record: () => withVisit(withNoneSeen(), current, payload.pages),
771
+ decks: (memory: DeckMemory) => withEveryDeckUnseen(memory),
772
+ },
773
+ } as const;
774
+ type Action = keyof typeof ACTIONS;
775
+
776
+ /** A button out of use keeps its place and its focus, so it is `aria-disabled`, not `disabled`. */
777
+ const setUsable = (button: HTMLButtonElement | undefined, usable: boolean) => {
778
+ if (usable) button?.removeAttribute('aria-disabled');
779
+ else button?.setAttribute('aria-disabled', 'true');
780
+ };
781
+
782
+ const buildLegend = () => {
783
+ const line = document.createElement('div');
784
+ line.className = 'explico-page-marks-legend';
785
+ const row = document.createElement('div');
786
+ row.className = 'explico-page-marks-legend__row';
787
+ const items = new Map<PageMark, HTMLElement>();
788
+ for (const mark of KINDS) {
789
+ // A marked link's description: every kind on screen has its text here.
790
+ const item = document.createElement('span');
791
+ item.id = `explico-page-mark-${mark}`;
792
+ item.className = 'explico-page-marks-legend__item';
793
+ item.dataset.pageMark = mark;
794
+ item.textContent = TEXT[mark];
795
+ item.hidden = true;
796
+ items.set(mark, item);
797
+ row.append(item);
798
+ }
799
+ // There from the start, empty, so a screen reader hears what it gains.
800
+ const status = document.createElement('p');
801
+ status.className = 'explico-page-marks-legend__status';
802
+ status.setAttribute('role', 'status');
803
+ const actions = document.createElement('span');
804
+ actions.className = 'explico-page-marks-legend__actions';
805
+ const buttons = new Map<Action, HTMLButtonElement>();
806
+ for (const action of Object.keys(ACTIONS) as Action[]) {
807
+ const button = document.createElement('button');
808
+ button.type = 'button';
809
+ button.className = 'explico-page-marks-legend__action';
810
+ button.dataset.pageMarksAction = action;
811
+ button.textContent = ACTIONS[action].label;
812
+ button.addEventListener('click', () => {
813
+ if (button.getAttribute('aria-disabled') === 'true') return;
814
+ const record = ACTIONS[action].record();
815
+ store.set(payload.key, encodeSeen(record));
816
+ if (decks.length > 0) {
817
+ readingMemoryStore(payload.base).set(deckKey, encodeDeckMemory(ACTIONS[action].decks(readDecks())));
818
+ }
819
+ apply(record);
820
+ // In the line, which stays: focus stays on the button.
821
+ status.textContent = ACTIONS[action].done;
822
+ // A deck's page open here marks its frames again.
823
+ if (decks.length > 0) window.dispatchEvent(new CustomEvent(DECK_MEMORY_EVENT));
824
+ });
825
+ buttons.set(action, button);
826
+ actions.append(button);
827
+ }
828
+ row.append(actions);
829
+ line.append(row, status);
830
+ place(line);
831
+ return { line, items, buttons };
832
+ };
833
+ let legend: ReturnType<typeof buildLegend> | undefined;
834
+
835
+ /** The line for the marks on screen, while `record` is kept; none without it. */
836
+ const showLegend = (
837
+ record: ReadonlyMap<string, string> | null,
838
+ shown: Set<PageMark>,
839
+ ) => {
840
+ if (!record) {
841
+ legend?.line.remove();
842
+ legend = undefined;
843
+ return;
844
+ }
845
+ legend ??= buildLegend();
846
+ for (const [mark, item] of legend.items) item.hidden = !shown.has(mark);
847
+ const frames = decks.length > 0 ? deckState() : { marked: false, seen: false };
848
+ setUsable(legend.buttons.get('seen'), shown.size > 0 || frames.marked);
849
+ setUsable(
850
+ legend.buttons.get('unseen'),
851
+ [...record.keys()].some((slug) => slug !== current?.slug) || frames.seen,
852
+ );
853
+ };
854
+
855
+ /** The record the marks on screen show, for a rebuild of the rows. */
856
+ let applied: ReadonlyMap<string, string> | null = null;
857
+
858
+ /** The marks for `record`, or none at all with `null`. */
859
+ const apply = (record: ReadonlyMap<string, string> | null) => {
860
+ applied = record;
861
+ const shown = new Set<PageMark>();
862
+ for (const link of root.querySelectorAll<HTMLAnchorElement>('li > a[href]')) {
863
+ // A row's mark is on its item, for its own link; the other
864
+ // edition's icon holds its own. A link that was that icon
865
+ // before a switch drops the mark it held.
866
+ const other = link.classList.contains(EDITION_OTHER_CLASS);
867
+ const holder = other ? link : link.parentElement!;
868
+ if (!other) delete link.dataset.pageMark;
869
+ // A chapter's section of its part's deck is no page of its own.
870
+ const page = link.hasAttribute(DECK_SECTION_ATTRIBUTE) ? undefined : byPath.get(pathOf(link.href));
871
+ const mark =
872
+ record && page && page !== current
873
+ ? markOf(page, record, payload)
874
+ : undefined;
875
+ if (mark) {
876
+ holder.dataset.pageMark = mark;
877
+ link.setAttribute('aria-describedby', `explico-page-mark-${mark}`);
878
+ shown.add(mark);
879
+ } else if (holder.dataset.pageMark || link.hasAttribute('aria-describedby')) {
880
+ delete holder.dataset.pageMark;
881
+ link.removeAttribute('aria-describedby');
882
+ }
883
+ }
884
+ for (const group of root.querySelectorAll<HTMLElement>('details')) {
885
+ setMarks(
886
+ group,
887
+ new Set(
888
+ [
889
+ ...group.querySelectorAll<HTMLElement>(
890
+ 'li[data-page-mark], a[data-page-mark]',
891
+ ),
892
+ ].map((item) => item.dataset.pageMark as PageMark),
893
+ ),
894
+ );
895
+ }
896
+ setMarks(panel, shown);
897
+ showLegend(record, shown);
898
+ };
899
+
900
+ let recorded = false;
901
+ const run = () => {
902
+ if (!rememberReading(payload.base)) {
903
+ apply(null);
904
+ if (changedNote) changedNote.hidden = true;
905
+ recorded = false;
906
+ return;
907
+ }
908
+ const before = decodeSeen(store.get(payload.key));
909
+ const seen = withVisit(before, current, payload.pages);
910
+ store.set(payload.key, encodeSeen(seen));
911
+ // Without storage nothing could ever be recorded: mark nothing.
912
+ if (store.get(payload.key) === null) return apply(null);
913
+ // The note says what this visit found, once: a yes given on this
914
+ // page records it and has nothing earlier to compare with.
915
+ if (changedNote && current && !recorded) {
916
+ const previous = before.get(current.slug);
917
+ changedNote.hidden = !(
918
+ payload.changed &&
919
+ previous !== undefined &&
920
+ previous !== current.id
921
+ );
922
+ }
923
+ recorded = true;
924
+ apply(seen);
925
+ };
926
+
927
+ window.addEventListener(READING_MEMORY_EVENT, run);
928
+ // The rows were rebuilt: the same record, on the links they hold now.
929
+ window.addEventListener(SIDEBAR_EDITIONS_EVENT, () => apply(applied));
930
+ // A deck's page recorded frames or reset its marks: the two actions may change use.
931
+ window.addEventListener(DECK_MEMORY_EVENT, () => apply(applied));
932
+ run();
152
933
  })();
153
934
  </script>
154
935
  <script>
@@ -222,7 +1003,13 @@ const partLinkPayload = JSON.stringify(partLinks).replace(/</g, '\\u003c');
222
1003
  * `position: fixed` (see `global.css`) and the anchor's own viewport
223
1004
  * coordinates from here. The tooltip stays the link's accessible name in
224
1005
  * the markup either way — this only moves where it is painted.
1006
+ *
1007
+ * The other edition's icon at the end of a labelled row
1008
+ * (`sidebar-editions.ts`) has no label beside it either: its name
1009
+ * ("Slides: …") is shown the same way.
225
1010
  */
1011
+ import { EDITION_OTHER_CLASS } from './sidebar-editions';
1012
+
226
1013
  (() => {
227
1014
  if (!matchMedia('(min-width: 50rem)').matches) return;
228
1015
  const panel = document.querySelector<HTMLElement>(
@@ -230,8 +1017,7 @@ const partLinkPayload = JSON.stringify(partLinks).replace(/</g, '\\u003c');
230
1017
  );
231
1018
  if (!panel) return;
232
1019
 
233
- const COMPACT_LINK =
234
- 'details[data-compact] > ul:not(.top-level) > li > a';
1020
+ const COMPACT_LINK = `details[data-compact] > ul:not(.top-level) > li > a, li > a.${EDITION_OTHER_CLASS}`;
235
1021
  const GAP = 7;
236
1022
  const EDGE = 8;
237
1023
  let open: HTMLAnchorElement | null = null;