jekyll-theme-zer0 1.28.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +430 -13
  3. data/_data/backlog.yml +356 -2
  4. data/_data/consumers.yml +109 -4
  5. data/_data/features.yml +157 -0
  6. data/_data/i18n/fr.yml +12 -7
  7. data/_data/i18n/manifest.yml +39 -12
  8. data/_data/ingredient_densities.yml +122 -0
  9. data/_data/navigation/main.yml +16 -0
  10. data/_data/recipe_courses.yml +64 -0
  11. data/_data/theme-manifest.yml +68 -40
  12. data/_data/ui-text.yml +8 -0
  13. data/_includes/README.md +15 -1
  14. data/_includes/components/background-customizer.html +2 -2
  15. data/_includes/components/cookie-consent.html +4 -4
  16. data/_includes/components/info-section.html +8 -3
  17. data/_includes/components/language-toggle.html +168 -21
  18. data/_includes/components/page-views-init.html +55 -0
  19. data/_includes/components/page-views.html +33 -0
  20. data/_includes/components/recipe-card.html +67 -0
  21. data/_includes/components/recipe-duration.html +50 -0
  22. data/_includes/components/recipe-grams.html +58 -0
  23. data/_includes/components/recipe-index.html +96 -0
  24. data/_includes/components/recipe-ingredients.html +90 -0
  25. data/_includes/components/recipe-meta.html +96 -0
  26. data/_includes/components/recipe-nutrition.html +57 -0
  27. data/_includes/components/recipe-qty.html +73 -0
  28. data/_includes/components/recipe-ratio.html +151 -0
  29. data/_includes/components/recipe-scaler.html +73 -0
  30. data/_includes/components/recipe-steps.html +86 -0
  31. data/_includes/components/recipe-temp.html +45 -0
  32. data/_includes/components/search-modal.html +2 -2
  33. data/_includes/components/shortcuts-modal.html +3 -0
  34. data/_includes/components/theme-info.html +8 -1
  35. data/_includes/content/jsonld-software.html +22 -3
  36. data/_includes/core/footer.html +18 -16
  37. data/_includes/core/head.html +12 -0
  38. data/_includes/core/header.html +7 -4
  39. data/_includes/navigation/section-sidebar.html +93 -11
  40. data/_includes/navigation/sidebar-left.html +2 -1
  41. data/_includes/navigation/sidebar-right.html +2 -1
  42. data/_includes/setup/wizard.html +173 -86
  43. data/_includes/stats/stats-metrics.html +2 -2
  44. data/_layouts/README.md +2 -0
  45. data/_layouts/admin.html +2 -2
  46. data/_layouts/article.html +5 -0
  47. data/_layouts/cookbook.html +88 -0
  48. data/_layouts/default.html +4 -4
  49. data/_layouts/recipe.html +274 -0
  50. data/_layouts/root.html +22 -3
  51. data/_layouts/section.html +39 -10
  52. data/_sass/components/_callout.scss +1 -1
  53. data/_sass/components/_footer.scss +37 -1
  54. data/_sass/components/_page-views.scss +36 -0
  55. data/_sass/components/_recipe.scss +506 -0
  56. data/_sass/components/_setup-wizard.scss +235 -0
  57. data/_sass/components/_ui-enhancements.scss +6 -6
  58. data/_sass/core/_navbar.scss +250 -15
  59. data/_sass/layouts/_landing.scss +2 -2
  60. data/_sass/tokens/_color.scss +6 -0
  61. data/_sass/tokens/_index.scss +2 -0
  62. data/_sass/tokens/_radius.scss +21 -0
  63. data/_sass/tokens/_typography.scss +4 -0
  64. data/_sass/utilities/_focus.scss +14 -0
  65. data/assets/css/main.scss +3 -0
  66. data/assets/js/modules/navigation/navbar.js +55 -0
  67. data/assets/js/page-views.js +372 -0
  68. data/assets/js/recipe-scaler.js +501 -0
  69. data/assets/js/search-modal.js +10 -0
  70. data/assets/js/setup-wizard.js +322 -20
  71. data/scripts/README.md +29 -0
  72. data/scripts/bin/audit-consumer +39 -7
  73. data/scripts/bin/giscus-discussions +213 -14
  74. data/scripts/bin/manifest +35 -12
  75. data/scripts/ci/agent_review_result.py +164 -0
  76. data/scripts/ci/test_agent_review_result.py +172 -0
  77. data/scripts/design-system-check.rb +170 -0
  78. data/scripts/lib/audit.sh +42 -2
  79. data/scripts/lint-liquid-raw.rb +137 -0
  80. data/scripts/test/lib/run_tests.sh +2 -1
  81. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  82. data/scripts/translate.rb +71 -15
  83. metadata +31 -2
@@ -0,0 +1,274 @@
1
+ ---
2
+ # Feature: ZER0-084
3
+ layout: default
4
+ ---
5
+ {%- comment -%}
6
+ Layout: recipe.html
7
+ Extends: default.html
8
+ Purpose: One recipe — headnote, fact bar, serving scaler, ingredients,
9
+ equipment, method, ratio table, notes, and nutrition, all driven by
10
+ structured front matter rather than hand-written markdown.
11
+ Front matter required: layout, title
12
+ Front matter optional (the whole cookbook schema):
13
+ description, image/preview, course, cookbook, cuisine, difficulty,
14
+ yield: {amount, unit, singular}, times: {prep, cook, rest, rest_label,
15
+ total}, oven: {temp_f, temp_c, mode}, ratio, ratio_basis, equipment,
16
+ ingredients, steps, notes, nutrition, source, source_url, scaler (false to
17
+ hide the controls), units (false to hide the unit switch)
18
+ Notes: Every amount is rendered as authored, so the page is complete and
19
+ correct without JavaScript; assets/js/recipe-scaler.js only rewrites
20
+ the numbers once a reader asks it to. Machine-readable data is emitted
21
+ once, as JSON-LD — the itemprops inside the components are inert
22
+ annotations unless a consumer adds their own itemscope.
23
+ {%- endcomment -%}
24
+
25
+ {%- assign _rl_id = page.slug | default: "recipe" -%}
26
+ {%- assign _rl_course_meta = site.data.recipe_courses[page.course] -%}
27
+ {%- assign _rl_cookbook = site.recipes | where: "layout", "cookbook" | where: "cookbook", page.cookbook | first -%}
28
+ {%- assign _rl_image = page.image | default: page.preview -%}
29
+ {%- assign _rl_show_scaler = true -%}
30
+ {%- if page.scaler == false -%}{%- assign _rl_show_scaler = false -%}{%- endif -%}
31
+
32
+ <article class="recipe" data-recipe data-recipe-id="{{ _rl_id }}">
33
+
34
+ <!-- ================================ -->
35
+ <!-- HEADER -->
36
+ <!-- ================================ -->
37
+ <header class="recipe-header">
38
+ {%- if _rl_cookbook or _rl_course_meta -%}
39
+ <p class="recipe-eyebrow">
40
+ {%- if _rl_cookbook -%}
41
+ <a href="{{ _rl_cookbook.url | relative_url }}"><i class="bi bi-journal-bookmark me-1" aria-hidden="true"></i>{{ _rl_cookbook.title }}</a>
42
+ {%- endif -%}
43
+ {%- if _rl_cookbook and _rl_course_meta %} <span class="mx-1" aria-hidden="true">•</span> {% endif -%}
44
+ {%- if _rl_course_meta -%}{{ _rl_course_meta.title | escape }}{%- endif -%}
45
+ </p>
46
+ {%- endif -%}
47
+
48
+ <h1 id="page-title" class="recipe-title">{{ page.title }}</h1>
49
+
50
+ {%- if page.description -%}
51
+ <p class="lead recipe-description">{{ page.description }}</p>
52
+ {%- endif -%}
53
+
54
+ {%- if page.source or page.author -%}
55
+ <p class="recipe-byline text-body-secondary small">
56
+ {%- if page.author %}By {{ page.author }}{% endif -%}
57
+ {%- if page.author and page.source %} <span class="mx-1" aria-hidden="true">•</span> {% endif -%}
58
+ {%- if page.source -%}
59
+ Adapted from
60
+ {%- if page.source_url %} <a href="{{ page.source_url }}" rel="noopener">{{ page.source | escape }}</a>
61
+ {%- else %} {{ page.source | escape }}{% endif -%}
62
+ {%- endif -%}
63
+ </p>
64
+ {%- endif -%}
65
+
66
+ {%- if _rl_image -%}
67
+ <figure class="recipe-hero-figure">
68
+ {%- include components/preview-image.html src=_rl_image alt=page.title class="recipe-hero-image" loading="eager" -%}
69
+ {%- if page.image_caption -%}
70
+ <figcaption class="small text-body-secondary mt-2">{{ page.image_caption | escape }}</figcaption>
71
+ {%- endif -%}
72
+ </figure>
73
+ {%- endif -%}
74
+ </header>
75
+
76
+ <!-- ================================ -->
77
+ <!-- HEADNOTE (the markdown body) -->
78
+ <!-- ================================ -->
79
+ {%- assign _rl_body = content | strip -%}
80
+ {%- if _rl_body != "" -%}
81
+ <div class="recipe-headnote">
82
+ {{ content }}
83
+ </div>
84
+ {%- endif -%}
85
+
86
+ <!-- ================================ -->
87
+ <!-- FACT BAR + SCALER -->
88
+ <!-- ================================ -->
89
+ {%- include components/recipe-meta.html -%}
90
+
91
+ {%- if _rl_show_scaler -%}
92
+ {%- include components/recipe-scaler.html recipe_id=_rl_id units=page.units -%}
93
+ <noscript>
94
+ <p class="recipe-noscript small text-body-secondary">
95
+ <i class="bi bi-info-circle me-1" aria-hidden="true"></i>Serving scaling and unit conversion need JavaScript.
96
+ Every amount below is shown exactly as the recipe was written.
97
+ </p>
98
+ </noscript>
99
+ {%- endif -%}
100
+
101
+ <!-- ================================ -->
102
+ <!-- INGREDIENTS + METHOD -->
103
+ <!-- ================================ -->
104
+ <div class="row g-4 g-lg-5 recipe-body">
105
+ <div class="col-lg-5">
106
+ {%- include components/recipe-ingredients.html id_prefix=_rl_id -%}
107
+
108
+ {%- if page.equipment and page.equipment.size > 0 -%}
109
+ <section class="recipe-equipment">
110
+ <h2 class="recipe-section-heading">
111
+ <i class="bi bi-tools me-2" aria-hidden="true"></i>Equipment
112
+ </h2>
113
+ <ul class="recipe-equipment-list list-unstyled">
114
+ {%- for _rl_e in page.equipment -%}
115
+ <li><i class="bi bi-dot" aria-hidden="true"></i>{{ _rl_e | escape }}</li>
116
+ {%- endfor -%}
117
+ </ul>
118
+ </section>
119
+ {%- endif -%}
120
+ </div>
121
+
122
+ <div class="col-lg-7">
123
+ {%- include components/recipe-steps.html -%}
124
+ </div>
125
+ </div>
126
+
127
+ <!-- ================================ -->
128
+ <!-- RATIO / FORMULA -->
129
+ <!-- ================================ -->
130
+ {%- include components/recipe-ratio.html -%}
131
+
132
+ <!-- ================================ -->
133
+ <!-- NOTES -->
134
+ <!-- ================================ -->
135
+ {%- if page.notes and page.notes.size > 0 -%}
136
+ <section class="recipe-notes">
137
+ <h2 class="recipe-section-heading">
138
+ <i class="bi bi-lightbulb me-2" aria-hidden="true"></i>Cook's notes
139
+ </h2>
140
+ <ul class="recipe-notes-list">
141
+ {%- for _rl_note in page.notes -%}
142
+ <li>{{ _rl_note | markdownify | remove: "<p>" | remove: "</p>" }}</li>
143
+ {%- endfor -%}
144
+ </ul>
145
+ </section>
146
+ {%- endif -%}
147
+
148
+ <!-- ================================ -->
149
+ <!-- NUTRITION -->
150
+ <!-- ================================ -->
151
+ {%- include components/recipe-nutrition.html -%}
152
+
153
+ <!-- ================================ -->
154
+ <!-- COMMENTS -->
155
+ <!-- ================================ -->
156
+ {%- if page.comments and site.giscus.enabled -%}
157
+ <section class="post-comments mt-5 pt-4 border-top" id="comments">
158
+ <h2 class="h4 mb-4"><i class="bi bi-chat-dots me-2" aria-hidden="true"></i>Comments</h2>
159
+ {%- include content/giscus.html -%}
160
+ </section>
161
+ {%- endif -%}
162
+ </article>
163
+
164
+ {%- comment -%}
165
+ ================================================================
166
+ STRUCTURED DATA — schema.org/Recipe as JSON-LD.
167
+ Emitted once, here, so search engines see exactly one Recipe entity.
168
+ ================================================================
169
+ {%- endcomment -%}
170
+ {%- assign _rl_prep = page.times.prep | default: 0 -%}
171
+ {%- assign _rl_cook = page.times.cook | default: 0 -%}
172
+ {%- assign _rl_rest = page.times.rest | default: 0 -%}
173
+ {%- if page.times.total -%}
174
+ {%- assign _rl_total = page.times.total -%}
175
+ {%- else -%}
176
+ {%- assign _rl_total = _rl_prep | plus: _rl_cook | plus: _rl_rest -%}
177
+ {%- endif -%}
178
+ {%- capture _rl_prep_iso -%}{%- include components/recipe-duration.html minutes=_rl_prep format="iso" -%}{%- endcapture -%}
179
+ {%- capture _rl_cook_iso -%}{%- include components/recipe-duration.html minutes=_rl_cook format="iso" -%}{%- endcapture -%}
180
+ {%- capture _rl_total_iso -%}{%- include components/recipe-duration.html minutes=_rl_total format="iso" -%}{%- endcapture -%}
181
+ {%- assign _rl_flat = "" | split: "" -%}
182
+ {%- for _rl_entry in page.ingredients -%}
183
+ {%- if _rl_entry.items -%}
184
+ {%- for _rl_i in _rl_entry.items -%}{%- assign _rl_flat = _rl_flat | push: _rl_i -%}{%- endfor -%}
185
+ {%- else -%}
186
+ {%- assign _rl_flat = _rl_flat | push: _rl_entry -%}
187
+ {%- endif -%}
188
+ {%- endfor -%}
189
+ {%- assign _rl_flat_steps = "" | split: "" -%}
190
+ {%- for _rl_entry in page.steps -%}
191
+ {%- if _rl_entry.items -%}
192
+ {%- for _rl_s in _rl_entry.items -%}{%- assign _rl_flat_steps = _rl_flat_steps | push: _rl_s -%}{%- endfor -%}
193
+ {%- else -%}
194
+ {%- assign _rl_flat_steps = _rl_flat_steps | push: _rl_entry -%}
195
+ {%- endif -%}
196
+ {%- endfor -%}
197
+ <script type="application/ld+json">
198
+ {
199
+ "@context": "https://schema.org",
200
+ "@type": "Recipe",
201
+ "name": {{ page.title | jsonify }},
202
+ "url": {{ page.url | absolute_url | jsonify }}
203
+ {%- if page.description %},
204
+ "description": {{ page.description | strip_html | strip | jsonify }}
205
+ {%- endif -%}
206
+ {%- if _rl_image %},
207
+ "image": {{ _rl_image | absolute_url | jsonify }}
208
+ {%- endif -%}
209
+ {%- if page.author %},
210
+ "author": { "@type": "Person", "name": {{ page.author | jsonify }} }
211
+ {%- endif -%}
212
+ {%- if page.date %},
213
+ "datePublished": {{ page.date | date_to_xmlschema | jsonify }}
214
+ {%- endif -%}
215
+ {%- assign _rl_prep_iso = _rl_prep_iso | strip -%}
216
+ {%- if _rl_prep_iso != "" %},
217
+ "prepTime": {{ _rl_prep_iso | jsonify }}
218
+ {%- endif -%}
219
+ {%- assign _rl_cook_iso = _rl_cook_iso | strip -%}
220
+ {%- if _rl_cook_iso != "" %},
221
+ "cookTime": {{ _rl_cook_iso | jsonify }}
222
+ {%- endif -%}
223
+ {%- assign _rl_total_iso = _rl_total_iso | strip -%}
224
+ {%- if _rl_total_iso != "" %},
225
+ "totalTime": {{ _rl_total_iso | jsonify }}
226
+ {%- endif -%}
227
+ {%- if page.yield.amount %},
228
+ {%- capture _rl_yield_text %}{{ page.yield.amount }} {{ page.yield.unit | default: "servings" }}{% endcapture %}
229
+ "recipeYield": {{ _rl_yield_text | strip | jsonify }}
230
+ {%- endif -%}
231
+ {%- if _rl_course_meta %},
232
+ "recipeCategory": {{ _rl_course_meta.title | jsonify }}
233
+ {%- endif -%}
234
+ {%- if page.cuisine %},
235
+ "recipeCuisine": {{ page.cuisine | jsonify }}
236
+ {%- endif -%}
237
+ {%- if page.tags and page.tags.size > 0 %},
238
+ "keywords": {{ page.tags | join: ", " | jsonify }}
239
+ {%- endif -%}
240
+ {%- if _rl_flat.size > 0 %},
241
+ "recipeIngredient": [
242
+ {%- assign _rl_first = true -%}
243
+ {%- for _rl_i in _rl_flat -%}
244
+ {%- if _rl_i.item -%}
245
+ {%- capture _rl_line %}{% if _rl_i.qty %}{{ _rl_i.qty }} {% endif %}{% if _rl_i.unit %}{{ _rl_i.unit }} {% endif %}{{ _rl_i.item }}{% if _rl_i.prep %}, {{ _rl_i.prep }}{% endif %}{% endcapture -%}
246
+ {%- unless _rl_first %},{% endunless %}{%- assign _rl_first = false -%}
247
+ {{ _rl_line | strip | jsonify }}
248
+ {%- endif -%}
249
+ {%- endfor -%}
250
+ ]
251
+ {%- endif -%}
252
+ {%- if _rl_flat_steps.size > 0 %},
253
+ "recipeInstructions": [
254
+ {%- assign _rl_first = true -%}
255
+ {%- for _rl_s in _rl_flat_steps -%}
256
+ {%- if _rl_s.text -%}{%- assign _rl_stext = _rl_s.text -%}{%- else -%}{%- assign _rl_stext = _rl_s -%}{%- endif -%}
257
+ {%- assign _rl_stext = _rl_stext | strip_html | strip -%}
258
+ {%- if _rl_stext != "" -%}
259
+ {%- unless _rl_first %},{% endunless %}{%- assign _rl_first = false -%}
260
+ { "@type": "HowToStep"{% if _rl_s.title %}, "name": {{ _rl_s.title | jsonify }}{% endif %}, "text": {{ _rl_stext | jsonify }} }
261
+ {%- endif -%}
262
+ {%- endfor -%}
263
+ ]
264
+ {%- endif -%}
265
+ {%- if page.nutrition.calories %},
266
+ {%- capture _rl_serving_size %}1 {{ page.yield.singular | default: "serving" }}{% endcapture %}
267
+ "nutrition": {
268
+ "@type": "NutritionInformation",
269
+ "servingSize": {{ _rl_serving_size | strip | jsonify }},
270
+ "calories": {{ page.nutrition.calories | append: " calories" | jsonify }}
271
+ }
272
+ {%- endif %}
273
+ }
274
+ </script>
data/_layouts/root.html CHANGED
@@ -48,10 +48,21 @@
48
48
  {%- endif -%}
49
49
  <html lang="{{ page.lang | default: site.locale | slice: 0, 2 | default: 'en' }}" class="no-js" data-bs-theme="{{ _bs_theme }}" data-color-mode-default="{{ _cm }}" data-color-mode-lock="{{ site.color_mode_lock | default: false }}" data-theme-skin="{{ site.theme_skin | default: 'air' }}" data-zer0-bg="{{ site.theme_background.enabled | default: true }}">
50
50
  <head>
51
+ <!-- Progressive-enhancement hook: upgrade <html class="no-js"> to "js".
52
+ MUST be the first child of <head>, inline, and neither `defer` nor
53
+ `async` — it has to run before the first stylesheet is fetched or
54
+ `html.js` rules would flash. It cannot live in core/head.html:
55
+ Google Tag Manager is emitted above everything else there, so
56
+ "first in that include" is not "first in <head>".
57
+ classList.replace() is a no-op when `no-js` is absent, so this is
58
+ safe unconditionally. The served HTML keeps `no-js` on <html> so
59
+ the JS-disabled branch stays correct. -->
60
+ <script>document.documentElement.classList.replace("no-js", "js");</script>
61
+
51
62
  <!-- =============================================== -->
52
63
  <!-- HEAD SECTION: Meta tags, styles, and SEO setup -->
53
64
  <!-- =============================================== -->
54
-
65
+
55
66
  <!-- Core site metadata, stylesheets, and external dependencies -->
56
67
  {% include core/head.html %}
57
68
 
@@ -107,8 +118,16 @@
107
118
  This is the ONE <main> landmark per page — child layouts (default/section/
108
119
  news) must use non-landmark wrappers (<div>) inside it (issue #299) — so
109
120
  assistive tech, search engines, and AI content extractors can isolate the
110
- primary content from the surrounding header/nav/footer chrome. -->
111
- <main id="main-content">
121
+ primary content from the surrounding header/nav/footer chrome.
122
+
123
+ tabindex="-1" is REQUIRED, not decorative: <main> is not natively
124
+ focusable, and Safari/WebKit (historically Firefox too) only scroll on a
125
+ fragment jump to a non-focusable container — keyboard focus stays put, so
126
+ the next Tab returns the user to the header nav they just skipped
127
+ (WCAG 2.4.1 Bypass Blocks). -1 keeps it out of the sequential Tab order
128
+ while allowing programmatic focus. The focus ring it would otherwise
129
+ paint is suppressed in _sass/utilities/_focus.scss. -->
130
+ <main id="main-content" tabindex="-1">
112
131
  <!-- Machine-translation disclosure — renders only on generated
113
132
  translations (page.machine_translated); no-op elsewhere. -->
114
133
  {%- include components/translation-notice.html -%}
@@ -182,8 +182,9 @@ layout: root
182
182
  {% include navigation/section-sidebar.html
183
183
  section_posts=section_posts
184
184
  sub_categories=sub_categories
185
- page_title=page.title
186
- sidebar_id="sectionSidebar"
185
+ page_title=page.title
186
+ sidebar_id="sectionSidebar"
187
+ section_style=section_style
187
188
  %}
188
189
  </div>
189
190
 
@@ -419,7 +420,17 @@ layout: root
419
420
  <section id="all-posts" class="grid-section mb-5 scroll-mt-5">
420
421
  <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
421
422
  {% for gpost in section_posts %}
422
- <div class="col" data-tags="{{ gpost.tags | join: ' ' | slugify }}">
423
+ {%- comment -%}
424
+ Slugify each tag separately and separate with a space. Joining
425
+ first and slugifying the result turned the separators into
426
+ hyphens, making a tag boundary indistinguishable from a hyphen
427
+ inside a slug ("edge-ai"), which broke filtering.
428
+ The trailing space is deliberate: guarding the separator with a
429
+ last-iteration conditional gives that conditional a
430
+ whitespace-only body, which Liquid treats as a blank block and
431
+ renders as nothing — silently gluing the tags together.
432
+ {%- endcomment -%}
433
+ <div class="col" data-tags="{% for t in gpost.tags %}{{ t | slugify }} {% endfor %}">
423
434
  <div class="card h-100 border-0 shadow-sm hover-lift">
424
435
  <div class="position-relative">
425
436
  {% if gpost.breaking %}
@@ -482,7 +493,8 @@ layout: root
482
493
 
483
494
  <section id="all-posts" class="list-section mb-5 scroll-mt-5">
484
495
  {% for lpost in section_posts %}
485
- <article class="card border-0 shadow-sm mb-3 hover-lift">
496
+ {%- comment -%} Per-tag slugify + space separator — see the grid branch above. {%- endcomment -%}
497
+ <article class="card border-0 shadow-sm mb-3 hover-lift" data-tags="{% for t in lpost.tags %}{{ t | slugify }} {% endfor %}">
486
498
  <div class="row g-0">
487
499
  <div class="col-md-3">
488
500
  <a href="{{ lpost.url | relative_url }}">
@@ -597,17 +609,34 @@ layout: root
597
609
  btn.addEventListener('click', function() {
598
610
  const filter = this.dataset.filter;
599
611
 
600
- // Update active button
612
+ // Update active state across every filter control on the page — the
613
+ // topic pills here and the section sidebar's topic buttons both carry
614
+ // [data-filter], so they stay in sync. Only pills get the btn-* colour
615
+ // swap; the sidebar's controls are .nav-link and style themselves off
616
+ // .active alone.
601
617
  document.querySelectorAll('[data-filter]').forEach(b => {
602
- b.classList.remove('btn-primary', 'active');
603
- b.classList.add('btn-outline-secondary');
618
+ b.classList.remove('active');
619
+ if (b.classList.contains('btn')) {
620
+ b.classList.remove('btn-primary');
621
+ b.classList.add('btn-outline-secondary');
622
+ }
604
623
  });
605
- this.classList.remove('btn-outline-secondary');
606
- this.classList.add('btn-primary', 'active');
624
+ this.classList.add('active');
625
+ if (this.classList.contains('btn')) {
626
+ this.classList.remove('btn-outline-secondary');
627
+ this.classList.add('btn-primary');
628
+ }
607
629
 
608
630
  // Filter cards
631
+ // Match whole tags, not substrings. `data-tags` is a space-separated list
632
+ // of individually-slugified tags, so a slug never spans a separator and
633
+ // filtering by "ai" no longer also matches a post tagged "edge-ai".
634
+ // (This used to disagree with the sidebar's own count: the badge is built
635
+ // in Liquid with `post.tags contains subcat` — exact membership — so a
636
+ // topic showing 5 posts could reveal 6 cards when clicked.)
609
637
  document.querySelectorAll('[data-tags]').forEach(card => {
610
- if (filter === 'all' || card.dataset.tags.includes(filter)) {
638
+ const cardTags = (card.dataset.tags || '').split(' ').filter(Boolean);
639
+ if (filter === 'all' || cardTags.indexOf(filter) !== -1) {
611
640
  card.style.display = '';
612
641
  } else {
613
642
  card.style.display = 'none';
@@ -17,7 +17,7 @@
17
17
 
18
18
  background: var(--zer0-color-bg-elevated);
19
19
  border-left: 0.25rem solid var(--zer0-callout-color, var(--zer0-color-primary));
20
- border-radius: 0.375rem;
20
+ border-radius: var(--zer0-radius);
21
21
  color: var(--zer0-color-ink);
22
22
  }
23
23
 
@@ -64,6 +64,12 @@
64
64
  }
65
65
  }
66
66
 
67
+ // Element-agnostic: applied to <a> (external credits), <span> (credits with
68
+ // no url) and <button> (the in-page "Info" dialog trigger — it opens the
69
+ // #info-section offcanvas rather than navigating, so it is a button, not a
70
+ // link; WCAG 4.1.2). Reboot already inherits margin/font for <button>, but
71
+ // not background, border or colour — reset those and restate the colours
72
+ // `.bd-footer a` supplies so all three elements render identically.
67
73
  .powered-by-link {
68
74
  display: inline-flex;
69
75
  align-items: center;
@@ -75,10 +81,18 @@
75
81
  text-decoration: none;
76
82
  border-radius: var(--bs-border-radius-sm, 0.25rem);
77
83
  white-space: nowrap;
84
+ // Reboot sets `-webkit-appearance: button`, which the unprefixed property
85
+ // does not cancel on older WebKit — reset both.
86
+ -webkit-appearance: none;
87
+ appearance: none;
88
+ background: none;
89
+ border: 0;
90
+ color: var(--bs-body-color);
78
91
 
79
92
  &:hover,
80
93
  &:focus-visible {
81
94
  text-decoration: none;
95
+ color: var(--bs-link-hover-color);
82
96
  }
83
97
  }
84
98
 
@@ -132,6 +146,9 @@
132
146
  // Block padding grows the tap target without changing the visual text size;
133
147
  // icon links get an explicit minimum box. (`d-block` links — e.g. the
134
148
  // truncated Latest Posts titles — keep their display but gain the padding.)
149
+ // `> button` is matched alongside `> a` because the policy row's "Cookie
150
+ // Preferences" control is a <button> (it opens a modal rather than
151
+ // navigating) and must keep the same 1.75rem tap target as its siblings.
135
152
  .footer-dark-block {
136
153
  ul.list-unstyled li > a,
137
154
  p > a {
@@ -139,7 +156,8 @@
139
156
  padding-block: 0.25rem;
140
157
  }
141
158
 
142
- ul.list-inline li > a {
159
+ ul.list-inline li > a,
160
+ ul.list-inline li > button {
143
161
  display: inline-flex;
144
162
  align-items: center;
145
163
  justify-content: center;
@@ -171,6 +189,24 @@
171
189
  }
172
190
  }
173
191
 
192
+ // A <button> that must sit in running text exactly like the <a> beside it —
193
+ // used by the "Cookie Preferences" control, which opens #cookieSettingsModal
194
+ // rather than navigating (WCAG 4.1.2). Reboot inherits margin/font for
195
+ // <button> but leaves the UA background, border and padding in place, so
196
+ // strip those; colour comes from the `.text-light` utility as before.
197
+ .footer-inline-button {
198
+ // Reboot sets `-webkit-appearance: button`, which the unprefixed property
199
+ // does not cancel on older WebKit — reset both.
200
+ -webkit-appearance: none;
201
+ appearance: none;
202
+ background: none;
203
+ border: 0;
204
+ padding: 0;
205
+ vertical-align: baseline;
206
+ text-align: inherit;
207
+ cursor: pointer;
208
+ }
209
+
174
210
  .footer-policy-links {
175
211
  @media (max-width: 767.98px) {
176
212
  font-size: 0.8125rem;
@@ -0,0 +1,36 @@
1
+ // Feature: ZER0-083
2
+ // ============================================================================
3
+ // Page-view counter badge
4
+ // ----------------------------------------------------------------------------
5
+ // Markup: _includes/components/page-views.html
6
+ // Behaviour: assets/js/page-views.js
7
+ // Config: _config.yml → page_views:
8
+ // Theming: inherits the surrounding meta-row color (.post-meta is
9
+ // .text-muted), so it follows [data-bs-theme] light/dark for free.
10
+ // ============================================================================
11
+
12
+ .page-views {
13
+ display: inline-flex;
14
+ align-items: baseline;
15
+ gap: .25rem;
16
+ white-space: nowrap;
17
+
18
+ // The badge is server-rendered hidden and revealed by JS once a count is
19
+ // known. `!important` because a utility class on the include's `class`
20
+ // param (e.g. `d-inline-flex`) would otherwise win over [hidden].
21
+ &[hidden] {
22
+ display: none !important;
23
+ }
24
+
25
+ // Digits change as the count updates — tabular figures keep the meta row
26
+ // from reflowing when 9 becomes 10.
27
+ &__count {
28
+ font-variant-numeric: tabular-nums;
29
+ }
30
+
31
+ // Leading separator for meta rows; hides with the badge because it lives
32
+ // inside it (see the include's `separator` param).
33
+ &__sep {
34
+ display: inline;
35
+ }
36
+ }