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,45 @@
1
+ {% comment %} Feature: ZER0-084 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: recipe-temp
4
+ Path: _includes/components/recipe-temp.html
5
+ Purpose: A temperature that follows the reader's unit choice — oven
6
+ settings, water temperature, doneness targets. Renders BOTH
7
+ scales by default so the page is complete without JavaScript;
8
+ the scaler then narrows it to whichever system is selected.
9
+ Params: f (number, optional) — degrees Fahrenheit
10
+ c (number, optional) — degrees Celsius
11
+ (give either one; the other is derived)
12
+ label (string, optional, default: "") — prefix, e.g. "Oven"
13
+ class (string, optional, default: "")
14
+ Depends on: assets/js/recipe-scaler.js, _sass/components/_recipe.scss
15
+ Notes: Derived values round to the nearest 5° above 100° — the
16
+ precision an oven dial actually has — and to the nearest degree
17
+ below it. Author both `f:` and `c:` to pin exact values.
18
+ Drop it straight into recipe prose:
19
+ include components/recipe-temp.html f=375
20
+ {%- endcomment -%}
21
+ {%- if include.f == nil and include.c == nil -%}
22
+ {%- comment -%} ERROR: recipe-temp requires `f` or `c` {%- endcomment -%}
23
+ {%- else -%}
24
+ {%- if include.f != nil -%}
25
+ {%- assign _rt_f = include.f | round -%}
26
+ {%- else -%}
27
+ {%- assign _rt_fraw = include.c | times: 9.0 | divided_by: 5.0 | plus: 32 -%}
28
+ {%- if _rt_fraw >= 100 -%}
29
+ {%- assign _rt_f = _rt_fraw | divided_by: 5.0 | round | times: 5 -%}
30
+ {%- else -%}
31
+ {%- assign _rt_f = _rt_fraw | round -%}
32
+ {%- endif -%}
33
+ {%- endif -%}
34
+ {%- if include.c != nil -%}
35
+ {%- assign _rt_c = include.c | round -%}
36
+ {%- else -%}
37
+ {%- assign _rt_craw = include.f | minus: 32 | times: 5.0 | divided_by: 9.0 -%}
38
+ {%- if _rt_craw >= 100 -%}
39
+ {%- assign _rt_c = _rt_craw | divided_by: 5.0 | round | times: 5 -%}
40
+ {%- else -%}
41
+ {%- assign _rt_c = _rt_craw | round -%}
42
+ {%- endif -%}
43
+ {%- endif -%}
44
+ <span class="recipe-temp {{ include.class | default: '' }}" data-recipe-temp data-temp-f="{{ _rt_f }}" data-temp-c="{{ _rt_c }}">{% if include.label %}{{ include.label | escape }} {% endif %}{{ _rt_f }} °F ({{ _rt_c }} °C)</span>
45
+ {%- endif -%}
@@ -45,9 +45,9 @@
45
45
  <div class="modal-dialog modal-dialog-centered">
46
46
  <div class="modal-content">
47
47
  <div class="modal-header">
48
- <h5 class="modal-title" id="siteSearchModalLabel">
48
+ <h2 class="modal-title h5" id="siteSearchModalLabel">
49
49
  <i class="bi bi-search me-2"></i>Search
50
- </h5>
50
+ </h2>
51
51
  <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
52
52
  </div>
53
53
  <div class="modal-body">
@@ -30,6 +30,9 @@
30
30
  <dt class="col-4 col-sm-3 text-end"><kbd>/</kbd></dt>
31
31
  <dd class="col-8 col-sm-9 mb-2">{{ ui.shortcuts_search | default: "Open search" }}</dd>
32
32
 
33
+ <dt class="col-4 col-sm-3 text-end"><kbd>⌘</kbd>/<kbd>Ctrl</kbd>+<kbd>K</kbd></dt>
34
+ <dd class="col-8 col-sm-9 mb-2">{{ ui.shortcuts_search | default: "Open search" }}</dd>
35
+
33
36
  <dt class="col-4 col-sm-3 text-end"><kbd>[</kbd></dt>
34
37
  <dd class="col-8 col-sm-9 mb-2">{{ ui.shortcuts_prev_section | default: "Previous section" }}</dd>
35
38
 
@@ -54,7 +54,14 @@
54
54
  <div class="col-6">
55
55
  <div class="p-2 bg-body-tertiary rounded text-center">
56
56
  <small class="text-body-secondary d-block">Last Build</small>
57
- <code class="small">{{ site.time | date: "%b %d, %H:%M" }}</code>
57
+ {%- comment -%}
58
+ data-testid is the stable hook the pixel-snapshot tier masks on
59
+ (test/visual/features/appearance-snapshot.spec.js). This stamp has
60
+ minute resolution, so it becomes an unconditional snapshot flake the
61
+ moment this panel is shown by default rather than inside a collapsed
62
+ offcanvas. Don't remove the attribute without updating that mask.
63
+ {%- endcomment -%}
64
+ <code class="small" data-testid="theme-build-stamp">{{ site.time | date: "%b %d, %H:%M" }}</code>
58
65
  </div>
59
66
  </div>
60
67
 
@@ -9,22 +9,41 @@
9
9
  engines to accurately cite and describe the zer0-mistakes
10
10
  Jekyll theme as a SoftwareApplication entity.
11
11
 
12
+ OPT-IN ONLY — this describes THE THEME, not the site rendering it.
13
+ The payload below is hardcoded to zer0-mistakes: its RubyGems download
14
+ URL, its GitHub repository, its MIT licence, its feature list, its
15
+ mascot screenshot, and a Person node naming the theme's author. Emitted
16
+ from a consumer's homepage it asserts, in machine-readable form, that
17
+ THAT site is a free open-source Ruby gem authored by someone else — e.g.
18
+ a consulting practice's homepage claiming to be a developer tool.
19
+
20
+ It therefore renders only when a site explicitly opts in with
21
+ `jsonld_software_application: true` in _config.yml, which the theme's own
22
+ site does. Consumers get no software schema (their standard SEO metadata
23
+ is unaffected) unless they deliberately turn this on.
24
+
12
25
  Template Logic:
26
+ - Requires site.jsonld_software_application == true
13
27
  - Renders SoftwareApplication schema on the homepage only
14
28
  - Links to a WebPage wrapper via @id references
15
29
  - Pulls version, author, and description from site config
16
30
 
17
31
  Dependencies:
18
- - _config.yml: site.url, site.title, site.description, site.author
32
+ - _config.yml: jsonld_software_application, site.url, site.title,
33
+ site.description, site.author
19
34
  - lib/jekyll-theme-zer0/version.rb (version referenced in config)
20
35
 
21
36
  Usage:
22
37
  - Included automatically in _includes/core/head.html
23
- - Conditional: only renders when page.permalink == "/"
38
+ - Conditional: opted in AND page.permalink == "/"
24
39
  ===================================================================
25
40
  -->
26
41
 
27
- {% if page.permalink == "/" or page.url == "/" %}
42
+ {%- assign _is_homepage = false -%}
43
+ {%- if page.permalink == "/" or page.url == "/" -%}
44
+ {%- assign _is_homepage = true -%}
45
+ {%- endif -%}
46
+ {% if site.jsonld_software_application and _is_homepage %}
28
47
  <script type="application/ld+json">
29
48
  {
30
49
  "@context": "https://schema.org",
@@ -76,15 +76,16 @@
76
76
  </span>
77
77
  {% endif %}
78
78
  {% if forloop.last and powered_by_count > 0 %}
79
- <a href="#"
80
- class="powered-by-link"
81
- data-bs-toggle="offcanvas"
82
- data-bs-target="#info-section"
83
- aria-label="View theme information and system details"
84
- title="Theme Info">
79
+ <button type="button"
80
+ class="powered-by-link"
81
+ data-bs-toggle="offcanvas"
82
+ data-bs-target="#info-section"
83
+ aria-haspopup="dialog"
84
+ aria-label="View theme information and system details"
85
+ title="Theme Info">
85
86
  <i class="{{ site.default_icon }} bi-info-circle" aria-hidden="true"></i>
86
87
  <span class="powered-by-link-text">Info</span>
87
- </a>
88
+ </button>
88
89
  </span>
89
90
  </li>
90
91
  {% else %}
@@ -93,15 +94,16 @@
93
94
  {% endfor %}
94
95
  {% if powered_by_count == 0 %}
95
96
  <li>
96
- <a href="#"
97
- class="powered-by-link"
98
- data-bs-toggle="offcanvas"
99
- data-bs-target="#info-section"
100
- aria-label="View theme information and system details"
101
- title="Theme Info">
97
+ <button type="button"
98
+ class="powered-by-link"
99
+ data-bs-toggle="offcanvas"
100
+ data-bs-target="#info-section"
101
+ aria-haspopup="dialog"
102
+ aria-label="View theme information and system details"
103
+ title="Theme Info">
102
104
  <i class="{{ site.default_icon }} bi-info-circle" aria-hidden="true"></i>
103
105
  <span class="powered-by-link-text">Info</span>
104
- </a>
106
+ </button>
105
107
  </li>
106
108
  {% endif %}
107
109
  </ul>
@@ -239,9 +241,9 @@
239
241
  <li class="list-inline-item"><a href="{{ site.terms_of_service_url | default: '/terms-of-service' | relative_url }}" class="text-light text-decoration-none">Terms of Service</a></li>
240
242
  {% endif %}
241
243
  <li class="list-inline-item">
242
- <a href="#" class="text-light text-decoration-none" data-bs-toggle="modal" data-bs-target="#cookieSettingsModal">
244
+ <button type="button" class="footer-inline-button text-light text-decoration-none" data-bs-toggle="modal" data-bs-target="#cookieSettingsModal">
243
245
  Cookie Preferences
244
- </a>
246
+ </button>
245
247
  </li>
246
248
  </ul>
247
249
  </div>
@@ -51,10 +51,22 @@
51
51
  <script defer src="{{ '/assets/js/table-copy.js' | relative_url }}"></script>
52
52
  <script defer src="{{ '/assets/js/ui-enhancements.js' | relative_url }}"></script>
53
53
 
54
+ <!-- Page-view counter — injects its config and loads assets/js/page-views.js.
55
+ Renders nothing on its own; components/page-views.html places the badge.
56
+ Gated on site.page_views.enabled inside the include. -->
57
+ {% include components/page-views-init.html %}
58
+
54
59
  <!-- ================================ -->
55
60
  <!-- THIRD PARTY INTEGRATIONS -->
56
61
  <!-- ================================ -->
57
62
 
63
+ <!-- Recipe scaling & unit conversion - Only on pages that render recipe
64
+ quantities: the `recipe` layout, or any page that opts in with
65
+ `recipe_tools: true` (e.g. a post embedding components/recipe-*.html). -->
66
+ {% if page.layout == "recipe" or page.recipe_tools %}
67
+ <script defer src="{{ '/assets/js/recipe-scaler.js' | relative_url }}"></script>
68
+ {% endif %}
69
+
58
70
  <!-- Mermaid Diagrams - Conditional loading based on page front matter -->
59
71
  {% if page.mermaid %}
60
72
  {% include components/mermaid.html %}
@@ -58,7 +58,7 @@
58
58
  <!-- ========================== -->
59
59
  <!-- MAIN NAVIGATION CONTAINER -->
60
60
  <!-- ========================== -->
61
- <nav class="container-xl order-2 order-lg-1 d-flex align-items-center gap-1 flex-nowrap navbar-main w-100" aria-label="Primary navigation">
61
+ <nav class="container-fluid order-2 order-lg-1 d-flex align-items-center gap-1 flex-nowrap navbar-main w-100" aria-label="Primary navigation">
62
62
 
63
63
  <!-- Leading cluster: sidebar toggle + brand + titles (grid column 1 on lg+) -->
64
64
  <div class="navbar-main-start d-flex align-items-center gap-1 gap-lg-2 min-w-0 flex-shrink-1 flex-grow-1">
@@ -151,8 +151,9 @@
151
151
  <!-- UTILITY CONTROLS -->
152
152
  <!-- ========================== -->
153
153
  <div class="navbar-utility-controls d-flex align-items-center flex-shrink-0" role="group" aria-label="Utility controls">
154
- <!-- Language Toggle (renders only when translation.languages is set) -->
155
- {% include components/language-toggle.html %}
154
+ <!-- Language selection lives in the Settings offcanvas
155
+ (components/info-section.html, Appearance tab) so the bar
156
+ keeps its width for the main navigation items. -->
156
157
 
157
158
  <!-- Search Modal Toggle -->
158
159
  <button
@@ -161,10 +162,12 @@
161
162
  data-search-toggle
162
163
  aria-label="{{ ui.shortcuts_search | default: 'Open search' }}"
163
164
  aria-haspopup="dialog"
164
- title="{{ ui.search_button_label | default: 'Search' }}"
165
+ aria-keyshortcuts="/ Meta+K Control+K"
166
+ title="{{ ui.search_button_title | default: 'Search — press / or Ctrl+K' }}"
165
167
  >
166
168
  <i class="{{site.default_icon}} bi-search" aria-hidden="true"></i>
167
169
  <span class="nav-link-text d-none d-xl-inline ms-1">{{ ui.search_button_label | default: "Search" }}</span>
170
+ <kbd class="nav-search-kbd d-none d-xl-inline-flex" aria-hidden="true">/</kbd>
168
171
  </button>
169
172
 
170
173
  <!-- Settings Offcanvas Toggle -->
@@ -12,13 +12,17 @@
12
12
  - sub_categories: Array of unique tags/topics from section posts
13
13
  - page_title: Title of the current section
14
14
  - sidebar_id: Unique ID for the sidebar (default: "sectionSidebar")
15
-
15
+ - section_style: The parent layout's display style, one of grid | list |
16
+ magazine (default: "grid"). Decides whether topics render as scroll
17
+ anchors or as filter buttons — see the Topic controls note below.
18
+
16
19
  Features:
17
20
  - Desktop: Fixed sticky sidebar on the left
18
21
  - Mobile: Offcanvas drawer
19
22
  - Topic navigation with article counts
20
23
  - Section statistics
21
- - Smooth scroll to sections
24
+ - Smooth scroll to sections (magazine style, where scroll targets exist)
25
+ - Card filtering via [data-filter] (grid/list styles)
22
26
 
23
27
  Dependencies:
24
28
  - Bootstrap 5 offcanvas component
@@ -30,6 +34,42 @@
30
34
  {% assign section_posts = include.section_posts %}
31
35
  {% assign sub_categories = include.sub_categories %}
32
36
  {% assign page_title = include.page_title | default: page.title %}
37
+ {% assign section_style = include.section_style | default: "grid" %}
38
+
39
+ {% comment %}
40
+ Topic controls: link vs filter.
41
+
42
+ Only the `magazine` branch of _layouts/section.html emits per-subcategory
43
+ `<section id="{{ subcat | slugify }}">` scroll targets, and only for the first
44
+ 5 sub-categories that still have a post once the featured hero is excluded.
45
+ The `grid` and `list` branches emit no sub-topic ids at all — they filter
46
+ `[data-tags]` cards through `[data-filter]` buttons instead.
47
+
48
+ So an <a href="#slug"> here is only ever valid under magazine, and only for
49
+ that exact set. Emitting one anywhere else produced guaranteed-dead anchors:
50
+ invalid markup, a dead click for keyboard/AT users, and a scrollspy that could
51
+ never activate. `_linkable_subcats` below reproduces the layout's set exactly;
52
+ every other style renders filter buttons, which the layout's existing
53
+ `[data-filter]` handler already wires up with no extra JS.
54
+ {% endcomment %}
55
+ {% assign _anchors_resolve = false %}
56
+ {% if section_style == "magazine" %}{% assign _anchors_resolve = true %}{% endif %}
57
+
58
+ {% assign _linkable_subcats = "" | split: "" %}
59
+ {% if _anchors_resolve %}
60
+ {% assign _featured = section_posts | where: "featured", true | first %}
61
+ {% unless _featured %}{% assign _featured = section_posts | first %}{% endunless %}
62
+ {% for subcat in sub_categories limit: 5 %}
63
+ {% assign _subcat_posts = section_posts | where_exp: "post", "post.tags contains subcat" %}
64
+ {% assign _remaining = 0 %}
65
+ {% for p in _subcat_posts %}
66
+ {% unless p.url == _featured.url %}{% assign _remaining = _remaining | plus: 1 %}{% endunless %}
67
+ {% endfor %}
68
+ {% if _remaining > 0 %}
69
+ {% assign _linkable_subcats = _linkable_subcats | push: subcat %}
70
+ {% endif %}
71
+ {% endfor %}
72
+ {% endif %}
33
73
 
34
74
  <!-- ================================ -->
35
75
  <!-- DESKTOP SIDEBAR (Fixed Sticky) -->
@@ -47,21 +87,37 @@
47
87
  </div>
48
88
  <div class="card-body p-0">
49
89
  <nav class="nav flex-column sidebar-nav">
90
+ {% if _anchors_resolve %}
50
91
  <a class="nav-link active" href="#all-posts" data-section="all-posts">
51
92
  <i class="bi bi-grid me-2"></i>
52
93
  <span class="nav-text">All Articles</span>
53
94
  <span class="badge bg-secondary ms-auto">{{ section_posts.size }}</span>
54
95
  </a>
55
- {% for subcat in sub_categories limit: 15 %}
96
+ {% for subcat in _linkable_subcats %}
56
97
  {% assign subcat_posts = section_posts | where_exp: "post", "post.tags contains subcat" %}
57
- {% if subcat_posts.size > 0 %}
58
98
  <a class="nav-link" href="#{{ subcat | slugify }}" data-section="{{ subcat | slugify }}">
59
99
  <i class="bi bi-tag me-2"></i>
60
100
  <span class="nav-text">{{ subcat | capitalize }}</span>
61
101
  <span class="badge bg-light text-dark ms-auto">{{ subcat_posts.size }}</span>
62
102
  </a>
103
+ {% endfor %}
104
+ {% else %}
105
+ <button type="button" class="nav-link text-start active" data-filter="all">
106
+ <i class="bi bi-grid me-2"></i>
107
+ <span class="nav-text">All Articles</span>
108
+ <span class="badge bg-secondary ms-auto">{{ section_posts.size }}</span>
109
+ </button>
110
+ {% for subcat in sub_categories limit: 15 %}
111
+ {% assign subcat_posts = section_posts | where_exp: "post", "post.tags contains subcat" %}
112
+ {% if subcat_posts.size > 0 %}
113
+ <button type="button" class="nav-link text-start" data-filter="{{ subcat | slugify }}">
114
+ <i class="bi bi-tag me-2"></i>
115
+ <span class="nav-text">{{ subcat | capitalize }}</span>
116
+ <span class="badge bg-light text-dark ms-auto">{{ subcat_posts.size }}</span>
117
+ </button>
63
118
  {% endif %}
64
119
  {% endfor %}
120
+ {% endif %}
65
121
  </nav>
66
122
  </div>
67
123
  {% comment %} Existence-gate the tags index: a remote-theme consumer without
@@ -107,26 +163,40 @@
107
163
  <!-- ================================ -->
108
164
  <div class="offcanvas offcanvas-start" tabindex="-1" id="{{ sidebar_id }}" aria-labelledby="{{ sidebar_id }}Label">
109
165
  <div class="offcanvas-header bg-primary text-white">
110
- <h5 class="offcanvas-title" id="{{ sidebar_id }}Label">
166
+ <h2 class="offcanvas-title h5" id="{{ sidebar_id }}Label">
111
167
  <i class="bi bi-bookmark me-2"></i>{{ page_title }} Topics
112
- </h5>
168
+ </h2>
113
169
  <button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
114
170
  </div>
115
171
  <div class="offcanvas-body p-0">
116
172
  <nav class="nav flex-column">
173
+ {% if _anchors_resolve %}
117
174
  <a class="nav-link active bg-light py-3 border-bottom" href="#all-posts" data-bs-dismiss="offcanvas">
118
175
  <i class="bi bi-grid me-2"></i>All Articles
119
176
  <span class="badge bg-secondary float-end">{{ section_posts.size }}</span>
120
177
  </a>
121
- {% for subcat in sub_categories %}
178
+ {% for subcat in _linkable_subcats %}
122
179
  {% assign subcat_posts = section_posts | where_exp: "post", "post.tags contains subcat" %}
123
- {% if subcat_posts.size > 0 %}
124
180
  <a class="nav-link text-body-secondary py-3 border-bottom" href="#{{ subcat | slugify }}" data-bs-dismiss="offcanvas">
125
181
  <i class="bi bi-tag me-2"></i>{{ subcat | capitalize }}
126
182
  <span class="badge bg-light text-dark float-end">{{ subcat_posts.size }}</span>
127
183
  </a>
184
+ {% endfor %}
185
+ {% else %}
186
+ <button type="button" class="nav-link text-start active bg-light py-3 border-bottom w-100" data-filter="all" data-bs-dismiss="offcanvas">
187
+ <i class="bi bi-grid me-2"></i>All Articles
188
+ <span class="badge bg-secondary float-end">{{ section_posts.size }}</span>
189
+ </button>
190
+ {% for subcat in sub_categories %}
191
+ {% assign subcat_posts = section_posts | where_exp: "post", "post.tags contains subcat" %}
192
+ {% if subcat_posts.size > 0 %}
193
+ <button type="button" class="nav-link text-start text-body-secondary py-3 border-bottom w-100" data-filter="{{ subcat | slugify }}" data-bs-dismiss="offcanvas">
194
+ <i class="bi bi-tag me-2"></i>{{ subcat | capitalize }}
195
+ <span class="badge bg-light text-dark float-end">{{ subcat_posts.size }}</span>
196
+ </button>
128
197
  {% endif %}
129
198
  {% endfor %}
199
+ {% endif %}
130
200
  </nav>
131
201
  {% if _tags_page %}
132
202
  <div class="p-3">
@@ -169,6 +239,16 @@
169
239
  overflow: hidden;
170
240
  text-overflow: ellipsis;
171
241
  }
242
+
243
+ /* In grid/list styles the topic controls are <button data-filter> (there is
244
+ no per-topic scroll target to link to). Bootstrap's .nav-link already
245
+ clears button background/border; these are the two properties a <button>
246
+ still needs to render identically to the anchor form. */
247
+ .sidebar-nav button.nav-link,
248
+ .offcanvas button.nav-link {
249
+ width: 100%;
250
+ text-align: left;
251
+ }
172
252
 
173
253
  /* Offcanvas mobile styling */
174
254
  .offcanvas .nav-link {
@@ -194,8 +274,10 @@
194
274
  if (target) {
195
275
  target.scrollIntoView({ behavior: 'smooth' });
196
276
 
197
- // Update active state in sidebar
198
- document.querySelectorAll('.sidebar-nav .nav-link').forEach(link => {
277
+ // Update active state in sidebar. Scoped to anchors: in grid/list
278
+ // styles the topic controls are <button data-filter> elements whose
279
+ // active state belongs to the layout's filter handler, not here.
280
+ document.querySelectorAll('.sidebar-nav a.nav-link').forEach(link => {
199
281
  link.classList.remove('active');
200
282
  });
201
283
 
@@ -220,7 +302,7 @@
220
302
  entries.forEach(entry => {
221
303
  if (entry.isIntersecting) {
222
304
  const id = entry.target.getAttribute('id');
223
- document.querySelectorAll('.sidebar-nav .nav-link').forEach(link => {
305
+ document.querySelectorAll('.sidebar-nav a.nav-link').forEach(link => {
224
306
  link.classList.remove('active');
225
307
  if (link.getAttribute('href') === `#${id}`) {
226
308
  link.classList.add('active');
@@ -79,7 +79,8 @@
79
79
  aria-controls="bdSidebar"
80
80
  aria-expanded="true"
81
81
  aria-label="Hide documentation sidebar"
82
- title="Hide documentation sidebar">
82
+ aria-keyshortcuts="b"
83
+ title="Hide documentation sidebar (b)">
83
84
  <i class="bi bi-layout-sidebar-inset" aria-hidden="true"></i>
84
85
  <span class="visually-hidden">Hide documentation sidebar</span>
85
86
  </button>
@@ -45,7 +45,8 @@
45
45
  aria-controls="tocContents"
46
46
  aria-expanded="true"
47
47
  aria-label="Hide table of contents"
48
- title="Hide table of contents">
48
+ aria-keyshortcuts="t"
49
+ title="Hide table of contents (t)">
49
50
  <i class="bi bi-layout-sidebar-inset-reverse" aria-hidden="true"></i>
50
51
  <span class="visually-hidden">Hide table of contents</span>
51
52
  </button>