jekyll-theme-zer0 1.30.0 → 1.31.1

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +748 -571
  3. data/README.md +17 -3
  4. data/_data/backlog.yml +33 -10
  5. data/_data/consumers.yml +99 -0
  6. data/_data/features.yml +1 -1
  7. data/_data/i18n/fr.yml +30 -14
  8. data/_data/i18n/manifest.yml +63 -45
  9. data/_data/theme-manifest.yml +41 -39
  10. data/_data/ui-text.yml +1 -0
  11. data/_includes/README.md +1 -1
  12. data/_includes/analytics/posthog.html +28 -9
  13. data/_includes/components/README.md +1 -1
  14. data/_includes/components/background-image.html +7 -1
  15. data/_includes/components/cookie-consent.html +30 -30
  16. data/_includes/components/halfmoon.html +5 -3
  17. data/_includes/components/mermaid.html +14 -7
  18. data/_includes/components/preview-image.html +22 -3
  19. data/_includes/components/theme-preview-gallery.html +1 -1
  20. data/_includes/content/intro.html +21 -3
  21. data/_includes/content/seo.html +8 -0
  22. data/_includes/content/sitemap.html +220 -263
  23. data/_includes/core/branding.html +8 -15
  24. data/_includes/core/footer.html +3 -1
  25. data/_includes/core/head.html +33 -22
  26. data/_includes/core/header.html +14 -2
  27. data/_includes/core/tokens-inline.html +16 -1
  28. data/_includes/navigation/breadcrumbs.html +21 -15
  29. data/_includes/navigation/navbar.html +9 -6
  30. data/_includes/setup/wizard.html +5 -1
  31. data/_layouts/article.html +15 -3
  32. data/_layouts/home.html +13 -1
  33. data/_layouts/note.html +2 -1
  34. data/_layouts/notebook.html +2 -1
  35. data/_layouts/root.html +22 -0
  36. data/_sass/components/_cookie-banner.scss +48 -0
  37. data/_sass/components/_setup-wizard.scss +19 -1
  38. data/_sass/components/_ui-enhancements.scss +11 -0
  39. data/_sass/core/_navbar.scss +163 -70
  40. data/_sass/core/_sidebar-extras.scss +2 -14
  41. data/_sass/core/code-copy.scss +27 -8
  42. data/_sass/theme/_skins.scss +6 -2
  43. data/assets/css/extension-points-demo.css +27 -0
  44. data/assets/data/wiki-index.json +19 -0
  45. data/assets/js/auto-hide-nav.js +53 -13
  46. data/assets/js/code-copy.js +72 -1
  47. data/assets/js/extension-points-demo.js +48 -0
  48. data/assets/js/halfmoon.js +11 -1
  49. data/assets/js/mermaid-diagrams.js +128 -22
  50. data/assets/js/obsidian-local-graph.js +27 -5
  51. data/assets/js/search-modal.js +35 -3
  52. data/assets/js/setup-wizard.js +7 -2
  53. data/scripts/bin/validate +1 -0
  54. data/scripts/ci/test_visual_evidence_autogen.py +64 -2
  55. data/scripts/ci/visual_evidence_autogen.py +14 -4
  56. data/scripts/features/install-preview-generator +1 -1
  57. data/scripts/lib/README.md +12 -0
  58. data/scripts/lib/preview_generator.py +2 -2
  59. data/scripts/validate-features.rb +5 -8
  60. metadata +4 -2
@@ -6,10 +6,12 @@
6
6
  // Breakpoints:
7
7
  // - Desktop >= 992px (inline nav; label density via @container bd-nav)
8
8
  // - Mobile < 992px (offcanvas)
9
- // Nav label tiers (center track / bd-nav container width):
9
+ // Nav label tiers (center track / bd-nav container width) — TWO, never truncating:
10
10
  // - >= 51rem (~816px): icon + full label
11
- // - 41-50.99rem: label only (icons dropped so the labels still fit)
12
- // - < 41rem (~656px): icon-only (tooltips on lg–xl viewports)
11
+ // - < 51rem: icon-only (tooltips on lg–xl viewports)
12
+ // Icons are present in BOTH tiers. The old bare-label middle tier and the
13
+ // `text-overflow: ellipsis` safety net were removed in #405 — a truncated
14
+ // label ("Quicksta…") is worse than an icon with a tooltip.
13
15
  // ==============================================================================
14
16
 
15
17
  // -----------------------------------------------------------------------------
@@ -92,6 +94,20 @@
92
94
  }
93
95
  }
94
96
 
97
+ // The below-lg menu toggle carries a visible "Menu" label beside its glyph, so
98
+ // it cannot be confused with the sidebar hamburger sitting next to it (#405).
99
+ .navbar-toggler.navbar-toggler-labeled {
100
+ display: inline-flex;
101
+ align-items: center;
102
+ gap: 0.375rem;
103
+ width: auto;
104
+
105
+ .navbar-toggler-text {
106
+ font-size: 0.9375rem;
107
+ line-height: 1;
108
+ }
109
+ }
110
+
95
111
  // Shared icon-only nav styling (applied via @container bd-nav below)
96
112
  @mixin navbar-nav-icon-only {
97
113
  #bdNavbar .nav-link .nav-link-text {
@@ -396,16 +412,29 @@
396
412
  transform: rotate(180deg);
397
413
  }
398
414
 
399
- // Label density degrades in three tiers, measured on the center track
400
- // (the `bd-nav` container), so items fit instead of ellipsizing:
415
+ // Label density has TWO tiers, measured on the center track (the `bd-nav`
416
+ // container), so items either fit whole or drop to icons — never truncate:
401
417
  // >= 51rem (816px) icon + label — the roomy default
402
- // 41-50.99rem label only — icons dropped to buy ~20px/item
403
- // < 41rem (656px) icon only — labels no longer fit at all
404
- // Each boundary is the MEASURED width the tier below it needs for the
405
- // theme's seven demo items (815px with icons, 647px without), so a tier only
406
- // engages once its own content fits. Items keep min-width:0 + ellipsis as the
407
- // last-resort safety net for consumers with more or longer entries.
408
- @container bd-nav (min-width: 41rem) {
418
+ // < 51rem icon only — icon + label no longer fits
419
+ // 51rem is the MEASURED width the icon+label row needs for the theme's seven
420
+ // demo items (815px), so the upper tier engages exactly when its own content
421
+ // fits and the lower one takes over otherwise.
422
+ //
423
+ // There used to be a middle 41-50.99rem tier that dropped the ICONS to buy
424
+ // ~20px per item, plus `text-overflow: ellipsis` as a last-resort safety net.
425
+ // Both are gone (#405): the ellipsis produced "Quicksta…", and a clipped
426
+ // label is worse than an icon with a tooltip — the icon is at least honest
427
+ // about being an abbreviation. Icons now survive at every tier, and the
428
+ // icon-only cutoff moved 41rem -> 51rem to take over the band the middle
429
+ // tier used to hold. That is a deliberate behaviour change at 656-816px of
430
+ // center track: those widths used to show bare labels and now show icons,
431
+ // with `.nav-tooltip` (992-1199px) carrying the name on hover.
432
+ //
433
+ // Consumers with materially more (or longer) items than the theme's seven
434
+ // demo entries can exceed 51rem of content; with shrink and ellipsis both
435
+ // removed, the row overflows rather than truncates. A container query
436
+ // cannot measure content, so raise the boundary below if that happens.
437
+ @container bd-nav (min-width: 51rem) {
409
438
  #bdNavbar .navbar-nav {
410
439
  gap: 0.125rem;
411
440
  }
@@ -414,28 +443,34 @@
414
443
  padding: 0.375rem;
415
444
  }
416
445
 
446
+ // No `overflow: hidden` / `text-overflow: ellipsis` here: this tier only
447
+ // engages once the whole icon+label row fits, so a clipped label would be
448
+ // a bug rather than a fallback. Labels stay whole or the row goes
449
+ // icon-only (#405).
417
450
  #bdNavbar .nav-link .nav-link-text {
418
451
  display: inline !important;
419
452
  margin-left: 0;
420
453
  min-width: 0;
421
- overflow: hidden;
422
- text-overflow: ellipsis;
423
454
  white-space: nowrap;
424
455
  transition: opacity 0.15s ease-in-out;
425
456
  }
426
457
 
458
+ // Items do NOT shrink in this tier. Shrinking is what produced the
459
+ // clipped label this tier removes: the tier only engages once the full
460
+ // icon+label row fits, so an item that shrinks here is being cut for no
461
+ // reason.
427
462
  #bdNavbar .nav-item,
428
463
  #bdNavbar .nav-hover-dropdown {
429
- flex: 0 1 auto;
464
+ flex: 0 0 auto;
430
465
  min-width: 0;
431
466
  }
432
467
 
433
468
  #bdNavbar .nav-item > .nav-link:not(.dropdown-toggle-split),
434
469
  #bdNavbar .nav-hover-dropdown > .nav-link:first-child {
435
- flex: 1 1 auto;
470
+ flex: 0 0 auto;
436
471
  min-width: 0;
437
472
  max-width: 100%;
438
- overflow: hidden;
473
+ overflow: visible;
439
474
  gap: 0.25rem;
440
475
  }
441
476
 
@@ -444,52 +479,72 @@
444
479
  font-size: 1rem;
445
480
  }
446
481
 
447
- #bdNavbar .nav-link:hover {
482
+ // Hover lives on the ROW, not on each control. With the chevron overlaying
483
+ // the link's padding box (below), a per-control background would light
484
+ // only half the row as the pointer crossed it.
485
+ #bdNavbar .nav-item:not(.nav-hover-dropdown) > .nav-link:hover,
486
+ #bdNavbar .nav-hover-dropdown:hover > .nav-link:first-child {
448
487
  background-color: var(--bs-tertiary-bg);
449
488
  border-radius: 0.375rem;
450
489
  }
451
490
 
452
- .nav-hover-dropdown > .nav-link {
453
- border-top-right-radius: 0;
454
- border-bottom-right-radius: 0;
455
- }
456
-
457
- .nav-hover-dropdown > .dropdown-toggle-split {
458
- border-top-left-radius: 0;
459
- border-bottom-left-radius: 0;
460
- min-width: 1.25rem;
461
- min-height: 0;
462
- width: 1.25rem;
463
- padding-left: 0;
464
- padding-right: 0;
465
- margin-left: 0 !important;
466
- }
467
- }
468
-
469
- // Middle tier — the labels fit but the icons no longer do. Dropping the
470
- // decorative icon (the label is the accessible name) and tightening the
471
- // chevron buys back roughly 20px per item, which is the difference between
472
- // "Notebooks" and "N…" at 992-1200px viewports.
473
- @container bd-nav (min-width: 41rem) and (max-width: 50.99rem) {
474
- #bdNavbar .nav-link i {
475
- display: none;
491
+ // Chevron merged into the parent link's box (#405). It stays a real
492
+ // <button> — keyboard reachable, `aria-expanded`/`aria-haspopup` intact —
493
+ // and is only repositioned: absolutely placed over the right end of the
494
+ // link's padding, which the link reserves with extra padding-right. There
495
+ // is no longer a gap between the two, so the dead zone between label and
496
+ // chevron is gone and the whole row is one hover target.
497
+ // Every rule below is scoped with #bdNavbar deliberately: the tier's own
498
+ // `#bdNavbar .nav-link { padding: 0.375rem }` above carries an id, so a
499
+ // class-only `padding-right` here would lose to it and the chevron would
500
+ // have no reserved space to sit in.
501
+ #bdNavbar .nav-hover-dropdown {
502
+ position: relative;
476
503
  }
477
504
 
478
- #bdNavbar .nav-item > .nav-link:not(.dropdown-toggle-split),
505
+ // 1.5rem (24px) is the WCAG 2.2 SC 2.5.8 target-size minimum: the
506
+ // chevron overlay below is this wide, so the link reserves the same.
507
+ // (It was 1.25rem = 20px, which axe flagged as `target-size`.)
479
508
  #bdNavbar .nav-hover-dropdown > .nav-link:first-child {
480
- gap: 0;
509
+ border-radius: 0.375rem;
510
+ padding-right: 1.5rem;
481
511
  }
482
512
 
483
- // Scoped with #bdNavbar to outrank the shared base rule at the top of this
484
- // file, which sets the chevron to 1.5rem.
485
513
  #bdNavbar .nav-hover-dropdown > .dropdown-toggle-split {
486
- min-width: 1rem;
487
- width: 1rem;
514
+ position: absolute;
515
+ top: 0;
516
+ right: 0;
517
+ bottom: 0;
518
+ z-index: 1;
519
+ display: flex;
520
+ align-items: center;
521
+ justify-content: center;
522
+ width: 1.5rem;
523
+ min-width: 1.5rem;
524
+ min-height: 1.5rem;
525
+ margin: 0 !important;
526
+ padding: 0;
527
+ background-color: transparent;
528
+ border: 0;
529
+ border-radius: 0 0.375rem 0.375rem 0;
530
+ }
531
+
532
+ // The overlay must not paint its own hover background — the row already
533
+ // does. Without this the chevron reads as a separate control again, which
534
+ // is the whole defect. `!important` because the shared base block at the
535
+ // top of this file sets the chevron's own hover background with equal
536
+ // specificity and later source order.
537
+ #bdNavbar .nav-hover-dropdown > .dropdown-toggle-split:hover,
538
+ #bdNavbar .nav-hover-dropdown > .dropdown-toggle-split:focus-visible,
539
+ #bdNavbar .nav-hover-dropdown > .dropdown-toggle-split[aria-expanded="true"] {
540
+ background-color: transparent !important;
488
541
  }
489
542
  }
490
543
 
491
- // Icon-only when the center track is too narrow even for bare labels
492
- @container bd-nav (max-width: 40.99rem) {
544
+ // Icon-only when the center track cannot fit the icon+label row. The
545
+ // boundary is the complement of the tier above so the two tiers tile the
546
+ // whole range with no gap.
547
+ @container bd-nav (max-width: 50.99rem) {
493
548
  @include navbar-nav-icon-only;
494
549
  }
495
550
  }
@@ -1099,8 +1154,7 @@
1099
1154
  align-items: center;
1100
1155
  }
1101
1156
 
1102
- #navbar .site-title-text,
1103
- #navbar .site-subtitle-text {
1157
+ #navbar .site-title-text {
1104
1158
  display: inline-block;
1105
1159
  }
1106
1160
 
@@ -1137,9 +1191,28 @@
1137
1191
 
1138
1192
  // Desktop (lg+): 3-column grid — [brand/title | main nav | utilities] so nav cannot paint over Search/Settings
1139
1193
  @media (min-width: 992px) {
1194
+ // Optical centering (#405) WITHOUT breaking the container queries.
1195
+ //
1196
+ // The obvious spelling — `1fr auto 1fr` — is a trap here. Track 2 must stay
1197
+ // a `1fr` (a DEFINITE width): `.bd-navbar-nav-viewport` inside it is
1198
+ // `width: 100%` + `container-type: inline-size`, and that percentage only
1199
+ // resolves against a definite track. Make track 2 `auto` and it becomes
1200
+ // content-sized, size containment hands `bd-nav` a degenerate width, and
1201
+ // EVERY `@container bd-nav` label tier stops firing — the bar collapses to
1202
+ // icon-only at all widths, which is the opposite of what #405 is for.
1203
+ //
1204
+ // So the nav keeps the elastic middle track and the two side tracks are
1205
+ // equalised instead, via a shared floor. Whenever the brand and the
1206
+ // utility cluster both fit inside it (the common case), the side tracks
1207
+ // are the same width and the menubar is centred in the bar, not merely
1208
+ // centred in its own track. The floor is deliberately small so it never
1209
+ // steals the width the label tier needs at 1280px.
1140
1210
  #navbar .navbar-main {
1141
1211
  display: grid !important;
1142
- grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
1212
+ grid-template-columns:
1213
+ minmax(var(--zer0-navbar-side-min, 9rem), auto)
1214
+ minmax(0, 1fr)
1215
+ minmax(var(--zer0-navbar-side-min, 9rem), auto);
1143
1216
  align-items: center;
1144
1217
  column-gap: 0.75rem;
1145
1218
  row-gap: 0.25rem;
@@ -1208,8 +1281,7 @@
1208
1281
  display: none;
1209
1282
  }
1210
1283
 
1211
- #navbar .site-title,
1212
- #navbar .site-subtitle {
1284
+ #navbar .site-title {
1213
1285
  flex-shrink: 1;
1214
1286
  min-width: 0;
1215
1287
  }
@@ -1227,15 +1299,6 @@
1227
1299
  max-width: none;
1228
1300
  }
1229
1301
 
1230
- #navbar .site-subtitle .site-subtitle-text {
1231
- overflow: hidden;
1232
- text-overflow: ellipsis;
1233
- white-space: nowrap;
1234
- display: inline-block;
1235
- vertical-align: bottom;
1236
- max-width: none;
1237
- }
1238
-
1239
1302
  // Progressive brand degradation (container width, not viewport).
1240
1303
  // The home-link icons add ~108px to the brand cluster, so they only appear
1241
1304
  // once the bar is wide enough to carry them AND the full menubar
@@ -1247,12 +1310,6 @@
1247
1310
  }
1248
1311
  }
1249
1312
 
1250
- @container navbar-main (max-width: 68rem) {
1251
- #navbar .site-subtitle {
1252
- display: none !important;
1253
- }
1254
- }
1255
-
1256
1313
  @container navbar-main (max-width: 50rem) {
1257
1314
  #navbar .site-title .site-title-text {
1258
1315
  max-width: 100%;
@@ -1313,11 +1370,47 @@
1313
1370
  vertical-align: middle;
1314
1371
  }
1315
1372
 
1373
+ // Phones: at the 20px .navbar-brand size a typical title loses its tail
1374
+ // once the sidebar toggle, logo, search and labelled Menu toggle share the
1375
+ // bar ("Lifehacker.dev" -> "Lifehacker…" on post pages at 400px, 121px
1376
+ // available for 133px of text). Step the title down so it fits; the
1377
+ // ellipsis above still covers titles that are longer still. The logo's
1378
+ // Bootstrap margin-end (1rem) also stacks on the brand group's 0.5rem gap
1379
+ // there; the gap alone is enough spacing and hands the title 16px back.
1380
+ @media (max-width: 575.98px) {
1381
+ #navbar .site-title-text {
1382
+ font-size: 1rem;
1383
+ }
1384
+
1385
+ #navbar .navbar-brand-group > .navbar-brand {
1386
+ margin-right: 0;
1387
+ }
1388
+ }
1389
+
1390
+ @media (max-width: 374.98px) {
1391
+ #navbar .site-title-text {
1392
+ font-size: 0.9375rem;
1393
+ }
1394
+ }
1395
+
1316
1396
  #navbar .navbar-toggler {
1317
1397
  width: 2.5rem;
1318
1398
  height: 2.5rem;
1319
1399
  }
1320
1400
 
1401
+ // The square above is for BARE-GLYPH togglers. The labelled menu toggle
1402
+ // (#405) also carries a "Menu" span, and 2.5rem cannot hold glyph + gap +
1403
+ // label — the text would escape the button box and push the page past the
1404
+ // viewport at every width below lg, which is exactly what makes a
1405
+ // fixed-top bar read as "cut off". `.navbar-toggler-labeled`'s own
1406
+ // `width: auto` loses to the ID selector above on specificity (0,2,0 vs
1407
+ // 1,1,0); re-assert it here, where it outranks.
1408
+ #navbar .navbar-toggler.navbar-toggler-labeled {
1409
+ width: auto;
1410
+ min-width: 2.5rem;
1411
+ flex-shrink: 0;
1412
+ }
1413
+
1321
1414
  #navbar .nav-search-button {
1322
1415
  width: 2.5rem;
1323
1416
  height: 2.5rem;
@@ -20,20 +20,8 @@
20
20
 
21
21
  // MOVED → _sass/layouts/_navbar-extras.scss (token-aware FAB stacking)
22
22
  // MOVED → _sass/components/_cookie-banner.scss (token-aware, dark-mode-safe)
23
- // Retained shim: keep stacked-button responsive sizing inside the banner
24
- // because it is a banner-internal concern, not a banner-shell concern.
25
- .cookie-consent-banner {
26
- @media (max-width: 768px) {
27
- .btn {
28
- width: 100%;
29
- margin-bottom: 0.5rem;
30
-
31
- &:last-child {
32
- margin-bottom: 0;
33
- }
34
- }
35
- }
36
- }
23
+ // The banner's internal layout (formerly a full-width stacked-button shim
24
+ // here) lives with the shell in _sass/components/_cookie-banner.scss.
37
25
 
38
26
  // Active TOC link highlighting
39
27
  .bd-toc nav a.active {
@@ -357,14 +357,33 @@ pre.highlight > button:focus {
357
357
  opacity: 1;
358
358
  }
359
359
 
360
- .button,
361
- button:not(.copy) {
362
- display: inline-block;
363
- padding: 0 20px;
364
- border-radius: 4px;
365
- border: 1px solid #bbb;
366
- font-size: 11px;
367
- cursor: pointer;
360
+ // Extra actions inside a code block — SCOPED TO THIS COMPONENT (#412).
361
+ //
362
+ // This block used to read `.button, button:not(.copy)` with no scope at all.
363
+ // An element selector in a component partial is a site-wide rule: it restyled
364
+ // EVERY button on a consumer site that was not the theme's own copy button —
365
+ // `padding: 0 20px`, `font-size: 11px`, and a hardcoded `#bbb` border that
366
+ // bypassed the design tokens entirely. At specificity (0,1,1) it also outranked
367
+ // `.btn` and any consumer's own `.my-button`, so escaping it meant a
368
+ // specificity fight rather than a cascade (it-journey#634 had to scope to
369
+ // `.code-block-header .itj-clip-btn` to win). A component partial styles its
370
+ // own component and nothing else.
371
+ //
372
+ // The rule is kept, not deleted: a consumer that adds an action to the header
373
+ // (see the `zer0:code-block-ready` contract in assets/js/code-copy.js) should
374
+ // inherit the copy button's shape instead of arriving unstyled. Values are
375
+ // tokens now, so a skin moves them.
376
+ .code-block-header,
377
+ pre.highlight {
378
+ > .button,
379
+ > button:not(.copy) {
380
+ display: inline-block;
381
+ padding: 0 var(--zer0-space-3);
382
+ border-radius: var(--zer0-radius-sm);
383
+ border: 1px solid var(--zer0-color-border);
384
+ font-size: var(--zer0-text-sm);
385
+ cursor: pointer;
386
+ }
368
387
  }
369
388
 
370
389
  // Non-highlight fenced code blocks
@@ -78,11 +78,15 @@
78
78
  }
79
79
 
80
80
  // Links (Bootstrap + theme)
81
- a:not(.btn):not(.nav-link):not(.dropdown-item) {
81
+ // `.badge` is excluded so tag/category chips rendered as <a class="badge
82
+ // bg-*"> keep Bootstrap's badge text colour (--bs-badge-color, #fff) instead
83
+ // of the skin link colour. On neon that was #5e22cc on bg-primary #8338ec
84
+ // (1.45:1) and on bg-secondary (1.74:1).
85
+ a:not(.btn):not(.nav-link):not(.dropdown-item):not(.badge) {
82
86
  color: var(--bs-link-color);
83
87
  }
84
88
 
85
- a:not(.btn):hover {
89
+ a:not(.btn):not(.badge):hover {
86
90
  color: var(--bs-link-hover-color);
87
91
  }
88
92
 
@@ -0,0 +1,27 @@
1
+ /* ==========================================================================
2
+ extension-points-demo.css — a live consumer of the per-page `styles:` hook
3
+ ==========================================================================
4
+
5
+ Loaded ONLY by /docs/customization/extension-points/, through that page's
6
+ `styles:` frontmatter. Its job is to prove the hook works and to mark the
7
+ demo action button so the page's own prose can point at it.
8
+
9
+ Note what it does NOT have to do: fight a specificity war. Before #412,
10
+ `_sass/core/code-copy.scss` shipped a bare `button:not(.copy)` rule at
11
+ specificity (0,1,1), which outranked any single class a consumer could
12
+ write — so downstream code had to scope to `.code-block-header .my-btn`
13
+ just to set its own padding. That rule is scoped to the component now, and
14
+ a plain class is enough again.
15
+ ========================================================================== */
16
+
17
+ .zer0-demo-action {
18
+ background: transparent;
19
+ color: var(--zer0-color-text-muted);
20
+ font-family: var(--zer0-font-mono);
21
+ line-height: 1.8;
22
+ }
23
+
24
+ .zer0-demo-action:hover,
25
+ .zer0-demo-action:focus-visible {
26
+ color: inherit;
27
+ }
@@ -142,11 +142,30 @@ sitemap: false
142
142
  {%- endif -%}
143
143
  {%- endfor -%}
144
144
  {%- assign outgoing = outgoing | uniq -%}
145
+ {%- comment -%}
146
+ `lastmod` and `description` (#412) — both already on `doc` in this loop,
147
+ so neither costs a new traversal. A notes/library UI needs them:
148
+ `lastmod` to sort by recency or show staleness, and `description`
149
+ because `excerpt` is body-derived while `description` is the AUTHORED
150
+ subtitle, which is the better card line whenever it exists.
151
+
152
+ Source order for the date follows the rest of the theme
153
+ (_includes/core/head.html, _layouts/article.html): `last_modified_at`,
154
+ then `lastmod`, then the document's own `date`. Emitted as ISO-8601 via
155
+ `date_to_xmlschema` so a consumer can sort strings without parsing.
156
+ Both keys are always present and always defined — `null` when the
157
+ document carries neither — rather than being conditionally omitted,
158
+ because a key that sometimes disappears is a worse contract than one
159
+ that is sometimes null.
160
+ {%- endcomment -%}
161
+ {%- assign doc_lastmod = doc.last_modified_at | default: doc.lastmod | default: doc.date -%}
145
162
  {
146
163
  "title": {{ doc.title | default: basename | jsonify }},
147
164
  "basename": {{ basename | jsonify }},
148
165
  "url": {{ doc.url | jsonify }},
149
166
  "collection": {{ doc.collection | default: nil | jsonify }},
167
+ "lastmod": {% if doc_lastmod %}{{ doc_lastmod | date_to_xmlschema | jsonify }}{% else %}null{% endif %},
168
+ "description": {{ doc.description | default: nil | jsonify }},
150
169
  "tags": {{ doc.tags | default: empty | jsonify }},
151
170
  "categories": {{ doc.categories | default: empty | jsonify }},
152
171
  "aliases": {{ aliases | jsonify }},
@@ -34,21 +34,61 @@
34
34
  // The measured height is also published as --zer0-header-height so
35
35
  // stylesheets (theme or consumer) can align overlays with the real
36
36
  // header instead of hard-coding its pixel height.
37
- function updateBodyPadding() {
38
- const navbarHeight = navbar.offsetHeight;
39
- document.body.style.paddingTop = navbarHeight + 'px';
40
- document.documentElement.style.setProperty('--zer0-header-height', navbarHeight + 'px');
37
+ //
38
+ // Layout is never read synchronously here. Reading navbar.offsetHeight
39
+ // at DOMContentLoaded (and again on every resize) forced a style +
40
+ // layout pass while stylesheets were still settling (Lighthouse
41
+ // "forced reflow", ~233ms per read on a throttled phone). Instead:
42
+ // - ResizeObserver delivers the navbar's border-box height after the
43
+ // browser's own layout pass, before paint, so the padding still
44
+ // lands in the first frame;
45
+ // - without ResizeObserver, the read happens in a requestAnimationFrame
46
+ // callback, and the write in the frame after it, so a read never
47
+ // follows a write in the same pass;
48
+ // - the last height is cached and nothing is written when it is
49
+ // unchanged.
50
+ let lastNavbarHeight = -1;
51
+
52
+ function applyNavbarHeight(height) {
53
+ height = Math.round(height);
54
+ if (height === lastNavbarHeight) return;
55
+ lastNavbarHeight = height;
56
+ document.body.style.paddingTop = height + 'px';
57
+ document.documentElement.style.setProperty('--zer0-header-height', height + 'px');
41
58
  }
42
59
 
43
- // Initial padding setup
44
- updateBodyPadding();
45
-
46
- // Update padding on window resize with debounce
47
- let resizeTimeout;
48
- window.addEventListener('resize', function() {
49
- clearTimeout(resizeTimeout);
50
- resizeTimeout = setTimeout(updateBodyPadding, 150);
51
- }, { passive: true });
60
+ if (typeof window.ResizeObserver === 'function') {
61
+ const navbarObserver = new ResizeObserver(function(entries) {
62
+ const entry = entries[entries.length - 1];
63
+ const box = entry.borderBoxSize && (entry.borderBoxSize[0] || entry.borderBoxSize);
64
+ // Layout is already clean inside a ResizeObserver callback, so
65
+ // the offsetHeight fallback (old engines without borderBoxSize)
66
+ // does not force a reflow.
67
+ applyNavbarHeight(box && box.blockSize ? box.blockSize : entry.target.offsetHeight);
68
+ });
69
+ navbarObserver.observe(navbar);
70
+ } else {
71
+ let measureQueued = false;
72
+ const measureNavbar = function() {
73
+ if (measureQueued) return;
74
+ measureQueued = true;
75
+ window.requestAnimationFrame(function() {
76
+ const height = navbar.offsetHeight; // read
77
+ window.requestAnimationFrame(function() {
78
+ measureQueued = false;
79
+ applyNavbarHeight(height); // write, next frame
80
+ });
81
+ });
82
+ };
83
+ measureNavbar();
84
+
85
+ // Update padding on window resize with debounce
86
+ let resizeTimeout;
87
+ window.addEventListener('resize', function() {
88
+ clearTimeout(resizeTimeout);
89
+ resizeTimeout = setTimeout(measureNavbar, 150);
90
+ }, { passive: true });
91
+ }
52
92
 
53
93
  // Enhanced scroll handler with better logic
54
94
  function handleScroll() {
@@ -1,4 +1,53 @@
1
1
  // Feature: ZER0-030
2
+ //
3
+ // CONSUMER CONTRACT — code-block extension points (#412).
4
+ // ------------------------------------------------------
5
+ // `.code-block-header` is built HERE, at runtime, not emitted at build time, so
6
+ // a consumer adding a second action to code blocks has nothing in the markup to
7
+ // hook. Before this contract existed the only route was a `MutationObserver`
8
+ // over `#main-content` plus a guess at the wrapper's shape — and wrapper
9
+ // nesting is exactly what a theme refactor changes, so the guess was one
10
+ // release away from breaking. it-journey#634 got it wrong and silently rendered
11
+ // two buttons on every block (26 across 13) until it added a claim marker.
12
+ //
13
+ // Two ways in, both stable:
14
+ //
15
+ // document.addEventListener('zer0:code-block-ready', function (e) {
16
+ // e.detail; // { wrapper, header, pre, code, lang }
17
+ // });
18
+ //
19
+ // window.zer0OnCodeBlock(function (detail) { … }); // same shape, REPLAYED
20
+ //
21
+ // The event bubbles from the wrapper, fires exactly once per code block, and is
22
+ // dispatched after the block is fully decorated — line numbers, a11y attributes
23
+ // and the copy button are all in place before a consumer sees it. `header` is
24
+ // null for a standalone `<pre>` outside a Rouge wrapper, which has no header
25
+ // row; `lang` is null when the block declares no language.
26
+ //
27
+ // Because the sweep runs on `DOMContentLoaded`, a listener registered later
28
+ // would miss every block — so `window.__zer0CodeBlocks` holds the detail of
29
+ // every block already processed, and `zer0OnCodeBlock` replays that array
30
+ // before subscribing. Late registration is the NORMAL case for a deferred
31
+ // consumer script, not an edge case.
32
+ //
33
+ // Docs: docs/development/EXTENSION-POINTS.md
34
+ window.__zer0CodeBlocks = window.__zer0CodeBlocks || [];
35
+
36
+ window.zer0OnCodeBlock = function (listener) {
37
+ if (typeof listener !== 'function') return function () {};
38
+ window.__zer0CodeBlocks.forEach(function (detail) {
39
+ listener(detail);
40
+ });
41
+ var handler = function (event) {
42
+ listener(event.detail);
43
+ };
44
+ document.addEventListener('zer0:code-block-ready', handler);
45
+ // Returned so a consumer can stop listening; harmless to ignore.
46
+ return function () {
47
+ document.removeEventListener('zer0:code-block-ready', handler);
48
+ };
49
+ };
50
+
2
51
  document.addEventListener('DOMContentLoaded', function () {
3
52
  var LANG_LABELS = {
4
53
  shell: 'bash',
@@ -157,12 +206,18 @@ document.addEventListener('DOMContentLoaded', function () {
157
206
  });
158
207
  });
159
208
 
209
+ var wrapper;
210
+ var header = null;
160
211
  if (rougeWrapper) {
161
- var header = ensureHeader(rougeWrapper, lang);
212
+ wrapper = rougeWrapper;
213
+ header = ensureHeader(rougeWrapper, lang);
162
214
  header.appendChild(button);
163
215
  rougeWrapper.classList.toggle('code-block--single-line', isSingleLine);
164
216
  rougeWrapper.closest('.highlighter-rouge').classList.add('has-code-header');
165
217
  } else {
218
+ // A standalone <pre> is its own wrapper and has no header row: the copy
219
+ // button is positioned against the <pre> itself.
220
+ wrapper = preElement;
166
221
  if (getComputedStyle(preElement).position === 'static') {
167
222
  preElement.style.position = 'relative';
168
223
  }
@@ -171,5 +226,21 @@ document.addEventListener('DOMContentLoaded', function () {
171
226
  }
172
227
 
173
228
  preElement.classList.add('has-copy-button');
229
+
230
+ // Published LAST, so a consumer never sees a half-decorated block. Exactly
231
+ // once per block: `preElements` is a Set, and the `.copy` guard above makes
232
+ // a second sweep over the same block a no-op. See the contract at the top.
233
+ var detail = {
234
+ wrapper: wrapper,
235
+ header: header,
236
+ pre: preElement,
237
+ code: codeElement,
238
+ lang: lang
239
+ };
240
+ window.__zer0CodeBlocks.push(detail);
241
+ wrapper.dispatchEvent(new CustomEvent('zer0:code-block-ready', {
242
+ bubbles: true,
243
+ detail: detail
244
+ }));
174
245
  });
175
246
  });