jekyll-theme-zer0 1.27.0 → 1.29.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +812 -13
  3. data/_data/backlog.yml +356 -2
  4. data/_data/consumers.yml +252 -0
  5. data/_data/features.yml +234 -5
  6. data/_data/i18n/fr.yml +103 -0
  7. data/_data/i18n/manifest.yml +1529 -0
  8. data/_data/ingredient_densities.yml +122 -0
  9. data/_data/navigation/docs.yml +2 -0
  10. data/_data/navigation/main.yml +22 -0
  11. data/_data/recipe_courses.yml +64 -0
  12. data/_data/series.yml +19 -0
  13. data/_data/theme-manifest.yml +387 -277
  14. data/_data/ui-text.yml +8 -0
  15. data/_includes/README.md +36 -3
  16. data/_includes/analytics/google-tag-manager-body.html +11 -2
  17. data/_includes/analytics/google-tag-manager-head.html +16 -6
  18. data/_includes/analytics/posthog.html +33 -4
  19. data/_includes/components/abc-letter.html +43 -0
  20. data/_includes/components/background-customizer.html +2 -2
  21. data/_includes/components/book-card.html +42 -0
  22. data/_includes/components/book-nav.html +80 -0
  23. data/_includes/components/book-plate.html +31 -0
  24. data/_includes/components/book-toc.html +47 -0
  25. data/_includes/components/bookshelf.html +68 -0
  26. data/_includes/components/card-grid.html +60 -0
  27. data/_includes/components/cookie-consent.html +4 -4
  28. data/_includes/components/data-card.html +95 -0
  29. data/_includes/components/halfmoon.html +5 -1
  30. data/_includes/components/info-section.html +8 -3
  31. data/_includes/components/language-toggle.html +168 -21
  32. data/_includes/components/page-feedback.html +65 -2
  33. data/_includes/components/page-views-init.html +55 -0
  34. data/_includes/components/page-views.html +33 -0
  35. data/_includes/components/recipe-card.html +67 -0
  36. data/_includes/components/recipe-duration.html +50 -0
  37. data/_includes/components/recipe-grams.html +58 -0
  38. data/_includes/components/recipe-index.html +96 -0
  39. data/_includes/components/recipe-ingredients.html +90 -0
  40. data/_includes/components/recipe-meta.html +96 -0
  41. data/_includes/components/recipe-nutrition.html +57 -0
  42. data/_includes/components/recipe-qty.html +73 -0
  43. data/_includes/components/recipe-ratio.html +151 -0
  44. data/_includes/components/recipe-scaler.html +73 -0
  45. data/_includes/components/recipe-steps.html +86 -0
  46. data/_includes/components/recipe-temp.html +45 -0
  47. data/_includes/components/search-modal.html +2 -2
  48. data/_includes/components/shortcuts-modal.html +3 -0
  49. data/_includes/components/theme-controls-bar.html +10 -2
  50. data/_includes/components/theme-customizer.html +8 -2
  51. data/_includes/components/theme-info.html +8 -1
  52. data/_includes/content/jsonld-software.html +22 -3
  53. data/_includes/content/seo.html +9 -3
  54. data/_includes/core/color-mode-init.html +13 -4
  55. data/_includes/core/favicon.html +46 -0
  56. data/_includes/core/footer.html +18 -16
  57. data/_includes/core/head.html +21 -0
  58. data/_includes/core/header.html +7 -4
  59. data/_includes/custom/body-end.html +18 -0
  60. data/_includes/custom/body-start.html +17 -0
  61. data/_includes/custom/footer.html +18 -0
  62. data/_includes/custom/head.html +18 -0
  63. data/_includes/navigation/local-graph.html +28 -2
  64. data/_includes/navigation/nav-tree.html +3 -3
  65. data/_includes/navigation/section-sidebar.html +93 -11
  66. data/_includes/navigation/sidebar-config.html +21 -0
  67. data/_includes/navigation/sidebar-left.html +2 -1
  68. data/_includes/navigation/sidebar-nav.html +4 -0
  69. data/_includes/navigation/sidebar-pagetree.html +150 -0
  70. data/_includes/navigation/sidebar-right.html +2 -1
  71. data/_includes/navigation/unified-drawer.html +8 -2
  72. data/_includes/obsidian/full-graph.html +165 -136
  73. data/_includes/setup/wizard.html +173 -86
  74. data/_includes/stats/stats-metrics.html +2 -2
  75. data/_layouts/404.html +260 -0
  76. data/_layouts/README.md +4 -0
  77. data/_layouts/admin.html +2 -2
  78. data/_layouts/article.html +5 -0
  79. data/_layouts/book-abc.html +106 -0
  80. data/_layouts/book-story.html +91 -0
  81. data/_layouts/book.html +113 -0
  82. data/_layouts/collection.html +17 -5
  83. data/_layouts/cookbook.html +88 -0
  84. data/_layouts/default.html +4 -4
  85. data/_layouts/home.html +5 -2
  86. data/_layouts/landing.html +9 -0
  87. data/_layouts/news.html +155 -31
  88. data/_layouts/recipe.html +274 -0
  89. data/_layouts/root.html +38 -5
  90. data/_layouts/section.html +96 -25
  91. data/_sass/components/_book.scss +423 -0
  92. data/_sass/components/_callout.scss +1 -1
  93. data/_sass/components/_footer.scss +37 -1
  94. data/_sass/components/_page-views.scss +36 -0
  95. data/_sass/components/_recipe.scss +506 -0
  96. data/_sass/components/_setup-wizard.scss +235 -0
  97. data/_sass/components/_ui-enhancements.scss +6 -6
  98. data/_sass/core/_navbar.scss +263 -15
  99. data/_sass/core/_obsidian.scss +286 -6
  100. data/_sass/layouts/_landing.scss +2 -2
  101. data/_sass/theme/_backgrounds.scss +21 -8
  102. data/_sass/tokens/_color.scss +6 -0
  103. data/_sass/tokens/_index.scss +2 -0
  104. data/_sass/tokens/_radius.scss +21 -0
  105. data/_sass/tokens/_typography.scss +4 -0
  106. data/_sass/utilities/_focus.scss +14 -0
  107. data/assets/css/main.scss +4 -0
  108. data/assets/js/auto-hide-nav.js +5 -1
  109. data/assets/js/halfmoon.js +26 -0
  110. data/assets/js/modules/navigation/navbar.js +55 -0
  111. data/assets/js/obsidian-graph.js +702 -264
  112. data/assets/js/obsidian-local-graph.js +161 -54
  113. data/assets/js/page-views.js +372 -0
  114. data/assets/js/recipe-scaler.js +501 -0
  115. data/assets/js/search-modal.js +14 -1
  116. data/assets/js/setup-wizard.js +322 -20
  117. data/scripts/README.md +29 -0
  118. data/scripts/bin/audit-consumer +39 -7
  119. data/scripts/bin/giscus-discussions +213 -14
  120. data/scripts/bin/manifest +66 -16
  121. data/scripts/bin/validate +5 -1
  122. data/scripts/ci/agent_review_result.py +164 -0
  123. data/scripts/ci/test_agent_review_result.py +172 -0
  124. data/scripts/design-system-check.rb +170 -0
  125. data/scripts/install/README.md +47 -6
  126. data/scripts/install/ai/client.sh +302 -93
  127. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  128. data/scripts/install/ai/wizard.sh +10 -5
  129. data/scripts/install/apply.sh +7 -3
  130. data/scripts/install/cli.sh +54 -4
  131. data/scripts/install/config.sh +167 -0
  132. data/scripts/install/doctor.sh +38 -0
  133. data/scripts/install/plan.sh +10 -0
  134. data/scripts/install/spec.sh +15 -7
  135. data/scripts/install/template.sh +4 -0
  136. data/scripts/lib/audit.sh +42 -2
  137. data/scripts/lint-liquid-raw.rb +137 -0
  138. data/scripts/propagate.rb +277 -0
  139. data/scripts/test/lib/run_tests.sh +2 -1
  140. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  141. data/scripts/translate.rb +161 -15
  142. metadata +56 -2
@@ -8,12 +8,45 @@
8
8
  * and offers Download / Copy actions. Pure vanilla JS, no deps
9
9
  * beyond Bootstrap 5 (already on the page).
10
10
  *
11
+ * The step container's stable height is NOT handled here. `.wizard-panes` is a
12
+ * CSS grid with every pane in one cell, so the browser sizes it to the tallest
13
+ * pane on every reflow. An earlier measurePanes() did this in JS — un-hiding
14
+ * each pane, reading offsetHeight, restoring it — and came back ~16px short,
15
+ * which put the shortest step's Back/Next 16px above everyone else's.
16
+ *
17
+ * T-040 / #408 added, in the order the pieces depend on each other:
18
+ * 1. updatePreview() — now fires on every input anywhere in the wizard,
19
+ * not just on entering step 5, because the preview is
20
+ * a persistent panel rather than a step-5 element.
21
+ * 2. validateField() — email/URL checked on blur, `is-invalid` + a message;
22
+ * cleared as soon as the user types again.
23
+ * 3. saveDraft() — every value mirrored to localStorage under
24
+ * `zer0-setup-draft`, debounced 300ms, restored on
25
+ * load, acknowledged by the "Draft saved" chip, and
26
+ * cleared when the file is downloaded.
27
+ * 4. refreshStepper() — the vertical stepper's done/active/locked state. A
28
+ * step is locked until every EARLIER step validates;
29
+ * going back is always allowed.
30
+ *
31
+ * Everything degrades: localStorage failures (private mode, quota) are
32
+ * swallowed, and a missing element short-circuits rather than throwing.
33
+ *
11
34
  * ===================================================================
12
35
  */
13
36
 
14
37
  (function () {
15
38
  'use strict';
16
39
 
40
+ var DRAFT_KEY = 'zer0-setup-draft';
41
+ var DRAFT_DEBOUNCE_MS = 300;
42
+
43
+ // Step order is the source of truth for "earlier" / "later" in the stepper.
44
+ var STEP_IDS = ['tab-identity', 'tab-urls', 'tab-collections', 'tab-analytics', 'tab-review'];
45
+
46
+ var draftTimer = null;
47
+ var chipTimer = null;
48
+ var restoring = false; // suppresses draft writes while we replay a draft
49
+
17
50
  // ── helpers ────────────────────────────────────────────────────────
18
51
 
19
52
  /**
@@ -220,6 +253,224 @@
220
253
  return lines.join('\n');
221
254
  }
222
255
 
256
+ // ── inline validation ──────────────────────────────────────────────
257
+
258
+ var VALIDATION_MESSAGES = {
259
+ email: 'Enter a valid email address, e.g. you@example.com.',
260
+ url: 'Enter a full URL including https://, e.g. https://example.com.'
261
+ };
262
+
263
+ /**
264
+ * Validate one field. Empty is always OK — nothing in this wizard is
265
+ * required, and flagging an untouched field as an error would be wrong.
266
+ * Returns true when the field is acceptable.
267
+ */
268
+ function validateField(el) {
269
+ if (!el || !el.classList.contains('cfg-field')) return true;
270
+ var type = (el.getAttribute('type') || '').toLowerCase();
271
+ if (type !== 'email' && type !== 'url') return true;
272
+
273
+ var ok = el.value.trim() === '' || el.checkValidity();
274
+ el.classList.toggle('is-invalid', !ok);
275
+
276
+ var feedback = document.getElementById(el.id + '-feedback');
277
+ if (feedback) feedback.textContent = ok ? '' : VALIDATION_MESSAGES[type];
278
+ return ok;
279
+ }
280
+
281
+ /** Is every field inside this pane currently acceptable? */
282
+ function paneIsValid(pane) {
283
+ if (!pane) return true;
284
+ var fields = pane.querySelectorAll('.cfg-field');
285
+ for (var i = 0; i < fields.length; i += 1) {
286
+ var el = fields[i];
287
+ var type = (el.getAttribute('type') || '').toLowerCase();
288
+ if (type !== 'email' && type !== 'url') continue;
289
+ if (el.value.trim() !== '' && !el.checkValidity()) return false;
290
+ }
291
+ return true;
292
+ }
293
+
294
+ /** The recommended-but-empty fields, by their human label. */
295
+ function unfilledRecommended() {
296
+ var missing = [];
297
+ document.querySelectorAll('[data-recommended]').forEach(function (el) {
298
+ if (el.value.trim() === '') missing.push(el.getAttribute('data-recommended'));
299
+ });
300
+ return missing;
301
+ }
302
+
303
+ /** Render the Review step's warning list. */
304
+ function renderReviewWarnings() {
305
+ var host = document.getElementById('wizard-review-warnings');
306
+ if (!host) return;
307
+ var missing = unfilledRecommended();
308
+
309
+ if (!missing.length) {
310
+ host.innerHTML = '<div class="alert alert-success mb-0" role="alert">' +
311
+ '<i class="bi bi-check2-circle"></i> Every recommended field is filled in.</div>';
312
+ return;
313
+ }
314
+
315
+ var items = missing.map(function (name) {
316
+ // Labels come from our own markup, but build the DOM through
317
+ // textContent-equivalent escaping anyway rather than interpolating.
318
+ var li = document.createElement('li');
319
+ li.textContent = name;
320
+ return li.outerHTML;
321
+ }).join('');
322
+
323
+ host.innerHTML = '<div class="alert alert-warning mb-0" role="alert">' +
324
+ '<i class="bi bi-exclamation-triangle"></i> ' +
325
+ '<strong>Recommended fields still empty.</strong> The file is valid without them, ' +
326
+ 'but filling them in improves SEO and the site chrome:' +
327
+ '<ul class="mb-0 mt-2">' + items + '</ul></div>';
328
+ }
329
+
330
+ // ── stepper state ──────────────────────────────────────────────────
331
+
332
+ /**
333
+ * Recompute done / active / locked across the vertical stepper.
334
+ *
335
+ * Rule: a step is reachable when every EARLIER step validates. That makes
336
+ * "Back is always allowed" fall out for free (earlier steps are, by
337
+ * definition, behind a prefix that already validated) while stopping the
338
+ * user from carrying a malformed email or URL forward.
339
+ */
340
+ function refreshStepper() {
341
+ var buttons = STEP_IDS.map(function (id) { return document.getElementById(id); });
342
+ var activeIndex = buttons.findIndex(function (b) {
343
+ return b && b.classList.contains('is-active');
344
+ });
345
+ if (activeIndex < 0) activeIndex = 0;
346
+
347
+ var blocked = false;
348
+ buttons.forEach(function (btn, i) {
349
+ if (!btn) return;
350
+ var pane = document.querySelector(btn.getAttribute('data-bs-target'));
351
+ var valid = paneIsValid(pane);
352
+
353
+ var locked = blocked && i > activeIndex;
354
+ btn.classList.toggle('is-active', i === activeIndex);
355
+ btn.classList.toggle('is-done', i < activeIndex && valid);
356
+ btn.classList.toggle('is-locked', locked);
357
+ btn.disabled = locked;
358
+ btn.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
359
+
360
+ var state = btn.querySelector('.wizard-step-state');
361
+ if (state) {
362
+ state.textContent = i === activeIndex ? 'current step'
363
+ : locked ? 'locked until earlier steps are valid'
364
+ : i < activeIndex && valid ? 'completed' : '';
365
+ }
366
+
367
+ if (!valid) blocked = true;
368
+ });
369
+
370
+ // A Next button cannot skip past a step that has not validated.
371
+ document.querySelectorAll('.btn-next').forEach(function (btn) {
372
+ var target = document.getElementById(btn.getAttribute('data-next'));
373
+ btn.disabled = !!(target && target.disabled);
374
+ });
375
+ }
376
+
377
+ // ── draft persistence ──────────────────────────────────────────────
378
+
379
+ function collectDraft() {
380
+ var data = { fields: {}, checks: {}, step: null };
381
+ document.querySelectorAll('#setup-wizard .cfg-field').forEach(function (el) {
382
+ if (el.id) data.fields[el.id] = el.value;
383
+ });
384
+ document.querySelectorAll('#setup-wizard input[type="checkbox"]').forEach(function (el) {
385
+ if (el.id) data.checks[el.id] = el.checked;
386
+ });
387
+ var active = document.querySelector('#wizardTabs .wizard-step.is-active');
388
+ if (active) data.step = active.id;
389
+ return data;
390
+ }
391
+
392
+ function showDraftChip() {
393
+ var chip = document.getElementById('wizard-draft-chip');
394
+ if (!chip) return;
395
+ chip.hidden = false;
396
+ chip.classList.add('is-visible');
397
+ clearTimeout(chipTimer);
398
+ chipTimer = setTimeout(function () { chip.classList.remove('is-visible'); }, 2000);
399
+ }
400
+
401
+ function writeDraft() {
402
+ try {
403
+ localStorage.setItem(DRAFT_KEY, JSON.stringify(collectDraft()));
404
+ return true;
405
+ } catch (e) { /* private mode or quota — the wizard still works */ }
406
+ return false;
407
+ }
408
+
409
+ function saveDraft() {
410
+ if (restoring) return;
411
+ clearTimeout(draftTimer);
412
+ draftTimer = setTimeout(function () {
413
+ draftTimer = null;
414
+ if (writeDraft()) showDraftChip();
415
+ }, DRAFT_DEBOUNCE_MS);
416
+ }
417
+
418
+ /**
419
+ * Write a PENDING debounced draft out immediately.
420
+ *
421
+ * The 300ms debounce means the last edit before the page goes away is still
422
+ * only a queued timer, and a timer does not survive navigation: check a
423
+ * collection box and hit reload inside that window and the change is simply
424
+ * lost, even though the chip may already have acknowledged an EARLIER save.
425
+ * `pagehide` and the hidden `visibilitychange` are the two events that still
426
+ * fire reliably on reload, tab close and bfcache eviction, so the flush hangs
427
+ * off both. No-op when nothing is queued, so it can never resurrect a draft
428
+ * that `clearDraft()` has just removed.
429
+ */
430
+ function flushDraft() {
431
+ if (draftTimer === null || restoring) return;
432
+ clearTimeout(draftTimer);
433
+ draftTimer = null;
434
+ writeDraft();
435
+ }
436
+
437
+ function clearDraft() {
438
+ clearTimeout(draftTimer);
439
+ draftTimer = null; // …so a later flush cannot write the draft back
440
+ try { localStorage.removeItem(DRAFT_KEY); } catch (e) { /* ignore */ }
441
+ var chip = document.getElementById('wizard-draft-chip');
442
+ if (chip) { chip.classList.remove('is-visible'); chip.hidden = true; }
443
+ }
444
+
445
+ function restoreDraft() {
446
+ var raw;
447
+ try { raw = localStorage.getItem(DRAFT_KEY); } catch (e) { return; }
448
+ if (!raw) return;
449
+
450
+ var data;
451
+ try { data = JSON.parse(raw); } catch (e) { clearDraft(); return; }
452
+ if (!data || typeof data !== 'object') return;
453
+
454
+ restoring = true;
455
+ Object.keys(data.fields || {}).forEach(function (id) {
456
+ var el = document.getElementById(id);
457
+ if (el && el.classList.contains('cfg-field')) el.value = data.fields[id];
458
+ });
459
+ Object.keys(data.checks || {}).forEach(function (id) {
460
+ var el = document.getElementById(id);
461
+ if (el && el.type === 'checkbox') el.checked = !!data.checks[id];
462
+ });
463
+
464
+ // Re-enter the step the user left off on, but only if the draft still
465
+ // validates up to there — otherwise a saved bad value could strand them
466
+ // on a step the stepper would refuse to unlock.
467
+ var target = data.step && STEP_IDS.indexOf(data.step) !== -1
468
+ ? document.getElementById(data.step) : null;
469
+ if (target && !target.disabled) showStep(target);
470
+
471
+ restoring = false;
472
+ }
473
+
223
474
  // ── actions ────────────────────────────────────────────────────────
224
475
 
225
476
  function updatePreview() {
@@ -229,6 +480,18 @@
229
480
  }
230
481
  }
231
482
 
483
+ /** Activate a step button, updating stepper state alongside Bootstrap's. */
484
+ function showStep(btn) {
485
+ if (!btn || btn.disabled) return;
486
+ document.querySelectorAll('#wizardTabs .wizard-step').forEach(function (b) {
487
+ b.classList.remove('is-active');
488
+ });
489
+ btn.classList.add('is-active');
490
+ if (window.bootstrap && window.bootstrap.Tab) new bootstrap.Tab(btn).show();
491
+ refreshStepper();
492
+ if (btn.id === 'tab-review') renderReviewWarnings();
493
+ }
494
+
232
495
  function downloadYAML() {
233
496
  var yaml = buildYAML();
234
497
  var blob = new Blob([yaml], { type: 'text/yaml;charset=utf-8' });
@@ -240,6 +503,9 @@
240
503
  a.click();
241
504
  document.body.removeChild(a);
242
505
  URL.revokeObjectURL(url);
506
+ // The draft exists to survive a reload mid-wizard. Once the file is in the
507
+ // user's hands the wizard is finished, so the draft is stale, not useful.
508
+ clearDraft();
243
509
  }
244
510
 
245
511
  function copyYAML() {
@@ -257,40 +523,62 @@
257
523
  // ── wiring ─────────────────────────────────────────────────────────
258
524
 
259
525
  document.addEventListener('DOMContentLoaded', function () {
260
- // Next / Prev buttons
261
- document.querySelectorAll('.btn-next').forEach(function (btn) {
526
+ var wizard = document.getElementById('setup-wizard');
527
+ if (!wizard) return;
528
+
529
+ // Next / Prev buttons. Back is never blocked; Next is disabled by
530
+ // refreshStepper() when the step it points at has not unlocked.
531
+ wizard.querySelectorAll('.btn-next').forEach(function (btn) {
262
532
  btn.addEventListener('click', function () {
263
- var target = btn.getAttribute('data-next');
264
- var tab = document.getElementById(target);
265
- if (tab) new bootstrap.Tab(tab).show();
266
- updatePreview();
533
+ showStep(document.getElementById(btn.getAttribute('data-next')));
267
534
  });
268
535
  });
269
536
 
270
- document.querySelectorAll('.btn-prev').forEach(function (btn) {
537
+ wizard.querySelectorAll('.btn-prev').forEach(function (btn) {
271
538
  btn.addEventListener('click', function () {
272
- var target = btn.getAttribute('data-prev');
273
- var tab = document.getElementById(target);
274
- if (tab) new bootstrap.Tab(tab).show();
539
+ showStep(document.getElementById(btn.getAttribute('data-prev')));
275
540
  });
276
541
  });
277
542
 
278
- // Live update on review tab activation
279
- var reviewTab = document.getElementById('tab-review');
280
- if (reviewTab) {
281
- reviewTab.addEventListener('shown.bs.tab', updatePreview);
282
- }
543
+ // Clicking a stepper entry directly.
544
+ wizard.querySelectorAll('#wizardTabs .wizard-step').forEach(function (btn) {
545
+ btn.addEventListener('click', function () { showStep(btn); });
546
+ });
547
+
548
+ // The preview is persistent now, so it regenerates on EVERY input rather
549
+ // than on entering step 5. Delegated, so fields added later are covered.
550
+ wizard.addEventListener('input', function (event) {
551
+ var el = event.target;
552
+ updatePreview();
553
+ // Typing clears a previous error rather than nagging mid-entry; the
554
+ // re-check happens on blur.
555
+ if (el.classList && el.classList.contains('is-invalid')) validateField(el);
556
+ saveDraft();
557
+ });
558
+
559
+ wizard.addEventListener('change', function () {
560
+ updatePreview();
561
+ refreshStepper();
562
+ saveDraft();
563
+ });
564
+
565
+ // Validate email / URL on blur (capture: `blur` does not bubble).
566
+ wizard.addEventListener('blur', function (event) {
567
+ if (!event.target.classList || !event.target.classList.contains('cfg-field')) return;
568
+ validateField(event.target);
569
+ refreshStepper();
570
+ }, true);
283
571
 
284
572
  // Description char count
285
573
  var descField = document.getElementById('cfg-description');
286
574
  var descCount = document.getElementById('desc-count');
287
575
  if (descField && descCount) {
288
- descField.addEventListener('input', function () {
289
- descCount.textContent = descField.value.length;
290
- });
576
+ var syncCount = function () { descCount.textContent = descField.value.length; };
577
+ descField.addEventListener('input', syncCount);
578
+ descCount.textContent = descField.value.length;
291
579
  }
292
580
 
293
- // Download / Copy
581
+ // Download / Copy — always enabled, at every step.
294
582
  var dlBtn = document.getElementById('btn-download');
295
583
  if (dlBtn) dlBtn.addEventListener('click', downloadYAML);
296
584
 
@@ -300,8 +588,22 @@
300
588
  var copyFullBtn = document.getElementById('btn-copy-full');
301
589
  if (copyFullBtn) copyFullBtn.addEventListener('click', copyYAML);
302
590
 
303
- // Initial preview
591
+ // A queued draft write must not be lost to a reload or a tab close.
592
+ window.addEventListener('pagehide', flushDraft);
593
+ document.addEventListener('visibilitychange', function () {
594
+ if (document.visibilityState === 'hidden') flushDraft();
595
+ });
596
+
597
+ // Order matters: restore values first so the initial preview and stepper
598
+ // state both reflect the real content.
599
+ restoreDraft();
600
+ if (descField && descCount) descCount.textContent = descField.value.length;
304
601
  updatePreview();
602
+ refreshStepper();
603
+
604
+ // No pane measurement and no resize listener: the grid in
605
+ // _setup-wizard.scss keeps `#wizardTabContent` as tall as the tallest pane
606
+ // at every width, without JS having to be told the width changed.
305
607
  });
306
608
 
307
609
  })();
data/scripts/README.md CHANGED
@@ -25,6 +25,10 @@ scripts/
25
25
  │ ├── pixelate_images.py # Pure-stdlib pixelate/quantize engine
26
26
  │ ├── install-preview-generator # Preview generator installer
27
27
  │ └── validate_preview_urls.py # Preview URL validator
28
+ ├── ci/ # Helpers called directly by workflow steps
29
+ │ ├── classify_changes.py # Change classifier (issue-pr-auto-merge)
30
+ │ ├── agent_review_result.py # Did the Claude content review actually run?
31
+ │ └── test_agent_review_result.py # …its tests (CI-run via test/lib bridge)
28
32
  ├── utils/ # Utility scripts
29
33
  │ ├── analyze-commits # Commit analyzer for version bumps
30
34
  │ ├── fix-markdown # Markdown formatting fixer
@@ -171,6 +175,31 @@ Validate preview image URLs in frontmatter.
171
175
  python3 scripts/features/validate_preview_urls.py [--verbose] [--suggestions]
172
176
  ```
173
177
 
178
+ ### CI Helpers (scripts/ci/)
179
+
180
+ #### `agent_review_result.py`
181
+
182
+ Decides whether the Claude tier of `ai-content-review.yml` **actually produced a review**, and fails the job when it did not.
183
+
184
+ Before this existed, the agent step captured the Claude CLI's exit status, echoed it, and then posted whatever landed on stdout as the review. When the OAuth credential was revoked, `Failed to authenticate. API Error: 401 OAuth access token has been revoked.` became the "review" and every job still reported success (issue #418) — for days, across several PRs, with nothing on the checks list to say the editorial review had stopped happening.
185
+
186
+ Note that a non-zero exit code would not have caught it: the CLI reported the auth failure on stdout. Three conditions are classified — a non-zero exit, no output at all, and a short output that leads with a known CLI failure signature. Any of them writes an explicit failure notice for the sticky comment, emits a `::error::` annotation, and exits 1.
187
+
188
+ ```bash
189
+ python3 scripts/ci/agent_review_result.py \
190
+ --status "$status" --stdout /tmp/agent-review.md \
191
+ --stderr /tmp/agent-err.log --out /tmp/agent-review-final.md
192
+ # exit 0 = the review is real · 1 = the tier failed · 2 = bad invocation
193
+
194
+ python3 scripts/ci/test_agent_review_result.py # tests, incl. the verbatim #418 output
195
+ ```
196
+
197
+ The tests run in CI through `scripts/test/lib/test_agent_review_result.sh`, which `run_tests.sh` sources and `./scripts/bin/test` executes on every PR.
198
+
199
+ #### `classify_changes.py`
200
+
201
+ Classifies a PR's changed files for `issue-pr-auto-merge.yml` (`--content-only`).
202
+
174
203
  ### Content Validation
175
204
 
176
205
  #### `lint-pages`
@@ -118,14 +118,35 @@ done
118
118
 
119
119
  CONSUMER_PATH="$(cd "$CONSUMER_PATH" && pwd)"
120
120
 
121
+ # In --format json, stdout must be PURE JSON so `jq` / `json.load` can consume
122
+ # it directly. The human-readable log lines are still useful, so send them to
123
+ # stderr rather than dropping them. (Left on stdout, they made every --format
124
+ # json run unparseable even after the trailing-comma fix.)
125
+ if [[ "$FORMAT" == "json" ]]; then
126
+ info() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
127
+ step() { echo -e "${CYAN}[STEP]${NC} $1" >&2; }
128
+ success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
129
+ warn() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
130
+ fi
131
+
121
132
  # ---------------------------------------------------------------------------
122
133
  step "Auditing consumer: $CONSUMER_PATH"
123
134
 
124
- # Detect or confirm mode
135
+ # CONSUMER_ROOT holds the config/override files; CONSUMER_PATH is where the
136
+ # theme-shadowing files actually live. They differ when the consumer sets
137
+ # Jekyll's `source:` (zer0-pages keeps its whole site tree under `pages/`).
138
+ CONSUMER_ROOT="$CONSUMER_PATH"
139
+ CONSUMER_SOURCE_REL=$(detect_consumer_source "$CONSUMER_ROOT")
140
+ if [[ -n "$CONSUMER_SOURCE_REL" && -d "$CONSUMER_ROOT/$CONSUMER_SOURCE_REL" ]]; then
141
+ CONSUMER_PATH="$CONSUMER_ROOT/$CONSUMER_SOURCE_REL"
142
+ info "Jekyll source: $CONSUMER_SOURCE_REL/ — scanning there, not the repo root"
143
+ fi
144
+
145
+ # Detect or confirm mode (from the repo root, where _config.yml lives)
125
146
  if [[ -n "$MODE_OVERRIDE" ]]; then
126
147
  MODE="$MODE_OVERRIDE"
127
148
  else
128
- MODE=$(detect_consumer_mode "$CONSUMER_PATH")
149
+ MODE=$(detect_consumer_mode "$CONSUMER_ROOT")
129
150
  info "Detected mode: $MODE"
130
151
  fi
131
152
 
@@ -133,7 +154,7 @@ fi
133
154
  if [[ -n "$THEME_PATH_OVERRIDE" ]]; then
134
155
  RESOLVED_THEME="$THEME_PATH_OVERRIDE"
135
156
  else
136
- RESOLVED_THEME=$(resolve_theme_path "$MODE" "$CONSUMER_PATH" "$THEME_ROOT")
157
+ RESOLVED_THEME=$(resolve_theme_path "$MODE" "$CONSUMER_ROOT" "$THEME_ROOT")
137
158
  fi
138
159
 
139
160
  if [[ -z "$RESOLVED_THEME" || ! -d "$RESOLVED_THEME" ]]; then
@@ -149,7 +170,10 @@ if [[ ! -f "$MANIFEST" ]]; then
149
170
  fi
150
171
 
151
172
  # Load overrides manifest from consumer
152
- OVERRIDES_FILE="$CONSUMER_PATH/.theme-overrides.yml"
173
+ # Declared overrides sit next to _config.yml at the repo root; fall back to the
174
+ # source dir so either placement works.
175
+ OVERRIDES_FILE="$CONSUMER_ROOT/.theme-overrides.yml"
176
+ [[ -f "$OVERRIDES_FILE" ]] || OVERRIDES_FILE="$CONSUMER_PATH/.theme-overrides.yml"
153
177
  OVERRIDE_PATHS_TMP=$(mktemp)
154
178
  trap 'rm -f "$OVERRIDE_PATHS_TMP"' EXIT
155
179
 
@@ -190,8 +214,14 @@ if [[ ${#THEMABLE_DIRS[@]} -eq 0 ]]; then
190
214
  THEMABLE_DIRS=("_layouts" "_includes" "_sass" "assets/css" "assets/js")
191
215
  fi
192
216
  if [[ ${#REQUIRED_PLUGIN_FILES[@]} -eq 0 && ${#OPTIONAL_PLUGIN_FILES[@]} -eq 0 ]]; then
193
- REQUIRED_PLUGIN_FILES=("_plugins/obsidian_links.rb")
194
- OPTIONAL_PLUGIN_FILES=("_plugins/admin_page_urls.rb" "_plugins/content_statistics_generator.rb" "_plugins/theme_version.rb")
217
+ # No manifest at all. Nothing is genuinely required (GitHub Pages loads no
218
+ # local plugins), so every known plugin is offered as optional.
219
+ OPTIONAL_PLUGIN_FILES=(
220
+ "_plugins/obsidian_links.rb"
221
+ "_plugins/admin_page_urls.rb"
222
+ "_plugins/content_statistics_generator.rb"
223
+ "_plugins/theme_version.rb"
224
+ )
195
225
  fi
196
226
 
197
227
  # ---------------------------------------------------------------------------
@@ -316,7 +346,9 @@ fi
316
346
  # ---------------------------------------------------------------------------
317
347
  # Summary
318
348
  # ---------------------------------------------------------------------------
319
- [[ "$FORMAT" == "json" ]] && echo ']}'
349
+ # Entries are printed without a trailing newline (see print_classification),
350
+ # so close the array on its own line.
351
+ [[ "$FORMAT" == "json" ]] && printf '\n]}\n'
320
352
 
321
353
  if [[ "$FORMAT" == "text" ]]; then
322
354
  echo ""