pandorga 1.8.1 → 1.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03c8db4a237e9092a2f0d9256c118a162476cb1d056fc88518cfeccd60379bed
4
- data.tar.gz: e8a2725fbc96d9c02ad55e39dce2c8e98d1eec14f20996ae5636d60f2d54181e
3
+ metadata.gz: 5ce749863130651664857439b365492b57207e7d0aba994315be703200fe0f48
4
+ data.tar.gz: 1b8f5293944f8cd27c76ee413f69e02869e9dc1cd6619aa48cfde1daf2355b98
5
5
  SHA512:
6
- metadata.gz: a4e9108eac5982492245285b4954dc1e43a0ca10534411a8a221af35c5520a5a65c3a0fc9e8700bfc9c27588e41ee332a0204ebb697b9339514128e70b5237d1
7
- data.tar.gz: 8ae715831b65d21cf206f071841f6c444f0b99598d93a2917ccaa09f4eecce658e2e93c0b61a75e375afa9b9a9baf0a6f17a47c70584f4ac6c5f656f8a728e75
6
+ metadata.gz: 157e81581c2e84cf398ab699902f1a3a17023e26b4fdbc27e76cba5013f1f7e2f5609cafe2dc65cf9bad36c9027bdb520d0ab22891324c9dfc63c3aabda76abc
7
+ data.tar.gz: b016e51119eadedc1ae7b063800580dfa04519cc84f0f9cae4237a4a9e4a356c8d329ac44d8d9da4220e48bab33b68a74c891e59e50a07c947685750d178377f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.0] — 2026-10-13
4
+
5
+ ### Added
6
+
7
+ - Optional `pandorga.home.hero_landscape` (home front matter
8
+ `hero_landscape` overrides it): a 16:9 image for viewports at or below
9
+ 767px, via `<picture>`, shown whole in a 16:9 plate
10
+
11
+ ### Changed
12
+
13
+ - Without that key the phone hero plate stays 3:4. It no longer cover-crops
14
+ the portrait into a 4:3 band
15
+ - The hero image keeps `fetchpriority="high"`. A configured landscape file
16
+ is preloaded on narrow viewports. The portrait probe sets `fetchPriority`
17
+ to `high`, applies `srcset` before `src`, and the plate draw starts with
18
+ the other home fetches
19
+ - Home links `tailwind.css` before the icon scripts so that sheet is not
20
+ held behind parser-blocking script
21
+
22
+ Text faces still load after the first paint with `display=swap` and the
23
+ metric-matched fallbacks from 1.8.4. One listing shell still serves every
24
+ detail URL.
25
+
26
+ ## [1.8.5] — 2026-10-13
27
+
28
+ ### Fixed
29
+
30
+ - Home hero `sizes` nudged above the plate width so 1.75–2× DPR picks a
31
+ sharp 3:4 variant; document that hero variants must be portrait fills
32
+
33
+ ## [1.8.4] — 2026-10-12
34
+
35
+ ### Fixed
36
+
37
+ - Metric-matched local fallbacks for Cinzel Decorative, Cinzel, Newsreader,
38
+ Ancizar Serif, and Marcellus SC so `display=swap` does not reflow the home
39
+ hero (CLS)
40
+ - Home hero `portraits.json` includes `srcset` / `sizes` when
41
+ `variants/<stem>-{480,960,1440}.jpg` exist under the hero folder
42
+
43
+ No `pandorga:` config keys were added or renamed. Font roles and the
44
+ ledger fallbacks are unchanged.
45
+
46
+ ## [1.8.3] — 2026-10-12
47
+
48
+ ### Fixed
49
+
50
+ - Detail pages load MathJax only when a TeX block nears the viewport (not on
51
+ every article open), then typeset visible blocks first; below-fold formulas
52
+ follow via IntersectionObserver with yields between blocks
53
+ - Listing layouts (articles / blog) no longer pull the MathJax script
54
+ - Long detail bodies mount in batches so the opening can paint before the
55
+ full DOM lands
56
+ - Figure and cut-in images use `decoding="async"` plus width/height hints
57
+ with existing `loading="lazy"`
58
+
59
+ No `pandorga:` config keys were added or renamed. Font roles and the
60
+ ledger fallbacks are unchanged.
61
+
62
+
63
+ ## [1.8.2] — 2026-10-12
64
+
65
+ ### Fixed
66
+
67
+ - Home listing CTAs: more space between the label and its underline
68
+ - Home index panels (Sources / Network / Bibliography): shared plate flex and
69
+ card-scale titles so the three cards align
70
+ - Articles archive: `random` sort shows **Order: random** (was falling back to
71
+ last revised)
72
+ - Listing masthead tag lines (Rascunhos, Sources, peers) use body size instead
73
+ of micro / mono
74
+ - CV contact tiles: quieter badge and name type
75
+ - Dates / meta (`--type-meta`) +2pt; content titles (`--type-title`) +10%;
76
+ top-bar wordmark +25%
77
+ - Theorem / proof heads sit inline with the opening sentence (no line break
78
+ after the name / **Proof.**)
79
+ - Proof QED square (`□`) is +2pt over the body size
80
+
81
+ No `pandorga:` config keys were added or renamed. Font roles and the
82
+ ledger fallbacks are unchanged.
83
+
3
84
  ## [1.8.1] — 2026-10-12
4
85
 
5
86
  ### Fixed
data/DESIGN.md CHANGED
@@ -779,12 +779,12 @@ those tokens — no one-off rem sizes for roles that already have a step.
779
779
  | `--type-display-lg` | 3.6515 | ~64 | display-hero |
780
780
  | `--type-page-lg` | 2.9015 | ~51 | headline-lg |
781
781
  | `--type-section-lg` | 2.1515 | ~38 | headline-md |
782
- | `--type-title-lg` | 2.4015 | ~42 | detail H1 |
782
+ | `--type-title-lg` | 2.64165 | ~46 | detail H1 |
783
783
  | `--type-card` | 1.4015 | ~25 | headline-sm |
784
784
  | `--type-prose` | 1.2 | ~21.1 | reading body, under 1024px |
785
785
  | `--type-prose` (≥1024px) | 1.25 | ~22 | reading body, desktop |
786
786
  | `--type-body` | 1.0758 | ~19 | body-md |
787
- | `--type-meta` | 0.8125 | ~14 | body-sm |
787
+ | `--type-meta` | calc(0.8125rem + 2pt) | ~16 | body-sm |
788
788
  | `--type-label` | 0.8485 | ~15 | label-md |
789
789
  | `--type-mono` | 0.8504 | ~15 | notation |
790
790
 
@@ -115,23 +115,35 @@
115
115
  return m ? String(m[1] != null ? m[1] : m[2] || '') : '';
116
116
  }
117
117
 
118
+ /* Put the head inside the first block so "Corollary 1." / "Proof." sit on
119
+ the same line as the opening sentence (paper style), not above it. */
120
+ function prependInlineHead(html, head) {
121
+ var body = String(html || '');
122
+ var m = body.match(/^(\s*<p\b[^>]*>)/i);
123
+ if (m) return body.replace(m[1], m[1] + head);
124
+ return head + body;
125
+ }
126
+
118
127
  function renderTheoremHtml(openTag, inner) {
119
128
  var name = attrValue(openTag, 'name').trim();
120
129
  var description = attrValue(openTag, 'description').trim();
121
130
  var head = '';
122
131
  if (name) {
123
- head = '<p class="theorem-head"><span class="theorem-name">' + escapeHtml(name) + '</span>';
132
+ head = '<span class="theorem-head"><span class="theorem-name">' + escapeHtml(name) + '</span>';
124
133
  if (description) {
125
134
  head += ' <span class="theorem-description">(' + escapeHtml(description) + ')</span>';
126
135
  }
127
- head += '.</p>';
136
+ head += '. </span>';
128
137
  }
129
- return '<div class="theorem">' + head + '<div class="theorem-body">' + parseInlineMarkdown(inner) + '</div></div>';
138
+ var body = parseInlineMarkdown(inner);
139
+ if (head) body = prependInlineHead(body, head);
140
+ return '<div class="theorem"><div class="theorem-body">' + body + '</div></div>';
130
141
  }
131
142
 
132
143
  function renderProofHtml(inner) {
133
- var head = '<p class="proof-head"><span class="proof-name">Proof</span>.</p>';
134
- return '<div class="proof">' + head + '<div class="proof-body">' + parseInlineMarkdown(inner) + '</div></div>';
144
+ var head = '<span class="proof-head"><span class="proof-name">Proof</span>. </span>';
145
+ var body = prependInlineHead(parseInlineMarkdown(inner), head);
146
+ return '<div class="proof"><div class="proof-body">' + body + '</div></div>';
135
147
  }
136
148
 
137
149
  function preprocessMarkdownDivs(markdown) {
@@ -184,7 +196,104 @@
184
196
  return html;
185
197
  }
186
198
 
199
+ var MATHJAX_SRC = 'https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js';
200
+ var mathJaxLoadPromise = null;
201
+
202
+ function rootHasTeX(root) {
203
+ var text = (root && root.textContent) || '';
204
+ return text.indexOf('$') !== -1 || text.indexOf('\\(') !== -1 || text.indexOf('\\[') !== -1;
205
+ }
206
+
207
+ function elementNeedsMath(el) {
208
+ if (!el || el.nodeType !== 1) return false;
209
+ if (el.getAttribute('data-math-done') === '1') return false;
210
+ if (el.querySelector && el.querySelector('mjx-container')) return false;
211
+ return rootHasTeX(el);
212
+ }
213
+
214
+ function collectMathBlocks(root) {
215
+ var out = [];
216
+ if (!root) return out;
217
+ Array.prototype.forEach.call(root.children || [], function (el) {
218
+ if (elementNeedsMath(el)) out.push(el);
219
+ });
220
+ return out;
221
+ }
222
+
223
+ function blockNearViewport(el, marginPx) {
224
+ var rect = el.getBoundingClientRect();
225
+ var vh = window.innerHeight || 800;
226
+ var margin = typeof marginPx === 'number' ? marginPx : vh;
227
+ return rect.top < vh + margin && rect.bottom > -margin;
228
+ }
229
+
230
+ function yieldToMain() {
231
+ return new Promise(function (resolve) {
232
+ if (typeof window.requestIdleCallback === 'function') {
233
+ window.requestIdleCallback(function () { resolve(); }, { timeout: 48 });
234
+ } else {
235
+ window.setTimeout(resolve, 0);
236
+ }
237
+ });
238
+ }
239
+
240
+ function ensureMathJaxConfig() {
241
+ if (window.MathJax && window.MathJax.tex) return;
242
+ window.MathJax = {
243
+ startup: { typeset: false },
244
+ tex: {
245
+ inlineMath: [['$', '$'], ['\\(', '\\)']],
246
+ displayMath: [['$$', '$$'], ['\\[', '\\]']]
247
+ }
248
+ };
249
+ }
250
+
251
+ function loadMathJaxScript() {
252
+ if (mathJaxLoadPromise) return mathJaxLoadPromise;
253
+ ensureMathJaxConfig();
254
+ if (window.MathJax && typeof window.MathJax.typesetPromise === 'function') {
255
+ mathJaxLoadPromise = Promise.resolve(window.MathJax);
256
+ return mathJaxLoadPromise;
257
+ }
258
+ mathJaxLoadPromise = new Promise(function (resolve, reject) {
259
+ var existing = document.getElementById('MathJax-script') ||
260
+ document.querySelector('script[src*="mathjax"][src*="tex-mml-chtml"]');
261
+ if (existing) {
262
+ if (window.MathJax && typeof window.MathJax.typesetPromise === 'function') {
263
+ resolve(window.MathJax);
264
+ return;
265
+ }
266
+ existing.addEventListener('load', function () { resolve(window.MathJax); });
267
+ existing.addEventListener('error', reject);
268
+ return;
269
+ }
270
+ var script = document.createElement('script');
271
+ script.id = 'MathJax-script';
272
+ script.src = MATHJAX_SRC;
273
+ script.async = true;
274
+ script.onload = function () {
275
+ var mj = window.MathJax;
276
+ if (mj && mj.startup && mj.startup.promise) {
277
+ mj.startup.promise.then(function () { resolve(mj); }).catch(reject);
278
+ } else {
279
+ resolve(mj);
280
+ }
281
+ };
282
+ script.onerror = function () {
283
+ reject(new Error('MathJax failed to load'));
284
+ };
285
+ document.head.appendChild(script);
286
+ });
287
+ return mathJaxLoadPromise;
288
+ }
289
+
187
290
  async function waitForMathJax() {
291
+ try {
292
+ await loadMathJaxScript();
293
+ } catch (error) {
294
+ console.error(error);
295
+ return false;
296
+ }
188
297
  var mj = window.MathJax;
189
298
  if (!mj) return false;
190
299
  if (mj.startup && mj.startup.promise) {
@@ -198,10 +307,89 @@
198
307
  return typeof mj.typesetPromise === 'function';
199
308
  }
200
309
 
310
+ async function typesetMathBlock(el) {
311
+ if (!el || el.getAttribute('data-math-done') === '1') return;
312
+ if (el.querySelector && el.querySelector('mjx-container')) {
313
+ el.setAttribute('data-math-done', '1');
314
+ return;
315
+ }
316
+ if (!rootHasTeX(el)) return;
317
+ if (!(await waitForMathJax())) return;
318
+ try {
319
+ await window.MathJax.typesetPromise([el]);
320
+ } catch (error) {
321
+ console.error(error);
322
+ return;
323
+ }
324
+ el.setAttribute('data-math-done', '1');
325
+ }
326
+
327
+ function observeMathBlocks(blocks) {
328
+ if (!blocks.length) return;
329
+ if (typeof IntersectionObserver === 'undefined') {
330
+ (async function () {
331
+ for (var i = 0; i < blocks.length; i++) {
332
+ await yieldToMain();
333
+ await typesetMathBlock(blocks[i]);
334
+ }
335
+ })();
336
+ return;
337
+ }
338
+ var queue = [];
339
+ var draining = false;
340
+
341
+ async function drain() {
342
+ if (draining) return;
343
+ draining = true;
344
+ while (queue.length) {
345
+ var el = queue.shift();
346
+ await typesetMathBlock(el);
347
+ if (queue.length) await yieldToMain();
348
+ }
349
+ draining = false;
350
+ }
351
+
352
+ var observer = new IntersectionObserver(function (entries) {
353
+ entries.forEach(function (entry) {
354
+ if (!entry.isIntersecting) return;
355
+ observer.unobserve(entry.target);
356
+ queue.push(entry.target);
357
+ drain();
358
+ });
359
+ }, { root: null, rootMargin: '120% 0px', threshold: 0 });
360
+
361
+ blocks.forEach(function (el) { observer.observe(el); });
362
+ }
363
+
364
+ /* Load MathJax only when a TeX block is near the viewport. Typeset those
365
+ blocks first, then the rest as the reader scrolls. Returns after the
366
+ visible pass so the detail shell can paint without waiting on below-fold
367
+ formulas. */
201
368
  async function typesetMath(root) {
202
369
  try {
203
- if (!(await waitForMathJax())) return;
204
- await window.MathJax.typesetPromise(root ? [root] : undefined);
370
+ if (!root || !rootHasTeX(root)) return;
371
+
372
+ var blocks = collectMathBlocks(root);
373
+ if (!blocks.length) {
374
+ if (!(await waitForMathJax())) return;
375
+ await window.MathJax.typesetPromise([root]);
376
+ return;
377
+ }
378
+
379
+ var immediate = [];
380
+ var deferred = [];
381
+ var margin = (window.innerHeight || 800) * 1.25;
382
+ blocks.forEach(function (el) {
383
+ if (blockNearViewport(el, margin)) immediate.push(el);
384
+ else deferred.push(el);
385
+ });
386
+
387
+ for (var i = 0; i < immediate.length; i++) {
388
+ await typesetMathBlock(immediate[i]);
389
+ if (i < immediate.length - 1) await yieldToMain();
390
+ }
391
+
392
+ observeMathBlocks(deferred);
205
393
  } catch (error) {
206
394
  console.error(error);
207
395
  }
@@ -26,7 +26,7 @@
26
26
  var src = resolveAssetUrl(attrs.src);
27
27
  var sideClass = attrs.side === 'right' ? ' article-cut-in--right' : '';
28
28
  return '<figure class="article-cut-in-plate' + sideClass + '">' +
29
- '<img class="article-cut-in' + sideClass + '" src="' + escapeAttr(src) + '" alt="' + escapeAttr(attrs.alt || '') + '" width="120" loading="lazy">' +
29
+ '<img class="article-cut-in' + sideClass + '" src="' + escapeAttr(src) + '" alt="' + escapeAttr(attrs.alt || '') + '" width="120" loading="lazy" decoding="async">' +
30
30
  '</figure>';
31
31
  }
32
32
 
@@ -36,9 +36,12 @@
36
36
  var caption = attrs.caption || '';
37
37
  var lbCaption = figureLightboxCaption(caption, attrs.source);
38
38
  var lbAttr = lbCaption ? ' data-lightbox-caption="' + escapeAttr(lbCaption) + '"' : '';
39
+ /* Reserve a 16:9 box until the file paints. Diagrams vary; a stable
40
+ ratio beats an unsized image for CLS on long articles. */
41
+ var sizeAttr = ' width="960" height="540"';
39
42
  return [
40
43
  '<figure class="my-8 mx-auto text-center w-full" style="display:block;margin-left:auto;margin-right:auto;' + width + '">',
41
- '<img src="' + escapeAttr(src) + '" alt="' + escapeAttr(attrs.alt || caption) + '" class="figure-lightbox-trigger mx-auto max-w-full h-auto block" loading="lazy" tabindex="0" role="button" aria-label="Enlarge image"' + lbAttr + '>',
44
+ '<img src="' + escapeAttr(src) + '" alt="' + escapeAttr(attrs.alt || caption) + '" class="figure-lightbox-trigger mx-auto max-w-full h-auto block" loading="lazy" decoding="async"' + sizeAttr + ' tabindex="0" role="button" aria-label="Enlarge image"' + lbAttr + '>',
42
45
  caption || attrs.source ? '<figcaption class="mt-3 font-body">' + includeCaptionHtml(caption) + figureSourceHtml(attrs.source) + '</figcaption>' : '',
43
46
  '</figure>'
44
47
  ].join('');
@@ -813,12 +813,42 @@
813
813
 
814
814
  var body = document.createElement('div');
815
815
  body.className = 'article-content';
816
- body.innerHTML = await renderRichContent(options.bodyMarkdown, fm);
817
- applyDropCapClass(body, fm);
816
+ /* Mount the opening blocks first so title-adjacent prose can paint before
817
+ the rest of a long article lands in the DOM (mobile LCP / TBT). */
818
+ var staging = document.createElement('div');
819
+ staging.innerHTML = await renderRichContent(options.bodyMarkdown, fm);
820
+ var nodes = Array.prototype.slice.call(staging.childNodes);
818
821
  section.appendChild(body);
819
-
820
822
  if (options.mainColumn) options.mainColumn.appendChild(section);
821
823
 
824
+ var firstBatch = 6;
825
+ var batchSize = 12;
826
+ var ni = 0;
827
+ for (; ni < nodes.length && ni < firstBatch; ni++) {
828
+ body.appendChild(nodes[ni]);
829
+ }
830
+ applyDropCapClass(body, fm);
831
+ await new Promise(function (resolve) {
832
+ if (typeof window.requestAnimationFrame === 'function') {
833
+ window.requestAnimationFrame(function () { window.setTimeout(resolve, 0); });
834
+ } else {
835
+ window.setTimeout(resolve, 0);
836
+ }
837
+ });
838
+ while (ni < nodes.length) {
839
+ var end = Math.min(ni + batchSize, nodes.length);
840
+ for (; ni < end; ni++) body.appendChild(nodes[ni]);
841
+ if (ni < nodes.length) {
842
+ await new Promise(function (resolve) {
843
+ if (typeof window.requestIdleCallback === 'function') {
844
+ window.requestIdleCallback(function () { resolve(); }, { timeout: 48 });
845
+ } else {
846
+ window.setTimeout(resolve, 0);
847
+ }
848
+ });
849
+ }
850
+ }
851
+
822
852
  await enhanceArticleContent(body, fm);
823
853
 
824
854
  assignHeadingIds(body);
@@ -885,11 +915,14 @@
885
915
 
886
916
  async function renderRichContent(markdown, frontMatter) {
887
917
  var fm = frontMatter || {};
888
- var index = await ensureXciteIndex();
889
- var bibIndex = await ensureBibliographyIndex();
918
+ /* Kick index fetches immediately so they overlap with front-matter strip. */
919
+ var indexPromise = ensureXciteIndex();
920
+ var bibPromise = ensureBibliographyIndex();
890
921
  var assignPrep = preprocessTranscriptionAssigns(stripFrontMatter(markdown));
891
922
  var prepared = assignPrep.text;
892
923
  var transcriptionAssigns = assignPrep.assignMap;
924
+ var index = await indexPromise;
925
+ var bibIndex = await bibPromise;
893
926
  var replacements = [];
894
927
  prepared = prepared.replace(/\{%-?\s*include\s+transcription\.html\s+([\s\S]*?)-?%\}/g, function (match, raw) {
895
928
  var replacementIndex = replacements.length;
@@ -578,10 +578,13 @@
578
578
  }
579
579
  return new Promise(function (resolve) {
580
580
  var probe = new Image();
581
+ probe.fetchPriority = 'high';
582
+ applyHeroPortraitSources(probe, chosen);
581
583
  probe.onload = function () {
582
- img.src = nextSrc;
583
584
  img.alt = nextAlt;
584
585
  applyHeroPortraitSources(img, chosen);
586
+ img.fetchPriority = 'high';
587
+ img.src = nextSrc;
585
588
  img.classList.add('is-shown');
586
589
  resolve(true);
587
590
  };
@@ -610,9 +613,44 @@
610
613
  return next();
611
614
  }
612
615
 
616
+ function heroLandscapeActive(img) {
617
+ var url = img && img.getAttribute('data-hero-landscape');
618
+ if (!url || !String(url).trim() || !window.matchMedia) return false;
619
+ return window.matchMedia('(max-width: 767px)').matches;
620
+ }
621
+
622
+ function clearHeroSources(img) {
623
+ img.removeAttribute('src');
624
+ img.removeAttribute('srcset');
625
+ img.removeAttribute('sizes');
626
+ img.alt = '';
627
+ }
628
+
629
+ function watchHeroFrame(img) {
630
+ var url = img.getAttribute('data-hero-landscape');
631
+ if (!url || !String(url).trim() || !window.matchMedia) return;
632
+ var mq = window.matchMedia('(max-width: 767px)');
633
+ if (!mq.addEventListener) return;
634
+ mq.addEventListener('change', function () {
635
+ if (mq.matches) {
636
+ clearHeroSources(img);
637
+ img.classList.add('is-shown');
638
+ return;
639
+ }
640
+ img.classList.remove('is-shown');
641
+ loadJson('pages/home/portraits.json').then(function (data) {
642
+ return showHeroPortrait(img, heroPortraitEntries(data));
643
+ }).catch(function (error) {
644
+ console.error(error);
645
+ });
646
+ });
647
+ }
648
+
613
649
  function hydrateHeroPortrait() {
614
650
  var img = document.querySelector('[data-content-hero-portrait]');
615
651
  if (!img) return Promise.resolve();
652
+ watchHeroFrame(img);
653
+ if (heroLandscapeActive(img)) return Promise.resolve();
616
654
  return loadJson('pages/home/portraits.json').then(function (data) {
617
655
  return showHeroPortrait(img, heroPortraitEntries(data));
618
656
  }).catch(function (error) {
@@ -621,6 +659,7 @@
621
659
  }
622
660
 
623
661
  function hydratePageFragments() {
662
+ hydrateHeroPortrait();
624
663
  var headers = Array.prototype.slice.call(document.querySelectorAll('[data-content-page-header]'));
625
664
  var chain = Promise.resolve();
626
665
  headers.forEach(function (el) {
@@ -644,7 +683,6 @@
644
683
  hydrateHomeSections();
645
684
  hydrateBandArt();
646
685
  hydrateContacts();
647
- hydrateHeroPortrait();
648
686
  });
649
687
  }
650
688
 
@@ -31,7 +31,7 @@
31
31
  {%- else -%}
32
32
  <figure class="home-index-plate" data-content-band-art="{{ _art_key }}" hidden></figure>
33
33
  {%- endif -%}
34
- <h3 class="section-title home-index-title">{{ _panel_title }}</h3>
34
+ <h3 class="home-index-title">{{ _panel_title }}</h3>
35
35
  <a class="ledger-cta" href="{{ _panel_path }}">
36
36
  <span>Open</span>
37
37
  <span class="material-symbols-outlined ledger-cta-arrow" aria-hidden="true">chevron_right</span>
@@ -1,10 +1,32 @@
1
- <!-- MathJax configuration for Jekyll with inline math support -->
1
+ <!-- MathJax: config only. The content runtime loads the library on demand when
2
+ a detail page actually contains TeX, then typesets visible blocks first. -->
2
3
  <script>
3
4
  window.MathJax = {
4
- tex: {
5
- inlineMath: [['$', '$'], ['\\(', '\\)']],
6
- displayMath: [['$$', '$$'], ['\\[', '\\]']]
7
- }
5
+ startup: { typeset: false },
6
+ tex: {
7
+ inlineMath: [['$', '$'], ['\\(', '\\)']],
8
+ displayMath: [['$$', '$$'], ['\\[', '\\]']]
9
+ }
8
10
  };
11
+
12
+ /* Studio / private text layout: typeset once the runtime exists. Public detail
13
+ pages call typesetMath from enhanceArticleContent instead. */
14
+ (function () {
15
+ function runStaticMath() {
16
+ var root = document.querySelector('.article-content');
17
+ if (!root || !window.ContentRuntime || typeof window.ContentRuntime.typesetMath !== 'function') {
18
+ return;
19
+ }
20
+ var text = root.textContent || '';
21
+ if (text.indexOf('$') === -1 && text.indexOf('\\(') === -1 && text.indexOf('\\[') === -1) {
22
+ return;
23
+ }
24
+ window.ContentRuntime.typesetMath(root);
25
+ }
26
+ if (window.ContentRuntime) {
27
+ runStaticMath();
28
+ } else {
29
+ document.addEventListener('pandorga:runtime', runStaticMath, { once: true });
30
+ }
31
+ })();
9
32
  </script>
10
- <script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js"></script>
@@ -126,8 +126,6 @@ layout: shell
126
126
  </div>
127
127
 
128
128
  </main>
129
- {% include mathjax.html %}
130
-
131
129
  <script>
132
130
  (function bootArticles() {
133
131
  'use strict';
@@ -168,7 +166,8 @@ layout: shell
168
166
 
169
167
  var SORT_LABELS = {
170
168
  updated: 'Order: last revised',
171
- published: 'Order: date published'
169
+ published: 'Order: date published',
170
+ random: 'Order: random'
172
171
  };
173
172
 
174
173
  var allArticles = [];
data/_layouts/blog.html CHANGED
@@ -90,8 +90,6 @@ layout: shell
90
90
  </div>
91
91
 
92
92
  </main>
93
- {% include mathjax.html %}
94
-
95
93
  <script>
96
94
  (function bootBlog() {
97
95
  'use strict';
data/_layouts/home.html CHANGED
@@ -17,6 +17,7 @@ layout: shell
17
17
  {%- assign _hero_name = site.pandorga.identity | pandorga_hero_name: site.author, site.title -%}
18
18
  {%- assign _cv_page = site.pages | where: "key", "cv" | first -%}
19
19
  {%- assign _hero_art = site.pandorga.home.hero_art | default: "page/hero-default.html" -%}
20
+ {%- assign _hero_landscape = page.hero_landscape | default: site.pandorga.home.hero_landscape | default: "" | strip -%}
20
21
  {%- assign _home_bands = site.data.pandorga_home_bands -%}
21
22
 
22
23
  <main class="home-ledger">
@@ -53,8 +54,15 @@ layout: shell
53
54
  <span class="shard-accent home-hero-shard" aria-hidden="true"></span>
54
55
  <div class="home-hero-plate-wrap">
55
56
  <span class="ledger-frame-crosshairs" aria-hidden="true"></span>
56
- <figure class="home-hero-plate">
57
- <img alt="" width="1200" height="800" fetchpriority="high" data-content-hero-portrait />
57
+ <figure class="home-hero-plate{% if _hero_landscape != blank %} home-hero-plate--landscape{% endif %}">
58
+ {%- if _hero_landscape != blank -%}
59
+ <picture>
60
+ <source media="(max-width: 767px)" srcset="{{ _hero_landscape | escape }}" />
61
+ <img alt="" width="1600" height="900" fetchpriority="high" decoding="sync" data-content-hero-portrait data-hero-landscape="{{ _hero_landscape | escape }}" />
62
+ </picture>
63
+ {%- else -%}
64
+ <img alt="" width="960" height="1280" fetchpriority="high" decoding="sync" data-content-hero-portrait />
65
+ {%- endif -%}
58
66
  <span class="ledger-frame-corners" aria-hidden="true"></span>
59
67
  </figure>
60
68
  </div>
data/_layouts/shell.html CHANGED
@@ -66,6 +66,10 @@
66
66
  {%- endcomment -%}
67
67
  <link href="https://fonts.googleapis.com" rel="preconnect" />
68
68
  <link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
69
+ {%- assign _hero_landscape = page.hero_landscape | default: site.pandorga.home.hero_landscape | default: "" | strip -%}
70
+ {%- if page.layout == "home" and _hero_landscape != blank -%}
71
+ <link rel="preload" as="image" href="{{ _hero_landscape | escape }}" fetchpriority="high" media="(max-width: 767px)" />
72
+ {%- endif -%}
69
73
  {% include theme/critical-preload.html %}
70
74
  <link rel="stylesheet" href="/assets/css/base.css" />
71
75
  <link rel="stylesheet" href="/assets/css/global-link.css" />
@@ -120,6 +124,11 @@
120
124
  {%- for _sheet in _ledger -%}
121
125
  <link rel="stylesheet" href="/assets/css/ledger/{{ _sheet }}.css" />
122
126
  {%- endfor -%}
127
+ {%- comment -%}
128
+ Utilities before the icon scripts. Those scripts are parser-blocking,
129
+ so a sheet after them waits until they finish before the scanner sees it.
130
+ {%- endcomment -%}
131
+ <link rel="stylesheet" href="/assets/css/tailwind.css" />
123
132
  {% include theme/font-loader.html %}
124
133
  {%- comment -%}
125
134
  Named icons only. The full variable face is about a megabyte, and a
@@ -194,7 +203,6 @@
194
203
  <style>
195
204
  :root { --drop-cap-lines: {{ site.drop_cap_lines | default: 2 }}; }
196
205
  </style>
197
- <link rel="stylesheet" href="/assets/css/tailwind.css" />
198
206
  </head>
199
207
 
200
208
  <body class="bg-background text-on-surface">
@@ -1012,8 +1012,9 @@ a.xcite-ref:hover {
1012
1012
  and `<div class="proof" markdown="1">`. Theorems are bare (no plate): the
1013
1013
  runtime builds the head from name / description and italicizes the body.
1014
1014
  Proofs sit on a gray band that bleeds to the edges of `.text-page-main`
1015
- while the text keeps the body measure; the runtime prefixes "Proof." and
1016
- a QED square lands on the last line of the body. */
1015
+ while the text keeps the body measure; the runtime prefixes "Proof." into
1016
+ the first paragraph (same line as the opening sentence) and a QED square
1017
+ lands on the last line of the body. */
1017
1018
  .article-content .theorem {
1018
1019
  display: block;
1019
1020
  width: 100%;
@@ -1026,7 +1027,6 @@ a.xcite-ref:hover {
1026
1027
 
1027
1028
  .article-content .theorem-head,
1028
1029
  .article-content .proof-head {
1029
- margin: 0 0 0.5em;
1030
1030
  font-style: normal;
1031
1031
  font-weight: var(--weight-body);
1032
1032
  }
@@ -1046,6 +1046,12 @@ a.xcite-ref:hover {
1046
1046
  font-style: italic;
1047
1047
  }
1048
1048
 
1049
+ /* Head is injected into the first paragraph; keep it upright against the
1050
+ italic theorem body. */
1051
+ .article-content .theorem-body .theorem-head {
1052
+ font-style: normal;
1053
+ }
1054
+
1049
1055
  .article-content .theorem-body > :first-child,
1050
1056
  .article-content .proof-body > :first-child {
1051
1057
  margin-top: 0;
@@ -1085,7 +1091,7 @@ a.xcite-ref:hover {
1085
1091
  content: "□";
1086
1092
  float: right;
1087
1093
  margin-left: 0.35em;
1088
- font-size: inherit;
1094
+ font-size: calc(1em + 2pt);
1089
1095
  line-height: inherit;
1090
1096
  color: var(--color-on-surface);
1091
1097
  pointer-events: none;
data/assets/css/base.css CHANGED
@@ -1,3 +1,53 @@
1
+ /* ── Metric-matched fallbacks (font-display: swap) ──
2
+ Local Times/Liberation faces scaled to the web fonts that paint the
3
+ home hero before the Google kit arrives. Without these, swap CLS on
4
+ home is ~0.15. Metrics from UPM-normalised average glyph width vs
5
+ Liberation Serif Regular (Times New Roman metric twin). */
6
+ @font-face {
7
+ font-family: "Pandorga Title Fallback";
8
+ src: local("Times New Roman"), local("Times"), local("Liberation Serif");
9
+ size-adjust: 148.98%;
10
+ ascent-override: 65.51%;
11
+ descent-override: 24.97%;
12
+ line-gap-override: 0%;
13
+ }
14
+
15
+ @font-face {
16
+ font-family: "Pandorga Cinzel Fallback";
17
+ src: local("Times New Roman"), local("Times"), local("Liberation Serif");
18
+ size-adjust: 139.21%;
19
+ ascent-override: 70.11%;
20
+ descent-override: 26.72%;
21
+ line-gap-override: 0%;
22
+ }
23
+
24
+ @font-face {
25
+ font-family: "Pandorga Body Fallback";
26
+ src: local("Times New Roman"), local("Times"), local("Liberation Serif");
27
+ size-adjust: 103.26%;
28
+ ascent-override: 71.18%;
29
+ descent-override: 25.66%;
30
+ line-gap-override: 0%;
31
+ }
32
+
33
+ @font-face {
34
+ font-family: "Pandorga Ancizar Fallback";
35
+ src: local("Times New Roman"), local("Times"), local("Liberation Serif");
36
+ size-adjust: 101.28%;
37
+ ascent-override: 88.86%;
38
+ descent-override: 29.62%;
39
+ line-gap-override: 0%;
40
+ }
41
+
42
+ @font-face {
43
+ font-family: "Pandorga Label Fallback";
44
+ src: local("Times New Roman"), local("Times"), local("Liberation Serif");
45
+ size-adjust: 112.98%;
46
+ ascent-override: 86.22%;
47
+ descent-override: 24.76%;
48
+ line-gap-override: 0%;
49
+ }
50
+
1
51
  /* ── Material Symbols ── */
2
52
  /* Doubled class so this beats the Google stylesheet, which arrives later
3
53
  and sets font-size: 24px. That late size change moves the hero. */
@@ -122,8 +172,8 @@ html {
122
172
  --type-section-lg: 2.1515rem;
123
173
 
124
174
  /* Content titles — Newsreader Regular 400 */
125
- --type-title: 2.0015rem;
126
- --type-title-lg: 2.4015rem;
175
+ --type-title: 2.20165rem;
176
+ --type-title-lg: 2.64165rem;
127
177
  --type-card: 1.4015rem;
128
178
  --type-card-lg: 1.5515rem;
129
179
 
@@ -140,7 +190,8 @@ html {
140
190
  /* Card/row blurbs, standfirsts, project/source copy — same as body. */
141
191
  --type-subtitle: var(--type-body);
142
192
  --type-dek: 0.9375rem;
143
- --type-meta: 0.8125rem;
193
+ /* Dates, kickers, quiet meta — +2pt over the prior 0.8125rem step. */
194
+ --type-meta: calc(0.8125rem + 2pt);
144
195
 
145
196
  /* Chrome — quiet relative to body (tuned for Marcellus SC cap-height to match icons) */
146
197
  --type-label: 0.8485rem;
@@ -405,10 +456,13 @@ html body {
405
456
  font-variant-numeric: tabular-nums;
406
457
  }
407
458
 
408
- /* Masthead tag line under the page H1. */
459
+ /* Masthead tag line under the page H1. Body size so "Channels. Sites." /
460
+ "Blog pessoal. …" reads with the standfirst, not as micro-label chrome.
461
+ Beats `.code-technical` when a listing also puts that class on the span. */
409
462
  [data-content-header-tags],
410
- .page-header-tags {
411
- font-size: var(--type-dek);
463
+ .page-header-tags,
464
+ [data-content-header-tags].code-technical {
465
+ font-size: var(--type-body);
412
466
  line-height: 1.45;
413
467
  letter-spacing: 0.04em;
414
468
  }
@@ -950,7 +1004,7 @@ a.ledger-chip:hover {
950
1004
  text-decoration: underline;
951
1005
  text-decoration-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
952
1006
  text-decoration-thickness: 1.5px;
953
- text-underline-offset: 0.28em;
1007
+ text-underline-offset: 0.48em;
954
1008
  text-underline-position: under;
955
1009
  }
956
1010
 
@@ -43,7 +43,7 @@
43
43
  .site-wordmark {
44
44
  display: none;
45
45
  font-family: var(--font-title);
46
- font-size: var(--type-dek);
46
+ font-size: calc(var(--type-dek) * 1.25);
47
47
  line-height: 1;
48
48
  white-space: nowrap;
49
49
  }
@@ -137,6 +137,7 @@ html[data-theme="light"] .cv-product {
137
137
  margin-top: var(--space-md);
138
138
  color: var(--color-on-surface-variant);
139
139
  letter-spacing: 0.06em;
140
+ font-size: var(--type-body);
140
141
  }
141
142
 
142
143
  .cv-dossier-standfirst {
@@ -219,7 +220,19 @@ html[data-theme="light"] .cv-product {
219
220
  }
220
221
 
221
222
  .cv-contacts .group .material-symbols-outlined {
222
- font-size: var(--type-card);
223
+ font-size: var(--type-label-lg);
224
+ }
225
+
226
+ /* Badge (Network / Code) and name (LinkedIn / GitHub) were inheriting body /
227
+ nav sizes and reading louder than the dossier. Quiet label register. */
228
+ .cv-contacts .group .marginalia {
229
+ font-size: var(--type-meta);
230
+ letter-spacing: 0.06em;
231
+ }
232
+
233
+ .cv-contacts .group .font-label {
234
+ font-size: var(--type-label);
235
+ letter-spacing: 0.03em;
223
236
  }
224
237
 
225
238
  /* ── Section register ──────────────────────────────────────────────────
@@ -370,8 +370,10 @@
370
370
 
371
371
  /* ── Portrait plate ──
372
372
  Beside the copy on a wide page the plate is portrait (3:4): its height
373
- matches the left column and the width is W = 3/4 × H. Edge-to-edge on a
374
- phone it is landscape (4:3) from the band width. */
373
+ matches the left column and the width is W = 3/4 × H. On a phone it stays
374
+ 3:4. A wider box would cover-crop that portrait a second time. When
375
+ hero_landscape is set, the phone plate is 16:9 and the picture source is
376
+ that file. */
375
377
  .home-hero-plate {
376
378
  position: relative;
377
379
  z-index: 1;
@@ -389,12 +391,6 @@
389
391
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-on-surface) 10%, transparent);
390
392
  }
391
393
 
392
- @media (max-width: 767px) {
393
- .home-hero-plate {
394
- aspect-ratio: 4 / 3;
395
- }
396
- }
397
-
398
394
  :root:not([data-theme="dark"]) .home-hero-plate,
399
395
  html[data-theme="light"] .home-hero-plate {
400
396
  border-width: 1px;
@@ -451,6 +447,13 @@ html[data-theme="light"] .home-hero-plate {
451
447
  }
452
448
  }
453
449
 
450
+ .home-hero-plate picture {
451
+ display: block;
452
+ width: 100%;
453
+ height: 100%;
454
+ margin: 0;
455
+ }
456
+
454
457
  .home-hero-plate img {
455
458
  width: 100%;
456
459
  height: 100%;
@@ -469,6 +472,21 @@ html[data-theme="light"] .home-hero-plate {
469
472
  opacity: 1;
470
473
  }
471
474
 
475
+ /* 16:9 file in a 16:9 plate. contain shows the whole frame. The image is
476
+ visible without waiting for the portrait runtime, so it can be the LCP. */
477
+ @media (max-width: 767px) {
478
+ .home-hero-plate--landscape {
479
+ aspect-ratio: 16 / 9;
480
+ }
481
+
482
+ .home-hero-plate--landscape img {
483
+ object-fit: contain;
484
+ object-position: center center;
485
+ opacity: 1;
486
+ transition: none;
487
+ }
488
+ }
489
+
472
490
  .home-hero-plate:hover img {
473
491
  transform: scale(1.02);
474
492
  }
@@ -487,6 +505,7 @@ html[data-theme="light"] .home-hero-plate {
487
505
  vertical tick so each band reads as a ruled plate rather than a soft fade. */
488
506
  .home-band:not(.home-band--hero) .ledger-section-banner {
489
507
  padding-top: var(--space-lg);
508
+ padding-bottom: var(--space-sm);
490
509
  border-top: 1px solid var(--border-gentle);
491
510
  box-shadow: 0 4px 0 -3px var(--border-gentle-faint);
492
511
  position: relative;
@@ -1674,20 +1693,28 @@ button.home-project-inventory-row {
1674
1693
  margin-inline: var(--space-lg);
1675
1694
  }
1676
1695
 
1696
+ /* Card-scale title, not `.section-title`: that face is for band banners and
1697
+ was oversized once the three index panels sat in one row. Locked here so
1698
+ Sources / Network / Bibliography share one title register. */
1677
1699
  .home-index-title {
1678
1700
  margin: 0;
1701
+ font-family: var(--font-title);
1679
1702
  font-size: var(--type-card);
1703
+ font-weight: var(--weight-display);
1704
+ line-height: 1.2;
1705
+ color: var(--color-on-surface);
1706
+ font-synthesis: none;
1707
+ font-optical-sizing: auto;
1680
1708
  }
1681
1709
 
1682
- @media (min-width: 1024px) {
1683
- .home-index-title {
1684
- font-size: var(--type-card-lg);
1685
- }
1686
- }
1687
-
1688
- /* ── The plate ── */
1710
+ /* ── The plate ──
1711
+ Every panel grows its plate so the title + Open row lands on one baseline
1712
+ across Sources, Network, and Bibliography — the bib plate already flexed;
1713
+ the other two used to sit shorter and leave their titles higher. */
1689
1714
  .home-index-plate {
1690
1715
  align-self: stretch;
1716
+ flex: 1 1 auto;
1717
+ min-height: 0;
1691
1718
  margin: 0;
1692
1719
  background: var(--color-background);
1693
1720
  border-bottom: 1px solid var(--border-gentle-subtle);
@@ -1924,8 +1951,9 @@ html[data-theme="light"] .home-featured .ledger-poster {
1924
1951
  ends flush with the viewport and adds no scrollable width; this must stay
1925
1952
  below 768px, which is where .home-band-inner swaps that token for --gutter.
1926
1953
  The side hairlines go with it: a border pinned to the screen edge reads as a
1927
- frame cut off rather than as the plate's edge. Orientation still owns the
1928
- crop; object-fit: cover re-crops the image inside the wider box. */
1954
+ frame cut off rather than as the plate's edge. The plate stays 3:4, the
1955
+ same frame as the portrait file, so cover does not crop it again. A
1956
+ configured hero_landscape switches this query to 16:9 and contain. */
1929
1957
  @media (max-width: 767px) {
1930
1958
  .home-hero-portrait {
1931
1959
  max-width: none;
@@ -1952,8 +1980,6 @@ html[data-theme="light"] .home-featured .ledger-poster {
1952
1980
  #home-bib-panel .home-index-plate {
1953
1981
  display: flex;
1954
1982
  flex-direction: column;
1955
- flex: 1 1 auto;
1956
- min-height: 0;
1957
1983
  }
1958
1984
 
1959
1985
  #home-bib-panel .home-index-plate-head {
@@ -58,6 +58,7 @@
58
58
  is set in. */
59
59
  color: var(--color-primary);
60
60
  letter-spacing: 0.06em;
61
+ font-size: var(--type-body);
61
62
  }
62
63
 
63
64
  .projects-page .projects-state {
@@ -37,6 +37,7 @@
37
37
  the tag line should follow whatever `.page-accent--*` sets. */
38
38
  color: var(--color-primary);
39
39
  letter-spacing: 0.06em;
40
+ font-size: var(--type-body);
40
41
  }
41
42
 
42
43
  /* ══ Featured panel ══
@@ -24,7 +24,7 @@ module Pandorga
24
24
 
25
25
  gem "pandorga", "~> 1.3"
26
26
  # Local checkout: gem "pandorga", path: "../pandorga"
27
- # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.8.1"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.9.0"
28
28
  gem "csv"
29
29
  gem "base64"
30
30
  RUBY
@@ -15,6 +15,15 @@ module Pandorga
15
15
  FOLLOW = { subtitle: :body, code: :mono, cv_body: :body }.freeze
16
16
  MONO_FALLBACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace"
17
17
  FAMILY_NAME = /\A[\p{L}\p{N}][\p{L}\p{N} .+\-]*\z/
18
+ # Named faces defined in assets/css/base.css with size-adjust / ascent
19
+ # overrides so display=swap does not reflow the hero (home CLS).
20
+ METRIC_FALLBACK = {
21
+ "Cinzel Decorative" => "Pandorga Title Fallback",
22
+ "Cinzel" => "Pandorga Cinzel Fallback",
23
+ "Newsreader" => "Pandorga Body Fallback",
24
+ "Ancizar Serif" => "Pandorga Ancizar Fallback",
25
+ "Marcellus SC" => "Pandorga Label Fallback"
26
+ }.freeze
18
27
 
19
28
  DEFAULTS = {
20
29
  title: { family: "Cinzel Decorative", spec: "wght@400;700;900", fallback: "serif" },
@@ -78,8 +87,14 @@ module Pandorga
78
87
  face = resolved[role.to_sym]
79
88
  return "" unless face
80
89
 
81
- quoted = face[:family].to_s.empty? ? nil : "'#{escape_css(face[:family])}'"
82
- [quoted, face[:fallback].to_s.strip].compact.reject(&:empty?).join(", ")
90
+ family = face[:family].to_s
91
+ parts = []
92
+ parts << "'#{escape_css(family)}'" unless family.empty?
93
+ metric = METRIC_FALLBACK[family]
94
+ parts << "'#{escape_css(metric)}'" if metric
95
+ fallback = face[:fallback].to_s.strip
96
+ parts << fallback unless fallback.empty?
97
+ parts.join(", ")
83
98
  end
84
99
 
85
100
  def resolve_role(role, override, theme)
@@ -49,6 +49,9 @@ home:
49
49
  hero_art:
50
50
  type: string
51
51
  notes: Include path for hero decoration (default `page/hero-default.html`)
52
+ hero_landscape:
53
+ type: string
54
+ notes: Optional URL of a 16:9 image used at or below 767px. Home front matter `hero_landscape` overrides this key. Omitted, the phone plate stays 3:4
52
55
 
53
56
  fonts:
54
57
  title: "`pandorga.fonts`"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.8.1"
4
+ VERSION = "1.9.0"
5
5
  end
@@ -3,17 +3,38 @@
3
3
  # The home hero pool is the folder. The content export writes the public
4
4
  # paths to pages/home/portraits.json. The author uploads a photograph under
5
5
  # content/media/images/hero/ and does not keep a second list.
6
+ # Optional width variants live under variants/ so they are not extra draws.
7
+ # Variants should be 3:4 portrait fills (not landscape width-only resizes):
8
+ # the plate is object-fit cover at 3:4, including on a phone, so a 480×270
9
+ # landscape candidate is too short and looks soft. A separate 16:9 file for
10
+ # narrow viewports is pandorga.home.hero_landscape, not a pool variant.
6
11
 
7
12
  require "pathname"
8
13
 
9
14
  module HeroPortraits
10
15
  IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .webp .gif].freeze
16
+ WIDTHS = [480, 960, 1440].freeze
17
+ # Slightly above the plate CSS width (14/16rem at html 110%) so 1.75–2×
18
+ # DPR picks the next 3:4 variant instead of a soft 480w. Variants must be
19
+ # portrait fills; landscape width-only resizes are too short under cover.
20
+ SIZES = "(max-width: 767px) 100vw, (max-width: 1023px) 18rem, 20rem"
11
21
 
12
22
  module_function
13
23
 
14
24
  def entries(content_root)
15
- image_files(Pathname.new(content_root)).map do |file|
16
- { "src" => "/media/images/hero/#{file.basename}" }
25
+ root = Pathname.new(content_root)
26
+ image_files(root).map do |file|
27
+ entry = { "src" => "/media/images/hero/#{file.basename}" }
28
+ stem = file.basename.to_s.sub(/\.[^.]+\z/, "")
29
+ parts = WIDTHS.filter_map do |width|
30
+ variant = root.join("media/images/hero/variants/#{stem}-#{width}.jpg")
31
+ next unless variant.file?
32
+
33
+ "/media/images/hero/variants/#{variant.basename} #{width}w"
34
+ end
35
+ next entry if parts.empty?
36
+
37
+ entry.merge("srcset" => parts.join(", "), "sizes" => SIZES)
17
38
  end
18
39
  end
19
40
 
@@ -152,7 +152,7 @@ end
152
152
  warn "FAIL test-examples-build: #{name} home dropped the ledger font request"
153
153
  exit 1
154
154
  end
155
- unless html.include?("--font-title: 'Cinzel Decorative', serif")
155
+ unless html.include?("--font-title: 'Cinzel Decorative', 'Pandorga Title Fallback', serif")
156
156
  warn "FAIL test-examples-build: #{name} home dropped the title face"
157
157
  exit 1
158
158
  end
@@ -28,10 +28,23 @@ from_theme = Pandorga::Fonts.resolve(nil, theme)
28
28
  fail!("unset config dropped the ledger faces") unless Pandorga::Fonts.stylesheet_href(bare) == DEFAULT_HREF
29
29
  fail!("theme tokens drifted from the ledger faces") unless Pandorga::Fonts.stylesheet_href(from_theme) == DEFAULT_HREF
30
30
 
31
- fail!("title stack dropped Cinzel") unless Pandorga::Fonts.css_stack(from_theme, "title") == "'Cinzel Decorative', serif"
32
- fail!("body stack dropped the serif fallback") unless Pandorga::Fonts.css_stack(from_theme, "body") == "'Newsreader', Georgia, serif"
31
+ fail!("title stack dropped Cinzel") unless Pandorga::Fonts.css_stack(from_theme, "title") ==
32
+ "'Cinzel Decorative', 'Pandorga Title Fallback', serif"
33
+ fail!("body stack dropped the serif fallback") unless Pandorga::Fonts.css_stack(from_theme, "body") ==
34
+ "'Newsreader', 'Pandorga Body Fallback', Georgia, serif"
35
+ fail!("label stack dropped the metric fallback") unless Pandorga::Fonts.css_stack(from_theme, "label") ==
36
+ "'Marcellus SC', 'Pandorga Label Fallback', serif"
33
37
  fail!("mono stack dropped Courier Prime") unless Pandorga::Fonts.css_stack(from_theme, "mono").start_with?("'Courier Prime', ui-monospace")
34
38
 
39
+ site_faces = Pandorga::Fonts.resolve(
40
+ { "title" => "Cinzel", "body" => { "family" => "Ancizar Serif", "weights" => [300, 400, 600] } },
41
+ theme
42
+ )
43
+ fail!("Cinzel stack missed its metric fallback") unless Pandorga::Fonts.css_stack(site_faces, "title") ==
44
+ "'Cinzel', 'Pandorga Cinzel Fallback', serif"
45
+ fail!("Ancizar stack missed its metric fallback") unless Pandorga::Fonts.css_stack(site_faces, "body") ==
46
+ "'Ancizar Serif', 'Pandorga Ancizar Fallback', Georgia, serif"
47
+
35
48
  custom = Pandorga::Fonts.resolve(
36
49
  {
37
50
  "title" => "Fraunces",
@@ -54,6 +67,7 @@ broken = []
54
67
  Dir.glob(ROOT.join("**/*.{css,html,rb,js,mjs,md,liquid,yml}")).each do |path|
55
68
  next if path.include?("/studio/assets/")
56
69
  next if path.include?("/node_modules/")
70
+ next if path.include?("/_site/")
57
71
  next if path.end_with?("test-fonts.rb")
58
72
  text = File.read(path)
59
73
  next unless text.include?(token)
@@ -23,6 +23,7 @@ require_relative "../content/hero_portraits"
23
23
  ROOT = Pathname.new(__dir__).join("..", "..").expand_path
24
24
  EXPORT = ROOT.join("scripts/content/export-content-json.rb")
25
25
  LAYOUT = ROOT.join("_layouts/home.html")
26
+ SHELL = ROOT.join("_layouts/shell.html")
26
27
  HOME_CSS = ROOT.join("assets/css/ledger/home.css")
27
28
  RUNTIME = ROOT.join("_includes/content-runtime/60-fragments.html")
28
29
  SCHEMA = ROOT.join("studio/schema.yml")
@@ -96,9 +97,28 @@ check!(!layout.include?("profile-monologue"),
96
97
  "the deleted shell photograph must not be referenced")
97
98
  check!(!ROOT.join("assets/images/profile-monologue.JPG").exist?,
98
99
  "profile-monologue.JPG must not exist")
99
- hero_img = layout[/<img[^>]*data-content-hero-portrait[^>]*>/m]
100
- check!(hero_img && !hero_img.include?("src=") && hero_img.include?('alt=""'),
101
- "the plate starts without a photographic src and keeps an empty alt, got #{hero_img.inspect}")
100
+ hero_imgs = layout.scan(/<img[^>]*data-content-hero-portrait[^>]*>/m)
101
+ check!(hero_imgs.any?, "the plate image is missing")
102
+ hero_imgs.each do |hero_img|
103
+ check!(!hero_img.include?("src=") && hero_img.include?('alt=""'),
104
+ "the plate starts without a photographic src and keeps an empty alt, got #{hero_img.inspect}")
105
+ check!(hero_img.include?('fetchpriority="high"'),
106
+ "the hero image must request fetchpriority high, got #{hero_img.inspect}")
107
+ end
108
+ check!(layout.include?("page.hero_landscape") && layout.include?("site.pandorga.home.hero_landscape"),
109
+ "narrow hero reads front matter and pandorga.home.hero_landscape")
110
+ check!(layout.include?('media="(max-width: 767px)"') && layout.include?("<picture>"),
111
+ "a configured landscape file is a picture source for narrow viewports")
112
+ check!(layout.include?("data-hero-landscape"),
113
+ "the image carries the landscape URL for the runtime")
114
+ shell = SHELL.read
115
+ check!(shell.include?('rel="preload" as="image"') && shell.include?('fetchpriority="high"') &&
116
+ shell.include?('media="(max-width: 767px)"') && shell.include?("hero_landscape"),
117
+ "the shell preloads a configured landscape image on narrow viewports")
118
+ tailwind_at = shell.index('href="/assets/css/tailwind.css"')
119
+ icons_at = shell.index("window.__pandorgaIconsHref")
120
+ check!(tailwind_at && icons_at && tailwind_at < icons_at,
121
+ "tailwind must be linked before the icon scripts")
102
122
 
103
123
  home_css = HOME_CSS.read
104
124
  check!(home_css.include?("background: black"),
@@ -109,6 +129,14 @@ check!(plate_img&.match?(/opacity:\s*0/) && plate_img.match?(/transition:[^;]*op
109
129
  shown = home_css[/^\.home-hero-plate img\.is-shown \{.*?\n\}/m]
110
130
  check!(shown&.match?(/opacity:\s*1/),
111
131
  "a ready photograph fades to full opacity, got #{shown.inspect}")
132
+ check!(!home_css.include?("aspect-ratio: 4 / 3"),
133
+ "the phone plate must not cover-crop the portrait into 4:3")
134
+ landscape_plate = home_css[/@media \(max-width: 767px\) \{\s*\.home-hero-plate--landscape \{[^}]*\}/m]
135
+ check!(landscape_plate&.include?("aspect-ratio: 16 / 9"),
136
+ "a landscape plate is 16:9 on a phone, got #{landscape_plate.inspect}")
137
+ landscape_img = home_css[/\.home-hero-plate--landscape img \{[^}]*\}/m]
138
+ check!(landscape_img&.include?("object-fit: contain") && landscape_img.include?("opacity: 1"),
139
+ "the 16:9 file is shown whole and can paint without the portrait fade, got #{landscape_img.inspect}")
112
140
 
113
141
  runtime = RUNTIME.read
114
142
  start = runtime.index("function heroPortraitEntries")
@@ -125,6 +153,20 @@ onload = body[/probe\.onload = function \(\) \{.*?\n \};/m]
125
153
  check!(onload&.include?("img.src = nextSrc") && onload.include?("img.alt = nextAlt") &&
126
154
  onload.include?("img.classList.add('is-shown')"),
127
155
  "src, alt, and the fade-in must be applied together after load, got #{onload.inspect}")
156
+ check!(onload && onload.index("applyHeroPortraitSources") && onload.index("img.src = nextSrc") &&
157
+ onload.index("applyHeroPortraitSources") < onload.index("img.src = nextSrc"),
158
+ "srcset must be applied before src so the full file is not the first request")
159
+ check!(body.include?("probe.fetchPriority = 'high'"),
160
+ "the portrait probe must use fetchPriority high")
161
+ check!(body.include?("heroLandscapeActive") && body.include?("data-hero-landscape"),
162
+ "a narrow viewport with hero_landscape must not swap in a portrait")
163
+ check!(runtime.include?("hydrateHeroPortrait();"),
164
+ "hydratePageFragments must draw the plate before the later fragment chain")
165
+ plate_call = runtime.index("function hydratePageFragments")
166
+ later = runtime.index("hydrateHomeSections();", plate_call || 0)
167
+ check!(plate_call && runtime.index("hydrateHeroPortrait();", plate_call) &&
168
+ runtime.index("hydrateHeroPortrait();", plate_call) < later,
169
+ "the plate draw must start at the beginning of hydratePageFragments")
128
170
  onerror = body[/probe\.onerror = function \(\) \{.*?\n \};/m]
129
171
  check!(onerror && !onerror.include?("img.src") && !onerror.include?("is-shown"),
130
172
  "a failed image load must leave the plate black, got #{onerror.inspect}")
data/scripts/validate.sh CHANGED
@@ -68,6 +68,7 @@ TESTS=(
68
68
  scripts/test/test-fonts.rb
69
69
  scripts/test/test-css-delivery.rb
70
70
  scripts/test/test-discovery.rb
71
+ scripts/test/test-hero-portraits.rb
71
72
  )
72
73
 
73
74
  failed=0
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pandorga
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.1
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors