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
@@ -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,69 @@
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),
479
505
  #bdNavbar .nav-hover-dropdown > .nav-link:first-child {
480
- gap: 0;
506
+ border-radius: 0.375rem;
507
+ padding-right: 1.25rem;
481
508
  }
482
509
 
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
510
  #bdNavbar .nav-hover-dropdown > .dropdown-toggle-split {
486
- min-width: 1rem;
487
- width: 1rem;
511
+ position: absolute;
512
+ top: 0;
513
+ right: 0;
514
+ bottom: 0;
515
+ z-index: 1;
516
+ display: flex;
517
+ align-items: center;
518
+ justify-content: center;
519
+ width: 1.25rem;
520
+ min-width: 1.25rem;
521
+ min-height: 0;
522
+ margin: 0 !important;
523
+ padding: 0;
524
+ background-color: transparent;
525
+ border: 0;
526
+ border-radius: 0 0.375rem 0.375rem 0;
527
+ }
528
+
529
+ // The overlay must not paint its own hover background — the row already
530
+ // does. Without this the chevron reads as a separate control again, which
531
+ // is the whole defect. `!important` because the shared base block at the
532
+ // top of this file sets the chevron's own hover background with equal
533
+ // specificity and later source order.
534
+ #bdNavbar .nav-hover-dropdown > .dropdown-toggle-split:hover,
535
+ #bdNavbar .nav-hover-dropdown > .dropdown-toggle-split:focus-visible,
536
+ #bdNavbar .nav-hover-dropdown > .dropdown-toggle-split[aria-expanded="true"] {
537
+ background-color: transparent !important;
488
538
  }
489
539
  }
490
540
 
491
- // Icon-only when the center track is too narrow even for bare labels
492
- @container bd-nav (max-width: 40.99rem) {
541
+ // Icon-only when the center track cannot fit the icon+label row. The
542
+ // boundary is the complement of the tier above so the two tiers tile the
543
+ // whole range with no gap.
544
+ @container bd-nav (max-width: 50.99rem) {
493
545
  @include navbar-nav-icon-only;
494
546
  }
495
547
  }
@@ -1099,8 +1151,7 @@
1099
1151
  align-items: center;
1100
1152
  }
1101
1153
 
1102
- #navbar .site-title-text,
1103
- #navbar .site-subtitle-text {
1154
+ #navbar .site-title-text {
1104
1155
  display: inline-block;
1105
1156
  }
1106
1157
 
@@ -1137,9 +1188,28 @@
1137
1188
 
1138
1189
  // Desktop (lg+): 3-column grid — [brand/title | main nav | utilities] so nav cannot paint over Search/Settings
1139
1190
  @media (min-width: 992px) {
1191
+ // Optical centering (#405) WITHOUT breaking the container queries.
1192
+ //
1193
+ // The obvious spelling — `1fr auto 1fr` — is a trap here. Track 2 must stay
1194
+ // a `1fr` (a DEFINITE width): `.bd-navbar-nav-viewport` inside it is
1195
+ // `width: 100%` + `container-type: inline-size`, and that percentage only
1196
+ // resolves against a definite track. Make track 2 `auto` and it becomes
1197
+ // content-sized, size containment hands `bd-nav` a degenerate width, and
1198
+ // EVERY `@container bd-nav` label tier stops firing — the bar collapses to
1199
+ // icon-only at all widths, which is the opposite of what #405 is for.
1200
+ //
1201
+ // So the nav keeps the elastic middle track and the two side tracks are
1202
+ // equalised instead, via a shared floor. Whenever the brand and the
1203
+ // utility cluster both fit inside it (the common case), the side tracks
1204
+ // are the same width and the menubar is centred in the bar, not merely
1205
+ // centred in its own track. The floor is deliberately small so it never
1206
+ // steals the width the label tier needs at 1280px.
1140
1207
  #navbar .navbar-main {
1141
1208
  display: grid !important;
1142
- grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
1209
+ grid-template-columns:
1210
+ minmax(var(--zer0-navbar-side-min, 9rem), auto)
1211
+ minmax(0, 1fr)
1212
+ minmax(var(--zer0-navbar-side-min, 9rem), auto);
1143
1213
  align-items: center;
1144
1214
  column-gap: 0.75rem;
1145
1215
  row-gap: 0.25rem;
@@ -1208,8 +1278,7 @@
1208
1278
  display: none;
1209
1279
  }
1210
1280
 
1211
- #navbar .site-title,
1212
- #navbar .site-subtitle {
1281
+ #navbar .site-title {
1213
1282
  flex-shrink: 1;
1214
1283
  min-width: 0;
1215
1284
  }
@@ -1227,15 +1296,6 @@
1227
1296
  max-width: none;
1228
1297
  }
1229
1298
 
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
1299
  // Progressive brand degradation (container width, not viewport).
1240
1300
  // The home-link icons add ~108px to the brand cluster, so they only appear
1241
1301
  // once the bar is wide enough to carry them AND the full menubar
@@ -1247,12 +1307,6 @@
1247
1307
  }
1248
1308
  }
1249
1309
 
1250
- @container navbar-main (max-width: 68rem) {
1251
- #navbar .site-subtitle {
1252
- display: none !important;
1253
- }
1254
- }
1255
-
1256
1310
  @container navbar-main (max-width: 50rem) {
1257
1311
  #navbar .site-title .site-title-text {
1258
1312
  max-width: 100%;
@@ -1318,6 +1372,19 @@
1318
1372
  height: 2.5rem;
1319
1373
  }
1320
1374
 
1375
+ // The square above is for BARE-GLYPH togglers. The labelled menu toggle
1376
+ // (#405) also carries a "Menu" span, and 2.5rem cannot hold glyph + gap +
1377
+ // label — the text would escape the button box and push the page past the
1378
+ // viewport at every width below lg, which is exactly what makes a
1379
+ // fixed-top bar read as "cut off". `.navbar-toggler-labeled`'s own
1380
+ // `width: auto` loses to the ID selector above on specificity (0,2,0 vs
1381
+ // 1,1,0); re-assert it here, where it outranks.
1382
+ #navbar .navbar-toggler.navbar-toggler-labeled {
1383
+ width: auto;
1384
+ min-width: 2.5rem;
1385
+ flex-shrink: 0;
1386
+ }
1387
+
1321
1388
  #navbar .nav-search-button {
1322
1389
  width: 2.5rem;
1323
1390
  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
@@ -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
  });
@@ -0,0 +1,48 @@
1
+ // =============================================================================
2
+ // extension-points-demo.js — a live consumer of the theme's extension points
3
+ // =============================================================================
4
+ //
5
+ // Loaded ONLY by /docs/customization/extension-points/, through that page's
6
+ // `scripts:` frontmatter — which makes this file a demonstration of two
7
+ // contracts at once: the per-page script hook that loads it, and the
8
+ // `zer0:code-block-ready` event it subscribes to.
9
+ //
10
+ // It is deliberately the whole pattern in a dozen lines. The point of #412 is
11
+ // that a consumer adding an action to a code block should not need a
12
+ // MutationObserver, a timeout fallback, or a guess at how deeply the theme
13
+ // nests its wrappers — all three of which it-journey#634 had to carry, and the
14
+ // wrapper guess is what silently produced two buttons per block.
15
+ //
16
+ // Copy this shape. `zer0OnCodeBlock` replays the blocks that were decorated
17
+ // before this script ran, then subscribes for any that follow, so it does not
18
+ // matter whether you load early or late.
19
+ // =============================================================================
20
+ (function () {
21
+ 'use strict';
22
+
23
+ if (typeof window.zer0OnCodeBlock !== 'function') return;
24
+
25
+ window.zer0OnCodeBlock(function (detail) {
26
+ // A standalone <pre> outside a Rouge wrapper has no header row to hang an
27
+ // action on. `header` is null there, and that is the documented signal.
28
+ if (!detail.header) return;
29
+ if (detail.header.querySelector('.zer0-demo-action')) return;
30
+
31
+ var lines = (detail.code.textContent || '').replace(/\n$/, '').split('\n').length;
32
+
33
+ var button = document.createElement('button');
34
+ button.type = 'button';
35
+ button.className = 'zer0-demo-action';
36
+ button.textContent = lines + (lines === 1 ? ' line' : ' lines');
37
+ button.setAttribute(
38
+ 'aria-label',
39
+ 'This ' + (detail.lang || 'code') + ' block has ' + lines + ' lines'
40
+ );
41
+ button.addEventListener('click', function () {
42
+ detail.pre.focus();
43
+ });
44
+
45
+ // Before the copy button, so Copy keeps its place at the end of the row.
46
+ detail.header.insertBefore(button, detail.header.querySelector('.copy'));
47
+ });
48
+ })();