jekyll-theme-zer0 1.28.0 → 1.30.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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1143 -13
  3. data/_data/README.md +2 -0
  4. data/_data/ai.yml +5 -3
  5. data/_data/ai_pricing.yml +36 -0
  6. data/_data/backlog.yml +507 -2
  7. data/_data/consumers.yml +157 -9
  8. data/_data/features.yml +303 -20
  9. data/_data/feedback_types.yml +17 -12
  10. data/_data/i18n/fr.yml +12 -7
  11. data/_data/i18n/manifest.yml +39 -12
  12. data/_data/ingredient_densities.yml +122 -0
  13. data/_data/landing.yml +5 -2
  14. data/_data/navigation/main.yml +16 -0
  15. data/_data/navigation/quickstart.yml +4 -0
  16. data/_data/recipe_courses.yml +64 -0
  17. data/_data/site_builder.yml +874 -0
  18. data/_data/theme-manifest.yml +160 -124
  19. data/_data/ui-text.yml +26 -0
  20. data/_includes/README.md +26 -2
  21. data/_includes/analytics/posthog.html +2 -2
  22. data/_includes/components/admin-links.html +2 -2
  23. data/_includes/components/admin-tabs.html +2 -2
  24. data/_includes/components/ai-chat.html +14 -11
  25. data/_includes/components/analytics-dashboard.html +8 -8
  26. data/_includes/components/author-bio.html +1 -1
  27. data/_includes/components/author-card.html +10 -2
  28. data/_includes/components/author-eeat.html +4 -4
  29. data/_includes/components/background-customizer.html +10 -10
  30. data/_includes/components/background-image.html +114 -0
  31. data/_includes/components/background-settings.html +28 -15
  32. data/_includes/components/collection-manager.html +5 -5
  33. data/_includes/components/component-showcase.html +13 -13
  34. data/_includes/components/config-editor.html +12 -12
  35. data/_includes/components/config-viewer.html +8 -8
  36. data/_includes/components/cookie-consent.html +15 -15
  37. data/_includes/components/cta-button.html +7 -2
  38. data/_includes/components/dev-shortcuts.html +7 -7
  39. data/_includes/components/env-dashboard.html +8 -8
  40. data/_includes/components/env-switcher.html +9 -9
  41. data/_includes/components/feature-card.html +2 -2
  42. data/_includes/components/halfmoon.html +2 -2
  43. data/_includes/components/info-section.html +42 -37
  44. data/_includes/components/js-cdn.html +15 -15
  45. data/_includes/components/language-toggle.html +168 -21
  46. data/_includes/components/mermaid.html +72 -435
  47. data/_includes/components/nanobar.html +5 -5
  48. data/_includes/components/nav-editor.html +2 -2
  49. data/_includes/components/nav-export.html +2 -2
  50. data/_includes/components/nav-overview.html +2 -2
  51. data/_includes/components/page-feedback.html +45 -30
  52. data/_includes/components/page-views-init.html +55 -0
  53. data/_includes/components/page-views.html +33 -0
  54. data/_includes/components/post-card.html +22 -22
  55. data/_includes/components/post-type-badge.html +2 -2
  56. data/_includes/components/powered-by.html +2 -2
  57. data/_includes/components/preview-image.html +6 -0
  58. data/_includes/components/quick-index.html +2 -2
  59. data/_includes/components/recipe-card.html +67 -0
  60. data/_includes/components/recipe-duration.html +50 -0
  61. data/_includes/components/recipe-grams.html +58 -0
  62. data/_includes/components/recipe-index.html +96 -0
  63. data/_includes/components/recipe-ingredients.html +90 -0
  64. data/_includes/components/recipe-meta.html +96 -0
  65. data/_includes/components/recipe-nutrition.html +57 -0
  66. data/_includes/components/recipe-qty.html +73 -0
  67. data/_includes/components/recipe-ratio.html +151 -0
  68. data/_includes/components/recipe-scaler.html +73 -0
  69. data/_includes/components/recipe-steps.html +86 -0
  70. data/_includes/components/recipe-temp.html +45 -0
  71. data/_includes/components/search-modal.html +29 -4
  72. data/_includes/components/searchbar.html +2 -2
  73. data/_includes/components/shortcuts-modal.html +3 -0
  74. data/_includes/components/svg-background.html +2 -2
  75. data/_includes/components/theme-customizer.html +2 -2
  76. data/_includes/components/theme-info.html +14 -7
  77. data/_includes/components/theme-preview-gallery.html +22 -22
  78. data/_includes/content/giscus.html +2 -2
  79. data/_includes/content/intro.html +8 -8
  80. data/_includes/content/jsonld-faq.html +2 -2
  81. data/_includes/content/jsonld-software.html +24 -5
  82. data/_includes/content/seo.html +4 -4
  83. data/_includes/content/sitemap.html +27 -27
  84. data/_includes/content/toc.html +183 -183
  85. data/_includes/core/branding.html +6 -6
  86. data/_includes/core/console-capture.html +32 -74
  87. data/_includes/core/favicon.html +49 -7
  88. data/_includes/core/footer-fabs.html +17 -3
  89. data/_includes/core/footer.html +49 -34
  90. data/_includes/core/head.html +110 -86
  91. data/_includes/core/header.html +76 -54
  92. data/_includes/docs/bootstrap-docs.html +8 -8
  93. data/_includes/landing/landing-install-cards.html +2 -2
  94. data/_includes/landing/landing-quick-links.html +1 -1
  95. data/_includes/navigation/admin-nav.html +2 -2
  96. data/_includes/navigation/nav-tree.html +8 -8
  97. data/_includes/navigation/navbar.html +12 -12
  98. data/_includes/navigation/section-sidebar.html +109 -27
  99. data/_includes/navigation/sidebar-config.html +36 -2
  100. data/_includes/navigation/sidebar-left.html +17 -16
  101. data/_includes/navigation/sidebar-right.html +8 -7
  102. data/_includes/obsidian/full-graph.html +2 -2
  103. data/_includes/setup/claude-session.html +72 -0
  104. data/_includes/setup/prereq-checklist.html +90 -0
  105. data/_includes/setup/wizard.html +924 -222
  106. data/_includes/stats/stats-categories.html +8 -8
  107. data/_includes/stats/stats-header.html +14 -14
  108. data/_includes/stats/stats-metrics.html +14 -14
  109. data/_includes/stats/stats-no-data.html +12 -12
  110. data/_includes/stats/stats-overview.html +6 -6
  111. data/_includes/stats/stats-tags.html +8 -8
  112. data/_layouts/404.html +38 -24
  113. data/_layouts/README.md +2 -0
  114. data/_layouts/admin.html +24 -24
  115. data/_layouts/article.html +43 -33
  116. data/_layouts/author.html +20 -20
  117. data/_layouts/authors.html +2 -2
  118. data/_layouts/book-abc.html +12 -12
  119. data/_layouts/book-story.html +15 -15
  120. data/_layouts/book.html +12 -12
  121. data/_layouts/collection.html +33 -33
  122. data/_layouts/cookbook.html +88 -0
  123. data/_layouts/default.html +31 -28
  124. data/_layouts/home.html +23 -23
  125. data/_layouts/index.html +10 -10
  126. data/_layouts/landing.html +17 -17
  127. data/_layouts/news.html +44 -44
  128. data/_layouts/note.html +38 -38
  129. data/_layouts/notebook.html +34 -34
  130. data/_layouts/recipe.html +274 -0
  131. data/_layouts/root.html +92 -55
  132. data/_layouts/section.html +62 -33
  133. data/_layouts/setup.html +3 -3
  134. data/_layouts/sitemap-collection.html +49 -49
  135. data/_layouts/stats.html +40 -40
  136. data/_layouts/tag.html +12 -12
  137. data/_layouts/welcome.html +21 -21
  138. data/_sass/components/_callout.scss +1 -1
  139. data/_sass/components/_footer.scss +37 -1
  140. data/_sass/components/_mermaid.scss +375 -0
  141. data/_sass/components/_page-views.scss +36 -0
  142. data/_sass/components/_recipe.scss +506 -0
  143. data/_sass/components/_setup-wizard.scss +764 -0
  144. data/_sass/components/_ui-enhancements.scss +6 -6
  145. data/_sass/core/_navbar.scss +261 -46
  146. data/_sass/layouts/_landing.scss +2 -2
  147. data/_sass/layouts/_navbar-extras.scss +14 -4
  148. data/_sass/tokens/_color.scss +6 -0
  149. data/_sass/tokens/_index.scss +2 -0
  150. data/_sass/tokens/_radius.scss +21 -0
  151. data/_sass/tokens/_typography.scss +4 -0
  152. data/_sass/utilities/_focus.scss +14 -0
  153. data/assets/css/main.scss +4 -0
  154. data/assets/js/ai-chat.js +47 -5
  155. data/assets/js/fleet-feedback-capture.js +124 -0
  156. data/assets/js/fleet-feedback.js +853 -0
  157. data/assets/js/mermaid-diagrams.js +1267 -0
  158. data/assets/js/modules/navigation/config.js +9 -6
  159. data/assets/js/modules/navigation/navbar.js +55 -0
  160. data/assets/js/modules/navigation/scroll-spy.js +315 -80
  161. data/assets/js/modules/theme/appearance.js +8 -2
  162. data/assets/js/obsidian-wiki-links.js +8 -3
  163. data/assets/js/page-feedback.js +125 -192
  164. data/assets/js/page-views.js +372 -0
  165. data/assets/js/recipe-scaler.js +501 -0
  166. data/assets/js/search-modal.js +36 -0
  167. data/assets/js/setup-wizard.js +2279 -226
  168. data/assets/js/site-builder.js +1834 -0
  169. data/assets/js/ui-enhancements.js +11 -3
  170. data/scripts/README.md +44 -0
  171. data/scripts/ai/README.md +38 -0
  172. data/scripts/ai/api_call.rb +124 -0
  173. data/scripts/ai/usage.rb +314 -0
  174. data/scripts/ai/usage_report.rb +225 -0
  175. data/scripts/bin/audit-consumer +39 -7
  176. data/scripts/bin/giscus-discussions +213 -14
  177. data/scripts/bin/manifest +35 -12
  178. data/scripts/ci/agent_review_result.py +164 -0
  179. data/scripts/ci/test_agent_review_result.py +172 -0
  180. data/scripts/ci/test_visual_evidence_autogen.py +341 -0
  181. data/scripts/ci/visual_evidence_autogen.py +1060 -0
  182. data/scripts/content-review.rb +20 -1
  183. data/scripts/design-system-check.rb +170 -0
  184. data/scripts/lib/audit.sh +42 -2
  185. data/scripts/lint-liquid-raw.rb +137 -0
  186. data/scripts/test/integration/mermaid +22 -8
  187. data/scripts/test/lib/run_tests.sh +3 -1
  188. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  189. data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
  190. data/scripts/translate.rb +94 -16
  191. metadata +48 -2
@@ -0,0 +1,501 @@
1
+ // Feature: ZER0-084
2
+ /**
3
+ * Recipe scaling and unit conversion.
4
+ *
5
+ * Rewrites every [data-recipe-qty] and [data-recipe-temp] inside a
6
+ * [data-recipe] root when the reader changes the yield or the unit system.
7
+ * The server-rendered markup is already the correct recipe as written, so this
8
+ * file is pure progressive enhancement — it reveals the controls
9
+ * (.recipe-scaler is `hidden` until then) and never runs without them.
10
+ *
11
+ * Contract with the Liquid components (_includes/components/recipe-*.html):
12
+ * [data-recipe] root; data-recipe-id keys the saved scale
13
+ * [data-recipe-scaler] control bar; data-base-yield, data-yield-unit
14
+ * [data-recipe-qty] data-qty, data-qty-max, data-unit,
15
+ * data-grams-per-cup, data-weigh, data-scale
16
+ * [data-recipe-temp] data-temp-f, data-temp-c
17
+ * [data-recipe-name] data-singular, data-plural (countables only)
18
+ *
19
+ * The unit table below mirrors _includes/components/recipe-grams.html — change
20
+ * the two together.
21
+ */
22
+ (function () {
23
+ 'use strict';
24
+
25
+ var UNITS_KEY = 'zer0-recipe-units';
26
+ var SCALE_KEY_PREFIX = 'zer0-recipe-scale:';
27
+ var ML_PER_CUP = 236.588;
28
+
29
+ // Canonical unit table. `dim` is the dimension; `base` is the size of one
30
+ // unit in grams (mass) or millilitres (volume).
31
+ var UNITS = {
32
+ g: { dim: 'mass', base: 1, label: 'g' },
33
+ kg: { dim: 'mass', base: 1000, label: 'kg' },
34
+ mg: { dim: 'mass', base: 0.001, label: 'mg' },
35
+ oz: { dim: 'mass', base: 28.3495, label: 'oz' },
36
+ lb: { dim: 'mass', base: 453.592, label: 'lb' },
37
+ ml: { dim: 'volume', base: 1, label: 'ml' },
38
+ dl: { dim: 'volume', base: 100, label: 'dl' },
39
+ l: { dim: 'volume', base: 1000, label: 'l' },
40
+ tsp: { dim: 'volume', base: 4.92892, label: 'tsp' },
41
+ tbsp: { dim: 'volume', base: 14.7868, label: 'tbsp' },
42
+ cup: { dim: 'volume', base: ML_PER_CUP, label: 'cups' },
43
+ floz: { dim: 'volume', base: 29.5735, label: 'fl oz' },
44
+ pint: { dim: 'volume', base: 473.176, label: 'pints' },
45
+ quart: { dim: 'volume', base: 946.353, label: 'quarts' },
46
+ gallon: { dim: 'volume', base: 3785.41, label: 'gallons' }
47
+ };
48
+
49
+ // Everything an author might type, mapped to a canonical key above.
50
+ var ALIASES = {
51
+ g: 'g', gr: 'g', gram: 'g', grams: 'g', gramme: 'g', grammes: 'g',
52
+ kg: 'kg', kilo: 'kg', kilos: 'kg', kilogram: 'kg', kilograms: 'kg',
53
+ mg: 'mg', milligram: 'mg', milligrams: 'mg',
54
+ oz: 'oz', ounce: 'oz', ounces: 'oz',
55
+ lb: 'lb', lbs: 'lb', pound: 'lb', pounds: 'lb',
56
+ ml: 'ml', cc: 'ml', milliliter: 'ml', milliliters: 'ml',
57
+ millilitre: 'ml', millilitres: 'ml',
58
+ dl: 'dl', deciliter: 'dl', deciliters: 'dl',
59
+ l: 'l', liter: 'l', liters: 'l', litre: 'l', litres: 'l',
60
+ tsp: 'tsp', teaspoon: 'tsp', teaspoons: 'tsp',
61
+ tbsp: 'tbsp', tbs: 'tbsp', tablespoon: 'tbsp', tablespoons: 'tbsp',
62
+ cup: 'cup', cups: 'cup',
63
+ floz: 'floz', 'fl oz': 'floz', 'fluid ounce': 'floz', 'fluid ounces': 'floz',
64
+ pt: 'pint', pint: 'pint', pints: 'pint',
65
+ qt: 'quart', quart: 'quart', quarts: 'quart',
66
+ gal: 'gallon', gallon: 'gallon', gallons: 'gallon'
67
+ };
68
+
69
+ // Ladders used to pick a readable unit after converting. Ordered small→large.
70
+ var LADDERS = {
71
+ 'metric:mass': ['g', 'kg'],
72
+ 'metric:volume': ['ml', 'l'],
73
+ 'us:mass': ['oz', 'lb'],
74
+ 'us:volume': ['tsp', 'tbsp', 'cup', 'quart', 'gallon']
75
+ };
76
+
77
+ var VULGAR = {
78
+ 0.125: '⅛', 0.25: '¼', 0.333: '⅓', 0.375: '⅜',
79
+ 0.5: '½', 0.625: '⅝', 0.667: '⅔', 0.75: '¾',
80
+ 0.875: '⅞'
81
+ };
82
+ // Denominators US amounts are rounded to, largest quantity first. A whole
83
+ // cup is measured in quarters; anything under one, in eighths. Finer than
84
+ // that is precision no measuring spoon can deliver.
85
+ var US_STEPS = [
86
+ { min: 1, step: 0.25 },
87
+ { min: 0, step: 0.125 }
88
+ ];
89
+ // Units that read as fractions on a measuring cup or spoon rather than as
90
+ // decimals on a scale.
91
+ var FRACTIONAL_UNITS = ['tsp', 'tbsp', 'cup', 'floz', 'pint', 'quart', 'gallon', 'oz', 'lb'];
92
+
93
+ /* ------------------------------------------------------------------ *
94
+ * Unit helpers
95
+ * ------------------------------------------------------------------ */
96
+
97
+ function canonicalUnit(raw) {
98
+ if (!raw) return null;
99
+ var key = String(raw).toLowerCase().trim()
100
+ .replace(/\./g, '')
101
+ .replace(/-/g, ' ')
102
+ .replace(/\s+/g, ' ');
103
+ if (ALIASES[key]) return ALIASES[key];
104
+ if (ALIASES[key.replace(/\s/g, '')]) return ALIASES[key.replace(/\s/g, '')];
105
+ return null;
106
+ }
107
+
108
+ /** Pick the friendliest unit on a ladder for a magnitude in base units. */
109
+ function pickUnit(ladder, baseAmount) {
110
+ var chosen = ladder[0];
111
+ for (var i = 0; i < ladder.length; i++) {
112
+ var unit = UNITS[ladder[i]];
113
+ var value = baseAmount / unit.base;
114
+ // Step up while the amount would still read as at least one whole unit,
115
+ // so 1500 g becomes 1.5 kg but 900 g stays 900 g.
116
+ if (value >= 1) chosen = ladder[i];
117
+ }
118
+ return chosen;
119
+ }
120
+
121
+ /* ------------------------------------------------------------------ *
122
+ * Number formatting
123
+ * ------------------------------------------------------------------ */
124
+
125
+ function roundTo(value, step) {
126
+ return Math.round(value / step) * step;
127
+ }
128
+
129
+ /** "1½", "¾", "2" — US amounts read as fractions, never as 0.75. */
130
+ function formatFraction(value) {
131
+ var step = 0.0625;
132
+ for (var i = 0; i < US_STEPS.length; i++) {
133
+ if (value >= US_STEPS[i].min) { step = US_STEPS[i].step; break; }
134
+ }
135
+ var rounded = roundTo(value, step);
136
+ // Too small to round to even an eighth — show the decimal rather than "0".
137
+ if (rounded === 0) return trimNumber(value, 2);
138
+ var whole = Math.floor(rounded + 1e-9);
139
+ var frac = rounded - whole;
140
+ var key = Math.round(frac * 1000) / 1000;
141
+ if (key < 1e-6) return String(whole);
142
+ var glyph = VULGAR[key];
143
+ if (glyph) return (whole > 0 ? whole : '') + glyph;
144
+ return trimNumber(rounded, 2);
145
+ }
146
+
147
+ /** Metric amounts: precise when small, round when large. */
148
+ function formatMetric(value) {
149
+ if (value >= 100) return String(Math.round(value));
150
+ if (value >= 10) return trimNumber(Math.round(value * 2) / 2, 1);
151
+ if (value >= 1) return trimNumber(Math.round(value * 10) / 10, 1);
152
+ return trimNumber(Math.round(value * 100) / 100, 2);
153
+ }
154
+
155
+ /** Countable things ("3 eggs"): whole numbers, halves when small. */
156
+ function formatCount(value) {
157
+ if (value >= 10) return String(Math.round(value));
158
+ return formatFraction(value);
159
+ }
160
+
161
+ function trimNumber(value, decimals) {
162
+ var text = value.toFixed(decimals);
163
+ if (text.indexOf('.') !== -1) {
164
+ text = text.replace(/0+$/, '').replace(/\.$/, '');
165
+ }
166
+ return text;
167
+ }
168
+
169
+ var COUNTED_UNITS = { cup: 'cup', pint: 'pint', quart: 'quart', gallon: 'gallon' };
170
+
171
+ /**
172
+ * Recipe usage, not strict grammar: anything up to and including one takes
173
+ * the singular — "¾ cup", "1 cup", "1½ cups".
174
+ */
175
+ function pluralize(unitKey, value) {
176
+ var singular = COUNTED_UNITS[unitKey];
177
+ if (!singular) return UNITS[unitKey].label;
178
+ return value <= 1 + 1e-9 ? singular : singular + 's';
179
+ }
180
+
181
+ /* ------------------------------------------------------------------ *
182
+ * Quantity conversion
183
+ * ------------------------------------------------------------------ */
184
+
185
+ /**
186
+ * Convert one amount into the requested system.
187
+ * Returns { value, unit } where `unit` is display text (may be the
188
+ * author's original string when nothing convertible was found).
189
+ */
190
+ function convert(amount, rawUnit, system, density, allowWeigh) {
191
+ var key = canonicalUnit(rawUnit);
192
+
193
+ // "As written" still scales — it keeps the author's own unit words, but
194
+ // a scaled amount gets the matching singular/plural so halving "2 cups"
195
+ // reads "1 cup" rather than "1 cups".
196
+ if (!key || system === 'original') {
197
+ var label = rawUnit || '';
198
+ if (key && COUNTED_UNITS[key]) label = pluralize(key, amount);
199
+ return { value: amount, unit: label, key: null, sourceKey: key, keepLabel: true };
200
+ }
201
+
202
+ var unit = UNITS[key];
203
+ var target = system === 'metric' ? 'metric' : 'us';
204
+
205
+ // Volume ↔ weight, but only when we know how heavy a cup of it is.
206
+ var dim = unit.dim;
207
+ var baseAmount = amount * unit.base; // grams or millilitres
208
+
209
+ if (density && allowWeigh) {
210
+ if (target === 'metric' && dim === 'volume') {
211
+ baseAmount = (baseAmount / ML_PER_CUP) * density; // ml → g
212
+ dim = 'mass';
213
+ } else if (target === 'us' && dim === 'mass') {
214
+ baseAmount = (baseAmount / density) * ML_PER_CUP; // g → ml
215
+ dim = 'volume';
216
+ }
217
+ }
218
+
219
+ var ladder = LADDERS[target + ':' + dim];
220
+ if (!ladder) {
221
+ return { value: amount, unit: rawUnit || '', key: null, sourceKey: key, keepLabel: true };
222
+ }
223
+
224
+
225
+ var chosen = pickUnit(ladder, baseAmount);
226
+ var value = baseAmount / UNITS[chosen].base;
227
+ return { value: value, unit: pluralize(chosen, value), key: chosen };
228
+ }
229
+
230
+ /** Format a converted amount, choosing the notation its unit is measured in. */
231
+ function renderAmount(result) {
232
+ var unitKey = result.key || result.sourceKey;
233
+ var text;
234
+ if (!unitKey) {
235
+ text = formatCount(result.value); // "3 eggs", "1½ lemons"
236
+ } else if (FRACTIONAL_UNITS.indexOf(unitKey) !== -1) {
237
+ text = formatFraction(result.value); // "1¾ cups"
238
+ } else {
239
+ text = formatMetric(result.value); // "375 g", "1.5 l"
240
+ }
241
+ return result.unit ? text + ' ' + result.unit : text;
242
+ }
243
+
244
+ /* ------------------------------------------------------------------ *
245
+ * DOM updates
246
+ * ------------------------------------------------------------------ */
247
+
248
+ function updateQuantity(el, factor, system) {
249
+ var base = parseFloat(el.getAttribute('data-qty'));
250
+ if (isNaN(base)) return;
251
+
252
+ var scales = el.getAttribute('data-scale') !== 'false';
253
+ var allowWeigh = el.getAttribute('data-weigh') !== 'false';
254
+ var rawUnit = el.getAttribute('data-unit') || '';
255
+ var density = parseFloat(el.getAttribute('data-grams-per-cup'));
256
+ if (isNaN(density)) density = null;
257
+
258
+ var multiplier = scales ? factor : 1;
259
+ var parts = [renderAmount(convert(base * multiplier, rawUnit, system, density, allowWeigh))];
260
+
261
+ var max = parseFloat(el.getAttribute('data-qty-max'));
262
+ if (!isNaN(max) && max > base) {
263
+ var hi = renderAmount(convert(max * multiplier, rawUnit, system, density, allowWeigh));
264
+ // "2–3 cups", not "2 cups–3 cups".
265
+ var lo = parts[0];
266
+ var loParts = lo.split(' ');
267
+ var hiParts = hi.split(' ');
268
+ if (loParts.length > 1 && loParts.slice(1).join(' ') === hiParts.slice(1).join(' ')) {
269
+ parts = [loParts[0] + '–' + hi];
270
+ } else {
271
+ parts = [lo + '–' + hi];
272
+ }
273
+ }
274
+
275
+ var text = parts[0];
276
+ if (el.textContent !== text) el.textContent = text;
277
+
278
+ // Keep what the recipe actually said one hover away.
279
+ var original = el.getAttribute('data-original-text');
280
+ if (original && original !== text) {
281
+ el.title = 'As written: ' + original;
282
+ } else {
283
+ el.removeAttribute('title');
284
+ }
285
+ }
286
+
287
+ /**
288
+ * "2 large eggs" halved is "1 large egg", not "1 large eggs". Only applies to
289
+ * countable ingredients — where there is a unit, the unit carries the number
290
+ * and the ingredient name never changes.
291
+ */
292
+ function updateName(qtyEl, factor) {
293
+ var item = qtyEl.closest('.recipe-ingredient');
294
+ if (!item) return;
295
+ var name = item.querySelector('[data-recipe-name]');
296
+ if (!name) return;
297
+ if (qtyEl.getAttribute('data-unit')) return;
298
+
299
+ var base = parseFloat(qtyEl.getAttribute('data-qty'));
300
+ if (isNaN(base)) return;
301
+ var scales = qtyEl.getAttribute('data-scale') !== 'false';
302
+ var amount = base * (scales ? factor : 1);
303
+ // Same rule as the units: one or less takes the singular, so half an egg
304
+ // is "½ egg yolk" rather than "½ egg yolks".
305
+ var singular = amount <= 1 + 1e-9;
306
+ var text = singular ? name.getAttribute('data-singular') : name.getAttribute('data-plural');
307
+ if (text && name.textContent !== text) name.textContent = text;
308
+ }
309
+
310
+ function updateTemperature(el, system) {
311
+ var f = parseFloat(el.getAttribute('data-temp-f'));
312
+ var c = parseFloat(el.getAttribute('data-temp-c'));
313
+ if (isNaN(f) && isNaN(c)) return;
314
+ var label = el.getAttribute('data-temp-label') || '';
315
+ var text;
316
+ if (system === 'metric') {
317
+ text = c + ' °C';
318
+ } else if (system === 'us') {
319
+ text = f + ' °F';
320
+ } else {
321
+ text = f + ' °F (' + c + ' °C)';
322
+ }
323
+ el.textContent = label ? label + ' ' + text : text;
324
+ }
325
+
326
+ /* ------------------------------------------------------------------ *
327
+ * Storage (never fatal — private windows throw on access)
328
+ * ------------------------------------------------------------------ */
329
+
330
+ function readStore(key) {
331
+ try { return window.localStorage.getItem(key); } catch (e) { return null; }
332
+ }
333
+
334
+ function writeStore(key, value) {
335
+ try { window.localStorage.setItem(key, value); } catch (e) { /* ignore */ }
336
+ }
337
+
338
+ /* ------------------------------------------------------------------ *
339
+ * Controller
340
+ * ------------------------------------------------------------------ */
341
+
342
+ function setupRecipe(scaler) {
343
+ var scope = scaler.closest('[data-recipe]') || document;
344
+ var baseYield = parseFloat(scaler.getAttribute('data-base-yield'));
345
+ if (isNaN(baseYield) || baseYield <= 0) baseYield = 1;
346
+
347
+ var yieldUnit = scaler.getAttribute('data-yield-unit') || 'servings';
348
+ var recipeId = scaler.getAttribute('data-recipe-id') || 'recipe';
349
+ var scaleKey = SCALE_KEY_PREFIX + recipeId;
350
+
351
+ var input = scaler.querySelector('[data-recipe-yield-input]');
352
+ var status = scaler.querySelector('[data-recipe-status]');
353
+ var quantities = scope.querySelectorAll('[data-recipe-qty]');
354
+ var temperatures = scope.querySelectorAll('[data-recipe-temp]');
355
+
356
+ // Remember what the page shipped with so "As written" is exactly that.
357
+ var i;
358
+ for (i = 0; i < quantities.length; i++) {
359
+ quantities[i].setAttribute('data-original-text', quantities[i].textContent.trim());
360
+ }
361
+ for (i = 0; i < temperatures.length; i++) {
362
+ temperatures[i].setAttribute('data-original-text', temperatures[i].textContent.trim());
363
+ }
364
+
365
+ var state = {
366
+ target: baseYield,
367
+ system: readStore(UNITS_KEY) || 'original'
368
+ };
369
+ if (['original', 'metric', 'us'].indexOf(state.system) === -1) state.system = 'original';
370
+
371
+ var saved = parseFloat(readStore(scaleKey));
372
+ if (!isNaN(saved) && saved > 0) state.target = saved;
373
+
374
+ // ?scale=2 or ?servings=24 wins over the remembered value — that is what a
375
+ // shared link is for.
376
+ var params = new URLSearchParams(window.location.search);
377
+ var fromUrl = parseFloat(params.get('servings'));
378
+ var scaleParam = parseFloat(params.get('scale'));
379
+ if (!isNaN(fromUrl) && fromUrl > 0) state.target = fromUrl;
380
+ else if (!isNaN(scaleParam) && scaleParam > 0) state.target = baseYield * scaleParam;
381
+
382
+ function factor() {
383
+ return state.target / baseYield;
384
+ }
385
+
386
+ function render(announce) {
387
+ var f = factor();
388
+ var j;
389
+ for (j = 0; j < quantities.length; j++) {
390
+ // Nothing to recompute when the reader is looking at the authored
391
+ // units AND this amount is not moving — either because the scale is
392
+ // 1x, or because the amount is pinned (data-scale="false", e.g. the
393
+ // ratio table's per-unit weight). Re-formatting it anyway would nudge
394
+ // the authored 81.9 g to 82 g for no reason.
395
+ var pinned = quantities[j].getAttribute('data-scale') === 'false';
396
+ var still = Math.abs(f - 1) < 1e-9 || pinned;
397
+ if (state.system === 'original' && still) {
398
+ quantities[j].textContent = quantities[j].getAttribute('data-original-text');
399
+ quantities[j].removeAttribute('title');
400
+ } else {
401
+ updateQuantity(quantities[j], f, state.system);
402
+ }
403
+ updateName(quantities[j], f);
404
+ }
405
+ for (j = 0; j < temperatures.length; j++) {
406
+ updateTemperature(temperatures[j], state.system);
407
+ }
408
+
409
+ if (input) input.value = trimNumber(state.target, 2);
410
+
411
+ var multiplierButtons = scaler.querySelectorAll('[data-recipe-multiplier]');
412
+ for (j = 0; j < multiplierButtons.length; j++) {
413
+ var m = parseFloat(multiplierButtons[j].getAttribute('data-recipe-multiplier'));
414
+ var active = Math.abs(f - m) < 1e-6;
415
+ multiplierButtons[j].classList.toggle('active', active);
416
+ multiplierButtons[j].setAttribute('aria-pressed', active ? 'true' : 'false');
417
+ }
418
+
419
+ var unitButtons = scaler.querySelectorAll('[data-recipe-units]');
420
+ for (j = 0; j < unitButtons.length; j++) {
421
+ var on = unitButtons[j].getAttribute('data-recipe-units') === state.system;
422
+ unitButtons[j].classList.toggle('active', on);
423
+ unitButtons[j].setAttribute('aria-pressed', on ? 'true' : 'false');
424
+ }
425
+
426
+ scaler.setAttribute('data-current-system', state.system);
427
+ if (scope !== document) scope.setAttribute('data-scaled', Math.abs(f - 1) < 1e-9 ? 'false' : 'true');
428
+
429
+ if (announce && status) {
430
+ status.textContent = 'Scaled to ' + trimNumber(state.target, 2) + ' ' + yieldUnit +
431
+ ', shown in ' + (state.system === 'original' ? 'the original units' :
432
+ state.system === 'metric' ? 'metric units' : 'US units') + '.';
433
+ }
434
+ }
435
+
436
+ function setYield(value, announce) {
437
+ if (isNaN(value) || value <= 0) return;
438
+ state.target = Math.round(value * 100) / 100;
439
+ writeStore(scaleKey, String(state.target));
440
+ render(announce);
441
+ }
442
+
443
+ scaler.addEventListener('click', function (event) {
444
+ var button = event.target.closest('button');
445
+ if (!button || !scaler.contains(button)) return;
446
+
447
+ if (button.hasAttribute('data-recipe-step')) {
448
+ var direction = parseFloat(button.getAttribute('data-recipe-step'));
449
+ var stepSize = state.target <= 2 ? 0.5 : 1;
450
+ setYield(state.target + direction * stepSize, true);
451
+ } else if (button.hasAttribute('data-recipe-multiplier')) {
452
+ setYield(baseYield * parseFloat(button.getAttribute('data-recipe-multiplier')), true);
453
+ } else if (button.hasAttribute('data-recipe-units')) {
454
+ state.system = button.getAttribute('data-recipe-units');
455
+ writeStore(UNITS_KEY, state.system);
456
+ render(true);
457
+ } else if (button.hasAttribute('data-recipe-reset')) {
458
+ state.system = 'original';
459
+ writeStore(UNITS_KEY, state.system);
460
+ setYield(baseYield, true);
461
+ }
462
+ });
463
+
464
+ if (input) {
465
+ input.addEventListener('change', function () {
466
+ setYield(parseFloat(input.value), true);
467
+ });
468
+ }
469
+
470
+ scaler.hidden = false;
471
+ scaler.classList.add('recipe-scaler--ready');
472
+ render(false);
473
+ }
474
+
475
+ /** Cross off an ingredient as you use it. */
476
+ function setupChecklist(root) {
477
+ root.addEventListener('change', function (event) {
478
+ var box = event.target;
479
+ if (!box.hasAttribute || !box.hasAttribute('data-recipe-check')) return;
480
+ var item = box.closest('.recipe-ingredient');
481
+ if (item) item.classList.toggle('recipe-ingredient--done', box.checked);
482
+ });
483
+ }
484
+
485
+ function init() {
486
+ var scalers = document.querySelectorAll('[data-recipe-scaler]');
487
+ for (var i = 0; i < scalers.length; i++) {
488
+ setupRecipe(scalers[i]);
489
+ }
490
+ var roots = document.querySelectorAll('[data-recipe]');
491
+ for (var j = 0; j < roots.length; j++) {
492
+ setupChecklist(roots[j]);
493
+ }
494
+ }
495
+
496
+ if (document.readyState === 'loading') {
497
+ document.addEventListener('DOMContentLoaded', init);
498
+ } else {
499
+ init();
500
+ }
501
+ })();
@@ -59,6 +59,9 @@
59
59
  const searchForm = modalEl.querySelector('[data-search-form]');
60
60
  const resultsContainer = modalEl.querySelector('[data-search-results]');
61
61
  const emptyState = modalEl.querySelector('[data-search-empty]');
62
+ // Visually-hidden aria-live region. Announces a COUNT, not the results
63
+ // themselves — see the rationale in components/search-modal.html.
64
+ const statusRegion = modalEl.querySelector('[data-search-status]');
62
65
  const searchIndexUrl = new URL('/search.json', window.location.origin);
63
66
  let searchIndex = null;
64
67
  let searchIndexPromise = null;
@@ -109,6 +112,16 @@
109
112
  });
110
113
  });
111
114
 
115
+ // Hover hints ship as "Ctrl+K" in the markup; swap to the macOS glyph
116
+ // so the affordance matches the key the visitor actually has.
117
+ if (/Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent)) {
118
+ document.querySelectorAll('[data-search-toggle]').forEach((button) => {
119
+ if (button.title) {
120
+ button.title = button.title.replace(/Ctrl\+K/i, '⌘K');
121
+ }
122
+ });
123
+ }
124
+
112
125
  // Fallback keyboard shortcut ("/" or Cmd/Ctrl+K) in case other modules are unavailable
113
126
  document.addEventListener('keydown', (event) => {
114
127
  if (event.target.matches('input, textarea, select, [contenteditable="true"]')) {
@@ -172,6 +185,18 @@
172
185
  });
173
186
  }
174
187
 
188
+ /** Announce to screen readers. Guarded: the region is markup the theme
189
+ * ships, but a consumer shadowing search-modal.html may not have it. */
190
+ function announce(message) {
191
+ if (!statusRegion) return;
192
+ // Re-assigning the identical string is a no-op for most screen
193
+ // readers, so clear first when the message has not changed —
194
+ // otherwise "No results found." stays silent as the user keeps
195
+ // typing characters that all miss.
196
+ if (statusRegion.textContent === message) statusRegion.textContent = '';
197
+ statusRegion.textContent = message;
198
+ }
199
+
175
200
  function clearResults() {
176
201
  if (!resultsContainer) return;
177
202
  resultsContainer.innerHTML = '';
@@ -188,6 +213,9 @@
188
213
 
189
214
  if (!query) {
190
215
  clearResults();
216
+ // Silence on an emptied box: the user cleared it deliberately
217
+ // and does not need telling.
218
+ if (statusRegion) statusRegion.textContent = '';
191
219
  return;
192
220
  }
193
221
 
@@ -200,12 +228,20 @@
200
228
  ? 'No results found.'
201
229
  : 'Search is unavailable on this site.';
202
230
  resultsContainer.appendChild(empty);
231
+ announce(empty.textContent);
203
232
  return;
204
233
  }
205
234
 
206
235
  const list = document.createElement('div');
207
236
  list.className = 'list-group';
208
237
 
238
+ // Announce the count, not the results. `shown` is what the user can
239
+ // actually reach in the list; when the index matched more than that
240
+ // say so, or "8 results" for a 40-match query would be a lie.
241
+ const shown = Math.min(items.length, 8);
242
+ const suffix = items.length > shown ? ` of ${items.length}` : '';
243
+ announce(`${shown}${suffix} result${shown === 1 ? '' : 's'} for ${query}`);
244
+
209
245
  items.slice(0, 8).forEach((item) => {
210
246
  const link = document.createElement('a');
211
247
  link.className = 'list-group-item list-group-item-action';