jekyll-theme-zer0 1.31.1 → 1.31.3

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.
@@ -10,7 +10,7 @@
10
10
  Params: none — reads the optional `favicon:` block in _config.yml:
11
11
 
12
12
  favicon:
13
- ico : /favicon.ico # legacy .ico (default)
13
+ ico : /favicon.ico # legacy .ico (always emitted when set)
14
14
  svg : /assets/images/favicon.svg # scalable icon (optional)
15
15
  png : /assets/images/favicon-32.png # PNG icon (optional)
16
16
  png_size : 32x32 # sizes attr for png (optional)
@@ -20,9 +20,13 @@
20
20
  theme_color_light : "#ffffff" # light-scheme chrome (optional)
21
21
  theme_color_dark : "#212529" # dark-scheme chrome (optional)
22
22
 
23
- With NO config at all this still emits the explicit
24
- /favicon.ico link (every consumer site carries one at root)
25
- AND both theme-color tags. That last part is the point of the
23
+ With NO `favicon.ico` configured, the default icon link is
24
+ emitted only for a root icon file that actually exists in
25
+ site.static_files: /favicon.ico, else /favicon.png, else
26
+ /favicon.svg — and nothing at all when none exists. A
27
+ remote_theme consumer does not get this repo's favicon.ico, so
28
+ an unconditional /favicon.ico link 404'd on every page of such
29
+ sites (issue #536). Both theme-color tags are always emitted. That last part is the point of the
26
30
  fallbacks below: a remote_theme consumer does not inherit this
27
31
  repo's _config.yml (see remote-theme-checklist.md), so a
28
32
  config-gated tag emitted nothing at all on exactly the sites
@@ -31,8 +35,40 @@
31
35
  run through relative_url so baseurl deployments resolve.
32
36
  {%- endcomment -%}
33
37
  {%- assign _fav = site.favicon -%}
34
- {%- assign _fav_ico = _fav.ico | default: '/favicon.ico' -%}
35
- <link rel="icon" href="{{ _fav_ico | relative_url }}" sizes="32x32">
38
+ {%- comment -%}
39
+ Default icon (issue #536). A configured `favicon.ico` is trusted as-is.
40
+ Otherwise probe site.static_files (each entry's `path` is source-relative,
41
+ e.g. "/favicon.ico") for a root icon, in order .ico -> .png -> .svg, and
42
+ emit a link only for one that exists. The .png/.svg fallbacks are skipped
43
+ when the same kind is already configured via favicon.png / favicon.svg, so
44
+ the page never carries two links of one type.
45
+ {%- endcomment -%}
46
+ {%- assign _fav_default = '' -%}
47
+ {%- assign _fav_default_type = '' -%}
48
+ {%- if _fav.ico -%}
49
+ {%- assign _fav_default = _fav.ico -%}
50
+ {%- else -%}
51
+ {%- assign _fav_hit = site.static_files | where: "path", "/favicon.ico" | first -%}
52
+ {%- if _fav_hit -%}
53
+ {%- assign _fav_default = '/favicon.ico' -%}
54
+ {%- elsif _fav.png == nil -%}
55
+ {%- assign _fav_hit = site.static_files | where: "path", "/favicon.png" | first -%}
56
+ {%- if _fav_hit -%}
57
+ {%- assign _fav_default = '/favicon.png' -%}
58
+ {%- assign _fav_default_type = 'image/png' -%}
59
+ {%- endif -%}
60
+ {%- endif -%}
61
+ {%- if _fav_default == '' and _fav.svg == nil -%}
62
+ {%- assign _fav_hit = site.static_files | where: "path", "/favicon.svg" | first -%}
63
+ {%- if _fav_hit -%}
64
+ {%- assign _fav_default = '/favicon.svg' -%}
65
+ {%- assign _fav_default_type = 'image/svg+xml' -%}
66
+ {%- endif -%}
67
+ {%- endif -%}
68
+ {%- endif -%}
69
+ {%- if _fav_default != '' %}
70
+ <link rel="icon"{% if _fav_default_type != '' %} type="{{ _fav_default_type }}"{% endif %} href="{{ _fav_default | relative_url }}"{% if _fav_default_type == '' %} sizes="32x32"{% endif %}>
71
+ {%- endif %}
36
72
  {%- if _fav.svg %}
37
73
  <link rel="icon" type="image/svg+xml" href="{{ _fav.svg | relative_url }}">
38
74
  {%- endif %}
@@ -40,9 +40,8 @@
40
40
  {% assign footer_page_urls = site.html_pages | map: "url" | join: "|" | prepend: "|" | append: "|" %}
41
41
  {% comment %} Powered by Row {% endcomment %}
42
42
  <div class="container-xl footer-powered-by">
43
- <p class="footer-powered-by-copyright text-center mb-0">
44
- &copy; {{ site.time | date: "%Y" }} {{ site.name | default: site.title }}
45
- </p>
43
+ {%- comment -%} The copyright line lives once, in the bottom bar below.
44
+ A second "© year name" line here printed the notice twice (backlog T-054). {%- endcomment -%}
46
45
  <div class="footer-powered-by-row">
47
46
  <span class="footer-powered-by-label" id="footer-powered-by-label">
48
47
  {{ ui.powered_by | default: "Powered by" }}:
@@ -43,8 +43,17 @@
43
43
  {% comment %} ================================ {% endcomment %}
44
44
  {% comment %} UI strings resolve per page language (core/i18n.html sets `ui`) {% endcomment %}
45
45
  {%- include core/i18n.html -%}
46
- {% comment %} Accessibility: allows keyboard users to skip navigation {% endcomment %}
47
- <a href="#main-content" class="visually-hidden-focusable position-absolute top-0 start-0 z-3 m-3 btn btn-primary">
46
+ {% comment %} Accessibility: allows keyboard users to skip navigation.
47
+ Target: `#main-content` (the single <main> in root.html) unless the page or
48
+ a layout in its chain sets `skip_link_target` in front matter. Layouts with
49
+ their own navigation inside <main> point it past that chrome, e.g.
50
+ default.html (docs sidebar + TOC) → `page-content`. Jekyll merges layout
51
+ front matter up the chain with the innermost layout winning, so a child
52
+ layout (a site's quest layout, say) can override its parent's target.
53
+ `.zer0-skip-to-content` lifts it above the fixed-top navbar when focused
54
+ (_sass/utilities/_focus.scss); `z-3` left it underneath, invisible. {% endcomment %}
55
+ {%- assign skip_link_target = page.skip_link_target | default: layout.skip_link_target | default: "main-content" -%}
56
+ <a href="#{{ skip_link_target }}" class="zer0-skip-to-content visually-hidden-focusable position-absolute top-0 start-0 m-3 btn btn-primary">
48
57
  {{ ui.skip_main_content | default: "Skip to main content" }}
49
58
  </a>
50
59
 
@@ -178,7 +178,7 @@
178
178
  <div class="col-md-6">
179
179
  <label for="sb-model" class="form-label">Model</label>
180
180
  <select class="form-select form-select-sm" id="sb-model" disabled>
181
- <option value="{{ sb_cfg.model | default: site.ai_chat.model | default: 'claude-opus-4-8' }}">{{ sb_cfg.model | default: site.ai_chat.model | default: 'claude-opus-4-8' }}</option>
181
+ <option value="{{ sb_cfg.model | default: site.ai_chat.model | default: 'claude-opus-5-5' }}">{{ sb_cfg.model | default: site.ai_chat.model | default: 'claude-opus-5-5' }}</option>
182
182
  </select>
183
183
  <div class="form-text" id="sb-model-note">Sent with every request; the proxy can pin one with <code>CHAT_MODEL</code> / <code>XAI_CHAT_MODEL</code>.</div>
184
184
  </div>
@@ -1021,7 +1021,7 @@
1021
1021
  "endpoint": {{ sb_cfg.endpoint | default: "/api/wizard" | jsonify }},
1022
1022
  "chatEndpoint": {{ sb_cfg.chat_endpoint | default: site.ai_chat.endpoint | default: "/api/chat" | jsonify }},
1023
1023
  "provider": {{ sb_cfg.provider | default: "auto" | jsonify }},
1024
- "model": {{ sb_cfg.model | default: site.ai_chat.model | default: "claude-opus-4-8" | jsonify }},
1024
+ "model": {{ sb_cfg.model | default: site.ai_chat.model | default: "claude-opus-5-5" | jsonify }},
1025
1025
  "mode": {{ sb_cfg.mode | default: "guided" | jsonify }},
1026
1026
  "imageProvider": {{ sb_cfg.image_provider | default: "" | jsonify }},
1027
1027
  "maxTokens": {{ sb_cfg.max_tokens | default: 8192 }},
@@ -1,6 +1,9 @@
1
1
  ---
2
2
  # Features: ZER0-001, ZER0-008, ZER0-019
3
3
  layout: root
4
+ # The skip link (core/header.html) jumps past the docs sidebar, intro and
5
+ # TOC straight to the page body below.
6
+ skip_link_target: page-content
4
7
  ---
5
8
  {% comment %}
6
9
  ===================================================================
@@ -122,7 +125,11 @@ layout: root
122
125
  {% comment %} MAIN CONTENT BODY {% endcomment %}
123
126
  {% comment %} =============================== {% endcomment %}
124
127
  {% comment %} Where the actual page content is rendered {% endcomment %}
125
- <div class="bd-content ps-lg-2">
128
+ {% comment %} Skip-link target for this layout (see front matter). tabindex="-1"
129
+ lets the link move keyboard focus here, not just scroll (same reason
130
+ as #main-content in root.html); its focus ring is suppressed in
131
+ _sass/utilities/_focus.scss. {% endcomment %}
132
+ <div id="page-content" class="bd-content ps-lg-2" tabindex="-1">
126
133
  {{ content }}
127
134
 
128
135
  {%- comment -%}
@@ -35,4 +35,13 @@
35
35
  background-color: var(--zer0-color-link-hover, var(--bs-primary));
36
36
  transform: translateY(-2px);
37
37
  }
38
+
39
+ // `outline: 0` above removes the UA ring, and the hover-style change alone
40
+ // was the only focus cue (opacity, a darker fill, a 2px lift), which is not
41
+ // a visible indicator (WCAG 2.4.7). Same 2px primary ring as the site-wide
42
+ // :focus-visible rule in utilities/_focus.scss; mouse clicks stay ring-free.
43
+ &:focus-visible {
44
+ outline: 2px solid var(--zer0-color-primary, var(--bs-primary));
45
+ outline-offset: 2px;
46
+ }
38
47
  }
@@ -86,3 +86,34 @@
86
86
  }
87
87
  }
88
88
  }
89
+
90
+ // ----------------------------------------------------------------------------
91
+ // Reserve the banner's space while it is up
92
+ // ----------------------------------------------------------------------------
93
+ // components/cookie-consent.html sets `zer0-cookie-banner-open` on <html> and
94
+ // `--zer0-cookie-banner-inset` to the banner's height (kept current by a
95
+ // ResizeObserver) while the banner is shown, and removes both once a choice
96
+ // hides it. Without this the fixed bar covered the end of every page, the
97
+ // floating buttons (Back to top sat fully under it at 1366px), and whatever
98
+ // keyboard focus scrolled to near the bottom edge (WCAG 2.4.11).
99
+ html.zer0-cookie-banner-open {
100
+ // Focus and anchor scrolling stop above the banner, not under it.
101
+ scroll-padding-bottom: var(--zer0-cookie-banner-inset, 0px);
102
+
103
+ body {
104
+ // The last content on the page can scroll clear of the banner.
105
+ padding-bottom: var(--zer0-cookie-banner-inset, 0px);
106
+ }
107
+
108
+ // Lift the floating buttons (both FAB stacks) above the banner. `translate`
109
+ // composes with the `transform` hover/active lifts these buttons already
110
+ // use, so it does not fight them.
111
+ #backToTopBtn,
112
+ #aiChatToggle,
113
+ #obsidianLocalGraphFab,
114
+ .pf-fab,
115
+ .bd-toc-fab,
116
+ .bd-sidebar-fab {
117
+ translate: 0 calc(-1 * var(--zer0-cookie-banner-inset, 0px));
118
+ }
119
+ }
@@ -29,10 +29,21 @@ summary:focus-visible {
29
29
  // Safe here specifically because the element is tabindex="-1": it is never
30
30
  // reachable by Tab, so no keyboard user can land on it and be left without an
31
31
  // indicator. Do not generalise this selector to focusable elements.
32
- #main-content:focus {
32
+ #main-content:focus,
33
+ #page-content:focus {
33
34
  outline: none;
34
35
  }
35
36
 
37
+ // The header's "Skip to main content" link (core/header.html). Bootstrap's
38
+ // visually-hidden-focusable reveals it on focus, but it sat at `z-3` under the
39
+ // fixed-top navbar (z-index 1030), so a focused skip link was invisible. When
40
+ // focused it is pinned to the viewport above the navbar and the cookie banner.
41
+ .zer0-skip-to-content:focus,
42
+ .zer0-skip-to-content:focus-within {
43
+ position: fixed !important;
44
+ z-index: var(--zer0-layer-skip-link, 1100) !important;
45
+ }
46
+
36
47
  // Skip-to-content link — visually hidden until focused
37
48
  .zer0-skip-link {
38
49
  position: absolute;
@@ -36,6 +36,50 @@ import { TocVisibility } from './toc-visibility.js';
36
36
  import { SidebarVisibility } from './sidebar-visibility.js';
37
37
  import { Navbar } from './navbar.js';
38
38
 
39
+ /** Marker in an init step list: give the main thread back before continuing. */
40
+ const YIELD = Symbol('yield');
41
+
42
+ /**
43
+ * Yield to the main thread so pending input and rendering can run.
44
+ * Uses scheduler.yield() where available (keeps our priority), otherwise a
45
+ * macrotask via MessageChannel (no 4ms setTimeout clamp).
46
+ * @returns {Promise<void>}
47
+ */
48
+ function yieldToMain() {
49
+ if (typeof window !== 'undefined' && window.scheduler && typeof window.scheduler.yield === 'function') {
50
+ return window.scheduler.yield();
51
+ }
52
+ return new Promise(resolve => {
53
+ if (typeof MessageChannel === 'function') {
54
+ const channel = new MessageChannel();
55
+ channel.port1.onmessage = () => resolve();
56
+ channel.port2.postMessage(null);
57
+ } else {
58
+ setTimeout(resolve, 0);
59
+ }
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Run init steps in order, yielding at each YIELD marker. A step that throws
65
+ * is logged and does not stop the steps after it.
66
+ * @param {Array<Function|symbol>} steps
67
+ * @returns {Promise<void>}
68
+ */
69
+ async function runSteps(steps) {
70
+ for (const step of steps) {
71
+ if (step === YIELD) {
72
+ await yieldToMain();
73
+ continue;
74
+ }
75
+ try {
76
+ step();
77
+ } catch (error) {
78
+ console.error('Navigation: Initialization error', error);
79
+ }
80
+ }
81
+ }
82
+
39
83
  /**
40
84
  * Navigation Controller - Orchestrates all navigation modules
41
85
  */
@@ -54,59 +98,69 @@ export class Navigation {
54
98
  console.warn('Navigation: Already initialized');
55
99
  return this;
56
100
  }
101
+ this._initialized = true;
57
102
 
58
- try {
103
+ // Start-up is split across tasks so it never becomes one long task.
104
+ // Phase 1 (this task) wires the controls a user can reach right away:
105
+ // sidebar state, keyboard shortcuts, focus management and the navbar.
106
+ // None of them read layout. Each later step reads styles or layout
107
+ // (breakpoints, TOC/sidebar visibility, scroll spy), so each one runs
108
+ // in its own task after yielding to the main thread, letting the
109
+ // browser paint and handle input in between. `navigation:ready` is
110
+ // still dispatched once every module exists.
111
+ const steps = [
112
+ () => { this.modules.state = new SidebarState(); },
113
+ () => { this.modules.keyboard = new KeyboardShortcuts(); },
114
+ () => { this.modules.focus = new FocusManager(); },
115
+ () => {
116
+ // Initialize navbar (hover dropdowns, mobile menu, tooltips,
117
+ // focus trap) — formerly the standalone assets/js/navigation.js IIFE.
118
+ this.modules.navbar = new Navbar(config);
119
+ this.modules.navbar.init();
120
+ },
121
+ YIELD,
59
122
  // Sync breakpoints from --zer0-bp-* CSS custom properties so a fork
60
123
  // overriding tokens in SCSS automatically propagates to JS checks.
61
- syncBreakpointsFromCss();
62
-
63
- // Initialize sidebar state first (other modules may depend on it)
64
- this.modules.state = new SidebarState();
65
-
66
- const leftSidebar = document.querySelector(config.selectors.leftSidebar);
67
- const docsLayout = document.querySelector(config.selectors.docsLayout);
68
- if (leftSidebar && docsLayout && !docsLayout.classList.contains('bd-layout--no-sidebar')) {
69
- this.modules.sidebarVisibility = new SidebarVisibility();
70
- }
71
-
72
- // Initialize TOC-related modules only if TOC exists
73
- const toc = document.querySelector(config.selectors.toc);
74
- if (toc) {
124
+ // getComputedStyle forces a full style recalc, so it runs after the
125
+ // first yield, before the modules that use breakpoints.
126
+ () => { syncBreakpointsFromCss(); },
127
+ () => { this.modules.gestures = new SwipeGestures(); },
128
+ YIELD,
129
+ () => {
130
+ const leftSidebar = document.querySelector(config.selectors.leftSidebar);
131
+ const docsLayout = document.querySelector(config.selectors.docsLayout);
132
+ if (leftSidebar && docsLayout && !docsLayout.classList.contains('bd-layout--no-sidebar')) {
133
+ this.modules.sidebarVisibility = new SidebarVisibility();
134
+ }
135
+ },
136
+ YIELD,
137
+ () => {
138
+ // Initialize TOC-related modules only if TOC exists
139
+ if (!document.querySelector(config.selectors.toc)) {
140
+ console.log('Navigation: No TOC found, skipping scroll spy and smooth scroll');
141
+ return;
142
+ }
75
143
  this.modules.tocVisibility = new TocVisibility();
76
- this.modules.scrollSpy = new ScrollSpy();
77
144
  this.modules.smoothScroll = new SmoothScroll();
78
- } else {
79
- console.log('Navigation: No TOC found, skipping scroll spy and smooth scroll');
145
+ },
146
+ YIELD,
147
+ () => {
148
+ if (!this.modules.tocVisibility) return;
149
+ // ScrollSpy defers its own first measurement to idle time.
150
+ this.modules.scrollSpy = new ScrollSpy();
80
151
  }
81
-
82
- // Initialize keyboard shortcuts
83
- this.modules.keyboard = new KeyboardShortcuts();
84
-
85
- // Initialize gesture support
86
- this.modules.gestures = new SwipeGestures();
87
-
88
- // Initialize focus management
89
- this.modules.focus = new FocusManager();
90
-
91
- // Initialize navbar (hover dropdowns, mobile menu, tooltips,
92
- // focus trap) — formerly the standalone assets/js/navigation.js IIFE.
93
- this.modules.navbar = new Navbar(config);
94
- this.modules.navbar.init();
95
-
96
- this._initialized = true;
97
-
152
+ ];
153
+
154
+ runSteps(steps).then(() => {
98
155
  // Dispatch ready event
99
156
  document.dispatchEvent(new CustomEvent('navigation:ready', {
100
- detail: {
157
+ detail: {
101
158
  modules: Object.keys(this.modules)
102
159
  }
103
160
  }));
104
161
 
105
162
  console.log('Navigation: Successfully initialized modules:', Object.keys(this.modules));
106
-
107
- } catch (error) {
108
- console.error('Navigation: Initialization error', error);
109
- }
163
+ });
110
164
 
111
165
  return this;
112
166
  }
@@ -28,6 +28,8 @@
28
28
  * - Deterministic, position-based active section (no ratio guessing)
29
29
  * - rAF-throttled passive scroll listener; heading offsets are cached and
30
30
  * re-measured on resize / content reflow
31
+ * - First measurement deferred to idle time so start-up doesn't force a
32
+ * synchronous layout of the whole page
31
33
  * - Holds the clicked link active while a smooth scroll animates, so
32
34
  * intermediate headings don't flash
33
35
  * - Keeps the active link visible inside the TOC without ever scrolling
@@ -83,6 +85,24 @@ function scrollTop() {
83
85
  return window.scrollY !== undefined ? window.scrollY : window.pageYOffset;
84
86
  }
85
87
 
88
+ /** Latest moment (ms) the first measurement may be put off while the page is busy. */
89
+ const IDLE_TIMEOUT_MS = 1500;
90
+
91
+ /**
92
+ * Run `fn` when the main thread is idle (requestIdleCallback with a
93
+ * timeout), or after a short delay where that API is missing (Safari).
94
+ * @param {Function} fn
95
+ * @returns {{cancel: Function}}
96
+ */
97
+ function scheduleIdle(fn) {
98
+ if (typeof window.requestIdleCallback === 'function') {
99
+ const id = window.requestIdleCallback(fn, { timeout: IDLE_TIMEOUT_MS });
100
+ return { cancel: () => window.cancelIdleCallback(id) };
101
+ }
102
+ const id = window.setTimeout(fn, 200);
103
+ return { cancel: () => window.clearTimeout(id) };
104
+ }
105
+
86
106
  export class ScrollSpy {
87
107
  constructor() {
88
108
  this.tocLinks = getElements(config.selectors.tocLinks);
@@ -100,6 +120,7 @@ export class ScrollSpy {
100
120
  this._pendingExpires = 0;
101
121
 
102
122
  this._resizeObserver = null;
123
+ this._idleHandle = null;
103
124
 
104
125
  if (this.headings.length === 0 || this.tocLinks.length === 0) {
105
126
  console.log('ScrollSpy: No TOC or headings found, skipping initialization');
@@ -160,6 +181,27 @@ export class ScrollSpy {
160
181
  window.addEventListener('wheel', this._onUserScroll, { passive: true });
161
182
  window.addEventListener('touchstart', this._onUserScroll, { passive: true });
162
183
 
184
+ // The first measurement reads every heading's position, which forces
185
+ // a full synchronous layout if it runs while the page is still
186
+ // loading. On a long quest page that was one of the biggest long
187
+ // tasks at start-up. The TOC highlight isn't needed for first paint,
188
+ // so measure once the browser is idle (or after IDLE_TIMEOUT_MS at
189
+ // the latest), when layout is already done and the reads are cheap.
190
+ // A scroll before then still updates through the rAF path above.
191
+ this._idleHandle = scheduleIdle(() => {
192
+ this._idleHandle = null;
193
+ this._startMeasuring();
194
+ });
195
+
196
+ console.log(`ScrollSpy: Tracking ${this.headings.length} headings`);
197
+ }
198
+
199
+ /**
200
+ * Observe content reflow and paint the initial active link.
201
+ * Runs from idle time (see _init).
202
+ * @private
203
+ */
204
+ _startMeasuring() {
163
205
  // Images, embeds, mermaid diagrams and collapsibles change heading
164
206
  // offsets after load - re-measure when the content box resizes.
165
207
  if (typeof ResizeObserver !== 'undefined') {
@@ -169,8 +211,6 @@ export class ScrollSpy {
169
211
  }
170
212
 
171
213
  this._update();
172
-
173
- console.log(`ScrollSpy: Tracking ${this.headings.length} headings`);
174
214
  }
175
215
 
176
216
  /**
@@ -325,6 +365,11 @@ export class ScrollSpy {
325
365
  // heals itself rather than silently disappearing.
326
366
  if (this.currentActive === link && link.classList.contains(ACTIVE_CLASS)) return;
327
367
 
368
+ // Read layout for the TOC scroll adjustment BEFORE touching classes:
369
+ // reading after the class change below would force a synchronous
370
+ // style + layout pass on every highlight change.
371
+ const tocScroll = this._measureTocScroll(link);
372
+
328
373
  // Remove previous active state
329
374
  this.tocLinks.forEach(l => {
330
375
  if (l === link) return;
@@ -338,7 +383,7 @@ export class ScrollSpy {
338
383
  this.currentActive = link;
339
384
 
340
385
  // Scroll TOC to show active link (if needed)
341
- this._scrollTocToActiveLink(link);
386
+ if (tocScroll) tocScroll.container.scrollTop += tocScroll.delta;
342
387
 
343
388
  // Dispatch custom event for other modules
344
389
  document.dispatchEvent(new CustomEvent('navigation:sectionChange', {
@@ -381,18 +426,32 @@ export class ScrollSpy {
381
426
  * @param {Element} link
382
427
  */
383
428
  _scrollTocToActiveLink(link) {
429
+ const tocScroll = this._measureTocScroll(link);
430
+ if (tocScroll) tocScroll.container.scrollTop += tocScroll.delta;
431
+ }
432
+
433
+ /**
434
+ * How far the TOC's scroll container must move to show `link`.
435
+ * Read-only, so callers can measure before they write.
436
+ * @private
437
+ * @param {Element} link
438
+ * @returns {{container: Element, delta: number}|null}
439
+ */
440
+ _measureTocScroll(link) {
384
441
  const container = this._getTocScrollContainer(link);
385
- if (!container) return;
442
+ if (!container) return null;
386
443
 
387
444
  const linkRect = link.getBoundingClientRect();
388
445
  const containerRect = container.getBoundingClientRect();
389
446
  const margin = 8;
390
447
 
391
448
  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);
449
+ return { container, delta: -((containerRect.top + margin) - linkRect.top) };
395
450
  }
451
+ if (linkRect.bottom > containerRect.bottom - margin) {
452
+ return { container, delta: linkRect.bottom - (containerRect.bottom - margin) };
453
+ }
454
+ return null;
396
455
  }
397
456
 
398
457
  /**
@@ -434,6 +493,10 @@ export class ScrollSpy {
434
493
  window.removeEventListener('wheel', this._onUserScroll);
435
494
  window.removeEventListener('touchstart', this._onUserScroll);
436
495
  }
496
+ if (this._idleHandle) {
497
+ this._idleHandle.cancel();
498
+ this._idleHandle = null;
499
+ }
437
500
  if (this._resizeObserver) {
438
501
  this._resizeObserver.disconnect();
439
502
  this._resizeObserver = null;
@@ -35,7 +35,7 @@ cfg = begin
35
35
  rescue StandardError
36
36
  {}
37
37
  end
38
- MODEL = ENV['AI_MODEL'] || cfg['fallback_model'] || cfg['model'] || 'claude-opus-4-8'
38
+ MODEL = ENV['AI_MODEL'] || cfg['fallback_model'] || cfg['model'] || 'claude-opus-5-5'
39
39
  MAXTOK = (ENV['AI_MAX_TOKENS'] || cfg['max_tokens'] || 8000).to_i
40
40
  VERSION = cfg['api_version'] || '2023-06-01'
41
41
  BASE = cfg['api_base'] || 'https://api.anthropic.com'
@@ -82,6 +82,12 @@ def test_detect_slug() -> None:
82
82
  check("reads OUT = 'test/visual/evidence/<slug>' from a hand-rolled one",
83
83
  vea.detect_slug("const OUT = 'test/visual/evidence/navbar-fit';") == "navbar-fit")
84
84
  check("returns None when nothing names a folder", vea.detect_slug("console.log('hi')") is None)
85
+ hand_rolled = ("const OUT = path.join(process.cwd(), 'test/visual/evidence/dangling-aria-controls');\n"
86
+ "const CASES = [{ slug: 'navbar-toggle-fr-about' }];\n")
87
+ check("the folder a generator writes to beats a per-case `slug:` key",
88
+ vea.detect_slug(hand_rolled) == "dangling-aria-controls")
89
+ check("an explicit `// slug: '…'` comment wins",
90
+ vea.detect_slug("// slug: 'breadcrumbs'\nconst OUT = 'test/visual/evidence/other';") == "breadcrumbs")
85
91
 
86
92
 
87
93
  def test_plan_pr_454_shape() -> None:
@@ -141,6 +147,51 @@ def test_plan_respects_author_evidence() -> None:
141
147
  check("autogen evidence from THIS head is left alone", plan["jobs"] == [])
142
148
 
143
149
 
150
+ def test_plan_edited_existing_generator() -> None:
151
+ print("plan — PR edits an existing generator whose folder already has proof")
152
+ folder = "test/visual/evidence/navbar-tiers-405"
153
+ files = {GEN_405: GEN_405_SRC, f"{folder}/01-before-after.png": "", f"{folder}/metrics.json": "{}"}
154
+ plan = plan_for(["_includes/core/header.html", GEN_405], files)
155
+ check("needed", plan["needed"] is True)
156
+ check("one bespoke re-render of that generator's folder",
157
+ len(plan["jobs"]) == 1 and plan["jobs"][0]["kind"] == "bespoke"
158
+ and plan["jobs"][0]["generator"] == GEN_405 and plan["jobs"][0]["slug"] == "navbar-tiers-405")
159
+ check("…explained as stale evidence", "predates" in (plan["jobs"][0]["why"] if plan["jobs"] else ""))
160
+ plan = plan_for([GEN_405], files)
161
+ check("a generator-only edit (no UI paths) still schedules it", len(plan["jobs"]) == 1)
162
+ plan = plan_for([GEN_405, f"{folder}/01-before-after.png", f"{folder}/metrics.json"], files)
163
+ check("…but not when the PR re-rendered that folder itself", plan["jobs"] == [])
164
+ plan = plan_for(["_layouts/home.html", "test/visual/nameless-evidence.mjs"],
165
+ {"test/visual/nameless-evidence.mjs": "console.log('hi')"})
166
+ check("an edited generator with no detectable slug falls back to the generic job",
167
+ len(plan["jobs"]) == 1 and plan["jobs"][0]["kind"] == "generic")
168
+
169
+
170
+ def test_ensure_jekyll_disables_livereload() -> None:
171
+ print("render — ensure_jekyll starts the stack with LiveReload off")
172
+ import contextlib
173
+ import io
174
+ import os
175
+ import tempfile
176
+ calls = []
177
+ real_run, real_http_ok, cwd = vea.run, vea.http_ok, os.getcwd()
178
+ with tempfile.TemporaryDirectory() as tmp:
179
+ try:
180
+ os.chdir(tmp)
181
+ vea.http_ok = lambda url, timeout=3.0: bool(calls)
182
+ vea.run = lambda cmd, **kw: calls.append((list(cmd), kw.get("env")))
183
+ with contextlib.redirect_stdout(io.StringIO()):
184
+ started = vea.ensure_jekyll(5)
185
+ finally:
186
+ vea.run, vea.http_ok = real_run, real_http_ok
187
+ os.chdir(cwd)
188
+ up = [c for c in calls if c[0][:3] == ["docker", "compose", "up"]]
189
+ check("we started it", started is True and len(up) == 1)
190
+ check("with JEKYLL_LIVERELOAD=0 in its environment", bool(up) and (up[0][1] or {}).get("JEKYLL_LIVERELOAD") == "0")
191
+ wf = (REPO_ROOT / ".github/workflows/visual-evidence-autogen.yml").read_text(encoding="utf-8")
192
+ check("the workflow also exports JEKYLL_LIVERELOAD: \"0\"", 'JEKYLL_LIVERELOAD: "0"' in wf)
193
+
194
+
144
195
  def test_plan_out_of_scope() -> None:
145
196
  print("plan — nothing visual")
146
197
  plan = plan_for(["docs/systems/x.md", "pages/_posts/2026-09-05-x.md", "_data/backlog.yml"], {})
@@ -387,7 +438,8 @@ def test_workflow_wiring() -> None:
387
438
 
388
439
  def main() -> int:
389
440
  for t in (test_detect_slug, test_plan_pr_454_shape, test_plan_loop_guard_and_budget, test_plan_generic_fallback,
390
- test_plan_respects_author_evidence, test_plan_out_of_scope, test_styling_matches_ci_filter,
441
+ test_plan_respects_author_evidence, test_plan_edited_existing_generator,
442
+ test_ensure_jekyll_disables_livereload, test_plan_out_of_scope, test_styling_matches_ci_filter,
391
443
  test_ui_prefixes_match_gate, test_parse_results, test_parse_results_playwright_158_attachment_names,
392
444
  test_decide, test_stage_and_messages,
393
445
  test_stage_command_output, test_generic_generator_survives_livereload,