jekyll-theme-zer0 1.30.0 → 1.31.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +171 -0
  3. data/_data/backlog.yml +30 -7
  4. data/_data/consumers.yml +99 -0
  5. data/_data/features.yml +1 -1
  6. data/_data/theme-manifest.yml +35 -33
  7. data/_data/ui-text.yml +1 -0
  8. data/_includes/README.md +1 -1
  9. data/_includes/analytics/posthog.html +28 -9
  10. data/_includes/components/README.md +1 -1
  11. data/_includes/components/background-image.html +7 -1
  12. data/_includes/components/cookie-consent.html +30 -30
  13. data/_includes/components/mermaid.html +14 -7
  14. data/_includes/components/preview-image.html +22 -3
  15. data/_includes/components/theme-preview-gallery.html +1 -1
  16. data/_includes/content/intro.html +7 -0
  17. data/_includes/content/seo.html +8 -0
  18. data/_includes/content/sitemap.html +220 -263
  19. data/_includes/core/branding.html +8 -15
  20. data/_includes/core/head.html +29 -5
  21. data/_includes/core/header.html +14 -2
  22. data/_includes/core/tokens-inline.html +16 -1
  23. data/_includes/navigation/navbar.html +9 -6
  24. data/_includes/setup/wizard.html +5 -1
  25. data/_layouts/article.html +15 -3
  26. data/_layouts/home.html +13 -1
  27. data/_layouts/note.html +2 -1
  28. data/_layouts/notebook.html +2 -1
  29. data/_layouts/root.html +22 -0
  30. data/_sass/components/_cookie-banner.scss +48 -0
  31. data/_sass/components/_setup-wizard.scss +19 -1
  32. data/_sass/components/_ui-enhancements.scss +11 -0
  33. data/_sass/core/_navbar.scss +137 -70
  34. data/_sass/core/_sidebar-extras.scss +2 -14
  35. data/_sass/core/code-copy.scss +27 -8
  36. data/assets/css/extension-points-demo.css +27 -0
  37. data/assets/data/wiki-index.json +19 -0
  38. data/assets/js/auto-hide-nav.js +53 -13
  39. data/assets/js/code-copy.js +72 -1
  40. data/assets/js/extension-points-demo.js +48 -0
  41. data/assets/js/mermaid-diagrams.js +128 -22
  42. data/assets/js/obsidian-local-graph.js +27 -5
  43. data/assets/js/setup-wizard.js +7 -2
  44. data/scripts/ci/test_visual_evidence_autogen.py +64 -2
  45. data/scripts/ci/visual_evidence_autogen.py +14 -4
  46. data/scripts/features/install-preview-generator +1 -1
  47. data/scripts/lib/README.md +12 -0
  48. data/scripts/lib/preview_generator.py +2 -2
  49. metadata +4 -2
@@ -12,10 +12,21 @@
12
12
  * that keeps the diagram source on the page instead of losing it.
13
13
  *
14
14
  * Loaded by _includes/components/mermaid.html — only on pages that opt in
15
- * with `mermaid: true` — after the vendored mermaid.min.js. Both scripts
16
- * are `defer`, so this runs once the DOM is parsed but BEFORE
17
- * DOMContentLoaded: fences are converted before code-copy.js decorates
18
- * <pre> blocks, so a diagram never gets a copy button and a line gutter.
15
+ * with `mermaid: true`. The script is `defer`, so it runs once the DOM is
16
+ * parsed but BEFORE DOMContentLoaded: fences are converted before
17
+ * code-copy.js decorates <pre> blocks, so a diagram never gets a copy button
18
+ * and a line gutter.
19
+ *
20
+ * Lazy loading
21
+ * The Mermaid bundle (~1 MB minified) is NOT loaded by the include. This
22
+ * file converts the fences into figures (skeleton + toolbar) straight away,
23
+ * then injects the bundle only when the page really has a diagram AND the
24
+ * first one comes within LAZY_MARGIN of the viewport (IntersectionObserver).
25
+ * A `mermaid: true` page with no fences never downloads it, and a diagram
26
+ * far below the fold no longer costs the first paint. Once loaded, every
27
+ * figure renders. The bundle URL comes from window.zer0MermaidSrc (set by
28
+ * the include from site.mermaid.src). If a consumer still loads
29
+ * mermaid.min.js itself, the existing global is used and nothing is injected.
19
30
  *
20
31
  * Reads one JSON block injected by the include:
21
32
  * #mermaidConfig — securityLevel, toolbar/fullscreen/download flags, labels
@@ -36,6 +47,7 @@
36
47
  * mermaid.render(), so a theme switch re-renders from the original text.
37
48
  *
38
49
  * Public API (also what the Playwright spec drives):
50
+ * window.zer0Mermaid.load() -> Promise<mermaid> load the bundle now (no-op once loaded)
39
51
  * window.zer0Mermaid.renderAll(root?) -> Promise<figure[]> convert + render fences under root
40
52
  * window.zer0Mermaid.render(el, src?) -> Promise<figure> render one element (or replace it with src)
41
53
  * window.zer0Mermaid.refresh() -> Promise<figure[]> re-derive the palette, re-render every figure
@@ -94,6 +106,32 @@
94
106
  var SUPPORTS_DIALOG = typeof HTMLDialogElement !== "undefined" &&
95
107
  typeof HTMLDialogElement.prototype.showModal === "function";
96
108
 
109
+ // How far below (or above) the viewport a diagram may be before the
110
+ // Mermaid bundle is fetched — enough lead time that a reader scrolling at a
111
+ // normal pace finds it rendered.
112
+ var LAZY_MARGIN = "400px 0px";
113
+
114
+ // Bundle URL: the include sets window.zer0MermaidSrc from site.mermaid.src
115
+ // (default /assets/vendor/mermaid/mermaid.min.js) through relative_url. It
116
+ // is a script global, not DOM text, because it ends up in script.src.
117
+ // Fallback for an overridden include that doesn't set it: the vendored path
118
+ // next to this file. Only http(s) URLs are ever injected.
119
+ var SELF_SRC = (document.currentScript && document.currentScript.src) || "";
120
+ var MERMAID_SRC = safeScriptUrl(
121
+ (typeof window.zer0MermaidSrc === "string" && window.zer0MermaidSrc) ||
122
+ (SELF_SRC ? SELF_SRC.replace(/js\/mermaid-diagrams\.js(\?.*)?$/, "vendor/mermaid/mermaid.min.js") : "")
123
+ );
124
+
125
+ function safeScriptUrl(raw) {
126
+ if (!raw) return "";
127
+ try {
128
+ var u = new URL(raw, document.baseURI);
129
+ return (u.protocol === "https:" || u.protocol === "http:") ? u.href : "";
130
+ } catch (e) {
131
+ return "";
132
+ }
133
+ }
134
+
97
135
  var ZOOM_MIN = 0.5;
98
136
  var ZOOM_MAX = 4;
99
137
  var ZOOM_STEP = 1.25;
@@ -322,10 +360,11 @@
322
360
 
323
361
  var ink = tokenColor("--bs-body-color", dark ? "#dee2e6" : "#212529", bgRgb);
324
362
  var inkMuted = tokenColor("--bs-secondary-color", dark ? "#adb5bd" : "#6c757d", bgRgb);
325
- // The skin mixin writes both --bs-primary and --zer0-color-primary, but a
326
- // site-level `theme_color.main` re-pins the latter at :root after main.css,
327
- // so only --bs-primary reliably follows the active skin — it is what the
328
- // buttons and links the reader sees are painted with.
363
+ // Under a palette skin --bs-primary and --zer0-color-primary now agree
364
+ // (T-048: the skin beats `theme_color`). They still differ under the
365
+ // dark/contrast skins, where `theme_color.main` sets only the theme token.
366
+ // --bs-primary stays first because the buttons and links the reader sees
367
+ // are painted with it.
329
368
  var primary = tokenColor("--bs-primary", "var(--zer0-color-primary, #007bff)", bgRgb);
330
369
  var accent = tokenColor("--zer0-color-accent", "#ffe484", bgRgb);
331
370
  // Keep the brand usable as a border/line colour on this background.
@@ -1185,7 +1224,11 @@
1185
1224
  refreshTimer = setTimeout(function () { refresh(false); }, 80);
1186
1225
  }
1187
1226
 
1227
+ var themeWatched = false;
1228
+
1188
1229
  function watchTheme() {
1230
+ if (themeWatched) return;
1231
+ themeWatched = true;
1189
1232
  if (typeof MutationObserver === "function") {
1190
1233
  var observer = new MutationObserver(scheduleRefresh);
1191
1234
  // `style` catches the Appearance panel writing token overrides onto
@@ -1203,19 +1246,77 @@
1203
1246
  }
1204
1247
  }
1205
1248
 
1249
+ // ---------------------------------------------------------------------
1250
+ // Lazy bundle loading
1251
+ // ---------------------------------------------------------------------
1252
+ var mermaidPromise = null;
1253
+
1254
+ function mermaidReady() {
1255
+ return !!(window.mermaid && typeof window.mermaid.render === "function");
1256
+ }
1257
+
1258
+ function markUnavailable() {
1259
+ // Vendor bundle missing: leave the fences as readable code blocks.
1260
+ document.documentElement.classList.add("zer0-diagram-unavailable");
1261
+ if (window.console && console.warn) console.warn("[zer0-mermaid] mermaid.min.js did not load; diagrams left as source.");
1262
+ }
1263
+
1264
+ // Inject the Mermaid bundle once; every caller shares the same promise.
1265
+ function loadMermaid() {
1266
+ if (mermaidReady()) return Promise.resolve(window.mermaid);
1267
+ if (mermaidPromise) return mermaidPromise;
1268
+ mermaidPromise = new Promise(function (resolve, reject) {
1269
+ if (!MERMAID_SRC) { reject(new Error("no Mermaid bundle URL")); return; }
1270
+ var script = document.createElement("script");
1271
+ script.src = MERMAID_SRC;
1272
+ script.async = true;
1273
+ script.setAttribute("data-zer0-mermaid", "");
1274
+ script.onload = function () {
1275
+ if (mermaidReady()) resolve(window.mermaid);
1276
+ else reject(new Error("mermaid global missing after load"));
1277
+ };
1278
+ script.onerror = function () { reject(new Error("failed to load " + MERMAID_SRC)); };
1279
+ document.head.appendChild(script);
1280
+ });
1281
+ mermaidPromise.catch(function () { mermaidPromise = null; });
1282
+ return mermaidPromise;
1283
+ }
1284
+
1285
+ // Resolve when the first of `figures` is within LAZY_MARGIN of the viewport.
1286
+ function whenNearViewport(figures) {
1287
+ return new Promise(function (resolve) {
1288
+ if (typeof window.IntersectionObserver !== "function") { resolve(); return; }
1289
+ var io = new IntersectionObserver(function (entries) {
1290
+ if (entries.some(function (e) { return e.isIntersecting; })) {
1291
+ io.disconnect();
1292
+ resolve();
1293
+ }
1294
+ }, { rootMargin: LAZY_MARGIN });
1295
+ figures.forEach(function (f) { io.observe(f); });
1296
+ });
1297
+ }
1298
+
1206
1299
  // ---------------------------------------------------------------------
1207
1300
  // Public API + boot
1208
1301
  // ---------------------------------------------------------------------
1209
1302
  function renderAll(root) {
1210
- if (!window.mermaid) return Promise.resolve([]);
1211
- initMermaid(false);
1212
- var figures = collect(root || document);
1213
- return renderMany(figures);
1303
+ return loadMermaid().then(function () {
1304
+ initMermaid(false);
1305
+ watchTheme();
1306
+ var figures = collect(root || document);
1307
+ return renderMany(figures);
1308
+ }, function () { return []; });
1214
1309
  }
1215
1310
 
1216
1311
  function render(target, source) {
1217
- if (!window.mermaid) return Promise.resolve(null);
1312
+ return loadMermaid().then(function () {
1313
+ return renderOne(target, source);
1314
+ }, function () { return null; });
1315
+ }
1316
+
1317
+ function renderOne(target, source) {
1218
1318
  initMermaid(false);
1319
+ watchTheme();
1219
1320
  var figure;
1220
1321
  if (target && registry.has(target)) {
1221
1322
  figure = target;
@@ -1237,9 +1338,10 @@
1237
1338
  }
1238
1339
 
1239
1340
  window.zer0Mermaid = {
1240
- version: "3.0.0",
1341
+ version: "3.1.0",
1241
1342
  config: CONFIG,
1242
1343
  labels: LABELS,
1344
+ load: loadMermaid,
1243
1345
  renderAll: renderAll,
1244
1346
  render: render,
1245
1347
  refresh: function () { return refresh(true); },
@@ -1252,14 +1354,18 @@
1252
1354
  };
1253
1355
 
1254
1356
  function boot() {
1255
- if (!window.mermaid || typeof window.mermaid.render !== "function") {
1256
- // Vendor bundle missing: leave the fences as readable code blocks.
1257
- document.documentElement.classList.add("zer0-diagram-unavailable");
1258
- if (window.console && console.warn) console.warn("[zer0-mermaid] mermaid.min.js did not load; diagrams left as source.");
1259
- return;
1260
- }
1261
- renderAll(document);
1262
- watchTheme();
1357
+ // Fences become figures (skeleton + toolbar) now; Mermaid is not needed
1358
+ // for that. No diagram on the page → the bundle is never requested.
1359
+ var figures = collect(document);
1360
+ if (!figures.length) return;
1361
+ var start = mermaidReady() ? Promise.resolve() : whenNearViewport(figures);
1362
+ start
1363
+ .then(loadMermaid)
1364
+ .then(function () {
1365
+ initMermaid(false);
1366
+ watchTheme();
1367
+ return renderMany(allFigures.filter(function (f) { return f.isConnected; }));
1368
+ }, markUnavailable);
1263
1369
  }
1264
1370
 
1265
1371
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
@@ -8,7 +8,11 @@
8
8
  *
9
9
  * Loaded by _includes/navigation/local-graph.html inside a dedicated
10
10
  * collapsible side panel. Cytoscape.js is loaded lazily (and only once) from
11
- * the vendored copy under assets/vendor/cytoscape/ (no CDN).
11
+ * the vendored copy under assets/vendor/cytoscape/ (no CDN), and only when
12
+ * the panel is first opened: the panel is a closed offcanvas on page load, so
13
+ * fetching ~118 KB of cytoscape for a graph nobody has asked to see was pure
14
+ * main-thread cost on every indexed page. Until then the accessible
15
+ * text list of linked pages is kept up to date.
12
16
  *
13
17
  * Subgraph:
14
18
  * - center = current page (matched against entry.url, falling back to
@@ -592,6 +596,7 @@
592
596
  syncControls(prefs);
593
597
 
594
598
  var graphAvailable = null; // unknown until loadCytoscape resolves
599
+ var graphRequested = false;
595
600
 
596
601
  function rebuild() {
597
602
  var elements = buildSubgraph(entries, lookup, current, prefs.depth, {
@@ -603,6 +608,11 @@
603
608
  // Accessible text fallback (also the graceful degradation if
604
609
  // cytoscape can't load): a list of linked neighbours below the canvas.
605
610
  var fallback = renderTextFallback(container, elements, current);
611
+ if (graphAvailable === null) {
612
+ // Panel not opened yet: cytoscape is not loaded, keep the list.
613
+ setStatus(container, nodeCount + ' pages · ' + edgeCount + ' links', false);
614
+ return;
615
+ }
606
616
  if (graphAvailable === false) {
607
617
  container.hidden = true;
608
618
  setStatus(container, 'Showing linked pages (interactive graph unavailable).', false);
@@ -619,10 +629,22 @@
619
629
  if (container.__obsidianLocalGraph) rebuild();
620
630
  });
621
631
 
622
- loadCytoscape(function (ok) {
623
- graphAvailable = ok !== false;
624
- rebuild();
625
- });
632
+ function requestGraph() {
633
+ if (graphRequested) return;
634
+ graphRequested = true;
635
+ setStatus(container, 'Loading graph…', false);
636
+ loadCytoscape(function (ok) {
637
+ graphAvailable = ok !== false;
638
+ rebuild();
639
+ });
640
+ }
641
+
642
+ rebuild(); // text list + counts, no cytoscape yet
643
+ if (!panel || panel.classList.contains('show')) {
644
+ requestGraph();
645
+ } else {
646
+ panel.addEventListener('show.bs.offcanvas', requestGraph);
647
+ }
626
648
  })
627
649
  .catch(function (err) {
628
650
  // Sidebar panel failing is non-fatal — keep it hidden and stay quiet.
@@ -1981,10 +1981,15 @@
1981
1981
  return data;
1982
1982
  }
1983
1983
 
1984
+ // `is-visible` is the ONLY handle on the chip's appearance. It used to also
1985
+ // clear `hidden`, which the 2s timer below never restored (that timer only
1986
+ // drops `is-visible`, an opacity/visibility class) — so the first debounced
1987
+ // save moved the chip into layout permanently and shifted the whole wizard
1988
+ // down 7px (issue #265). _setup-wizard.scss reserves the box and hides it
1989
+ // with `visibility`, so there is nothing here to toggle but the class.
1984
1990
  function showDraftChip() {
1985
1991
  var chip = document.getElementById('wizard-draft-chip');
1986
1992
  if (!chip) return;
1987
- chip.hidden = false;
1988
1993
  chip.classList.add('is-visible');
1989
1994
  clearTimeout(chipTimer);
1990
1995
  chipTimer = setTimeout(function () { chip.classList.remove('is-visible'); }, 2000);
@@ -2016,7 +2021,7 @@
2016
2021
  draftTimer = null;
2017
2022
  try { localStorage.removeItem(DRAFT_KEY); } catch (e) { /* ignore */ }
2018
2023
  var chip = document.getElementById('wizard-draft-chip');
2019
- if (chip) { chip.classList.remove('is-visible'); chip.hidden = true; }
2024
+ if (chip) { chip.classList.remove('is-visible'); }
2020
2025
  }
2021
2026
 
2022
2027
  function restoreDraft() {
@@ -192,6 +192,25 @@ def test_parse_results() -> None:
192
192
  f.get("diff") == "test/visual-results/output/a/homepage-sunrise-diff.png")
193
193
 
194
194
 
195
+ def test_parse_results_playwright_158_attachment_names() -> None:
196
+ print("parse_playwright_results — Playwright 1.58+ names attachments with their extension")
197
+ results = {"suites": [{"title": "appearance-snapshot.spec.js", "suites": [{"title": "Theme skins", "suites": [
198
+ {"title": "skin: air", "specs": [{"title": "homepage visual snapshot", "tests": [{"results": [
199
+ {"status": "failed", "error": {"message": "2009 pixels (ratio 0.01 of all image pixels) are different."},
200
+ "attachments": [
201
+ {"name": "homepage-air-expected.png", "path": "/work/test/visual-results/output/a/homepage-air-expected.png"},
202
+ {"name": "homepage-air-actual.png", "path": "/work/test/visual-results/output/a/homepage-air-actual.png"},
203
+ {"name": "homepage-air-diff.png", "path": "/work/test/visual-results/output/a/homepage-air-diff.png"}]}]}]}]},
204
+ ]}]}]}
205
+ parsed = vea.parse_playwright_results(results)
206
+ f = parsed["failures"][0] if parsed["failures"] else {}
207
+ check("skin taken from the describe title", f.get("skin") == "air")
208
+ check("pixel count parsed", f.get("diff_px") == 2009)
209
+ check("expected resolved despite the .png suffix", f.get("expected") is not None)
210
+ check("actual resolved despite the .png suffix", f.get("actual") is not None)
211
+ check("diff resolved despite the .png suffix", f.get("diff") is not None)
212
+
213
+
195
214
  def test_decide() -> None:
196
215
  print("decide — only `intentional` on a failing verify may bless")
197
216
  fail = {"snapshots": {"verified": "fail"}}
@@ -259,6 +278,47 @@ def test_stage_command_output() -> None:
259
278
  check("one path per line when there is something to add", buf.getvalue() == "test/visual/evidence/x\n")
260
279
 
261
280
 
281
+ def test_generic_generator_survives_livereload() -> None:
282
+ """Run 36932006025 (#495): pr-evidence.mjs died at its third width with
283
+ "page.$: Execution context was destroyed" and wrote no metrics or PNGs.
284
+
285
+ The jammy run's `npm ci` created node_modules/ AFTER `jekyll serve --watch`
286
+ started; jekyll-watch ignores only the `exclude:` paths that exist when it
287
+ starts, so Jekyll regenerated and LiveReload reloaded the open page mid-shot.
288
+ Pinned from both ends: the watcher must ignore node_modules/, and the
289
+ generator must neither listen to LiveReload nor let one shot end the sweep.
290
+ """
291
+ print("generic generator — a mid-sweep LiveReload never aborts the sweep")
292
+ src = (REPO_ROOT / vea.GENERIC_GENERATOR).read_text(encoding="utf-8")
293
+ check("pr-evidence.mjs aborts the injected livereload.js",
294
+ re.search(r"const LIVERELOAD_URL_RE = /.*livereload", src) is not None
295
+ and "page.route(LIVERELOAD_URL_RE, (r) => r.abort())" in src)
296
+ unguarded = [ln.strip() for ln in src.splitlines()
297
+ if re.search(r"await page\.(\$|\$\$|evaluate)\(", ln) and ".catch(" not in ln]
298
+ check(f"every page-context call in pr-evidence.mjs is guarded ({unguarded or 'none unguarded'})",
299
+ not unguarded)
300
+
301
+ import contextlib
302
+ import io
303
+ import os
304
+ import tempfile
305
+ calls: list[tuple[str, bool]] = []
306
+ real_run, real_http_ok, cwd = vea.run, vea.http_ok, os.getcwd()
307
+ with tempfile.TemporaryDirectory() as tmp:
308
+ try:
309
+ os.chdir(tmp)
310
+ vea.http_ok = lambda url, timeout=3.0: any(c[0].startswith("docker compose up") for c in calls)
311
+ vea.run = lambda cmd, **kw: calls.append((" ".join(cmd), Path("node_modules").is_dir()))
312
+ with contextlib.redirect_stdout(io.StringIO()):
313
+ vea.ensure_jekyll(timeout=5)
314
+ finally:
315
+ vea.run, vea.http_ok = real_run, real_http_ok
316
+ os.chdir(cwd)
317
+ up = [had for cmd, had in calls if cmd.startswith("docker compose up")]
318
+ check("ensure_jekyll creates node_modules/ BEFORE `docker compose up` (so the watcher ignores it)",
319
+ up == [True])
320
+
321
+
262
322
  def test_lane_tooling_contract() -> None:
263
323
  """The regression that killed the lane's first real run on a real PR.
264
324
 
@@ -328,8 +388,10 @@ def test_workflow_wiring() -> None:
328
388
  def main() -> int:
329
389
  for t in (test_detect_slug, test_plan_pr_454_shape, test_plan_loop_guard_and_budget, test_plan_generic_fallback,
330
390
  test_plan_respects_author_evidence, test_plan_out_of_scope, test_styling_matches_ci_filter,
331
- test_ui_prefixes_match_gate, test_parse_results, test_decide, test_stage_and_messages,
332
- test_stage_command_output, test_lane_tooling_contract, test_workflow_wiring):
391
+ test_ui_prefixes_match_gate, test_parse_results, test_parse_results_playwright_158_attachment_names,
392
+ test_decide, test_stage_and_messages,
393
+ test_stage_command_output, test_generic_generator_survives_livereload,
394
+ test_lane_tooling_contract, test_workflow_wiring):
333
395
  t()
334
396
  print(f"\n{PASSED} passed, {len(FAILURES)} failed")
335
397
  for f in FAILURES:
@@ -400,13 +400,18 @@ def parse_playwright_results(results: dict) -> dict:
400
400
  m = re.search(r"(\d+) pixels", message)
401
401
  exp = act = dif = None
402
402
  for name, p in attachments.items():
403
- if name.endswith("-expected"):
403
+ # Playwright 1.58 names screenshot attachments with their
404
+ # file extension (e.g. "homepage-air-expected.png"); older
405
+ # versions did not. Strip it before matching the suffix so
406
+ # both forms resolve to the same three roles.
407
+ stem = re.sub(r"\.(png|jpe?g)$", "", name)
408
+ if stem.endswith("-expected"):
404
409
  exp = rel_path(p)
405
- elif name.endswith("-actual"):
410
+ elif stem.endswith("-actual"):
406
411
  act = rel_path(p)
407
- elif name.endswith("-diff"):
412
+ elif stem.endswith("-diff"):
408
413
  dif = rel_path(p)
409
- skin = skin or name[: -len("-diff")].replace("homepage-", "")
414
+ skin = skin or stem[: -len("-diff")].replace("homepage-", "")
410
415
  failures.append({
411
416
  "skin": skin or spec.get("title", "?"),
412
417
  "status": status,
@@ -447,6 +452,11 @@ def ensure_jekyll(timeout: int) -> bool:
447
452
  if http_ok(HEAD_URL + "/"):
448
453
  log(f"Jekyll already serving {HEAD_URL} — reusing it.")
449
454
  return False
455
+ # jekyll-watch ignores only the `exclude:` paths that EXIST when its watcher
456
+ # starts. On a fresh checkout node_modules/ does not, so the jammy run's
457
+ # `npm ci` would set off a regeneration whose LiveReload reloads every open
458
+ # page mid-shot (the pr-evidence.mjs crash on #495). Create it first.
459
+ Path("node_modules").mkdir(exist_ok=True)
450
460
  log("Starting Jekyll via docker compose…")
451
461
  run(["docker", "compose", "up", "-d"], capture=False)
452
462
  log(f"Waiting up to {timeout}s for {HEAD_URL} (cold bundle install + first build)…")
@@ -262,7 +262,7 @@ update_config() {
262
262
  preview_images:
263
263
  enabled: true
264
264
  provider: ${DEFAULT_PROVIDER} # renderer: openai, xai, stability, gemini, local
265
- model: "" # empty = renderer default (gpt-image-2, grok-2-image, ...)
265
+ model: "" # empty = renderer default (gpt-image-2, grok-imagine-image-2.0, ...)
266
266
  size: "1536x1024" # Landscape banner size
267
267
  quality: auto # auto for GPT Image; standard/hd for DALL-E 3
268
268
  style: "retro pixel art, 8-bit video game aesthetic, vibrant colors, nostalgic, clean pixel graphics"
@@ -153,6 +153,18 @@ publish_gem "1.2.0"
153
153
  create_github_release "1.2.0"
154
154
  ```
155
155
 
156
+ ### 🖼️ `preview_generator.py` - Preview Image Engine
157
+
158
+ The Python engine behind `scripts/generate-preview-images.sh`. Renderer defaults when `preview_images.model` is empty:
159
+
160
+ | provider | default model | credential |
161
+ | --- | --- | --- |
162
+ | `openai` (default) | `gpt-image-2` | `OPENAI_API_KEY` |
163
+ | `xai` | `grok-imagine-image-2.0` | `XAI_API_KEY` |
164
+ | `gemini` | `gemini-2.5-flash-image` | `GEMINI_API_KEY` |
165
+
166
+ Unit specs: `python3 test/test_preview_generator.py`.
167
+
156
168
  ## Testing
157
169
 
158
170
  Each library has comprehensive unit tests in `scripts/test/lib/`.
@@ -24,7 +24,7 @@ Architecture — Claude ORCHESTRATES, an image model RENDERS:
24
24
  -------- -------------------------------------- ----------------------------------
25
25
  openai gpt-image-2 / dall-e-3 (+ --enhance OPENAI_API_KEY
26
26
  (default) via /v1/images/edits)
27
- xai grok-2-image XAI_API_KEY
27
+ xai grok-imagine-image-2.0 XAI_API_KEY
28
28
  stability Stable Diffusion XL (v1 API) STABILITY_API_KEY
29
29
  gemini gemini-2.5-flash-image GEMINI_API_KEY
30
30
  local deterministic template SVG → PNG none (CI-safe; skips
@@ -1688,7 +1688,7 @@ class XAIProvider(Provider):
1688
1688
  return "XAI_API_KEY environment variable is required for the xAI provider"
1689
1689
 
1690
1690
  def default_model(self) -> str:
1691
- return "grok-2-image"
1691
+ return "grok-imagine-image-2.0"
1692
1692
 
1693
1693
  def generate(self, prompt, settings, out_base, ctx) -> ImageResult:
1694
1694
  model = effective_model(settings, self)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-theme-zer0
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.30.0
4
+ version: 1.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amr Abdel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-13 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -345,6 +345,7 @@ files:
345
345
  - _sass/utilities/_layout.scss
346
346
  - _sass/utilities/_motion.scss
347
347
  - assets/css/custom.css
348
+ - assets/css/extension-points-demo.css
348
349
  - assets/css/main.scss
349
350
  - assets/css/stats.css
350
351
  - assets/css/theme-npm-entry.scss
@@ -362,6 +363,7 @@ files:
362
363
  - assets/js/code-copy.js
363
364
  - assets/js/config-utility.js
364
365
  - assets/js/docs.min.js
366
+ - assets/js/extension-points-demo.js
365
367
  - assets/js/fleet-feedback-capture.js
366
368
  - assets/js/fleet-feedback.js
367
369
  - assets/js/halfmoon.js