pandorga 1.9.0 → 1.10.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: 5ce749863130651664857439b365492b57207e7d0aba994315be703200fe0f48
4
- data.tar.gz: 1b8f5293944f8cd27c76ee413f69e02869e9dc1cd6619aa48cfde1daf2355b98
3
+ metadata.gz: 57520c1d6ff472368d36e60fe8b2cfa73bd3a10ae64e5201d7ff2aae2d05d93f
4
+ data.tar.gz: 373041d44db6e27eff5cfb42e6086c5d78b54c1a57e682906ed74d4167f3efe7
5
5
  SHA512:
6
- metadata.gz: 157e81581c2e84cf398ab699902f1a3a17023e26b4fdbc27e76cba5013f1f7e2f5609cafe2dc65cf9bad36c9027bdb520d0ab22891324c9dfc63c3aabda76abc
7
- data.tar.gz: b016e51119eadedc1ae7b063800580dfa04519cc84f0f9cae4237a4a9e4a356c8d329ac44d8d9da4220e48bab33b68a74c891e59e50a07c947685750d178377f
6
+ metadata.gz: dea59bee7700b649e1db8740f8b825332b3c382e03ab8e7745ec3261dc7d31140571b26bdf2e7cb27f13910d6974aa7cb9e8f7a38a0dfb8c5064cc5c41c4400d
7
+ data.tar.gz: 7ce51565da0ef33816b8f95b37ba80e0300d91135049a80025719d4b91571be9a15c5fe0c3e1d13722f817b1c73f81e76a274fae54d11506e06da75eda0e9159
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.0] — 2026-10-14
4
+
5
+ ### Added
6
+
7
+ - A portrait entry in `pages/home/portraits.json` may set `landscape`, a
8
+ 16:9 URL. At or below 767px the home hero draws at random from the
9
+ entries that have one and paints that file. `pandorga.home.hero_landscape`
10
+ is the fallback when none do. The phone plate stays 16:9
11
+
12
+ ### Changed
13
+
14
+ - On article, draft, product, and project detail pages, category and tag
15
+ chips sit above the title
16
+ - On a phone, the detail title sits closer to the nav (`pt-10` to
17
+ `--space-xl`). The gaps inside the title block and before the contents
18
+ list each drop one spacing token. The contents list keeps `--space-md`
19
+ under it, inside the title plate. The stacked gap between that plate
20
+ and the reading column is `--space-md`. Desktop column gutter stays
21
+ 2rem, and 3rem from 1024px
22
+ - Article and draft section headings grow by 2pt on `h2` and 1pt on `h3`.
23
+ The same sizes apply to project and product bodies
24
+
25
+ ## [1.9.1] — 2026-10-14
26
+
27
+ ### Fixed
28
+
29
+ - The article description under the detail title uses `--type-prose`, the
30
+ same size as the body text. Family, weight, color, and spacing stay as
31
+ they were
32
+ - Bordered and boxed read-more pills do not underline the label.
33
+ Plain text read-more links stay underlined. Focus still shows the
34
+ outline
35
+
3
36
  ## [1.9.0] — 2026-10-13
4
37
 
5
38
  ### Added
@@ -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 {
@@ -745,7 +745,7 @@ body.figure-lightbox-open {
745
745
 
746
746
  .article-sidebar-description {
747
747
  font-family: var(--font-body);
748
- font-size: var(--type-body);
748
+ font-size: var(--type-prose);
749
749
  font-weight: var(--weight-body);
750
750
  line-height: var(--leading-body);
751
751
  letter-spacing: normal;
@@ -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
 
data/assets/css/base.css CHANGED
@@ -1026,6 +1026,16 @@ a.ledger-chip:hover {
1026
1026
  text-decoration-color: color-mix(in srgb, var(--color-link-hover) 75%, transparent);
1027
1027
  }
1028
1028
 
1029
+ /* Pill read-more only. The box is the affordance, so the label line would
1030
+ cross the border. Plain text read-more keeps the underline above.
1031
+ Focus keeps the outline. */
1032
+ :is(.ledger-cta--emphasis, .ledger-cta--emphasis-alt):is(:link, :visited, :hover, :focus, :focus-visible, :active),
1033
+ :is(.home-featured-foot, .writing-lead-foot) :is(.home-featured-cta, .writing-lead-cta):is(:link, :visited, :hover, :focus, :focus-visible, :active),
1034
+ :is(.ledger-cta--emphasis, .ledger-cta--emphasis-alt):is(:link, :visited, :hover, :focus, :focus-visible, :active)>span:not(.ledger-cta-arrow):not(.ledger-cta-icon),
1035
+ :is(.home-featured-foot, .writing-lead-foot) :is(.home-featured-cta, .writing-lead-cta).ledger-cta:is(:link, :visited, :hover, :focus, :focus-visible, :active)>span:not(.ledger-cta-arrow):not(.ledger-cta-icon) {
1036
+ text-decoration: none;
1037
+ }
1038
+
1029
1039
  .ledger-cta-arrow {
1030
1040
  transition: transform var(--motion-duration-fast) var(--motion-ease-default);
1031
1041
  }
@@ -403,6 +403,20 @@ 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
+ .text-detail-sidebar .article-sidebar-heading .ledger-chip-row {
417
+ margin: 0 0 var(--space-sm);
418
+ }
419
+
406
420
  /* Canonical shard, bottom-right, as in the mockup. Decoration only: it is
407
421
  suppressed below the tablet breakpoint like every other shard. */
408
422
  .text-detail-sidebar .article-sidebar-heading::after {
@@ -464,12 +478,11 @@ aside:has(> .text-detail-sidebar) {
464
478
  }
465
479
 
466
480
  /* Lead thesis. `description` is the article's own one-sentence claim — body
467
- face and body size, in legend ink so it sits under the H1 without matching
468
- the reading column. */
481
+ face at the prose size, in legend ink so it sits under the H1. */
469
482
  .text-detail-sidebar .article-sidebar-description {
470
483
  font-family: var(--font-body);
471
484
  font-style: normal;
472
- font-size: var(--type-body);
485
+ font-size: var(--type-prose);
473
486
  font-weight: var(--weight-body);
474
487
  line-height: var(--leading-body);
475
488
  color: var(--color-legend);
@@ -639,6 +652,22 @@ aside:has(> .text-detail-sidebar) {
639
652
  background: var(--color-surface-container-low);
640
653
  }
641
654
 
655
+ @media (max-width: 1023px) {
656
+ .text-detail-sidebar .article-toc--disclosure,
657
+ .projects-dossier .article-toc--disclosure {
658
+ margin-top: var(--space-md);
659
+ margin-bottom: var(--space-md);
660
+ }
661
+ }
662
+
663
+ /* The desktop contents card is the last block in the dossier when Related
664
+ Texts are absent, so the same inset keeps its border off the plate edge.
665
+ Related Texts bring a larger top margin, and the two margins collapse. */
666
+ .text-detail-sidebar .article-toc--sidebar,
667
+ .projects-dossier .article-toc--sidebar {
668
+ margin-bottom: var(--space-md);
669
+ }
670
+
642
671
  .article-toc-summary {
643
672
  padding: var(--space-sm) var(--space-md);
644
673
  font-weight: 400;
@@ -693,13 +722,13 @@ aside:has(> .text-detail-sidebar) {
693
722
  to the paragraph under it to stop the eye on its own, and the hairline was
694
723
  doing most of the work. h3 and the sidebar h1 move with it below.
695
724
 
696
- ## takes the card step (`--type-card-lg`), the size ### used to have.
697
- ### is the midpoint of that step and the reading size. #### is the reading
725
+ ## is the card step (`--type-card-lg`) plus 2pt.
726
+ ### is the midpoint of that step and the reading size, plus 1pt. #### is the reading
698
727
  size itself (`--type-prose`), so it matches the paragraph and still reads
699
728
  as a heading by weight. */
700
729
  .article-content h2 {
701
730
  font-family: var(--font-content-title);
702
- font-size: var(--type-card-lg);
731
+ font-size: calc(var(--type-card-lg) + 2pt);
703
732
  font-weight: var(--weight-title);
704
733
  font-synthesis: none;
705
734
  line-height: 1.25;
@@ -725,8 +754,8 @@ aside:has(> .text-detail-sidebar) {
725
754
  over a sentence-shaped heading they shout. */
726
755
  .article-content h3 {
727
756
  font-family: var(--font-content-title);
728
- /* Midpoint of ## (--type-card-lg) and #### (--type-prose). */
729
- font-size: calc((var(--type-card-lg) + var(--type-prose)) / 2);
757
+ /* Midpoint of ## (--type-card-lg) and #### (--type-prose), plus 1pt. */
758
+ font-size: calc((var(--type-card-lg) + var(--type-prose)) / 2 + 1pt);
730
759
  font-weight: var(--weight-title);
731
760
  font-synthesis: none;
732
761
  letter-spacing: normal;
@@ -1107,6 +1136,29 @@ nav.text-page-nav:not(:has(a)) {
1107
1136
  /* ══ Column rhythm at the breakpoints ══ */
1108
1137
  @media (max-width: 767px) {
1109
1138
 
1139
+ /* The listing shell's pt-10 is 2.5rem. On a phone detail the title sits
1140
+ closer to the nav. Listings keep pt-10. */
1141
+ main:has(#articles-detail:not(.hidden),
1142
+ #blog-detail:not(.hidden),
1143
+ #projects-detail:not(.hidden),
1144
+ #cv-detail:not(.hidden)) {
1145
+ padding-top: var(--space-xl);
1146
+ }
1147
+
1148
+ .grid.text-detail-grid {
1149
+ gap: var(--space-md);
1150
+ }
1151
+
1152
+ /* The reading column used to add its own top margin on top of this gap,
1153
+ so the two plates sat 3.5rem apart. The gap alone is the separation. */
1154
+ .text-page-main:is([data-dynamic-article-detail],
1155
+ [data-dynamic-post-detail],
1156
+ [data-dynamic-project-detail],
1157
+ [data-dynamic-product-detail],
1158
+ [data-dynamic-portfolio-detail]) {
1159
+ margin-top: 0;
1160
+ }
1161
+
1110
1162
  .text-page-main,
1111
1163
  .text-reading-column,
1112
1164
  .article-content {
@@ -1120,6 +1172,23 @@ nav.text-page-nav:not(:has(a)) {
1120
1172
  padding-right: var(--space-md);
1121
1173
  }
1122
1174
 
1175
+ /* One token tighter between the kicker, title, thesis, and the meta
1176
+ block. Desktop keeps the panel's --space-lg / --space-md rhythm: the
1177
+ dossier is a narrow column beside the reading measure, and the same
1178
+ cut presses the thesis against the title. */
1179
+ .text-detail-sidebar .article-sidebar-heading {
1180
+ padding-bottom: var(--space-md);
1181
+ }
1182
+
1183
+ .text-detail-sidebar .article-sidebar-heading .ledger-chip-row {
1184
+ margin-bottom: var(--space-xs);
1185
+ }
1186
+
1187
+ .text-detail-sidebar .article-sidebar-description,
1188
+ .text-detail-sidebar .article-sidebar-thumbnail {
1189
+ margin-top: var(--space-sm);
1190
+ }
1191
+
1123
1192
  /* A four-column benchmark table wanted 468px inside a 355px column at 390px
1124
1193
  and pushed the whole document sideways — every page on the site got a
1125
1194
  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 {
@@ -539,6 +539,30 @@
539
539
  which start at 768px. A project holds the split back to 1024px for the
540
540
  reason recorded in the layout: at tablet width the dossier is narrow enough
541
541
  to break a repository slug across three lines mid-word. */
542
+ @media (max-width: 767px) {
543
+ .projects-page .projects-hero-body {
544
+ padding: var(--space-lg);
545
+ }
546
+
547
+ .projects-page .projects-hero-title {
548
+ margin-top: var(--space-xs);
549
+ }
550
+
551
+ .projects-page .projects-hero-standfirst {
552
+ margin-top: var(--space-sm);
553
+ }
554
+ }
555
+
556
+ @media (max-width: 1023px) {
557
+ .projects-page .projects-hero {
558
+ margin-bottom: var(--space-xl);
559
+ }
560
+
561
+ .projects-page .grid.text-detail-grid {
562
+ gap: var(--space-md);
563
+ }
564
+ }
565
+
542
566
  @media (min-width: 768px) and (max-width: 1023px) {
543
567
  .projects-page .grid.text-detail-grid {
544
568
  grid-template-columns: minmax(0, 1fr);
@@ -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.0"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.10.0"
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.0"
4
+ VERSION = "1.10.0"
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.0
4
+ version: 1.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors