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,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
+ })();
@@ -74,8 +74,11 @@
74
74
  if (typeof bootstrap === 'undefined') return;
75
75
  const cookieEl = document.getElementById('cookieSettingsModal');
76
76
  const infoEl = document.getElementById('info-section');
77
+ const drawerEl = document.getElementById('zer0UnifiedDrawer');
77
78
  afterModalClosed(cookieEl, () => {
78
- afterOffcanvasClosed(infoEl, showSearchModal);
79
+ afterOffcanvasClosed(infoEl, () => {
80
+ afterOffcanvasClosed(drawerEl, showSearchModal);
81
+ });
79
82
  });
80
83
  };
81
84
 
@@ -106,6 +109,16 @@
106
109
  });
107
110
  });
108
111
 
112
+ // Hover hints ship as "Ctrl+K" in the markup; swap to the macOS glyph
113
+ // so the affordance matches the key the visitor actually has.
114
+ if (/Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent)) {
115
+ document.querySelectorAll('[data-search-toggle]').forEach((button) => {
116
+ if (button.title) {
117
+ button.title = button.title.replace(/Ctrl\+K/i, '⌘K');
118
+ }
119
+ });
120
+ }
121
+
109
122
  // Fallback keyboard shortcut ("/" or Cmd/Ctrl+K) in case other modules are unavailable
110
123
  document.addEventListener('keydown', (event) => {
111
124
  if (event.target.matches('input, textarea, select, [contenteditable="true"]')) {