jekyll-theme-zer0 1.27.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +812 -13
  3. data/_data/backlog.yml +356 -2
  4. data/_data/consumers.yml +252 -0
  5. data/_data/features.yml +234 -5
  6. data/_data/i18n/fr.yml +103 -0
  7. data/_data/i18n/manifest.yml +1529 -0
  8. data/_data/ingredient_densities.yml +122 -0
  9. data/_data/navigation/docs.yml +2 -0
  10. data/_data/navigation/main.yml +22 -0
  11. data/_data/recipe_courses.yml +64 -0
  12. data/_data/series.yml +19 -0
  13. data/_data/theme-manifest.yml +387 -277
  14. data/_data/ui-text.yml +8 -0
  15. data/_includes/README.md +36 -3
  16. data/_includes/analytics/google-tag-manager-body.html +11 -2
  17. data/_includes/analytics/google-tag-manager-head.html +16 -6
  18. data/_includes/analytics/posthog.html +33 -4
  19. data/_includes/components/abc-letter.html +43 -0
  20. data/_includes/components/background-customizer.html +2 -2
  21. data/_includes/components/book-card.html +42 -0
  22. data/_includes/components/book-nav.html +80 -0
  23. data/_includes/components/book-plate.html +31 -0
  24. data/_includes/components/book-toc.html +47 -0
  25. data/_includes/components/bookshelf.html +68 -0
  26. data/_includes/components/card-grid.html +60 -0
  27. data/_includes/components/cookie-consent.html +4 -4
  28. data/_includes/components/data-card.html +95 -0
  29. data/_includes/components/halfmoon.html +5 -1
  30. data/_includes/components/info-section.html +8 -3
  31. data/_includes/components/language-toggle.html +168 -21
  32. data/_includes/components/page-feedback.html +65 -2
  33. data/_includes/components/page-views-init.html +55 -0
  34. data/_includes/components/page-views.html +33 -0
  35. data/_includes/components/recipe-card.html +67 -0
  36. data/_includes/components/recipe-duration.html +50 -0
  37. data/_includes/components/recipe-grams.html +58 -0
  38. data/_includes/components/recipe-index.html +96 -0
  39. data/_includes/components/recipe-ingredients.html +90 -0
  40. data/_includes/components/recipe-meta.html +96 -0
  41. data/_includes/components/recipe-nutrition.html +57 -0
  42. data/_includes/components/recipe-qty.html +73 -0
  43. data/_includes/components/recipe-ratio.html +151 -0
  44. data/_includes/components/recipe-scaler.html +73 -0
  45. data/_includes/components/recipe-steps.html +86 -0
  46. data/_includes/components/recipe-temp.html +45 -0
  47. data/_includes/components/search-modal.html +2 -2
  48. data/_includes/components/shortcuts-modal.html +3 -0
  49. data/_includes/components/theme-controls-bar.html +10 -2
  50. data/_includes/components/theme-customizer.html +8 -2
  51. data/_includes/components/theme-info.html +8 -1
  52. data/_includes/content/jsonld-software.html +22 -3
  53. data/_includes/content/seo.html +9 -3
  54. data/_includes/core/color-mode-init.html +13 -4
  55. data/_includes/core/favicon.html +46 -0
  56. data/_includes/core/footer.html +18 -16
  57. data/_includes/core/head.html +21 -0
  58. data/_includes/core/header.html +7 -4
  59. data/_includes/custom/body-end.html +18 -0
  60. data/_includes/custom/body-start.html +17 -0
  61. data/_includes/custom/footer.html +18 -0
  62. data/_includes/custom/head.html +18 -0
  63. data/_includes/navigation/local-graph.html +28 -2
  64. data/_includes/navigation/nav-tree.html +3 -3
  65. data/_includes/navigation/section-sidebar.html +93 -11
  66. data/_includes/navigation/sidebar-config.html +21 -0
  67. data/_includes/navigation/sidebar-left.html +2 -1
  68. data/_includes/navigation/sidebar-nav.html +4 -0
  69. data/_includes/navigation/sidebar-pagetree.html +150 -0
  70. data/_includes/navigation/sidebar-right.html +2 -1
  71. data/_includes/navigation/unified-drawer.html +8 -2
  72. data/_includes/obsidian/full-graph.html +165 -136
  73. data/_includes/setup/wizard.html +173 -86
  74. data/_includes/stats/stats-metrics.html +2 -2
  75. data/_layouts/404.html +260 -0
  76. data/_layouts/README.md +4 -0
  77. data/_layouts/admin.html +2 -2
  78. data/_layouts/article.html +5 -0
  79. data/_layouts/book-abc.html +106 -0
  80. data/_layouts/book-story.html +91 -0
  81. data/_layouts/book.html +113 -0
  82. data/_layouts/collection.html +17 -5
  83. data/_layouts/cookbook.html +88 -0
  84. data/_layouts/default.html +4 -4
  85. data/_layouts/home.html +5 -2
  86. data/_layouts/landing.html +9 -0
  87. data/_layouts/news.html +155 -31
  88. data/_layouts/recipe.html +274 -0
  89. data/_layouts/root.html +38 -5
  90. data/_layouts/section.html +96 -25
  91. data/_sass/components/_book.scss +423 -0
  92. data/_sass/components/_callout.scss +1 -1
  93. data/_sass/components/_footer.scss +37 -1
  94. data/_sass/components/_page-views.scss +36 -0
  95. data/_sass/components/_recipe.scss +506 -0
  96. data/_sass/components/_setup-wizard.scss +235 -0
  97. data/_sass/components/_ui-enhancements.scss +6 -6
  98. data/_sass/core/_navbar.scss +263 -15
  99. data/_sass/core/_obsidian.scss +286 -6
  100. data/_sass/layouts/_landing.scss +2 -2
  101. data/_sass/theme/_backgrounds.scss +21 -8
  102. data/_sass/tokens/_color.scss +6 -0
  103. data/_sass/tokens/_index.scss +2 -0
  104. data/_sass/tokens/_radius.scss +21 -0
  105. data/_sass/tokens/_typography.scss +4 -0
  106. data/_sass/utilities/_focus.scss +14 -0
  107. data/assets/css/main.scss +4 -0
  108. data/assets/js/auto-hide-nav.js +5 -1
  109. data/assets/js/halfmoon.js +26 -0
  110. data/assets/js/modules/navigation/navbar.js +55 -0
  111. data/assets/js/obsidian-graph.js +702 -264
  112. data/assets/js/obsidian-local-graph.js +161 -54
  113. data/assets/js/page-views.js +372 -0
  114. data/assets/js/recipe-scaler.js +501 -0
  115. data/assets/js/search-modal.js +14 -1
  116. data/assets/js/setup-wizard.js +322 -20
  117. data/scripts/README.md +29 -0
  118. data/scripts/bin/audit-consumer +39 -7
  119. data/scripts/bin/giscus-discussions +213 -14
  120. data/scripts/bin/manifest +66 -16
  121. data/scripts/bin/validate +5 -1
  122. data/scripts/ci/agent_review_result.py +164 -0
  123. data/scripts/ci/test_agent_review_result.py +172 -0
  124. data/scripts/design-system-check.rb +170 -0
  125. data/scripts/install/README.md +47 -6
  126. data/scripts/install/ai/client.sh +302 -93
  127. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  128. data/scripts/install/ai/wizard.sh +10 -5
  129. data/scripts/install/apply.sh +7 -3
  130. data/scripts/install/cli.sh +54 -4
  131. data/scripts/install/config.sh +167 -0
  132. data/scripts/install/doctor.sh +38 -0
  133. data/scripts/install/plan.sh +10 -0
  134. data/scripts/install/spec.sh +15 -7
  135. data/scripts/install/template.sh +4 -0
  136. data/scripts/lib/audit.sh +42 -2
  137. data/scripts/lint-liquid-raw.rb +137 -0
  138. data/scripts/propagate.rb +277 -0
  139. data/scripts/test/lib/run_tests.sh +2 -1
  140. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  141. data/scripts/translate.rb +161 -15
  142. metadata +56 -2
@@ -0,0 +1,95 @@
1
+ {%- comment -%}
2
+ ===================================================================
3
+ DATA-CARD — one generic, data-driven Bootstrap card
4
+ ===================================================================
5
+
6
+ Path: _includes/components/data-card.html
7
+ Purpose: Render ONE card from a plain data hash so hub dashboards,
8
+ fleet registries, service grids, and link boards can be
9
+ data-driven instead of hand-written Bootstrap markup.
10
+ Usually invoked via components/card-grid.html.
11
+
12
+ Parameters:
13
+ item (hash, required) — the card data (see schema below)
14
+ heading_level (string) — heading tag for the title, default "h3"
15
+ heading_class (string) — classes for the heading, default "h5"
16
+ card_class (string) — extra classes on the .card element
17
+
18
+ Item schema (every key optional except title):
19
+ title card heading text
20
+ url makes the title a link
21
+ external true → the title link opens in a new tab
22
+ description body copy (plain text or inline HTML)
23
+ icon Bootstrap Icons class shown before the title (e.g. "bi-robot")
24
+ image image path/URL rendered via components/preview-image.html
25
+ image_alt alt text for the image (defaults to title)
26
+ badge short status label rendered next to the title
27
+ badge_variant Bootstrap color suffix for the badge (default "secondary")
28
+ badge_title tooltip for the badge
29
+ meta array of { icon, text } pills rendered under the body
30
+ buttons array of { label, url, icon, variant, external } footer actions
31
+ footer small muted footer text (rendered when no buttons)
32
+
33
+ Example (_data/projects.yml + a page):
34
+ {% for p in site.data.projects %}
35
+ {% include components/data-card.html item=p %}
36
+ {% endfor %}
37
+ ===================================================================
38
+ {%- endcomment -%}
39
+ {%- assign _dc = include.item -%}
40
+ {%- assign _dc_heading = include.heading_level | default: "h3" -%}
41
+ {%- assign _dc_heading_class = include.heading_class | default: "h5" -%}
42
+ <div class="card h-100 zer0-data-card {{ include.card_class }}">
43
+ {%- if _dc.image -%}
44
+ {%- assign _dc_img_alt = _dc.image_alt | default: _dc.title -%}
45
+ {%- if _dc.url -%}<a href="{{ _dc.url | relative_url }}" tabindex="-1" aria-hidden="true">{%- endif -%}
46
+ {%- include components/preview-image.html src=_dc.image alt=_dc_img_alt class="card-img-top" style="height: 160px; object-fit: cover;" -%}
47
+ {%- if _dc.url -%}</a>{%- endif -%}
48
+ {%- endif -%}
49
+ <div class="card-body d-flex flex-column">
50
+ <{{ _dc_heading }} class="card-title {{ _dc_heading_class }} mb-2 d-flex align-items-center">
51
+ {%- if _dc.icon %}<i class="bi {{ _dc.icon }} me-2" aria-hidden="true"></i>{% endif -%}
52
+ <span class="flex-grow-1">
53
+ {%- if _dc.url -%}
54
+ <a href="{{ _dc.url | relative_url }}" class="text-decoration-none text-body-emphasis"
55
+ {%- if _dc.external %} target="_blank" rel="noopener"{% endif %}>{{ _dc.title }}</a>
56
+ {%- else -%}
57
+ {{ _dc.title }}
58
+ {%- endif -%}
59
+ </span>
60
+ {%- if _dc.badge %}
61
+ <span class="badge text-bg-{{ _dc.badge_variant | default: 'secondary' }} ms-2"
62
+ {%- if _dc.badge_title %} title="{{ _dc.badge_title | escape }}"{% endif %}>{{ _dc.badge }}</span>
63
+ {%- endif %}
64
+ </{{ _dc_heading }}>
65
+ {%- if _dc.description %}
66
+ <p class="card-text text-body-secondary mb-2">{{ _dc.description }}</p>
67
+ {%- endif %}
68
+ {%- if _dc.meta and _dc.meta.size > 0 %}
69
+ <div class="d-flex flex-wrap gap-2 mt-auto pt-2">
70
+ {%- for m in _dc.meta %}
71
+ <span class="badge rounded-pill text-bg-light border text-body-secondary fw-normal">
72
+ {%- if m.icon %}<i class="bi {{ m.icon }} me-1" aria-hidden="true"></i>{% endif -%}
73
+ {{ m.text }}
74
+ </span>
75
+ {%- endfor %}
76
+ </div>
77
+ {%- endif %}
78
+ </div>
79
+ {%- if _dc.buttons and _dc.buttons.size > 0 %}
80
+ <div class="card-footer bg-transparent d-flex flex-wrap gap-2">
81
+ {%- for b in _dc.buttons %}
82
+ <a class="btn btn-sm btn-{{ b.variant | default: 'outline-primary' }}"
83
+ href="{{ b.url | relative_url }}"
84
+ {%- if b.external %} target="_blank" rel="noopener"{% endif %}>
85
+ {%- if b.icon %}<i class="bi {{ b.icon }} me-1" aria-hidden="true"></i>{% endif -%}
86
+ {{ b.label }}
87
+ </a>
88
+ {%- endfor %}
89
+ </div>
90
+ {%- elsif _dc.footer %}
91
+ <div class="card-footer bg-transparent">
92
+ <small class="text-body-secondary">{{ _dc.footer }}</small>
93
+ </div>
94
+ {%- endif %}
95
+ </div>
@@ -9,8 +9,11 @@
9
9
  page (settings offcanvas + theme-controls-bar).
10
10
  params: class (string, optional, default "") — extra btn-group classes,
11
11
  e.g. "w-100" to fill the parent.
12
+ config: site.color_mode_lock: true removes the control entirely — the
13
+ site pins one mode and visitor switching is disabled
14
+ (halfmoon.js + core/color-mode-init.html enforce the same).
12
15
  -->
13
-
16
+ {% unless site.color_mode_lock %}
14
17
  <div class="btn-group bd-theme-switch {{ include.class | default: '' }}" role="group" aria-label="Color mode">
15
18
  <button type="button" class="btn btn-outline-secondary btn-sm d-flex align-items-center justify-content-center gap-1" data-bs-theme-value="light" aria-pressed="false">
16
19
  <svg class="bi flex-shrink-0" width="1em" height="1em" fill="currentColor" aria-hidden="true"><use href="#sun-fill"></use></svg>
@@ -25,3 +28,4 @@
25
28
  <span>Auto</span>
26
29
  </button>
27
30
  </div>
31
+ {% endunless %}
@@ -7,13 +7,14 @@
7
7
  File: info-section.html
8
8
  Path: _includes/components/info-section.html
9
9
  Purpose: Unified settings offcanvas panel with three tabs —
10
- Appearance (color mode, skin, backgrounds, primary color),
10
+ Appearance (language, color mode, skin, backgrounds, primary color),
11
11
  Site (environment, theme & build info, admin links) and
12
12
  Developer (page location, metadata, source shortcuts).
13
13
 
14
14
  Dependencies:
15
15
  - Bootstrap 5 Offcanvas, Tabs
16
16
  - _includes/components/env-detect.html (sets is_production/build_env)
17
+ - _includes/components/language-toggle.html (panel variant — Language)
17
18
  - _includes/components/halfmoon.html (color-mode segmented control)
18
19
  - _includes/components/background-settings.html (skin + backgrounds)
19
20
  - _includes/components/env-switcher.html
@@ -44,9 +45,9 @@
44
45
  <!-- Offcanvas Header -->
45
46
  <div class="offcanvas-header border-bottom pb-0 d-block">
46
47
  <div class="d-flex align-items-center justify-content-between mb-2">
47
- <h5 class="offcanvas-title" id="infoSectionLabel">
48
+ <h2 class="offcanvas-title h5" id="infoSectionLabel">
48
49
  <i class="bi bi-gear-wide-connected me-2" aria-hidden="true"></i>Settings
49
- </h5>
50
+ </h2>
50
51
  <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
51
52
  </div>
52
53
 
@@ -82,6 +83,10 @@
82
83
  <!-- Appearance Tab — everything that changes how the site looks -->
83
84
  <div class="tab-pane fade show active" id="appearance-pane" role="tabpanel" aria-labelledby="appearance-tab" tabindex="0">
84
85
 
86
+ <!-- Language — renders its own heading, and nothing at all when
87
+ site.translation is disabled. NOT cached: page-dependent. -->
88
+ {% include components/language-toggle.html variant="panel" %}
89
+
85
90
  <!-- Color Mode -->
86
91
  <div class="mb-4">
87
92
  <h6 class="text-body-secondary small text-uppercase fw-semibold mb-2">
@@ -2,24 +2,61 @@
2
2
  {%- comment -%}
3
3
  Component: language-toggle
4
4
  Path: _includes/components/language-toggle.html
5
- Purpose: Navbar dropdown that switches between the English original and
6
- its machine-generated translations (scripts/translate.rb).
7
- Shows the source language plus every configured target
8
- language; targets without a generated translation for the
9
- current page render as disabled entries.
10
- Params: none — reads page.lang / page.url / page.translation_source_url
11
- and the generated _data/i18n/manifest.yml.
5
+ Purpose: Switches between the English original and its machine-generated
6
+ translations (scripts/translate.rb). Shows the source language
7
+ plus every configured target language. Every entry is a live
8
+ link: a target without a generated translation for the current
9
+ page falls back to the source page and is explained by one
10
+ footnote (it used to render as a disabled entry — see T-038).
11
+ Params: variant (string, optional, default "navbar")
12
+ - "navbar" — compact dropdown for the header utility cluster
13
+ - "panel" — self-contained "Language" section for the
14
+ Settings offcanvas (components/info-section.html),
15
+ rendered as an always-visible list of choices
16
+ with its own <h6> heading
17
+ Reads: page.lang / page.url / page.translation_source_url and the
18
+ generated _data/i18n/manifest.yml.
12
19
  Depends on: site.translation.* config, site.data.i18n.manifest,
13
20
  _data/i18n/languages.yml (display names), core/i18n.html,
14
- Bootstrap 5 dropdown, Bootstrap Icons (bi-translate)
21
+ Bootstrap 5 dropdown (navbar variant only), Bootstrap Icons
22
+ (bi-translate)
15
23
  Notes: Renders nothing when translation is disabled or no target
16
24
  languages are configured. The chosen language is remembered in
17
25
  localStorage("zer0-lang") for future UX (no auto-redirect).
18
26
  Must NOT be include_cached — the menu is page-dependent.
27
+ The theme ships the "panel" variant (the navbar is reserved for
28
+ main navigation); "navbar" stays for consumers that prefer the
29
+ header dropdown.
30
+
31
+ MENU CONTRACT (T-038 / #406) — shared by both variants:
32
+ * No DISABLED rows. A target language without a generated
33
+ translation is still a link; it goes to the source-language
34
+ page and still records the preference in zer0-lang, so the
35
+ menu never contains a dead end.
36
+ * The current language is marked with a check icon and a
37
+ subtle tint — NOT Bootstrap's `.active` primary fill, which
38
+ read as a selected nav item rather than "you are here".
39
+ * Machine-generated translations carry a small "auto" chip.
40
+ The source language never does (a human wrote it), and
41
+ neither do untranslated rows (nothing was generated).
42
+ * The per-row "(Not yet translated)" text is replaced by ONE
43
+ footnote under a divider, referenced from each untranslated
44
+ row via aria-describedby. Per-row text was the single
45
+ biggest contributor to menu width.
46
+ Trigger changes (icon-only, no caret) apply to the navbar
47
+ variant only — the panel has an <h6> heading, not a button.
48
+
49
+ New ui keys (lang_machine_translated, lang_machine_translated_title,
50
+ lang_untranslated_note) live in _data/ui-text.yml `en`. core/i18n.html
51
+ REPLACES the whole `ui` map on a translated page rather than merging
52
+ it, so until scripts/translate.rb regenerates _data/i18n/<lang>.yml
53
+ these keys are absent there — every use below therefore carries a
54
+ literal `| default:` fallback. Do not remove them.
19
55
  {%- endcomment -%}
20
56
  {%- assign _lt_langs = site.translation.languages -%}
21
57
  {%- if site.translation.enabled and _lt_langs and _lt_langs.size > 0 -%}
22
58
  {%- include core/i18n.html -%}
59
+ {%- assign _lt_variant = include.variant | default: 'navbar' -%}
23
60
  {%- assign _lt_source = site.translation.source_lang | default: 'en' -%}
24
61
  {%- assign _lt_current = page.lang | default: _lt_source -%}
25
62
  {%- if _lt_current == _lt_source -%}
@@ -30,25 +67,117 @@
30
67
  {%- assign _lt_entry = site.data.i18n.manifest.pages[_lt_src_url] -%}
31
68
  {%- assign _lt_names = site.data.i18n.languages -%}
32
69
  {%- assign _lt_source_name = _lt_names[_lt_source].native | default: _lt_source -%}
70
+ {%- comment -%}
71
+ Display name of the language being read, for the trigger's title/aria — the
72
+ icon-only button has no visible text, so the accessible name has to carry it.
73
+ {%- endcomment -%}
74
+ {%- if _lt_current == _lt_source -%}
75
+ {%- assign _lt_current_name = _lt_source_name -%}
76
+ {%- else -%}
77
+ {%- assign _lt_current_name = _lt_names[_lt_current].native | default: _lt_current -%}
78
+ {%- endif -%}
79
+ {%- comment -%}
80
+ Does ANY target language lack a translation of this page? Drives whether the
81
+ footnote + divider render at all — an all-translated menu shows neither.
82
+ A target we are currently reading is by definition translated.
83
+ {%- endcomment -%}
84
+ {%- assign _lt_untranslated = 0 -%}
85
+ {%- for _lt_l in _lt_langs -%}
86
+ {%- unless _lt_l == _lt_current or _lt_entry[_lt_l] -%}
87
+ {%- assign _lt_untranslated = _lt_untranslated | plus: 1 -%}
88
+ {%- endunless -%}
89
+ {%- endfor -%}
90
+ {%- assign _lt_note_id = 'zer0-lang-untranslated-note' -%}
91
+ {%- assign _lt_auto_label = ui.lang_machine_translated | default: 'auto' -%}
92
+ {%- assign _lt_auto_title = ui.lang_machine_translated_title | default: 'Machine-generated translation' -%}
93
+ {%- assign _lt_note_text = ui.lang_untranslated_note | default: 'Marked languages are not yet translated — those links open the original.' -%}
94
+ {%- assign _lt_fallback_url = _lt_src_url | default: '/' | relative_url -%}
95
+ {%- if _lt_variant == 'panel' -%}
96
+ <div class="mb-4 nav-lang-panel" id="zer0-lang-toggle">
97
+ <h6 class="text-body-secondary small text-uppercase fw-semibold mb-2">
98
+ <i class="bi bi-translate me-1" aria-hidden="true"></i>{{ ui.lang_toggle_label | default: "Language" }}
99
+ </h6>
100
+ <div class="list-group nav-lang-options" role="group" aria-label="{{ ui.lang_toggle_aria | default: 'Change language' }}">
101
+ {%- if _lt_current == _lt_source %}
102
+ <span class="list-group-item nav-lang-item is-current" aria-current="true">
103
+ <span class="nav-lang-code">{{ _lt_source | slice: 0, 2 | upcase }}</span>
104
+ <span class="nav-lang-name">{{ _lt_source_name }}</span>
105
+ <i class="bi bi-check2 nav-lang-check" aria-hidden="true"></i>
106
+ </span>
107
+ {%- else %}
108
+ <a class="list-group-item list-group-item-action nav-lang-item" data-lang="{{ _lt_source }}" hreflang="{{ _lt_source }}" href="{{ _lt_fallback_url }}">
109
+ <span class="nav-lang-code">{{ _lt_source | slice: 0, 2 | upcase }}</span>
110
+ <span class="nav-lang-name">{{ _lt_source_name }}</span>
111
+ </a>
112
+ {%- endif %}
113
+ {%- for _lt_lang in _lt_langs -%}
114
+ {%- assign _lt_name = _lt_names[_lt_lang].native | default: _lt_lang -%}
115
+ {%- assign _lt_t = _lt_entry[_lt_lang] %}
116
+ {%- if _lt_current == _lt_lang %}
117
+ <span class="list-group-item nav-lang-item is-current" aria-current="true">
118
+ <span class="nav-lang-code">{{ _lt_lang | slice: 0, 2 | upcase }}</span>
119
+ <span class="nav-lang-name">{{ _lt_name }}</span>
120
+ <span class="nav-lang-auto" title="{{ _lt_auto_title }}">{{ _lt_auto_label }}</span>
121
+ <i class="bi bi-check2 nav-lang-check" aria-hidden="true"></i>
122
+ </span>
123
+ {%- elsif _lt_t %}
124
+ <a class="list-group-item list-group-item-action nav-lang-item" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_lang }}" href="{{ _lt_t.url | relative_url }}">
125
+ <span class="nav-lang-code">{{ _lt_lang | slice: 0, 2 | upcase }}</span>
126
+ <span class="nav-lang-name">{{ _lt_name }}</span>
127
+ <span class="nav-lang-auto" title="{{ _lt_auto_title }}">{{ _lt_auto_label }}</span>
128
+ </a>
129
+ {%- else %}
130
+ {%- comment -%}
131
+ No translation yet — still a LINK, not a disabled row. It goes to the
132
+ source page and records the preference, so the reader lands on real
133
+ content instead of a dead entry. hreflang describes the DESTINATION,
134
+ which is the source language, not the one that was clicked.
135
+ {%- endcomment -%}
136
+ <a class="list-group-item list-group-item-action nav-lang-item is-untranslated" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_source }}" href="{{ _lt_fallback_url }}" aria-describedby="{{ _lt_note_id }}-panel">
137
+ <span class="nav-lang-code">{{ _lt_lang | slice: 0, 2 | upcase }}</span>
138
+ <span class="nav-lang-name">{{ _lt_name }}</span>
139
+ <span class="nav-lang-mark" aria-hidden="true">*</span>
140
+ </a>
141
+ {%- endif %}
142
+ {%- endfor %}
143
+ </div>
144
+ {%- if _lt_untranslated > 0 %}
145
+ <hr class="nav-lang-divider">
146
+ <p class="nav-lang-note" id="{{ _lt_note_id }}-panel">{{ _lt_note_text }}</p>
147
+ {%- endif %}
148
+ </div>
149
+ {%- else -%}
33
150
  <div class="dropdown nav-lang-toggle" id="zer0-lang-toggle">
151
+ {%- comment -%}
152
+ Icon-only, square, and caret-free. `dropdown-toggle` is what draws the
153
+ Bootstrap caret; it is NOT what wires the dropdown — Bootstrap binds on
154
+ [data-bs-toggle="dropdown"], so dropping the class costs no behaviour.
155
+ The visible EN/FR label is gone at every width (it was already hidden
156
+ below xl), so title + aria-label are the only accessible name and both
157
+ name the language being read.
158
+ {%- endcomment -%}
34
159
  <button
35
- class="btn nav-lang-button dropdown-toggle"
160
+ class="btn nav-lang-button"
36
161
  type="button"
37
162
  id="langToggleButton"
38
163
  data-bs-toggle="dropdown"
39
164
  aria-expanded="false"
40
165
  aria-label="{{ ui.lang_toggle_aria | default: 'Change language' }}"
41
- title="{{ ui.lang_toggle_label | default: 'Language' }}"
166
+ title="{{ ui.lang_toggle_label | default: 'Language' }}: {{ _lt_current_name }}"
42
167
  >
43
168
  <i class="{{ site.default_icon }} bi-translate" aria-hidden="true"></i>
44
- <span class="nav-link-text d-none d-xl-inline ms-1 text-uppercase">{{ _lt_current | slice: 0, 2 }}</span>
45
169
  </button>
46
- <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="langToggleButton">
170
+ <ul class="dropdown-menu dropdown-menu-end nav-lang-menu" aria-labelledby="langToggleButton">
47
171
  <li>
48
172
  {%- if _lt_current == _lt_source %}
49
- <span class="dropdown-item active" aria-current="true">{{ _lt_source_name }}</span>
173
+ <span class="dropdown-item nav-lang-item is-current" aria-current="true">
174
+ <span class="nav-lang-name">{{ _lt_source_name }}</span>
175
+ <i class="bi bi-check2 nav-lang-check" aria-hidden="true"></i>
176
+ </span>
50
177
  {%- else %}
51
- <a class="dropdown-item" data-lang="{{ _lt_source }}" hreflang="{{ _lt_source }}" href="{{ _lt_src_url | default: '/' | relative_url }}">{{ _lt_source_name }}</a>
178
+ <a class="dropdown-item nav-lang-item" data-lang="{{ _lt_source }}" hreflang="{{ _lt_source }}" href="{{ _lt_fallback_url }}">
179
+ <span class="nav-lang-name">{{ _lt_source_name }}</span>
180
+ </a>
52
181
  {%- endif %}
53
182
  </li>
54
183
  {%- for _lt_lang in _lt_langs -%}
@@ -56,23 +185,41 @@
56
185
  {%- assign _lt_t = _lt_entry[_lt_lang] %}
57
186
  <li>
58
187
  {%- if _lt_current == _lt_lang %}
59
- <span class="dropdown-item active" aria-current="true">{{ _lt_name }}</span>
188
+ <span class="dropdown-item nav-lang-item is-current" aria-current="true">
189
+ <span class="nav-lang-name">{{ _lt_name }}</span>
190
+ <span class="nav-lang-auto" title="{{ _lt_auto_title }}">{{ _lt_auto_label }}</span>
191
+ <i class="bi bi-check2 nav-lang-check" aria-hidden="true"></i>
192
+ </span>
60
193
  {%- elsif _lt_t %}
61
- <a class="dropdown-item" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_lang }}" href="{{ _lt_t.url | relative_url }}">{{ _lt_name }}</a>
194
+ <a class="dropdown-item nav-lang-item" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_lang }}" href="{{ _lt_t.url | relative_url }}">
195
+ <span class="nav-lang-name">{{ _lt_name }}</span>
196
+ <span class="nav-lang-auto" title="{{ _lt_auto_title }}">{{ _lt_auto_label }}</span>
197
+ </a>
62
198
  {%- else %}
63
- <span class="dropdown-item disabled" aria-disabled="true" title="{{ ui.lang_not_available | default: 'Not yet translated' }}">{{ _lt_name }} <small class="text-body-secondary">({{ ui.lang_not_available | default: 'Not yet translated' }})</small></span>
199
+ {%- comment -%} See the panel variant above — link, never a dead row. {%- endcomment -%}
200
+ <a class="dropdown-item nav-lang-item is-untranslated" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_source }}" href="{{ _lt_fallback_url }}" aria-describedby="{{ _lt_note_id }}">
201
+ <span class="nav-lang-name">{{ _lt_name }}</span>
202
+ <span class="nav-lang-mark" aria-hidden="true">*</span>
203
+ </a>
64
204
  {%- endif %}
65
205
  </li>
66
206
  {%- endfor %}
207
+ {%- if _lt_untranslated > 0 %}
208
+ <li><hr class="dropdown-divider"></li>
209
+ <li><p class="nav-lang-note" id="{{ _lt_note_id }}">{{ _lt_note_text }}</p></li>
210
+ {%- endif %}
67
211
  </ul>
68
212
  </div>
213
+ {%- endif -%}
69
214
  <script>
70
215
  // Remember the visitor's language pick (no auto-redirect — just preference).
216
+ // Delegated at document level so it covers both variants and stays correct
217
+ // if a consumer renders the component more than once per page.
71
218
  (function () {
72
- var toggle = document.getElementById('zer0-lang-toggle');
73
- if (!toggle) return;
74
- toggle.addEventListener('click', function (event) {
75
- var item = event.target.closest('a.dropdown-item[data-lang]');
219
+ if (window.__zer0LangPrefWired) return;
220
+ window.__zer0LangPrefWired = true;
221
+ document.addEventListener('click', function (event) {
222
+ var item = event.target.closest('#zer0-lang-toggle a[data-lang]');
76
223
  if (!item) return;
77
224
  try { localStorage.setItem('zer0-lang', item.getAttribute('data-lang')); } catch (e) { /* private mode */ }
78
225
  });
@@ -103,6 +103,14 @@
103
103
  <!-- Step 1 — request type -->
104
104
  <fieldset class="pf-field pf-types">
105
105
  <legend class="pf-legend">What kind of feedback?</legend>
106
+ {%- comment -%}
107
+ _data/feedback_types.yml is optional for consumers (remote_theme
108
+ cannot ship _data). Without it the widget falls back to a minimal
109
+ generic taxonomy (see pf_fallback_types below) instead of rendering
110
+ an empty selector. The fallback entries carry no GitHub labels and
111
+ no agent directive — labels must exist in the target repo or GitHub
112
+ silently drops them from prefilled issue URLs.
113
+ {%- endcomment -%}
106
114
  {%- assign pf_group = "" -%}
107
115
  {%- for t in site.data.feedback_types -%}
108
116
  {%- if t.group != pf_group -%}
@@ -124,6 +132,54 @@
124
132
  </span>
125
133
  </button>
126
134
  {%- if forloop.last -%}</div>{%- endif -%}
135
+ {%- else -%}
136
+ {%- comment -%}
137
+ _data/feedback_types.yml is optional for consumers (remote_theme
138
+ cannot ship _data). Without it, render this minimal generic
139
+ taxonomy instead of an empty selector. Ids/markup must stay in
140
+ sync with the fallback JSON in the #pageFeedbackTypes script
141
+ below. Fallback entries carry no GitHub labels (labels must exist
142
+ in the target repo or GitHub silently drops them) and no agent
143
+ directive.
144
+ {%- endcomment -%}
145
+ <p class="pf-types-group">This page</p>
146
+ <div class="pf-types-grid" role="radiogroup" aria-label="This page">
147
+ <button type="button" class="pf-type" role="radio" aria-checked="false"
148
+ data-pf-type-id="fix-page" tabindex="0">
149
+ <i class="bi bi-bug pf-type-icon" aria-hidden="true"></i>
150
+ <span class="pf-type-text">
151
+ <span class="pf-type-label">Report a problem</span>
152
+ <span class="pf-type-desc">A typo, broken link, wrong information, or something rendering badly</span>
153
+ </span>
154
+ </button>
155
+ <button type="button" class="pf-type" role="radio" aria-checked="false"
156
+ data-pf-type-id="improve-page" tabindex="-1">
157
+ <i class="bi bi-stars pf-type-icon" aria-hidden="true"></i>
158
+ <span class="pf-type-text">
159
+ <span class="pf-type-label">Improve this page</span>
160
+ <span class="pf-type-desc">Polish the copy, structure, or presentation</span>
161
+ </span>
162
+ </button>
163
+ </div>
164
+ <p class="pf-types-group">The site</p>
165
+ <div class="pf-types-grid" role="radiogroup" aria-label="The site">
166
+ <button type="button" class="pf-type" role="radio" aria-checked="false"
167
+ data-pf-type-id="feature" tabindex="-1">
168
+ <i class="bi bi-lightbulb pf-type-icon" aria-hidden="true"></i>
169
+ <span class="pf-type-text">
170
+ <span class="pf-type-label">Suggest a feature</span>
171
+ <span class="pf-type-desc">An idea that would make the site more useful</span>
172
+ </span>
173
+ </button>
174
+ <button type="button" class="pf-type" role="radio" aria-checked="false"
175
+ data-pf-type-id="question" tabindex="-1">
176
+ <i class="bi bi-question-circle pf-type-icon" aria-hidden="true"></i>
177
+ <span class="pf-type-text">
178
+ <span class="pf-type-label">Ask a question</span>
179
+ <span class="pf-type-desc">Something unclear that is not exactly a bug</span>
180
+ </span>
181
+ </button>
182
+ </div>
127
183
  {%- endfor -%}
128
184
  </fieldset>
129
185
 
@@ -219,9 +275,16 @@
219
275
  }
220
276
  </script>
221
277
 
222
- <!-- Request-type taxonomy (from _data/feedback_types.yml) -->
278
+ <!-- Request-type taxonomy (from _data/feedback_types.yml; generic fallback
279
+ when the site ships no data file — keep in sync with the fallback markup
280
+ in the pf-types fieldset above) -->
223
281
  <script id="pageFeedbackTypes" type="application/json">
224
- {{ site.data.feedback_types | jsonify }}
282
+ {% if site.data.feedback_types %}{{ site.data.feedback_types | jsonify }}{% else %}[
283
+ {"id":"fix-page","label":"Report a problem","icon":"bi-bug","group":"This page","scope":"page","description":"A typo, broken link, wrong information, or something rendering badly","labels":[],"agent":false,"placeholder":"What is wrong, where on the page, and what did you expect instead?"},
284
+ {"id":"improve-page","label":"Improve this page","icon":"bi-stars","group":"This page","scope":"page","description":"Polish the copy, structure, or presentation","labels":[],"agent":false,"placeholder":"What would make this page clearer, tighter, or more useful?"},
285
+ {"id":"feature","label":"Suggest a feature","icon":"bi-lightbulb","group":"The site","scope":"site","description":"An idea that would make the site more useful","labels":[],"agent":false,"placeholder":"Describe the feature and the problem it would solve."},
286
+ {"id":"question","label":"Ask a question","icon":"bi-question-circle","group":"The site","scope":"site","description":"Something unclear that is not exactly a bug","labels":[],"agent":false,"placeholder":"What would you like to know?"}
287
+ ]{% endif %}
225
288
  </script>
226
289
 
227
290
  <!-- Live page context (build-time front matter; runtime detail added by JS) -->
@@ -0,0 +1,55 @@
1
+ {% comment %} Feature: ZER0-083 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: page-views-init
4
+ Path: _includes/components/page-views-init.html
5
+ Purpose: Bootstrap the page-view counter — inject its configuration and
6
+ load the behaviour. Renders no visible markup; the badge lives
7
+ in components/page-views.html.
8
+ Params: none — configured via site.page_views
9
+ Depends on: assets/js/page-views.js
10
+ Included by: _includes/core/head.html (once per page)
11
+ Notes: Split from the badge include on purpose: the badge may appear
12
+ many times on a page, this must appear exactly once.
13
+ {%- endcomment -%}
14
+
15
+ {%- unless site.page_views.enabled -%}{%- comment -%} counter disabled {%- endcomment -%}
16
+ {%- else -%}
17
+ {%- comment -%}
18
+ `respect_dnt` / `respect_gpc` / `require_consent` default to the PostHog
19
+ privacy block so a site configures its privacy posture once. Liquid's
20
+ `default` filter treats `false` as "missing", so each flag is resolved with
21
+ an if-tag instead — otherwise an explicit `false` would silently flip true.
22
+ {%- endcomment -%}
23
+ {%- assign pv_dnt = true -%}
24
+ {%- if site.page_views.respect_dnt == false -%}{%- assign pv_dnt = false -%}{%- endif -%}
25
+ {%- assign pv_gpc = true -%}
26
+ {%- if site.page_views.respect_gpc == false -%}{%- assign pv_gpc = false -%}{%- endif -%}
27
+ {%- assign pv_consent = false -%}
28
+ {%- if site.page_views.require_consent -%}{%- assign pv_consent = true -%}{%- endif -%}
29
+ {%- assign pv_track = true -%}
30
+ {%- if site.page_views.track == false -%}{%- assign pv_track = false -%}{%- endif -%}
31
+ {%- assign pv_fallback = false -%}
32
+ {%- if site.page_views.fallback_local -%}{%- assign pv_fallback = true -%}{%- endif -%}
33
+
34
+ <!-- Page-view counter configuration consumed by assets/js/page-views.js -->
35
+ <script id="pageViewsConfig" type="application/json">
36
+ {
37
+ "enabled": true,
38
+ "provider": {{ site.page_views.provider | default: "local" | jsonify }},
39
+ "endpoint": {{ site.page_views.endpoint | default: "" | jsonify }},
40
+ "method": {{ site.page_views.method | default: "POST" | jsonify }},
41
+ "countKey": {{ site.page_views.count_key | default: "views" | jsonify }},
42
+ "dedupe": {{ site.page_views.dedupe | default: "session" | jsonify }},
43
+ "maxEntries": {{ site.page_views.max_entries | default: 500 }},
44
+ "track": {{ pv_track | jsonify }},
45
+ "respectDnt": {{ pv_dnt | jsonify }},
46
+ "respectGpc": {{ pv_gpc | jsonify }},
47
+ "requireConsent": {{ pv_consent | jsonify }},
48
+ "fallbackLocal": {{ pv_fallback | jsonify }},
49
+ "baseurl": {{ site.baseurl | default: "" | jsonify }},
50
+ "labelOne": {{ site.page_views.label_one | default: "view" | jsonify }},
51
+ "labelOther": {{ site.page_views.label_other | default: "views" | jsonify }}
52
+ }
53
+ </script>
54
+ <script defer src="{{ '/assets/js/page-views.js' | relative_url }}"></script>
55
+ {%- endunless -%}
@@ -0,0 +1,33 @@
1
+ {% comment %} Feature: ZER0-083 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: page-views
4
+ Path: _includes/components/page-views.html
5
+ Purpose: Inline "N views" badge for a page. Ships hidden and is revealed
6
+ by assets/js/page-views.js once a count is known, so a page
7
+ with no data never flashes "0 views".
8
+ Params: url (string, optional, default: page.url) — page to show
9
+ class (string, optional, default: "") — extra classes
10
+ icon (string, optional, default: "bi-eye")
11
+ separator (bool, optional, default: false) — render a
12
+ leading "•" INSIDE the badge, so the separator hides
13
+ with it in a meta row
14
+ Depends on: components/page-views-init.html (config + behaviour, loaded once
15
+ from core/head.html), _sass/components/_page-views.scss
16
+ Notes: Safe to include more than once per page — each badge resolves
17
+ its own `data-page-views` path. Paths are site-relative and
18
+ baseurl-free (page.url); page-views.js normalizes the runtime
19
+ location the same way.
20
+ {%- endcomment -%}
21
+
22
+ {%- unless site.page_views.enabled -%}{%- comment -%} counter disabled {%- endcomment -%}
23
+ {%- else -%}
24
+ {%- assign pv_path = include.url | default: page.url -%}
25
+ {%- assign pv_class = include.class | default: "" -%}
26
+ {%- assign pv_icon = include.icon | default: "bi-eye" -%}
27
+ <span class="page-views{% if pv_class != "" %} {{ pv_class }}{% endif %}" data-page-views="{{ pv_path | escape }}" hidden>
28
+ {%- if include.separator -%}<span class="page-views__sep mx-2" aria-hidden="true">•</span>{%- endif -%}
29
+ <i class="bi {{ pv_icon }} me-1" aria-hidden="true"></i>
30
+ <span class="page-views__count" data-page-views-count>0</span>
31
+ <span class="page-views__label" data-page-views-label>{{ site.page_views.label_other | default: "views" }}</span>
32
+ </span>
33
+ {%- endunless -%}
@@ -0,0 +1,67 @@
1
+ {% comment %} Feature: ZER0-084 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: recipe-card
4
+ Path: _includes/components/recipe-card.html
5
+ Purpose: Index card for one recipe — photo, title, one-line description,
6
+ and the three facts a cook scans for: total time, what it makes,
7
+ and how hard it is.
8
+ Params: recipe (document, required) — a `recipes` collection doc
9
+ heading_level (number, optional, default: 3) — the title's
10
+ heading level, so a card never skips a level under whatever
11
+ heading precedes it
12
+ class (string, optional, default: "")
13
+ Depends on: components/preview-image.html, components/recipe-duration.html,
14
+ _sass/components/_recipe.scss (.recipe-card)
15
+ Notes: Total time comes from `times.total` or prep + cook + rest, so
16
+ the card never disagrees with the recipe page. The whole card is
17
+ one link for a large tap target; the facts row is plain text so
18
+ it stays readable when images fail to load.
19
+ {%- endcomment -%}
20
+ {%- if include.recipe == nil -%}
21
+ {%- comment -%} ERROR: recipe-card requires `recipe` {%- endcomment -%}
22
+ {%- else -%}
23
+ {%- assign _rc_r = include.recipe -%}
24
+ {%- assign _rc_img = _rc_r.image | default: _rc_r.preview | default: site.teaser -%}
25
+ {%- assign _rc_prep = _rc_r.times.prep | default: 0 -%}
26
+ {%- assign _rc_cook = _rc_r.times.cook | default: 0 -%}
27
+ {%- assign _rc_rest = _rc_r.times.rest | default: 0 -%}
28
+ {%- if _rc_r.times.total -%}
29
+ {%- assign _rc_total = _rc_r.times.total -%}
30
+ {%- else -%}
31
+ {%- assign _rc_total = _rc_prep | plus: _rc_cook | plus: _rc_rest -%}
32
+ {%- endif -%}
33
+ {%- capture _rc_total_h -%}{%- include components/recipe-duration.html minutes=_rc_total -%}{%- endcapture -%}
34
+ {%- assign _rc_total_h = _rc_total_h | strip -%}
35
+ <article class="recipe-card h-100 {{ include.class | default: '' }}">
36
+ <a href="{{ _rc_r.url | relative_url }}" class="recipe-card-link d-flex flex-column h-100 text-decoration-none text-body">
37
+ {%- if _rc_img -%}
38
+ <div class="recipe-card-media">
39
+ {%- include components/preview-image.html src=_rc_img alt=_rc_r.title class="" -%}
40
+ </div>
41
+ {%- endif -%}
42
+ <div class="recipe-card-body">
43
+ {%- assign _rc_hl = include.heading_level | default: 3 -%}
44
+ <h{{ _rc_hl }} class="h5 recipe-card-title">{{ _rc_r.title }}</h{{ _rc_hl }}>
45
+ {%- if _rc_r.description -%}
46
+ <p class="recipe-card-description small text-body-secondary">{{ _rc_r.description | strip_html | truncate: 120 }}</p>
47
+ {%- endif -%}
48
+ <p class="recipe-card-facts small text-body-secondary mb-0">
49
+ {%- assign _rc_sep = false -%}
50
+ {%- if _rc_total_h != "" -%}
51
+ <span class="recipe-card-fact"><i class="bi bi-clock-history me-1" aria-hidden="true"></i>{{ _rc_total_h }}</span>
52
+ {%- assign _rc_sep = true -%}
53
+ {%- endif -%}
54
+ {%- if _rc_r.yield.amount -%}
55
+ {%- if _rc_sep %}<span class="mx-1" aria-hidden="true">•</span>{% endif -%}
56
+ <span class="recipe-card-fact"><i class="bi bi-pie-chart me-1" aria-hidden="true"></i>{{ _rc_r.yield.amount }} {{ _rc_r.yield.unit | default: "servings" | escape }}</span>
57
+ {%- assign _rc_sep = true -%}
58
+ {%- endif -%}
59
+ {%- if _rc_r.difficulty -%}
60
+ {%- if _rc_sep %}<span class="mx-1" aria-hidden="true">•</span>{% endif -%}
61
+ <span class="recipe-difficulty recipe-difficulty--{{ _rc_r.difficulty | downcase | strip }}">{{ _rc_r.difficulty | capitalize }}</span>
62
+ {%- endif -%}
63
+ </p>
64
+ </div>
65
+ </a>
66
+ </article>
67
+ {%- endif -%}