jekyll-theme-zer0 1.28.0 → 1.30.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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1143 -13
  3. data/_data/README.md +2 -0
  4. data/_data/ai.yml +5 -3
  5. data/_data/ai_pricing.yml +36 -0
  6. data/_data/backlog.yml +507 -2
  7. data/_data/consumers.yml +157 -9
  8. data/_data/features.yml +303 -20
  9. data/_data/feedback_types.yml +17 -12
  10. data/_data/i18n/fr.yml +12 -7
  11. data/_data/i18n/manifest.yml +39 -12
  12. data/_data/ingredient_densities.yml +122 -0
  13. data/_data/landing.yml +5 -2
  14. data/_data/navigation/main.yml +16 -0
  15. data/_data/navigation/quickstart.yml +4 -0
  16. data/_data/recipe_courses.yml +64 -0
  17. data/_data/site_builder.yml +874 -0
  18. data/_data/theme-manifest.yml +160 -124
  19. data/_data/ui-text.yml +26 -0
  20. data/_includes/README.md +26 -2
  21. data/_includes/analytics/posthog.html +2 -2
  22. data/_includes/components/admin-links.html +2 -2
  23. data/_includes/components/admin-tabs.html +2 -2
  24. data/_includes/components/ai-chat.html +14 -11
  25. data/_includes/components/analytics-dashboard.html +8 -8
  26. data/_includes/components/author-bio.html +1 -1
  27. data/_includes/components/author-card.html +10 -2
  28. data/_includes/components/author-eeat.html +4 -4
  29. data/_includes/components/background-customizer.html +10 -10
  30. data/_includes/components/background-image.html +114 -0
  31. data/_includes/components/background-settings.html +28 -15
  32. data/_includes/components/collection-manager.html +5 -5
  33. data/_includes/components/component-showcase.html +13 -13
  34. data/_includes/components/config-editor.html +12 -12
  35. data/_includes/components/config-viewer.html +8 -8
  36. data/_includes/components/cookie-consent.html +15 -15
  37. data/_includes/components/cta-button.html +7 -2
  38. data/_includes/components/dev-shortcuts.html +7 -7
  39. data/_includes/components/env-dashboard.html +8 -8
  40. data/_includes/components/env-switcher.html +9 -9
  41. data/_includes/components/feature-card.html +2 -2
  42. data/_includes/components/halfmoon.html +2 -2
  43. data/_includes/components/info-section.html +42 -37
  44. data/_includes/components/js-cdn.html +15 -15
  45. data/_includes/components/language-toggle.html +168 -21
  46. data/_includes/components/mermaid.html +72 -435
  47. data/_includes/components/nanobar.html +5 -5
  48. data/_includes/components/nav-editor.html +2 -2
  49. data/_includes/components/nav-export.html +2 -2
  50. data/_includes/components/nav-overview.html +2 -2
  51. data/_includes/components/page-feedback.html +45 -30
  52. data/_includes/components/page-views-init.html +55 -0
  53. data/_includes/components/page-views.html +33 -0
  54. data/_includes/components/post-card.html +22 -22
  55. data/_includes/components/post-type-badge.html +2 -2
  56. data/_includes/components/powered-by.html +2 -2
  57. data/_includes/components/preview-image.html +6 -0
  58. data/_includes/components/quick-index.html +2 -2
  59. data/_includes/components/recipe-card.html +67 -0
  60. data/_includes/components/recipe-duration.html +50 -0
  61. data/_includes/components/recipe-grams.html +58 -0
  62. data/_includes/components/recipe-index.html +96 -0
  63. data/_includes/components/recipe-ingredients.html +90 -0
  64. data/_includes/components/recipe-meta.html +96 -0
  65. data/_includes/components/recipe-nutrition.html +57 -0
  66. data/_includes/components/recipe-qty.html +73 -0
  67. data/_includes/components/recipe-ratio.html +151 -0
  68. data/_includes/components/recipe-scaler.html +73 -0
  69. data/_includes/components/recipe-steps.html +86 -0
  70. data/_includes/components/recipe-temp.html +45 -0
  71. data/_includes/components/search-modal.html +29 -4
  72. data/_includes/components/searchbar.html +2 -2
  73. data/_includes/components/shortcuts-modal.html +3 -0
  74. data/_includes/components/svg-background.html +2 -2
  75. data/_includes/components/theme-customizer.html +2 -2
  76. data/_includes/components/theme-info.html +14 -7
  77. data/_includes/components/theme-preview-gallery.html +22 -22
  78. data/_includes/content/giscus.html +2 -2
  79. data/_includes/content/intro.html +8 -8
  80. data/_includes/content/jsonld-faq.html +2 -2
  81. data/_includes/content/jsonld-software.html +24 -5
  82. data/_includes/content/seo.html +4 -4
  83. data/_includes/content/sitemap.html +27 -27
  84. data/_includes/content/toc.html +183 -183
  85. data/_includes/core/branding.html +6 -6
  86. data/_includes/core/console-capture.html +32 -74
  87. data/_includes/core/favicon.html +49 -7
  88. data/_includes/core/footer-fabs.html +17 -3
  89. data/_includes/core/footer.html +49 -34
  90. data/_includes/core/head.html +110 -86
  91. data/_includes/core/header.html +76 -54
  92. data/_includes/docs/bootstrap-docs.html +8 -8
  93. data/_includes/landing/landing-install-cards.html +2 -2
  94. data/_includes/landing/landing-quick-links.html +1 -1
  95. data/_includes/navigation/admin-nav.html +2 -2
  96. data/_includes/navigation/nav-tree.html +8 -8
  97. data/_includes/navigation/navbar.html +12 -12
  98. data/_includes/navigation/section-sidebar.html +109 -27
  99. data/_includes/navigation/sidebar-config.html +36 -2
  100. data/_includes/navigation/sidebar-left.html +17 -16
  101. data/_includes/navigation/sidebar-right.html +8 -7
  102. data/_includes/obsidian/full-graph.html +2 -2
  103. data/_includes/setup/claude-session.html +72 -0
  104. data/_includes/setup/prereq-checklist.html +90 -0
  105. data/_includes/setup/wizard.html +924 -222
  106. data/_includes/stats/stats-categories.html +8 -8
  107. data/_includes/stats/stats-header.html +14 -14
  108. data/_includes/stats/stats-metrics.html +14 -14
  109. data/_includes/stats/stats-no-data.html +12 -12
  110. data/_includes/stats/stats-overview.html +6 -6
  111. data/_includes/stats/stats-tags.html +8 -8
  112. data/_layouts/404.html +38 -24
  113. data/_layouts/README.md +2 -0
  114. data/_layouts/admin.html +24 -24
  115. data/_layouts/article.html +43 -33
  116. data/_layouts/author.html +20 -20
  117. data/_layouts/authors.html +2 -2
  118. data/_layouts/book-abc.html +12 -12
  119. data/_layouts/book-story.html +15 -15
  120. data/_layouts/book.html +12 -12
  121. data/_layouts/collection.html +33 -33
  122. data/_layouts/cookbook.html +88 -0
  123. data/_layouts/default.html +31 -28
  124. data/_layouts/home.html +23 -23
  125. data/_layouts/index.html +10 -10
  126. data/_layouts/landing.html +17 -17
  127. data/_layouts/news.html +44 -44
  128. data/_layouts/note.html +38 -38
  129. data/_layouts/notebook.html +34 -34
  130. data/_layouts/recipe.html +274 -0
  131. data/_layouts/root.html +92 -55
  132. data/_layouts/section.html +62 -33
  133. data/_layouts/setup.html +3 -3
  134. data/_layouts/sitemap-collection.html +49 -49
  135. data/_layouts/stats.html +40 -40
  136. data/_layouts/tag.html +12 -12
  137. data/_layouts/welcome.html +21 -21
  138. data/_sass/components/_callout.scss +1 -1
  139. data/_sass/components/_footer.scss +37 -1
  140. data/_sass/components/_mermaid.scss +375 -0
  141. data/_sass/components/_page-views.scss +36 -0
  142. data/_sass/components/_recipe.scss +506 -0
  143. data/_sass/components/_setup-wizard.scss +764 -0
  144. data/_sass/components/_ui-enhancements.scss +6 -6
  145. data/_sass/core/_navbar.scss +261 -46
  146. data/_sass/layouts/_landing.scss +2 -2
  147. data/_sass/layouts/_navbar-extras.scss +14 -4
  148. data/_sass/tokens/_color.scss +6 -0
  149. data/_sass/tokens/_index.scss +2 -0
  150. data/_sass/tokens/_radius.scss +21 -0
  151. data/_sass/tokens/_typography.scss +4 -0
  152. data/_sass/utilities/_focus.scss +14 -0
  153. data/assets/css/main.scss +4 -0
  154. data/assets/js/ai-chat.js +47 -5
  155. data/assets/js/fleet-feedback-capture.js +124 -0
  156. data/assets/js/fleet-feedback.js +853 -0
  157. data/assets/js/mermaid-diagrams.js +1267 -0
  158. data/assets/js/modules/navigation/config.js +9 -6
  159. data/assets/js/modules/navigation/navbar.js +55 -0
  160. data/assets/js/modules/navigation/scroll-spy.js +315 -80
  161. data/assets/js/modules/theme/appearance.js +8 -2
  162. data/assets/js/obsidian-wiki-links.js +8 -3
  163. data/assets/js/page-feedback.js +125 -192
  164. data/assets/js/page-views.js +372 -0
  165. data/assets/js/recipe-scaler.js +501 -0
  166. data/assets/js/search-modal.js +36 -0
  167. data/assets/js/setup-wizard.js +2279 -226
  168. data/assets/js/site-builder.js +1834 -0
  169. data/assets/js/ui-enhancements.js +11 -3
  170. data/scripts/README.md +44 -0
  171. data/scripts/ai/README.md +38 -0
  172. data/scripts/ai/api_call.rb +124 -0
  173. data/scripts/ai/usage.rb +314 -0
  174. data/scripts/ai/usage_report.rb +225 -0
  175. data/scripts/bin/audit-consumer +39 -7
  176. data/scripts/bin/giscus-discussions +213 -14
  177. data/scripts/bin/manifest +35 -12
  178. data/scripts/ci/agent_review_result.py +164 -0
  179. data/scripts/ci/test_agent_review_result.py +172 -0
  180. data/scripts/ci/test_visual_evidence_autogen.py +341 -0
  181. data/scripts/ci/visual_evidence_autogen.py +1060 -0
  182. data/scripts/content-review.rb +20 -1
  183. data/scripts/design-system-check.rb +170 -0
  184. data/scripts/lib/audit.sh +42 -2
  185. data/scripts/lint-liquid-raw.rb +137 -0
  186. data/scripts/test/integration/mermaid +22 -8
  187. data/scripts/test/lib/run_tests.sh +3 -1
  188. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  189. data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
  190. data/scripts/translate.rb +94 -16
  191. metadata +48 -2
@@ -51,12 +51,15 @@ export const config = {
51
51
  // SCROLL SPY SETTINGS
52
52
  // ===================================================================
53
53
  scrollSpy: {
54
- // Root margin for Intersection Observer
55
- // Negative values account for fixed headers
56
- rootMargin: '-80px 0px -80px 0px',
57
-
58
- // Intersection threshold levels
59
- threshold: [0, 0.25, 0.5, 0.75, 1]
54
+ // Reading line: distance (px) below the top of the viewport at which a
55
+ // heading becomes the "current" one. `null` derives it from the
56
+ // document's `scroll-padding-top` so the highlight matches where
57
+ // anchor navigation parks a heading; set a number to pin it.
58
+ offset: null,
59
+
60
+ // Slack (px) when comparing a heading against the reading line and
61
+ // when detecting the bottom of the page.
62
+ tolerance: 4
60
63
  },
61
64
 
62
65
  // ===================================================================
@@ -55,6 +55,7 @@ export class Navbar {
55
55
  this._setupMobileDropdowns();
56
56
  this._setupOutsideClickClose();
57
57
  this._setupOffcanvasReset();
58
+ this._setupSettingsExclusivity();
58
59
  this._setupNavTooltips();
59
60
  this._setupDropdownHoverDelay();
60
61
  this._setupFocusTrap();
@@ -224,6 +225,60 @@ export class Navbar {
224
225
  });
225
226
  }
226
227
 
228
+ /**
229
+ * Mobile: opening Settings from inside a nav panel must not leave the two
230
+ * stacked on top of each other.
231
+ *
232
+ * #bdNavbar (and the optional unified drawer) and #info-section are both
233
+ * `offcanvas-end` at Bootstrap's $zindex-offcanvas. #bdNavbar additionally
234
+ * lives inside the fixed header, which _navbar.scss lifts above the
235
+ * offcanvas backdrop while the menu is open — so a tap on the menu's own
236
+ * "Settings" item used to open Settings *behind* the still-open menu, with
237
+ * nothing on screen to show it had worked.
238
+ *
239
+ * Intercept the Bootstrap `show` event (capture phase, before Bootstrap's
240
+ * own handler runs) and defer it until the host panel has finished sliding
241
+ * out. Doing it on the event rather than on the trigger keeps the plain
242
+ * data-api working for every other entry point — footer, keyboard, a
243
+ * consumer's own button — and leaves the markup functional if this module
244
+ * never loads.
245
+ */
246
+ _setupSettingsExclusivity() {
247
+ const settingsEl = document.getElementById('info-section');
248
+ if (!settingsEl) return;
249
+
250
+ const hostEls = ['bdNavbar', 'zer0UnifiedDrawer']
251
+ .map((id) => document.getElementById(id))
252
+ .filter(Boolean);
253
+ if (!hostEls.length) return;
254
+
255
+ this._on(
256
+ settingsEl,
257
+ 'show.bs.offcanvas',
258
+ (event) => {
259
+ const Offcanvas = window.bootstrap?.Offcanvas;
260
+ if (!Offcanvas) return;
261
+ const openHost = hostEls.find((el) => el.classList.contains('show'));
262
+ if (!openHost) return;
263
+
264
+ event.preventDefault();
265
+ event.stopImmediatePropagation();
266
+
267
+ const hostInstance = Offcanvas.getInstance(openHost);
268
+ const show = () => Offcanvas.getOrCreateInstance(settingsEl).show();
269
+ if (!hostInstance) {
270
+ show();
271
+ return;
272
+ }
273
+ // `hidden.bs.offcanvas` fires after the slide-out transition, so
274
+ // the header's z-index lift has already been released by then.
275
+ openHost.addEventListener('hidden.bs.offcanvas', show, { once: true });
276
+ hostInstance.hide();
277
+ },
278
+ true,
279
+ );
280
+ }
281
+
227
282
  _setupNavTooltips() {
228
283
  if (typeof window.bootstrap === 'undefined' || !window.bootstrap.Tooltip) return;
229
284
  const navLinks = document.querySelectorAll('#bdNavbar .nav-link[title]');
@@ -1,27 +1,52 @@
1
1
  // Feature: ZER0-008
2
2
  /**
3
3
  * ===================================================================
4
- * SCROLL SPY - Intersection Observer Implementation
4
+ * SCROLL SPY - Reading-position TOC highlighting
5
5
  * ===================================================================
6
- *
6
+ *
7
7
  * File: scroll-spy.js
8
8
  * Path: assets/js/modules/navigation/scroll-spy.js
9
- * Purpose: Track visible sections and highlight corresponding TOC links
10
- *
9
+ * Purpose: Track the section being read and bold its TOC link
10
+ *
11
+ * How the active heading is chosen:
12
+ * The active heading is the LAST heading whose top has crossed the
13
+ * "reading line" - a horizontal line `offset` pixels below the top of the
14
+ * viewport, matching the `scroll-padding-top` that anchor navigation uses.
15
+ * Once the page is scrolled to the bottom, the last heading wins (trailing
16
+ * sections can be shorter than the viewport and would never reach the
17
+ * line). The answer is recomputed from scratch on every scroll frame, so
18
+ * the highlight cannot drift out of sync with the page.
19
+ *
20
+ * The previous implementation asked IntersectionObserver for the "most
21
+ * visible" heading. Headings are only a few pixels tall, so every heading
22
+ * inside the observer band reported the same intersectionRatio and the
23
+ * winner was whichever entry happened to be in that callback's batch -
24
+ * headings leaving the band never triggered a re-evaluation at all. That
25
+ * is what made the bolding jump around and stick on the wrong entry.
26
+ *
11
27
  * Features:
12
- * - Intersection Observer-based (better performance than scroll events)
13
- * - Active link highlighting with smooth transitions
14
- * - Auto-scroll TOC to keep active link visible
15
- *
28
+ * - Deterministic, position-based active section (no ratio guessing)
29
+ * - rAF-throttled passive scroll listener; heading offsets are cached and
30
+ * re-measured on resize / content reflow
31
+ * - Holds the clicked link active while a smooth scroll animates, so
32
+ * intermediate headings don't flash
33
+ * - Keeps the active link visible inside the TOC without ever scrolling
34
+ * the page itself
35
+ *
16
36
  * Usage:
17
37
  * import { ScrollSpy } from './scroll-spy.js';
18
38
  * const scrollSpy = new ScrollSpy();
19
- *
39
+ *
20
40
  * ===================================================================
21
41
  */
22
42
 
23
43
  import { config } from './config.js';
24
44
 
45
+ const ACTIVE_CLASS = 'active';
46
+
47
+ /** How long (ms) to keep a clicked TOC link active while the page animates. */
48
+ const CLICK_GUARD_MS = 1200;
49
+
25
50
  /**
26
51
  * Get element safely with error handling
27
52
  * @param {string} selector - CSS selector
@@ -50,90 +75,243 @@ function getElements(selector) {
50
75
  }
51
76
  }
52
77
 
78
+ /**
79
+ * Current vertical scroll position, normalized across browsers.
80
+ * @returns {number}
81
+ */
82
+ function scrollTop() {
83
+ return window.scrollY !== undefined ? window.scrollY : window.pageYOffset;
84
+ }
85
+
53
86
  export class ScrollSpy {
54
87
  constructor() {
55
88
  this.tocLinks = getElements(config.selectors.tocLinks);
56
89
  this.headings = this._getHeadings();
57
90
  this.currentActive = null;
58
- this.observer = null;
59
-
91
+
92
+ // Cached measurements - invalidated (not recomputed) by resize and
93
+ // content reflow, then refreshed inside the next animation frame.
94
+ this._offsetPx = 0;
95
+ this._needsMeasure = true;
96
+ this._frame = null;
97
+
98
+ // Click guard: id of the heading a TOC click is scrolling towards.
99
+ this._pendingId = null;
100
+ this._pendingExpires = 0;
101
+
102
+ this._resizeObserver = null;
103
+
60
104
  if (this.headings.length === 0 || this.tocLinks.length === 0) {
61
105
  console.log('ScrollSpy: No TOC or headings found, skipping initialization');
62
106
  return;
63
107
  }
64
108
 
109
+ this._onScroll = () => this._requestUpdate();
110
+ this._onReflow = () => this._invalidate();
111
+ this._onNavigationScroll = event => this._handleNavigationScroll(event);
112
+ this._onUserScroll = () => this._cancelClickGuard();
113
+
65
114
  this._init();
66
115
  }
67
116
 
68
117
  /**
69
- * Get all headings that have corresponding TOC links
118
+ * Get all headings that have corresponding TOC links, in document order.
119
+ * Duplicate links to the same heading keep the first link.
70
120
  * @private
71
- * @returns {Array<{element: Element, link: Element, id: string}>}
121
+ * @returns {Array<{element: Element, link: Element, id: string, top: number}>}
72
122
  */
73
123
  _getHeadings() {
74
124
  const headings = [];
125
+ const seen = new Set();
126
+
75
127
  this.tocLinks.forEach(link => {
76
128
  const href = link.getAttribute('href');
77
- if (href && href.startsWith('#')) {
78
- const id = href.substring(1);
79
- const heading = document.getElementById(id);
80
- if (heading) {
81
- headings.push({
82
- element: heading,
83
- link: link,
84
- id: id
85
- });
86
- }
87
- }
129
+ if (!href || !href.startsWith('#') || href.length < 2) return;
130
+
131
+ const id = decodeURIComponent(href.substring(1));
132
+ if (seen.has(id)) return;
133
+
134
+ const heading = document.getElementById(id);
135
+ if (!heading) return;
136
+
137
+ seen.add(id);
138
+ headings.push({ element: heading, link: link, id: id, top: 0 });
88
139
  });
140
+
89
141
  return headings;
90
142
  }
91
143
 
92
144
  /**
93
- * Initialize Intersection Observer
145
+ * Attach listeners and paint the initial active link.
94
146
  * @private
95
147
  */
96
148
  _init() {
97
- const observerOptions = {
98
- root: null,
99
- rootMargin: config.scrollSpy.rootMargin,
100
- threshold: config.scrollSpy.threshold
101
- };
102
-
103
- this.observer = new IntersectionObserver(
104
- entries => this._handleIntersection(entries),
105
- observerOptions
149
+ window.addEventListener('scroll', this._onScroll, { passive: true });
150
+ window.addEventListener('resize', this._onReflow);
151
+ window.addEventListener('load', this._onReflow);
152
+ window.addEventListener('hashchange', this._onScroll);
153
+
154
+ // A TOC click starts an animated scroll; hold that link active until
155
+ // the page lands (see _handleNavigationScroll)...
156
+ document.addEventListener('navigation:scroll', this._onNavigationScroll);
157
+
158
+ // ...but a reader who grabs the wheel mid-animation has taken over —
159
+ // drop the hold and follow them immediately.
160
+ window.addEventListener('wheel', this._onUserScroll, { passive: true });
161
+ window.addEventListener('touchstart', this._onUserScroll, { passive: true });
162
+
163
+ // Images, embeds, mermaid diagrams and collapsibles change heading
164
+ // offsets after load - re-measure when the content box resizes.
165
+ if (typeof ResizeObserver !== 'undefined') {
166
+ const content = getElement(config.selectors.mainContent) || document.body;
167
+ this._resizeObserver = new ResizeObserver(this._onReflow);
168
+ this._resizeObserver.observe(content);
169
+ }
170
+
171
+ this._update();
172
+
173
+ console.log(`ScrollSpy: Tracking ${this.headings.length} headings`);
174
+ }
175
+
176
+ /**
177
+ * Mark cached offsets stale and schedule a recompute.
178
+ * @private
179
+ */
180
+ _invalidate() {
181
+ this._needsMeasure = true;
182
+ this._requestUpdate();
183
+ }
184
+
185
+ /**
186
+ * Coalesce updates into one per animation frame.
187
+ * @private
188
+ */
189
+ _requestUpdate() {
190
+ if (this._frame !== null) return;
191
+ this._frame = requestAnimationFrame(() => this._update());
192
+ }
193
+
194
+ /**
195
+ * Distance (px) from the top of the viewport to the reading line.
196
+ * Derived from the document's `scroll-padding-top` so the highlight lines
197
+ * up with where anchor navigation parks a heading; `config.scrollSpy.offset`
198
+ * pins it explicitly when a fork needs to.
199
+ * @private
200
+ * @returns {number}
201
+ */
202
+ _readingOffset() {
203
+ const configured = config.scrollSpy.offset;
204
+ if (typeof configured === 'number' && Number.isFinite(configured)) {
205
+ return configured;
206
+ }
207
+
208
+ const padding = parseFloat(
209
+ getComputedStyle(document.documentElement).scrollPaddingTop
106
210
  );
211
+ if (Number.isFinite(padding)) return padding;
212
+
213
+ return config.smoothScroll.offset;
214
+ }
215
+
216
+ /**
217
+ * Cache each heading's document offset and sort into document order.
218
+ * @private
219
+ */
220
+ _measure() {
221
+ const offset = scrollTop();
107
222
 
108
- // Observe all headings
109
223
  this.headings.forEach(heading => {
110
- this.observer.observe(heading.element);
224
+ heading.top = heading.element.getBoundingClientRect().top + offset;
111
225
  });
226
+ this.headings.sort((a, b) => a.top - b.top);
112
227
 
113
- console.log(`ScrollSpy: Observing ${this.headings.length} headings`);
228
+ this._offsetPx = this._readingOffset();
229
+ this._needsMeasure = false;
114
230
  }
115
231
 
116
232
  /**
117
- * Handle intersection events
233
+ * True when the page cannot scroll any further down.
118
234
  * @private
119
- * @param {IntersectionObserverEntry[]} entries
235
+ * @returns {boolean}
120
236
  */
121
- _handleIntersection(entries) {
122
- // Find the most visible heading
123
- let mostVisible = null;
124
- let maxRatio = 0;
237
+ _atBottom() {
238
+ const doc = document.documentElement;
239
+ const tolerance = config.scrollSpy.tolerance;
240
+ return window.innerHeight + scrollTop() >= doc.scrollHeight - tolerance;
241
+ }
125
242
 
126
- entries.forEach(entry => {
127
- if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
128
- maxRatio = entry.intersectionRatio;
129
- mostVisible = this.headings.find(h => h.element === entry.target);
130
- }
131
- });
243
+ /**
244
+ * Resolve which heading the reader is currently in.
245
+ * @private
246
+ * @returns {{element: Element, link: Element, id: string, top: number}|null}
247
+ */
248
+ _resolveActive() {
249
+ if (this.headings.length === 0) return null;
250
+
251
+ // Trailing sections shorter than the viewport can never reach the
252
+ // reading line, so the bottom of the page belongs to the last heading.
253
+ if (this._atBottom()) return this.headings[this.headings.length - 1];
254
+
255
+ const line = scrollTop() + this._offsetPx + config.scrollSpy.tolerance;
256
+
257
+ let active = null;
258
+ for (let i = 0; i < this.headings.length; i++) {
259
+ if (this.headings[i].top > line) break;
260
+ active = this.headings[i];
261
+ }
262
+
263
+ // Above the first heading, the first section is the one being read.
264
+ return active || this.headings[0];
265
+ }
266
+
267
+ /**
268
+ * Recompute and apply the active link. Runs at most once per frame.
269
+ * @private
270
+ */
271
+ _update() {
272
+ this._frame = null;
273
+
274
+ if (this._needsMeasure) this._measure();
275
+
276
+ const active = this._resolveActive();
277
+ if (!active) return;
132
278
 
133
- // If we found a visible heading, activate it
134
- if (mostVisible) {
135
- this._setActiveLink(mostVisible.link);
279
+ if (this._pendingId !== null) {
280
+ const arrived = active.id === this._pendingId;
281
+ const expired = performance.now() > this._pendingExpires;
282
+ if (!arrived && !expired) return;
283
+ this._pendingId = null;
136
284
  }
285
+
286
+ this._setActiveLink(active.link);
287
+ }
288
+
289
+ /**
290
+ * Light the clicked link immediately and hold it until the animated
291
+ * scroll lands on it, so headings passed on the way don't flash.
292
+ * @private
293
+ * @param {CustomEvent} event
294
+ */
295
+ _handleNavigationScroll(event) {
296
+ const targetId = event && event.detail ? event.detail.targetId : null;
297
+ if (!targetId) return;
298
+
299
+ const heading = this.headings.find(h => h.id === targetId);
300
+ if (!heading) return;
301
+
302
+ this._pendingId = targetId;
303
+ this._pendingExpires = performance.now() + CLICK_GUARD_MS;
304
+ this._setActiveLink(heading.link);
305
+ }
306
+
307
+ /**
308
+ * Release the click guard so the positional rule takes over again.
309
+ * @private
310
+ */
311
+ _cancelClickGuard() {
312
+ if (this._pendingId === null) return;
313
+ this._pendingId = null;
314
+ this._requestUpdate();
137
315
  }
138
316
 
139
317
  /**
@@ -142,44 +320,78 @@ export class ScrollSpy {
142
320
  * @param {Element} link
143
321
  */
144
322
  _setActiveLink(link) {
145
- if (this.currentActive === link) return;
323
+ if (!link) return;
324
+ // Re-apply if something else stripped the class, so the highlight
325
+ // heals itself rather than silently disappearing.
326
+ if (this.currentActive === link && link.classList.contains(ACTIVE_CLASS)) return;
146
327
 
147
328
  // Remove previous active state
148
- this.tocLinks.forEach(l => l.classList.remove('active'));
329
+ this.tocLinks.forEach(l => {
330
+ if (l === link) return;
331
+ l.classList.remove(ACTIVE_CLASS);
332
+ l.removeAttribute('aria-current');
333
+ });
149
334
 
150
335
  // Add active state
151
- if (link) {
152
- link.classList.add('active');
153
- this.currentActive = link;
154
-
155
- // Scroll TOC to show active link (if needed)
156
- this._scrollTocToActiveLink(link);
157
-
158
- // Dispatch custom event for other modules
159
- document.dispatchEvent(new CustomEvent('navigation:sectionChange', {
160
- detail: {
161
- link: link,
162
- href: link.getAttribute('href')
163
- }
164
- }));
336
+ link.classList.add(ACTIVE_CLASS);
337
+ link.setAttribute('aria-current', 'true');
338
+ this.currentActive = link;
339
+
340
+ // Scroll TOC to show active link (if needed)
341
+ this._scrollTocToActiveLink(link);
342
+
343
+ // Dispatch custom event for other modules
344
+ document.dispatchEvent(new CustomEvent('navigation:sectionChange', {
345
+ detail: {
346
+ link: link,
347
+ href: link.getAttribute('href')
348
+ }
349
+ }));
350
+ }
351
+
352
+ /**
353
+ * Nearest scrollable ancestor of the TOC link, if any.
354
+ * `.bd-toc` scrolls on desktop and `.offcanvas-body` on mobile, so the
355
+ * container is resolved at call time rather than assumed.
356
+ * @private
357
+ * @param {Element} link
358
+ * @returns {Element|null}
359
+ */
360
+ _getTocScrollContainer(link) {
361
+ const hinted = getElement(config.selectors.tocContainer);
362
+ const scrolls = el =>
363
+ el.scrollHeight > el.clientHeight + 1 &&
364
+ /(auto|scroll)/.test(getComputedStyle(el).overflowY);
365
+
366
+ if (hinted && hinted.contains(link) && scrolls(hinted)) return hinted;
367
+
368
+ let node = link.parentElement;
369
+ while (node && node !== document.body) {
370
+ if (scrolls(node)) return node;
371
+ node = node.parentElement;
165
372
  }
373
+ return null;
166
374
  }
167
375
 
168
376
  /**
169
- * Scroll TOC container to show active link
377
+ * Keep the active link visible inside the TOC's own scroll container.
378
+ * Adjusts `scrollTop` directly - `scrollIntoView()` would bubble up and
379
+ * scroll the page, which feeds straight back into the spy.
170
380
  * @private
171
381
  * @param {Element} link
172
382
  */
173
383
  _scrollTocToActiveLink(link) {
174
- const tocContainer = getElement(config.selectors.tocContainer);
175
- if (!tocContainer) return;
384
+ const container = this._getTocScrollContainer(link);
385
+ if (!container) return;
176
386
 
177
387
  const linkRect = link.getBoundingClientRect();
178
- const containerRect = tocContainer.getBoundingClientRect();
388
+ const containerRect = container.getBoundingClientRect();
389
+ const margin = 8;
179
390
 
180
- // Check if link is out of view
181
- if (linkRect.top < containerRect.top || linkRect.bottom > containerRect.bottom) {
182
- link.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
391
+ if (linkRect.top < containerRect.top + margin) {
392
+ container.scrollTop -= (containerRect.top + margin) - linkRect.top;
393
+ } else if (linkRect.bottom > containerRect.bottom - margin) {
394
+ container.scrollTop += linkRect.bottom - (containerRect.bottom - margin);
183
395
  }
184
396
  }
185
397
 
@@ -204,14 +416,37 @@ export class ScrollSpy {
204
416
  }
205
417
 
206
418
  /**
207
- * Cleanup observer
419
+ * Cleanup listeners and observers
208
420
  */
209
421
  destroy() {
210
- if (this.observer) {
211
- this.observer.disconnect();
212
- this.observer = null;
422
+ if (this._onScroll) {
423
+ window.removeEventListener('scroll', this._onScroll);
424
+ window.removeEventListener('hashchange', this._onScroll);
425
+ }
426
+ if (this._onReflow) {
427
+ window.removeEventListener('resize', this._onReflow);
428
+ window.removeEventListener('load', this._onReflow);
213
429
  }
214
- this.tocLinks.forEach(l => l.classList.remove('active'));
430
+ if (this._onNavigationScroll) {
431
+ document.removeEventListener('navigation:scroll', this._onNavigationScroll);
432
+ }
433
+ if (this._onUserScroll) {
434
+ window.removeEventListener('wheel', this._onUserScroll);
435
+ window.removeEventListener('touchstart', this._onUserScroll);
436
+ }
437
+ if (this._resizeObserver) {
438
+ this._resizeObserver.disconnect();
439
+ this._resizeObserver = null;
440
+ }
441
+ if (this._frame !== null) {
442
+ cancelAnimationFrame(this._frame);
443
+ this._frame = null;
444
+ }
445
+
446
+ this.tocLinks.forEach(l => {
447
+ l.classList.remove(ACTIVE_CLASS);
448
+ l.removeAttribute('aria-current');
449
+ });
215
450
  this.currentActive = null;
216
451
  console.log('ScrollSpy: Destroyed');
217
452
  }
@@ -123,9 +123,15 @@
123
123
  // the panel's other h6 section headings.
124
124
  wrapper.className = 'zer0-appearance-panel mb-4';
125
125
  wrapper.innerHTML =
126
- '<h6 id="zer0-appearance-heading" class="text-body-secondary small text-uppercase fw-semibold mb-2">' +
126
+ // h3, not h6: this panel mounts inside the Settings offcanvas,
127
+ // whose title is an <h2>, alongside the Liquid-rendered section
128
+ // labels that are also <h3 class="h6">. The tag carries DEPTH,
129
+ // the .h6 class carries SIZE (#436). Injected at runtime, so no
130
+ // source grep finds it -- only a rendered test does, which is
131
+ // how this one was caught.
132
+ '<h3 id="zer0-appearance-heading" class="h6 text-body-secondary small text-uppercase fw-semibold mb-2">' +
127
133
  '<i class="bi bi-eyedropper me-1" aria-hidden="true"></i>Primary Color' +
128
- '</h6>' +
134
+ '</h3>' +
129
135
  pickerHtml('visually-hidden') +
130
136
  '<button type="button" class="btn btn-link btn-sm p-0" data-appearance-reset>' +
131
137
  '<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Reset color' +
@@ -213,9 +213,14 @@
213
213
  if (node.nodeType !== Node.TEXT_NODE) return false;
214
214
  var parent = node.parentNode;
215
215
  while (parent && parent !== document.body) {
216
- var tag = parent.nodeName;
217
- if (tag === 'CODE' || tag === 'PRE' || tag === 'A' || tag === 'SCRIPT' || tag === 'STYLE') return false;
218
- if (parent.classList && (parent.classList.contains('mermaid') || parent.classList.contains('obsidian-embed'))) return false;
216
+ // Upper-case the name: HTML elements report "STYLE" but elements in
217
+ // the SVG namespace report "style", so an SVG <style> slipped through
218
+ // and had its `#id` selectors rewritten into tag links. Inline SVG
219
+ // (Mermaid diagrams, icons) is graphics, never prose — skip the whole
220
+ // subtree rather than special-casing its children.
221
+ var tag = String(parent.nodeName || '').toUpperCase();
222
+ if (tag === 'CODE' || tag === 'PRE' || tag === 'A' || tag === 'SCRIPT' || tag === 'STYLE' || tag === 'SVG') return false;
223
+ if (parent.classList && (parent.classList.contains('mermaid') || parent.classList.contains('zer0-diagram') || parent.classList.contains('obsidian-embed'))) return false;
219
224
  parent = parent.parentNode;
220
225
  }
221
226
  var text = node.nodeValue;