pandorga 1.9.1 → 1.10.1

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: a5c4864b0ecb92d3a9cceac946312495c0371449bbb2bc42077606bff30f704f
4
- data.tar.gz: cb0dbfac5fb52318dafb2d07f9f8621d480220cd38bcef92d1d15eceac6b5784
3
+ metadata.gz: 12f24b429006ae62f3e74335afdf759a851068879cc639273a77d5cc95e8d9fd
4
+ data.tar.gz: efa405146c9212bcdff063abfbcadc8b03c5a222679894acd9db8dbeea522c92
5
5
  SHA512:
6
- metadata.gz: ccf07024fda631b0a2ee24a9a51f6dbf865cd93e8f9b141e6593d3512b478f119e8b2e43e7ed2a20730247c5752f2a4ffcdd4a4de58248b4bdb5c79cc8b5bc74
7
- data.tar.gz: 9106591a4826bd84bbaa006dae1a6bec544313956e9775830363a1e356ec5a986ef9e173a93495dc9704c55f7ba862ef45dad2f81a1cd399a227979aab05c5a1
6
+ metadata.gz: 2dbd18257d2507115b35b5370d0ef9e1da9e289cab3eb6861aa52532e1927a23fe834120c689a74d0331631ac929d0f88c71fe38b93563415a98de3d2e6176e9
7
+ data.tar.gz: 431866457e1c4f3d7945f6756b9331f9191923b6e353411b98540c9adcfdad75b80ad4bfaefd6200de6057cb4ca2021c94097b45c9fdd601d25dc7c99879aae2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.1] — 2026-10-14
4
+
5
+ ### Changed
6
+
7
+ - On article, draft, product, and project detail pages, the category pills
8
+ keep `--space-md` above and below, on a phone and in the desktop dossier
9
+ - The description under the title stays the body face at `--type-prose`,
10
+ and uses `--color-on-surface-variant`, the muted ink of secondary text
11
+ - On a phone, the contents dropdown links use `--type-prose`. The gap above
12
+ that dropdown is `--space-sm`. The gap under it stays `--space-md`, the
13
+ same inset as the pills. The date text keeps `--space-sm` under its own
14
+ rule and above the action rule
15
+
16
+ ## [1.10.0] — 2026-10-14
17
+
18
+ ### Added
19
+
20
+ - A portrait entry in `pages/home/portraits.json` may set `landscape`, a
21
+ 16:9 URL. At or below 767px the home hero draws at random from the
22
+ entries that have one and paints that file. `pandorga.home.hero_landscape`
23
+ is the fallback when none do. The phone plate stays 16:9
24
+
25
+ ### Changed
26
+
27
+ - On article, draft, product, and project detail pages, category and tag
28
+ chips sit above the title
29
+ - On a phone, the detail title sits closer to the nav (`pt-10` to
30
+ `--space-xl`). The gaps inside the title block and before the contents
31
+ list each drop one spacing token. The contents list keeps `--space-md`
32
+ under it, inside the title plate. The stacked gap between that plate
33
+ and the reading column is `--space-md`. Desktop column gutter stays
34
+ 2rem, and 3rem from 1024px
35
+ - Article and draft section headings grow by 2pt on `h2` and 1pt on `h3`.
36
+ The same sizes apply to project and product bodies
37
+
3
38
  ## [1.9.1] — 2026-10-14
4
39
 
5
40
  ### Fixed
@@ -6,6 +6,16 @@
6
6
  var description = fm.description || fm.tagline || '';
7
7
  var parts = ['<div class="md:sticky md:top-32 text-detail-sidebar">'];
8
8
 
9
+ var chips = '';
10
+ if (typeof renderTagChips === 'function') {
11
+ var tagBase = { posts: '/pages/blog/', articles: '/pages/articles/',
12
+ project: '/pages/projects/' }[fm.layout === 'project' ? 'project' :
13
+ (fm.layout === 'post' ? 'posts' : (fm.layout === 'article' ? 'articles' : ''))];
14
+ chips = renderTagChips(fm.tags, tagBase ? {
15
+ hrefFor: function (tag) { return tagBase + '?tag=' + encodeURIComponent(tag); }
16
+ } : undefined) || '';
17
+ }
18
+
9
19
  if (title) {
10
20
  parts.push('<div class="article-sidebar-heading mb-8">');
11
21
  if (fm.icon) {
@@ -14,6 +24,11 @@
14
24
  it green there regardless. */
15
25
  parts.push('<span class="material-symbols-outlined text-5xl md:text-6xl text-primary mb-4 block leading-none" aria-hidden="true">' + escapeHtml(fm.icon) + '</span>');
16
26
  }
27
+ /* Classification sits above the title, as a kicker. Tags replaced
28
+ category on articles and posts; an entry that still has category
29
+ keeps that line with the chips. */
30
+ if (fm.category) parts.push('<span class="text-tertiary font-bold">' + escapeHtml(fm.category) + '</span>');
31
+ if (chips) parts.push(chips);
17
32
  parts.push('<h1 class="text-title">' + escapeHtml(title) + '</h1>');
18
33
  if (fm.layout === 'post' && fm.thumbnail) {
19
34
  parts.push(
@@ -35,20 +50,8 @@
35
50
 
36
51
  parts.push('<div class="flex flex-col gap-6 text-tertiary font-label text-xs article-meta mb-8">');
37
52
  parts.push('<div class="flex flex-col gap-2">');
38
- // Tags replaced category on articles and posts; resources still classify
39
- // on category, so both are read.
40
- if (fm.category) parts.push('<span class="text-tertiary font-bold">' + escapeHtml(fm.category) + '</span>');
41
- // The private Liquid path renders these as links into the listing
42
- // filter; rendering them as inert spans here gave the same page two
43
- // different affordances depending on which path drew it.
44
- if (typeof renderTagChips === 'function') {
45
- var tagBase = { posts: '/pages/blog/', articles: '/pages/articles/',
46
- project: '/pages/projects/' }[fm.layout === 'project' ? 'project' :
47
- (fm.layout === 'post' ? 'posts' : (fm.layout === 'article' ? 'articles' : ''))];
48
- parts.push(renderTagChips(fm.tags, tagBase ? {
49
- hrefFor: function (tag) { return tagBase + '?tag=' + encodeURIComponent(tag); }
50
- } : undefined));
51
- }
53
+ if (!title && fm.category) parts.push('<span class="text-tertiary font-bold">' + escapeHtml(fm.category) + '</span>');
54
+ if (!title && chips) parts.push(chips);
52
55
  if (dateHtml) parts.push('<div class="mt-1">' + dateHtml + '</div>');
53
56
  if (fm.layout === 'product') {
54
57
  if (fm.company) parts.push('<span class="text-secondary font-bold">' + escapeHtml(fm.company) + '</span>');
@@ -519,6 +519,8 @@
519
519
  // content/media/images/hero/. One entry is chosen per document load.
520
520
  // A file that fails to load is skipped. The plate stays black until one
521
521
  // pool file has loaded, and stays black when every entry fails.
522
+ // At or below 767px the draw uses entries that carry `landscape` (a 16:9
523
+ // URL). The single data-hero-landscape URL is the fallback when none do.
522
524
  function heroPortraitEntries(data) {
523
525
  var list = Array.isArray(data) ? data : [];
524
526
  var entries = [];
@@ -536,6 +538,10 @@
536
538
  var sizes = typeof entry.sizes === 'string' ? entry.sizes.trim() : '';
537
539
  if (srcset) record.srcset = srcset;
538
540
  if (sizes) record.sizes = sizes;
541
+ // Optional 16:9 file for the phone plate. Absent, the entry stays a
542
+ // portrait and the narrow draw skips it.
543
+ var landscape = typeof entry.landscape === 'string' ? entry.landscape.trim() : '';
544
+ if (landscape) record.landscape = landscape;
539
545
  entries.push(record);
540
546
  }
541
547
  return entries;
@@ -595,6 +601,16 @@
595
601
  });
596
602
  }
597
603
 
604
+ function heroLandscapeEntries(entries) {
605
+ var list = [];
606
+ var i;
607
+ var rows = entries || [];
608
+ for (i = 0; i < rows.length; i++) {
609
+ if (rows[i] && rows[i].landscape) list.push(rows[i]);
610
+ }
611
+ return list;
612
+ }
613
+
598
614
  function showHeroPortrait(img, entries) {
599
615
  var chosen = pickHeroPortrait(entries, Math.random);
600
616
  if (!chosen) return Promise.resolve();
@@ -613,28 +629,123 @@
613
629
  return next();
614
630
  }
615
631
 
616
- function heroLandscapeActive(img) {
632
+ function configuredLandscape(img) {
617
633
  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;
634
+ url = url ? String(url).trim() : '';
635
+ return url;
620
636
  }
621
637
 
622
- function clearHeroSources(img) {
623
- img.removeAttribute('src');
624
- img.removeAttribute('srcset');
625
- img.removeAttribute('sizes');
626
- img.alt = '';
638
+ function heroPlate(img) {
639
+ var node = img && img.parentElement;
640
+ while (node) {
641
+ if (node.classList && node.classList.contains('home-hero-plate')) return node;
642
+ node = node.parentElement;
643
+ }
644
+ return null;
645
+ }
646
+
647
+ function setNarrowSource(img, url) {
648
+ var picture = img.parentElement;
649
+ if (!picture || String(picture.tagName).toUpperCase() !== 'PICTURE') return;
650
+ var source = picture.querySelector('source');
651
+ if (!url) {
652
+ if (source) source.removeAttribute('srcset');
653
+ return;
654
+ }
655
+ if (!source) {
656
+ source = document.createElement('source');
657
+ source.setAttribute('media', '(max-width: 767px)');
658
+ picture.insertBefore(source, img);
659
+ }
660
+ source.setAttribute('srcset', url);
661
+ }
662
+
663
+ function applyHeroLandscapeFile(img, chosen) {
664
+ var nextSrc = resolveAssetUrl(chosen && (chosen.landscape || chosen.src));
665
+ var nextAlt = (chosen && chosen.alt) || '';
666
+ if (!nextSrc) return Promise.resolve(false);
667
+ if (img.getAttribute('src') === nextSrc) {
668
+ img.alt = nextAlt;
669
+ img.removeAttribute('srcset');
670
+ img.removeAttribute('sizes');
671
+ setNarrowSource(img, nextSrc);
672
+ img.classList.add('is-shown');
673
+ return Promise.resolve(true);
674
+ }
675
+ return new Promise(function (resolve) {
676
+ var probe = new Image();
677
+ probe.fetchPriority = 'high';
678
+ probe.onload = function () {
679
+ img.alt = nextAlt;
680
+ img.removeAttribute('srcset');
681
+ img.removeAttribute('sizes');
682
+ setNarrowSource(img, nextSrc);
683
+ img.fetchPriority = 'high';
684
+ img.src = nextSrc;
685
+ img.classList.add('is-shown');
686
+ resolve(true);
687
+ };
688
+ probe.onerror = function () {
689
+ resolve(false);
690
+ };
691
+ probe.src = nextSrc;
692
+ });
693
+ }
694
+
695
+ function showHeroLandscape(img, entries) {
696
+ var chosen = pickHeroPortrait(entries, Math.random);
697
+ if (!chosen) return Promise.resolve(false);
698
+ var start = entries.indexOf(chosen);
699
+ var ordered = entries.slice(start).concat(entries.slice(0, start));
700
+ var index = 0;
701
+ function next() {
702
+ if (index >= ordered.length) return Promise.resolve(false);
703
+ var entry = ordered[index];
704
+ index += 1;
705
+ return applyHeroLandscapeFile(img, entry).then(function (ok) {
706
+ if (ok) return true;
707
+ return next();
708
+ });
709
+ }
710
+ return next();
711
+ }
712
+
713
+ function showConfiguredLandscape(img) {
714
+ var url = configuredLandscape(img);
715
+ if (!url) return Promise.resolve(false);
716
+ var plate = heroPlate(img);
717
+ if (plate) plate.classList.add('home-hero-plate--landscape');
718
+ return applyHeroLandscapeFile(img, { landscape: url, alt: '' });
719
+ }
720
+
721
+ function paintNarrowHero(img) {
722
+ img.classList.remove('is-shown');
723
+ return loadJson('pages/home/portraits.json').then(function (data) {
724
+ var entries = heroPortraitEntries(data);
725
+ var landscapes = heroLandscapeEntries(entries);
726
+ if (landscapes.length) {
727
+ var plate = heroPlate(img);
728
+ if (plate) plate.classList.add('home-hero-plate--landscape');
729
+ return showHeroLandscape(img, landscapes).then(function (ok) {
730
+ if (ok) return;
731
+ return showConfiguredLandscape(img);
732
+ });
733
+ }
734
+ if (configuredLandscape(img)) return showConfiguredLandscape(img);
735
+ return showHeroPortrait(img, entries);
736
+ }).catch(function (error) {
737
+ console.error(error);
738
+ if (configuredLandscape(img)) return showConfiguredLandscape(img);
739
+ });
627
740
  }
628
741
 
629
742
  function watchHeroFrame(img) {
630
- var url = img.getAttribute('data-hero-landscape');
631
- if (!url || !String(url).trim() || !window.matchMedia) return;
743
+ if (!window.matchMedia) return;
632
744
  var mq = window.matchMedia('(max-width: 767px)');
633
745
  if (!mq.addEventListener) return;
634
746
  mq.addEventListener('change', function () {
635
747
  if (mq.matches) {
636
- clearHeroSources(img);
637
- img.classList.add('is-shown');
748
+ paintNarrowHero(img);
638
749
  return;
639
750
  }
640
751
  img.classList.remove('is-shown');
@@ -650,7 +761,9 @@
650
761
  var img = document.querySelector('[data-content-hero-portrait]');
651
762
  if (!img) return Promise.resolve();
652
763
  watchHeroFrame(img);
653
- if (heroLandscapeActive(img)) return Promise.resolve();
764
+ if (window.matchMedia && window.matchMedia('(max-width: 767px)').matches) {
765
+ return paintNarrowHero(img);
766
+ }
654
767
  return loadJson('pages/home/portraits.json').then(function (data) {
655
768
  return showHeroPortrait(img, heroPortraitEntries(data));
656
769
  }).catch(function (error) {
@@ -413,6 +413,8 @@ layout: shell
413
413
  html += '<span class="shard-accent projects-hero-shard" aria-hidden="true"></span>';
414
414
  html += '<div class="projects-hero-body">';
415
415
  if (line) html += line;
416
+ var chips = runtime.renderTagChips(fm.tags, { hrefFor: tagHref });
417
+ if (chips) html += chips;
416
418
  html += '<h1 class="projects-hero-title">' + esc(name) + '</h1>';
417
419
  if (fm.title && fm.title !== name) {
418
420
  html += '<p class="projects-hero-subtitle">' + esc(fm.title) + '</p>';
@@ -464,9 +466,6 @@ layout: shell
464
466
  esc(String(fm.external_url).replace(/^https?:\/\/(www\.)?/i, '').replace(/\/+$/, '')) +
465
467
  '</a>');
466
468
  }
467
- var chips = runtime.renderTagChips(fm.tags, { hrefFor: tagHref });
468
- if (chips) rows += dossierRow('Tags', chips);
469
-
470
469
  var html = '<div class="lg:sticky lg:top-32 projects-dossier">';
471
470
  if (rows) {
472
471
  html += '<p class="projects-dossier-title">Dossier</p>';
data/_layouts/text.html CHANGED
@@ -66,11 +66,26 @@ layout: shell
66
66
  <div class="grid grid-cols-1 gap-8 lg:gap-12 items-start text-detail-grid">
67
67
  <aside>
68
68
  <div class="md:sticky md:top-32 text-detail-sidebar">
69
+ {% capture detail_tags %}
70
+ {% if page.tags and page.tags.size > 0 %}
71
+ <div class="ledger-chip-row">
72
+ {% for tag in page.tags %}
73
+ {% if tag_base %}
74
+ <a class="ledger-chip" href="{{ tag_base }}{{ tag | url_encode }}">{{ tag }}</a>
75
+ {% else %}
76
+ <span class="ledger-chip">{{ tag }}</span>
77
+ {% endif %}
78
+ {% endfor %}
79
+ </div>
80
+ {% endif %}
81
+ {% endcapture %}
82
+
69
83
  {% if page.collection == "posts" or page.collection == "articles" or page.layout == "product" or page.layout == "project" %}
70
84
  <div class="article-sidebar-heading mb-8">
71
85
  {% if page.icon %}
72
86
  <span class="material-symbols-outlined text-5xl md:text-6xl text-secondary mb-4 block leading-none" aria-hidden="true">{{ page.icon }}</span>
73
87
  {% endif %}
88
+ {{ detail_tags }}
74
89
  <h1 class="text-title">{{ page.title }}</h1>
75
90
  {% if page.collection == "posts" and page.thumbnail %}
76
91
  <figure class="article-sidebar-thumbnail">
@@ -87,20 +102,12 @@ layout: shell
87
102
  <div class="flex flex-col gap-6 text-tertiary font-label text-xs article-meta mb-8">
88
103
  <div class="flex flex-col gap-2">
89
104
  {% comment %}
90
- Classification. `tags` replaced `category` on articles and posts;
91
- an entry with no tags renders no chip row at all.
105
+ Classification sits in the heading, above the title. Collections
106
+ that do not render that heading keep the chips here.
92
107
  {% endcomment %}
93
- {% if page.tags and page.tags.size > 0 %}
94
- <div class="ledger-chip-row">
95
- {% for tag in page.tags %}
96
- {% if tag_base %}
97
- <a class="ledger-chip" href="{{ tag_base }}{{ tag | url_encode }}">{{ tag }}</a>
98
- {% else %}
99
- <span class="ledger-chip">{{ tag }}</span>
100
- {% endif %}
101
- {% endfor %}
102
- </div>
103
- {% endif %}
108
+ {% unless page.collection == "posts" or page.collection == "articles" or page.layout == "product" or page.layout == "project" %}
109
+ {{ detail_tags }}
110
+ {% endunless %}
104
111
 
105
112
  {% if page.collection == "posts" or page.collection == "articles" %}
106
113
  {% include page/date_evolution.html stacked=true %}
@@ -73,13 +73,13 @@
73
73
  }
74
74
 
75
75
  .article-content h2 {
76
- /* Same step ### used to take. */
77
- font-size: var(--type-card-lg);
76
+ /* Card step, plus 2pt. detail.css owns the same size when it loads. */
77
+ font-size: calc(var(--type-card-lg) + 2pt);
78
78
  }
79
79
 
80
80
  .article-content h3 {
81
- /* Halfway between ## and the reading size. */
82
- font-size: calc((var(--type-card-lg) + var(--type-prose)) / 2);
81
+ /* Halfway between ## and the reading size, plus 1pt. */
82
+ font-size: calc((var(--type-card-lg) + var(--type-prose)) / 2 + 1pt);
83
83
  }
84
84
 
85
85
  .article-content h4 {
@@ -749,7 +749,7 @@ body.figure-lightbox-open {
749
749
  font-weight: var(--weight-body);
750
750
  line-height: var(--leading-body);
751
751
  letter-spacing: normal;
752
- color: var(--color-legend);
752
+ color: var(--color-on-surface-variant);
753
753
  margin: 0;
754
754
  text-transform: none;
755
755
  }
@@ -923,7 +923,7 @@ body.figure-lightbox-open {
923
923
  .text-page-main[data-dynamic-article-detail],
924
924
  .text-page-main[data-dynamic-post-detail],
925
925
  .text-page-main[data-dynamic-portfolio-detail] {
926
- margin-top: 2.5rem;
926
+ margin-top: 0;
927
927
  }
928
928
  }
929
929
 
@@ -403,6 +403,27 @@ aside:has(> .text-detail-sidebar) {
403
403
  border-bottom: 0;
404
404
  }
405
405
 
406
+ /* Tag and category lines are the kicker, above the title. */
407
+ .text-detail-sidebar .article-sidebar-heading > span.text-tertiary {
408
+ display: block;
409
+ margin: 0 0 var(--space-xs);
410
+ font-family: var(--font-label);
411
+ font-size: var(--type-label-lg);
412
+ letter-spacing: 0.04em;
413
+ color: var(--color-tertiary);
414
+ }
415
+
416
+ /* The pills are the only thing between the plate edge and the title when
417
+ they lead the block, so the same token above and below centres them.
418
+ An icon above the row already leaves --space-md (its mb-4). */
419
+ .text-detail-sidebar .article-sidebar-heading:has(> .ledger-chip-row:first-child) {
420
+ padding-top: var(--space-md);
421
+ }
422
+
423
+ .text-detail-sidebar .article-sidebar-heading .ledger-chip-row {
424
+ margin: 0 0 var(--space-md);
425
+ }
426
+
406
427
  /* Canonical shard, bottom-right, as in the mockup. Decoration only: it is
407
428
  suppressed below the tablet breakpoint like every other shard. */
408
429
  .text-detail-sidebar .article-sidebar-heading::after {
@@ -463,15 +484,15 @@ aside:has(> .text-detail-sidebar) {
463
484
  color: var(--color-secondary);
464
485
  }
465
486
 
466
- /* Lead thesis. `description` is the article's own one-sentence claim — body
467
- face at the prose size, in legend ink so it sits under the H1. */
487
+ /* Lead thesis. `description` is the article's own one-sentence claim — the
488
+ body face at the prose size, in the same muted ink as other secondary text. */
468
489
  .text-detail-sidebar .article-sidebar-description {
469
490
  font-family: var(--font-body);
470
491
  font-style: normal;
471
492
  font-size: var(--type-prose);
472
493
  font-weight: var(--weight-body);
473
494
  line-height: var(--leading-body);
474
- color: var(--color-legend);
495
+ color: var(--color-on-surface-variant);
475
496
  margin: var(--space-md) 0 0;
476
497
  }
477
498
 
@@ -638,6 +659,42 @@ aside:has(> .text-detail-sidebar) {
638
659
  background: var(--color-surface-container-low);
639
660
  }
640
661
 
662
+ @media (max-width: 1023px) {
663
+ .text-detail-sidebar .article-toc--disclosure,
664
+ .projects-dossier .article-toc--disclosure {
665
+ margin-top: var(--space-md);
666
+ margin-bottom: var(--space-md);
667
+ }
668
+ }
669
+
670
+ /* Phone: the dropdown is the contents control. Its links match the reading
671
+ size. The gap above it shrinks; the gap under it stays the pill inset. */
672
+ @media (max-width: 767px) {
673
+ .text-detail-sidebar .article-toc--disclosure .article-toc-link,
674
+ .projects-dossier .article-toc--disclosure .article-toc-link {
675
+ font-size: var(--type-prose);
676
+ }
677
+
678
+ .text-detail-sidebar:has(.article-toc--disclosure) .article-meta,
679
+ .projects-dossier:has(.article-toc--disclosure) .article-meta,
680
+ .projects-page .projects-dossier:has(.article-toc--disclosure) .projects-dossier-rows {
681
+ padding-bottom: 0;
682
+ }
683
+
684
+ .text-detail-sidebar .article-toc--disclosure,
685
+ .projects-dossier .article-toc--disclosure {
686
+ margin-top: var(--space-sm);
687
+ }
688
+ }
689
+
690
+ /* The desktop contents card is the last block in the dossier when Related
691
+ Texts are absent, so the same inset keeps its border off the plate edge.
692
+ Related Texts bring a larger top margin, and the two margins collapse. */
693
+ .text-detail-sidebar .article-toc--sidebar,
694
+ .projects-dossier .article-toc--sidebar {
695
+ margin-bottom: var(--space-md);
696
+ }
697
+
641
698
  .article-toc-summary {
642
699
  padding: var(--space-sm) var(--space-md);
643
700
  font-weight: 400;
@@ -692,13 +749,13 @@ aside:has(> .text-detail-sidebar) {
692
749
  to the paragraph under it to stop the eye on its own, and the hairline was
693
750
  doing most of the work. h3 and the sidebar h1 move with it below.
694
751
 
695
- ## takes the card step (`--type-card-lg`), the size ### used to have.
696
- ### is the midpoint of that step and the reading size. #### is the reading
752
+ ## is the card step (`--type-card-lg`) plus 2pt.
753
+ ### is the midpoint of that step and the reading size, plus 1pt. #### is the reading
697
754
  size itself (`--type-prose`), so it matches the paragraph and still reads
698
755
  as a heading by weight. */
699
756
  .article-content h2 {
700
757
  font-family: var(--font-content-title);
701
- font-size: var(--type-card-lg);
758
+ font-size: calc(var(--type-card-lg) + 2pt);
702
759
  font-weight: var(--weight-title);
703
760
  font-synthesis: none;
704
761
  line-height: 1.25;
@@ -724,8 +781,8 @@ aside:has(> .text-detail-sidebar) {
724
781
  over a sentence-shaped heading they shout. */
725
782
  .article-content h3 {
726
783
  font-family: var(--font-content-title);
727
- /* Midpoint of ## (--type-card-lg) and #### (--type-prose). */
728
- font-size: calc((var(--type-card-lg) + var(--type-prose)) / 2);
784
+ /* Midpoint of ## (--type-card-lg) and #### (--type-prose), plus 1pt. */
785
+ font-size: calc((var(--type-card-lg) + var(--type-prose)) / 2 + 1pt);
729
786
  font-weight: var(--weight-title);
730
787
  font-synthesis: none;
731
788
  letter-spacing: normal;
@@ -1106,6 +1163,29 @@ nav.text-page-nav:not(:has(a)) {
1106
1163
  /* ══ Column rhythm at the breakpoints ══ */
1107
1164
  @media (max-width: 767px) {
1108
1165
 
1166
+ /* The listing shell's pt-10 is 2.5rem. On a phone detail the title sits
1167
+ closer to the nav. Listings keep pt-10. */
1168
+ main:has(#articles-detail:not(.hidden),
1169
+ #blog-detail:not(.hidden),
1170
+ #projects-detail:not(.hidden),
1171
+ #cv-detail:not(.hidden)) {
1172
+ padding-top: var(--space-xl);
1173
+ }
1174
+
1175
+ .grid.text-detail-grid {
1176
+ gap: var(--space-md);
1177
+ }
1178
+
1179
+ /* The reading column used to add its own top margin on top of this gap,
1180
+ so the two plates sat 3.5rem apart. The gap alone is the separation. */
1181
+ .text-page-main:is([data-dynamic-article-detail],
1182
+ [data-dynamic-post-detail],
1183
+ [data-dynamic-project-detail],
1184
+ [data-dynamic-product-detail],
1185
+ [data-dynamic-portfolio-detail]) {
1186
+ margin-top: 0;
1187
+ }
1188
+
1109
1189
  .text-page-main,
1110
1190
  .text-reading-column,
1111
1191
  .article-content {
@@ -1119,6 +1199,27 @@ nav.text-page-nav:not(:has(a)) {
1119
1199
  padding-right: var(--space-md);
1120
1200
  }
1121
1201
 
1202
+ /* One token tighter between the kicker, title, thesis, and the meta
1203
+ block. Desktop keeps the panel's --space-lg / --space-md rhythm: the
1204
+ dossier is a narrow column beside the reading measure, and the same
1205
+ cut presses the thesis against the title. */
1206
+ .text-detail-sidebar .article-sidebar-heading {
1207
+ padding-bottom: var(--space-md);
1208
+ }
1209
+
1210
+ /* The date row's own top rule leaves --space-sm above the text. The
1211
+ flex gap was stacking another --space-md on the action rule, so the
1212
+ date sat high in that band. Drop the gap and the action margin is the
1213
+ matching --space-sm under the text. */
1214
+ .text-detail-sidebar .article-meta {
1215
+ gap: 0;
1216
+ }
1217
+
1218
+ .text-detail-sidebar .article-sidebar-description,
1219
+ .text-detail-sidebar .article-sidebar-thumbnail {
1220
+ margin-top: var(--space-sm);
1221
+ }
1222
+
1122
1223
  /* A four-column benchmark table wanted 468px inside a 355px column at 390px
1123
1224
  and pushed the whole document sideways — every page on the site got a
1124
1225
  horizontal scrollbar because of one article. The ledger cell padding is
@@ -372,8 +372,8 @@
372
372
  Beside the copy on a wide page the plate is portrait (3:4): its height
373
373
  matches the left column and the width is W = 3/4 × H. On a phone it stays
374
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
+ hero_landscape is set, or a portrait entry has its own landscape, the
376
+ phone plate is 16:9 and the chosen 16:9 file is shown whole. */
377
377
  .home-hero-plate {
378
378
  position: relative;
379
379
  z-index: 1;
@@ -472,8 +472,10 @@ html[data-theme="light"] .home-hero-plate {
472
472
  opacity: 1;
473
473
  }
474
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. */
475
+ /* 16:9 file in a 16:9 plate. contain shows the whole frame. The image stays
476
+ hidden until the chosen file has loaded, so a fallback source does not
477
+ flash before a pool landscape replaces it. The plate is already 16:9, so
478
+ the wait does not move the layout. transition none so the paint is not a fade. */
477
479
  @media (max-width: 767px) {
478
480
  .home-hero-plate--landscape {
479
481
  aspect-ratio: 16 / 9;
@@ -482,9 +484,13 @@ html[data-theme="light"] .home-hero-plate {
482
484
  .home-hero-plate--landscape img {
483
485
  object-fit: contain;
484
486
  object-position: center center;
485
- opacity: 1;
487
+ opacity: 0;
486
488
  transition: none;
487
489
  }
490
+
491
+ .home-hero-plate--landscape img.is-shown {
492
+ opacity: 1;
493
+ }
488
494
  }
489
495
 
490
496
  .home-hero-plate:hover img {
@@ -241,6 +241,7 @@
241
241
  .projects-page .projects-lead-subtitle,
242
242
  .projects-page .projects-hero-subtitle {
243
243
  margin: 0;
244
+ font-family: var(--font-body);
244
245
  color: var(--color-on-surface-variant);
245
246
  letter-spacing: 0.02em;
246
247
  }
@@ -459,9 +460,10 @@
459
460
 
460
461
  .projects-page .projects-hero-standfirst {
461
462
  margin: var(--space-md) 0 0;
463
+ font-family: var(--font-body);
462
464
  font-size: var(--type-subtitle);
463
465
  line-height: var(--leading-body);
464
- color: var(--color-legend);
466
+ color: var(--color-on-surface-variant);
465
467
  max-width: var(--reading-measure);
466
468
  }
467
469
 
@@ -539,6 +541,49 @@
539
541
  which start at 768px. A project holds the split back to 1024px for the
540
542
  reason recorded in the layout: at tablet width the dossier is narrow enough
541
543
  to break a repository slug across three lines mid-word. */
544
+ @media (max-width: 767px) {
545
+ .projects-page .projects-hero-body {
546
+ padding: var(--space-lg);
547
+ }
548
+
549
+ .projects-page .projects-hero-title {
550
+ margin-top: var(--space-xs);
551
+ }
552
+
553
+ .projects-page .projects-hero-standfirst {
554
+ margin-top: var(--space-sm);
555
+ }
556
+ }
557
+
558
+ /* Tag row centred between the hero edge and the title. The column is a
559
+ flex stack with a --space-xs gap, so the margins make up the rest of
560
+ --space-md on each side. */
561
+ .projects-page .projects-hero-body > .ledger-chip-row {
562
+ margin-bottom: calc(var(--space-md) - var(--space-xs));
563
+ }
564
+
565
+ .projects-page .projects-hero-body > :not(.ledger-chip-row) + .ledger-chip-row {
566
+ margin-top: calc(var(--space-md) - var(--space-xs));
567
+ }
568
+
569
+ .projects-page .projects-hero-body > .ledger-chip-row + .projects-hero-title {
570
+ margin-top: 0;
571
+ }
572
+
573
+ .projects-page .projects-hero-body:has(> .ledger-chip-row:first-child) {
574
+ padding-top: var(--space-md);
575
+ }
576
+
577
+ @media (max-width: 1023px) {
578
+ .projects-page .projects-hero {
579
+ margin-bottom: var(--space-xl);
580
+ }
581
+
582
+ .projects-page .grid.text-detail-grid {
583
+ gap: var(--space-md);
584
+ }
585
+ }
586
+
542
587
  @media (min-width: 768px) and (max-width: 1023px) {
543
588
  .projects-page .grid.text-detail-grid {
544
589
  grid-template-columns: minmax(0, 1fr);
@@ -2,8 +2,21 @@
2
2
  title: Welcome to the full example
3
3
  key: welcome
4
4
  date: 2026-01-01
5
+ description: A short claim under the title, in the example voice.
5
6
  tags: [Writing]
6
7
  language: en
7
8
  ---
8
9
 
9
10
  Fictional persona content for `examples/full`.
11
+
12
+ ## Opening
13
+
14
+ The first section gives the contents list a heading.
15
+
16
+ ## Second plate
17
+
18
+ Another heading keeps the menu from collapsing to a single line.
19
+
20
+ ## Closing
21
+
22
+ A third heading is the minimum the contents list will draw.
@@ -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.9.1"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.10.1"
28
28
  gem "csv"
29
29
  gem "base64"
30
30
  RUBY
@@ -51,7 +51,7 @@ home:
51
51
  notes: Include path for hero decoration (default `page/hero-default.html`)
52
52
  hero_landscape:
53
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
54
+ notes: Optional URL of a 16:9 image. At or below 767px it is used only when no `pages/home/portraits.json` entry has its own `landscape` URL. Home front matter `hero_landscape` overrides this key. Omitted, and with no entry landscape, the phone plate stays 3:4
55
55
 
56
56
  fonts:
57
57
  title: "`pandorga.fonts`"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.9.1"
4
+ VERSION = "1.10.1"
5
5
  end
@@ -6,8 +6,9 @@
6
6
  # Optional width variants live under variants/ so they are not extra draws.
7
7
  # Variants should be 3:4 portrait fills (not landscape width-only resizes):
8
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.
9
+ # landscape candidate is too short and looks soft. A 16:9 file for a narrow
10
+ # viewport is the optional `landscape` field on a portraits.json entry.
11
+ # pandorga.home.hero_landscape is only the fallback when no entry has one.
11
12
 
12
13
  require "pathname"
13
14
 
@@ -30,10 +30,10 @@ if (!slice.includes('function pickHeroPortrait')) {
30
30
  }
31
31
 
32
32
  const api = new Function(
33
- `${slice}\nreturn { heroPortraitEntries, pickHeroPortrait };`
33
+ `${slice}\nreturn { heroPortraitEntries, heroLandscapeEntries, pickHeroPortrait };`
34
34
  )();
35
35
 
36
- const { heroPortraitEntries, pickHeroPortrait } = api;
36
+ const { heroPortraitEntries, heroLandscapeEntries, pickHeroPortrait } = api;
37
37
 
38
38
  let failed = 0;
39
39
 
@@ -81,6 +81,31 @@ same(
81
81
  'normalised rows'
82
82
  );
83
83
 
84
+ same(
85
+ heroPortraitEntries([
86
+ { src: '/media/images/hero/a.jpg', landscape: ' /media/images/hero/a-16x9.jpg ' },
87
+ { src: '/media/images/hero/b.jpg', landscape: '' },
88
+ { src: '/media/images/hero/c.jpg', landscape: ' ' },
89
+ { src: '/media/images/hero/d.jpg', landscape: 4 },
90
+ ]),
91
+ [
92
+ { src: '/media/images/hero/a.jpg', alt: '', landscape: '/media/images/hero/a-16x9.jpg' },
93
+ { src: '/media/images/hero/b.jpg', alt: '' },
94
+ { src: '/media/images/hero/c.jpg', alt: '' },
95
+ { src: '/media/images/hero/d.jpg', alt: '' },
96
+ ],
97
+ 'optional landscape'
98
+ );
99
+ same(
100
+ heroLandscapeEntries([
101
+ { src: '/media/images/hero/a.jpg', alt: '', landscape: '/media/images/hero/a-16x9.jpg' },
102
+ { src: '/media/images/hero/b.jpg', alt: '' },
103
+ ]),
104
+ [{ src: '/media/images/hero/a.jpg', alt: '', landscape: '/media/images/hero/a-16x9.jpg' }],
105
+ 'landscape pool'
106
+ );
107
+ same(heroLandscapeEntries([]), [], 'empty landscape pool');
108
+
84
109
  // AC-HERO-06
85
110
  const two = [
86
111
  { src: '/media/images/hero/a.JPG', alt: '' },
@@ -135,8 +135,11 @@ landscape_plate = home_css[/@media \(max-width: 767px\) \{\s*\.home-hero-plate--
135
135
  check!(landscape_plate&.include?("aspect-ratio: 16 / 9"),
136
136
  "a landscape plate is 16:9 on a phone, got #{landscape_plate.inspect}")
137
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}")
138
+ check!(landscape_img&.include?("object-fit: contain") && landscape_img.include?("opacity: 0"),
139
+ "the 16:9 plate stays empty until the chosen file loads, got #{landscape_img.inspect}")
140
+ landscape_shown = home_css[/\.home-hero-plate--landscape img\.is-shown \{[^}]*\}/m]
141
+ check!(landscape_shown&.include?("opacity: 1"),
142
+ "the chosen 16:9 file paints in the fixed plate, got #{landscape_shown.inspect}")
140
143
 
141
144
  runtime = RUNTIME.read
142
145
  start = runtime.index("function heroPortraitEntries")
@@ -158,8 +161,11 @@ check!(onload && onload.index("applyHeroPortraitSources") && onload.index("img.s
158
161
  "srcset must be applied before src so the full file is not the first request")
159
162
  check!(body.include?("probe.fetchPriority = 'high'"),
160
163
  "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")
164
+ check!(body.include?("heroLandscapeEntries") && body.include?("paintNarrowHero") &&
165
+ body.include?("entry.landscape"),
166
+ "a narrow viewport draws from entries that carry a landscape URL")
167
+ check!(body.include?("showConfiguredLandscape") && body.include?("data-hero-landscape"),
168
+ "hero_landscape is the fallback when no entry has a landscape")
163
169
  check!(runtime.include?("hydrateHeroPortrait();"),
164
170
  "hydratePageFragments must draw the plate before the later fragment chain")
165
171
  plate_call = runtime.index("function hydratePageFragments")
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.9.1
4
+ version: 1.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors