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
@@ -5,14 +5,16 @@
5
5
 
6
6
  File: branding.html
7
7
  Path: _includes/core/branding.html
8
- Purpose: Display site title and optional subtitle in navbar
9
-
8
+ Purpose: Display the site logo and title in the navbar
9
+
10
+ The navbar carries logo + title ONLY. `site.subtitle` used to render here as
11
+ a second `.navbar-brand` at lg+, which spent bar width the menubar needed for
12
+ its labels; it now belongs to the home hero (_layouts/home.html) — see #405.
13
+
10
14
  Dependencies:
11
15
  - site.title: Main site title from _config.yml
12
- - site.subtitle: Optional subtitle from _config.yml
13
16
  - site.default_icon: Icon framework class (e.g., 'bi')
14
17
  - site.title_icon: Icon for title display
15
- - site.subtitle_icon: Icon for subtitle display
16
18
  ===================================================================
17
19
  {% endcomment %}
18
20
  {% comment %} Color Schema Override
@@ -43,14 +45,5 @@
43
45
  </a>
44
46
  </div>
45
47
 
46
- {% comment %} If a subtitle exists {% endcomment %}
47
- {%- assign _subtitle = site.subtitle | strip -%}
48
- {%- if _subtitle != "" -%}
49
- <div class="navbar-brand site-subtitle d-none d-lg-inline">
50
- <a class="nav-link" href="{{ '/' | relative_url }}" aria-label="{{ _subtitle }} — {{ site.title }} home">
51
- <span class="site-subtitle-text">
52
- {{ _subtitle }}
53
- </span>
54
- </a>
55
- </div>
56
- {% endif %}
48
+ {% comment %} The subtitle is deliberately NOT rendered here — see the header
49
+ comment above and the home hero in _layouts/home.html (#405). {% endcomment %}
@@ -19,7 +19,7 @@
19
19
  - seo.html: SEO meta tags and Open Graph data
20
20
  - google-analytics.html: Google Analytics tracking
21
21
  - google-tag-manager-head.html: GTM head section
22
- - posthog.html: PostHog product analytics (opt-in via site.posthog.enabled)
22
+ - (posthog.html is included from _layouts/root.html at the end of <body>, not here)
23
23
 
24
24
  Performance Notes:
25
25
  - Scripts loaded in head for immediate availability
@@ -160,9 +160,10 @@ window.MathJax = {
160
160
  {% comment %} Only load in production AND when an ID is configured; the include also guards against dev hostnames {% endcomment %}
161
161
  {% if jekyll.environment == "production" and site.google_analytics %}{% include analytics/google-analytics.html %}{% endif %}
162
162
 
163
- {% comment %} PostHog analytics - Configured via the `posthog:` block in _config.yml {% endcomment %}
164
- {% comment %} Only load in production AND when explicitly enabled; the include itself also respects Do Not Track {% endcomment %}
165
- {% if jekyll.environment == "production" and site.posthog.enabled %}{% include analytics/posthog.html %}{% endif %}
163
+ {% comment %} PostHog analytics is NOT included here. _layouts/root.html already
164
+ includes analytics/posthog.html at the end of <body> (since the include was
165
+ added), so this second copy, added in #317, emitted the snippet twice and
166
+ called posthog.init() twice on every production page. {% endcomment %}
166
167
 
167
168
 
168
169
  {% comment %} ================================ {% endcomment %}
@@ -200,7 +201,8 @@ window.MathJax = {
200
201
  {% comment %} DESIGN TOKEN OVERRIDES {% endcomment %}
201
202
  {% comment %} ========================== {% endcomment %}
202
203
  {% comment %} Inline <style> block emitted AFTER main.css so site-config `theme_color` keys
203
- and any per-user Appearance overrides win over compiled defaults. {% endcomment %}
204
+ and any per-user Appearance overrides win over compiled defaults. The tokens a
205
+ palette skin sets are scoped so that the skin still wins (see tokens-inline.html). {% endcomment %}
204
206
  {% include core/tokens-inline.html %}
205
207
 
206
208
  {% comment %} Optional fork overrides: opt-in via `site.user_overrides: true` in _config.yml.
@@ -214,6 +216,28 @@ window.MathJax = {
214
216
  <link rel="stylesheet" href="{{ '/assets/css/stats.css' | relative_url }}">
215
217
  {% endif %}
216
218
 
219
+ {% comment %} Per-page stylesheets from frontmatter (#412):
220
+
221
+ ---
222
+ styles:
223
+ - /assets/css/notes.css
224
+ - https://cdn.example.com/x.css
225
+ ---
226
+
227
+ A contract, not a convention. Before this, attaching CSS to one page meant a
228
+ literal <link> inside the content file or a custom layout — which works, and
229
+ puts asset tags in prose. A single string is accepted as well as a list.
230
+ Site-relative paths go through relative_url so a baseurl deployment is
231
+ correct; absolute URLs are passed through untouched.
232
+
233
+ Emits NOTHING when the key is absent — the whitespace control here keeps a
234
+ page without `styles:` byte-identical to its previous build, which is why
235
+ the comment and the `endif` both close with `-%}`. {% endcomment -%}
236
+ {% if page.styles %}{% assign page_styles = page.styles %}{% if page_styles.first == nil %}{% assign page_styles = page_styles | split: "||" %}{% endif %}{% for style_href in page_styles %}
237
+ <link rel="stylesheet" href="{% if style_href contains '//' %}{{ style_href }}{% else %}{{ style_href | relative_url }}{% endif %}">
238
+ {%- endfor %}
239
+ {% endif -%}
240
+
217
241
  {% comment %} Consumer hook: end of <head>. Inject JSON-LD, font preloads, verification
218
242
  tags, or extra meta by shadowing _includes/custom/head.html in your site —
219
243
  no fork of this file needed. The theme ships the stub empty. {% endcomment %}
@@ -210,15 +210,27 @@
210
210
  {%- assign _menu_target = "#bdNavbar" -%}
211
211
  {%- assign _menu_controls = "bdNavbar" -%}
212
212
  {%- endif -%}
213
+ {% comment %} Below lg this is a LABELLED control, not a bare glyph (#405).
214
+ Two toggles coexist here — the sidebar hamburger and this one — and
215
+ three-dots vs. list gave no clue which opened what. The gear is
216
+ `d-lg-none`-excluded above (desktop only; on mobile its contents are
217
+ reached through the drawer), so labelling this one is what makes the
218
+ pair unambiguous.
219
+ aria-label matches the visible "Menu" text verbatim (rather than the
220
+ older, more descriptive "Toggle main navigation") so the accessible
221
+ name CONTAINS the visible label in every locale — WCAG 2.5.3 Label in
222
+ Name — instead of only coincidentally in English. {% endcomment %}
223
+ {%- assign _menu_toggle_label = ui.nav_menu_toggle_label | default: "Menu" -%}
213
224
  <div class="bd-navbar-toggle d-lg-none">
214
- <button class="navbar-toggler p-2"
225
+ <button class="navbar-toggler navbar-toggler-labeled p-2"
215
226
  type="button"
216
227
  data-bs-toggle="offcanvas"
217
228
  data-bs-target="{{ _menu_target }}"
218
229
  aria-controls="{{ _menu_controls }}"
219
- aria-label="Toggle main navigation"
230
+ aria-label="{{ _menu_toggle_label }}"
220
231
  aria-expanded="false">
221
232
  <span class="bi bi-three-dots" aria-hidden="true"></span>
233
+ <span class="navbar-toggler-text">{{ _menu_toggle_label }}</span>
222
234
  </button>
223
235
  </div>
224
236
  </div>
@@ -16,21 +16,36 @@
16
16
 
17
17
  Only emits a key if the config value is present, so the default cascade
18
18
  remains intact when consumers omit `theme_color` from _config.yml.
19
+
20
+ Precedence (T-048 / #459): the active skin beats `theme_color`.
21
+ The skin palettes in _sass/theme/_skins.scss set --zer0-color-primary,
22
+ --zer0-color-link and --zer0-color-accent together with --bs-primary.
23
+ So the config values for those three tokens are only emitted when no
24
+ palette skin is active. Otherwise this block would win on source order,
25
+ because `:root` and `[data-theme-skin]` have the same specificity, and the
26
+ theme layer would disagree with Bootstrap. `palette_skins` must list every
27
+ skin that _skins.scss gives a palette; test/visual/features/appearance.spec.js
28
+ checks that.
19
29
  ===================================================================
20
30
  {%- endcomment -%}
21
31
  {%- assign tc = site.theme_color -%}
32
+ {%- assign palette_skins = "air,aqua,dirt,neon,mint,plum,sunrise" | split: "," -%}
22
33
  {%- if tc -%}
23
34
  <style id="zer0-tokens-inline">
24
35
  :root {
25
- {%- if tc.main %} --zer0-color-primary: {{ tc.main }};{%- endif %}
26
36
  {%- if tc.secondary %} --zer0-color-secondary: {{ tc.secondary }};{%- endif %}
27
37
  {%- if tc.red %} --zer0-color-danger: {{ tc.red }};{%- endif %}
28
38
  {%- if tc.yellow %} --zer0-color-warning: {{ tc.yellow }};{%- endif %}
29
39
  {%- if tc.green %} --zer0-color-success: {{ tc.green }};{%- endif %}
30
40
  {%- if tc.teal %} --zer0-color-info: {{ tc.teal }};{%- endif %}
41
+ }
42
+ {%- if tc.main or tc.blue or tc.purple %}
43
+ :root{% for s in palette_skins %}:not([data-theme-skin="{{ s }}"]){% endfor %} {
44
+ {%- if tc.main %} --zer0-color-primary: {{ tc.main }};{%- endif %}
31
45
  {%- if tc.blue %} --zer0-color-link: {{ tc.blue }};{%- endif %}
32
46
  {%- if tc.purple %} --zer0-color-accent: {{ tc.purple }};{%- endif %}
33
47
  }
48
+ {%- endif %}
34
49
  </style>
35
50
  {%- endif -%}
36
51
 
@@ -49,7 +49,7 @@
49
49
  class="nav-link"
50
50
  href="{{ link.url | relative_url }}"
51
51
  aria-label="{{ link.title }}"
52
- {%- if link.url == page.url -%} aria-current="page"{%- endif -%}
52
+ {% if link.url == page.url %}aria-current="page"{% endif %}
53
53
  title="{{ link.title }}"
54
54
  >
55
55
  {%- if link.icon -%}
@@ -61,8 +61,12 @@
61
61
  {% comment %} Split toggle opens the dropdown (click on mobile, hover on desktop) {% endcomment %}
62
62
  {% comment %} Note: No data-bs-toggle="dropdown" — custom JS handles all interactions
63
63
  to avoid Bootstrap's native handler conflicting with our toggle logic {% endcomment %}
64
+ {% comment %} No `ms-1`: at lg+ the chevron is absolutely positioned over the
65
+ parent link's reserved padding (_navbar.scss), so a left margin would
66
+ re-open the dead zone between label and chevron that #405 closed. Below
67
+ lg the offcanvas layout keeps them as ordinary siblings. {% endcomment %}
64
68
  <button
65
- class="nav-link dropdown-toggle dropdown-toggle-split ms-1"
69
+ class="nav-link dropdown-toggle dropdown-toggle-split"
66
70
  type="button"
67
71
  id="dropdown-{{ link.title | slugify }}"
68
72
  aria-expanded="false"
@@ -78,8 +82,7 @@
78
82
  <a
79
83
  class="dropdown-item"
80
84
  href="{{ child.url | relative_url }}"
81
-
82
- {%- if child.url == page.url -%} aria-current="page"{%- endif -%}
85
+ {% if child.url == page.url %}aria-current="page"{% endif %}
83
86
  >
84
87
  {%- if child.icon -%}
85
88
  <i class="{{site.default_icon}} {{ child.icon }} me-2" aria-hidden="true"></i>
@@ -96,7 +99,7 @@
96
99
  class="nav-link"
97
100
  href="{{ link.url | relative_url }}"
98
101
  aria-label="{{ link.title }}"
99
- {%- if link.url == page.url -%} aria-current="page"{%- endif -%}
102
+ {% if link.url == page.url %}aria-current="page"{% endif %}
100
103
  title="{{ link.title }}"
101
104
  >
102
105
  {%- if link.icon -%}
@@ -125,7 +128,7 @@
125
128
  href="{{ col_url | relative_url }}"
126
129
  aria-label="{{ col_title }}"
127
130
  title="{{ col_title }}"
128
- {%- if page.collection == collection.label -%} aria-current="page"{%- endif -%}
131
+ {% if page.collection == collection.label %}aria-current="page"{% endif %}
129
132
  >
130
133
  <span class="nav-link-text">{{ col_title }}</span>
131
134
  </a>
@@ -62,7 +62,11 @@
62
62
  </p>
63
63
  </div>
64
64
  <div class="d-flex align-items-center gap-2">
65
- <span id="wizard-draft-chip" class="wizard-draft-chip" hidden aria-live="polite">
65
+ {% comment %} No `hidden` here: the chip must hold its box from first paint, or the
66
+ first debounced save grows this header — permanently — and every offset below it
67
+ moves with it (issue #265). _setup-wizard.scss hides it with `visibility`
68
+ instead, which keeps the box and still keeps it out of the a11y tree. {% endcomment %}
69
+ <span id="wizard-draft-chip" class="wizard-draft-chip" aria-live="polite">
66
70
  <i class="bi bi-cloud-check" aria-hidden="true"></i> Draft saved
67
71
  </span>
68
72
  <button class="btn btn-outline-secondary btn-sm" type="button" id="btn-reset"
@@ -76,7 +76,12 @@ layout: default
76
76
  {% endif %}
77
77
  {% endif %}
78
78
 
79
- <article id="main" class="post h-entry" role="main" itemscope itemtype="https://schema.org/BlogPosting">
79
+ {% comment %} No main role and no generic main id on this element: root.html already
80
+ provides the page's single <main id="main-content"> landmark, and a main role here
81
+ both duplicated it and suppressed this element's native `article` landmark (issue
82
+ #484, the same class of defect as #299). The generic id had no referents either —
83
+ the skip link targets #main-content. Asserted by test/visual/core/landmarks.spec.js. {% endcomment %}
84
+ <article class="post h-entry" itemscope itemtype="https://schema.org/BlogPosting">
80
85
 
81
86
  {% comment %} ================================ {% endcomment %}
82
87
  {% comment %} BREAKING NEWS ALERT BANNER {% endcomment %}
@@ -234,6 +239,10 @@ layout: default
234
239
  post_type defaults (issue #303).
235
240
  Nested ifs keep the "only when page.preview exists" guard intact (Liquid has
236
241
  no parentheses, so a flat `a or b and c` would misgroup).
242
+ The hero is the above-the-fold LCP element, so it loads eagerly at high
243
+ priority (the include defaults to lazy); its box is reserved in CSS by
244
+ `.featured-hero img` (components/_ui-enhancements.scss), not by width/height
245
+ attributes, because preview assets vary in shape (issue #485).
237
246
  {% endcomment %}
238
247
  {% assign show_hero = false %}
239
248
  {% if page.preview %}
@@ -247,7 +256,10 @@ layout: default
247
256
  src=page.preview
248
257
  alt=page.title
249
258
  class="w-100 rounded-3 shadow"
250
- style="max-height: 500px; object-fit: cover;"
259
+ style="max-height: 500px; object-fit: cover;"
260
+ loading="eager"
261
+ fetchpriority="high"
262
+ decoding="async"
251
263
  %}
252
264
  {% if page.preview_caption %}
253
265
  <figcaption class="text-muted small mt-2 text-center">{{ page.preview_caption }}</figcaption>
@@ -469,7 +481,7 @@ layout: default
469
481
  {% endif %}
470
482
  <a href="{{ rpost.url | relative_url }}" class="text-decoration-none">
471
483
  {% assign rpost_img = rpost.preview | default: site.teaser %}
472
- {% include components/preview-image.html src=rpost_img alt=rpost.title class="card-img-top" %}
484
+ {% include components/preview-image.html src=rpost_img alt=rpost.title class="card-img-top" width="1536" height="1024" %}
473
485
  </a>
474
486
  </div>
475
487
  <div class="card-body">
data/_layouts/home.html CHANGED
@@ -62,7 +62,19 @@ layout: root
62
62
  {%- if page.title and page.hide_title != true and page.hide_intro != true -%}
63
63
  <h1 class="page-heading">{{ page.title }}</h1>
64
64
  {%- endif -%}
65
-
65
+
66
+ {% comment %} ========================== {% endcomment %}
67
+ {% comment %} SITE SUBTITLE (HOME HERO) {% endcomment %}
68
+ {% comment %} ========================== {% endcomment %}
69
+ {% comment %} `site.subtitle` reads here, on the home hero, instead of in the {% endcomment %}
70
+ {% comment %} navbar, where it competed with the menubar for bar width (#405). {% endcomment %}
71
+ {% comment %} Suppressed alongside the heading, so a custom hero that supplies {% endcomment %}
72
+ {% comment %} its own copy is not doubled up. {% endcomment %}
73
+ {%- assign _subtitle = site.subtitle | strip -%}
74
+ {%- if _subtitle != "" and page.hide_title != true and page.hide_intro != true -%}
75
+ <p class="site-subtitle-text home-subtitle lead text-body-secondary">{{ _subtitle }}</p>
76
+ {%- endif -%}
77
+
66
78
  {% comment %} ========================== {% endcomment %}
67
79
  {% comment %} MAIN CONTENT AREA {% endcomment %}
68
80
  {% comment %} ========================== {% endcomment %}
data/_layouts/note.html CHANGED
@@ -38,7 +38,8 @@ layout: default
38
38
  ===================================================================
39
39
  {% endcomment %}
40
40
 
41
- <article id="main" class="note-article h-entry" role="main" itemscope itemtype="https://schema.org/Article">
41
+ {% comment %} No main role and no generic main id — see article.html (issue #484). {% endcomment %}
42
+ <article class="note-article h-entry" itemscope itemtype="https://schema.org/Article">
42
43
 
43
44
  {% comment %} ================================ {% endcomment %}
44
45
  {% comment %} NOTE HEADER {% endcomment %}
@@ -34,7 +34,8 @@ layout: default
34
34
  ===================================================================
35
35
  {% endcomment %}
36
36
 
37
- <article id="main" class="notebook-article h-entry" role="main" itemscope itemtype="https://schema.org/TechArticle">
37
+ {% comment %} No main role and no generic main id — see article.html (issue #484). {% endcomment %}
38
+ <article class="notebook-article h-entry" itemscope itemtype="https://schema.org/TechArticle">
38
39
 
39
40
  {% comment %} ================================ {% endcomment %}
40
41
  {% comment %} NOTEBOOK HEADER {% endcomment %}
data/_layouts/root.html CHANGED
@@ -193,6 +193,28 @@
193
193
  {% include_cached components/js-cdn.html %}
194
194
  </div>
195
195
 
196
+ {% comment %} Per-page scripts from frontmatter (#412):
197
+
198
+ ---
199
+ scripts:
200
+ - /assets/js/notes-clipper.js
201
+ ---
202
+
203
+ The counterpart of `styles:` in _includes/core/head.html, loaded here
204
+ so a page script runs AFTER the theme's own bundle (js-cdn.html) and
205
+ can rely on the contracts it publishes — `zer0:code-block-ready`, for
206
+ one. Deferred, so it never blocks the parse. A single string is
207
+ accepted as well as a list; site-relative paths go through
208
+ relative_url, absolute URLs pass through untouched.
209
+
210
+ Emits NOTHING when the key is absent, so a page without `scripts:`
211
+ is byte-identical to its previous build — hence the `-%}` on both
212
+ the comment and the `endif`. {% endcomment -%}
213
+ {% if page.scripts %}{% assign page_scripts = page.scripts %}{% if page_scripts.first == nil %}{% assign page_scripts = page_scripts | split: "||" %}{% endif %}{% for script_src in page_scripts %}
214
+ <script defer src="{% if script_src contains '//' %}{{ script_src }}{% else %}{{ script_src | relative_url }}{% endif %}"></script>
215
+ {%- endfor %}
216
+ {% endif -%}
217
+
196
218
  {% comment %} Analytics Integration {% endcomment %}
197
219
  {%- include analytics/posthog.html -%}
198
220
 
@@ -38,3 +38,51 @@
38
38
  transform: translateY(0);
39
39
  }
40
40
  }
41
+
42
+ // ----------------------------------------------------------------------------
43
+ // Compact bar layout
44
+ // ----------------------------------------------------------------------------
45
+ // The banner must never eat the first screen. Phones get a two-line summary
46
+ // over ONE row of three equal buttons (the old full-width stack was 324px at
47
+ // 400px wide); md+ is a single row, copy left and actions right. Button
48
+ // height is left to .btn-sm and the touch-device 44px minimum in
49
+ // components/_ui-enhancements.scss.
50
+ .cookie-consent-banner__inner {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: 0.5rem;
54
+ padding-block: 0.5rem;
55
+
56
+ @media (min-width: 768px) {
57
+ flex-direction: row;
58
+ align-items: center;
59
+ gap: 1rem;
60
+ }
61
+ }
62
+
63
+ .cookie-consent-banner__text {
64
+ line-height: 1.35;
65
+
66
+ @media (min-width: 768px) {
67
+ flex: 1 1 auto;
68
+ min-width: 0;
69
+ }
70
+ }
71
+
72
+ .cookie-consent-banner__actions {
73
+ display: flex;
74
+ gap: 0.5rem;
75
+
76
+ .btn {
77
+ flex: 1 1 0;
78
+ white-space: nowrap;
79
+ }
80
+
81
+ @media (min-width: 768px) {
82
+ flex: 0 0 auto;
83
+
84
+ .btn {
85
+ flex: 0 0 auto;
86
+ }
87
+ }
88
+ }
@@ -104,14 +104,32 @@
104
104
 
105
105
  // "Draft saved" acknowledgement. Kept in the layout (no display toggling) so
106
106
  // its appearance never shifts the header.
107
+ //
108
+ // That was always the intent of this rule, and for a long time it was only the
109
+ // intent: the markup carried `hidden` and setup-wizard.js cleared it on the
110
+ // first debounced save, so the chip went from `display: none` to a 26px box
111
+ // while the "Start over" `.btn-sm` beside it is 19px. The header grew and
112
+ // stayed grown, one-way, taking `#wizardTabContent` and every Back/Next row
113
+ // below it down with it (issue #265) — 7px where the header's right-hand block
114
+ // had already wrapped onto its own line, a whole wrapped line where those 7px
115
+ // were what tipped it over.
116
+ //
117
+ // `visibility` rather than `display` is what makes the prose above true: the
118
+ // box is reserved from first paint, so nothing moves when the chip appears,
119
+ // and `visibility: hidden` still keeps it out of the accessibility tree while
120
+ // it has nothing to say. The `visibility` transition is discrete — it flips to
121
+ // visible at the start of the fade in and back to hidden at the end of the
122
+ // fade out, so the text is never readable at opacity 0.
107
123
  .wizard-draft-chip {
108
124
  padding: 0.25rem 0.5rem;
109
125
  font-size: 0.75rem;
110
126
  color: var(--bs-success, #198754);
127
+ visibility: hidden;
111
128
  opacity: 0;
112
- transition: opacity 0.2s ease;
129
+ transition: opacity 0.2s ease, visibility 0.2s ease;
113
130
 
114
131
  &.is-visible {
132
+ visibility: visible;
115
133
  opacity: 1;
116
134
  }
117
135
  }
@@ -53,6 +53,17 @@ img {
53
53
  }
54
54
  }
55
55
 
56
+ // Article hero (_layouts/article.html, figure.featured-hero) — the LCP image.
57
+ // Reserve its box before it loads so the text below never shifts (issue #485).
58
+ // A fixed ratio in CSS rather than width/height attributes: preview assets are
59
+ // 3:2 (1024x683) generated, but some are portrait or external, and a declared
60
+ // intrinsic size that disagrees with the file would itself cause the shift.
61
+ // object-fit: cover (inline on the <img>) crops, never distorts; the inline
62
+ // max-height: 500px still caps the box on wide viewports.
63
+ .featured-hero img {
64
+ aspect-ratio: 3 / 2;
65
+ }
66
+
56
67
  // Hero content animations
57
68
  .bg-primary .container-xl {
58
69
  animation: fadeInUp 0.8s ease-out;