jekyll-theme-zer0 1.31.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/_data/ai.yml +1 -1
- data/_data/backlog.yml +54 -2
- data/_data/features.yml +9 -2
- data/_data/site_builder.yml +1 -1
- data/_data/theme-manifest.yml +19 -19
- data/_includes/analytics/google-analytics.html +46 -9
- data/_includes/analytics/posthog.html +101 -77
- data/_includes/components/ai-chat.html +1 -1
- data/_includes/components/cookie-consent.html +29 -0
- data/_includes/components/page-feedback.html +1 -1
- data/_includes/content/intro.html +22 -2
- data/_includes/content/sitemap.html +173 -17
- data/_includes/core/favicon.html +42 -6
- data/_includes/core/footer.html +2 -3
- data/_includes/core/header.html +11 -2
- data/_includes/setup/wizard.html +2 -2
- data/_layouts/default.html +8 -1
- data/_sass/components/_back-to-top.scss +9 -0
- data/_sass/components/_cookie-banner.scss +31 -0
- data/_sass/utilities/_focus.scss +12 -1
- data/assets/js/modules/navigation/index.js +94 -40
- data/assets/js/modules/navigation/scroll-spy.js +70 -7
- data/scripts/ai/api_call.rb +1 -1
- data/scripts/ci/test_visual_evidence_autogen.py +53 -1
- data/scripts/ci/visual_evidence_autogen.py +26 -7
- data/scripts/install/ai/client.sh +1 -1
- data/scripts/lib/preview_generator.py +1 -1
- data/scripts/translate.rb +1 -1
- metadata +2 -2
data/_includes/core/favicon.html
CHANGED
|
@@ -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 (
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
{%-
|
|
35
|
-
|
|
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 %}
|
data/_includes/core/footer.html
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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" }}:
|
data/_includes/core/header.html
CHANGED
|
@@ -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
|
|
47
|
-
|
|
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
|
|
data/_includes/setup/wizard.html
CHANGED
|
@@ -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-
|
|
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-
|
|
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 }},
|
data/_layouts/default.html
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
data/_sass/utilities/_focus.scss
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
this.modules.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
}
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
data/scripts/ai/api_call.rb
CHANGED
|
@@ -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-
|
|
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,
|
|
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,
|