pandorga 1.8.4 → 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: 120307b06e8e2a6083eb4684bef760944c0fa334155262ea202be58493e0cdd6
4
- data.tar.gz: e17010ea2587987f38dc6236944c99c4790a380d87fcd994cc1462f5d184bee0
3
+ metadata.gz: 5ce749863130651664857439b365492b57207e7d0aba994315be703200fe0f48
4
+ data.tar.gz: 1b8f5293944f8cd27c76ee413f69e02869e9dc1cd6619aa48cfde1daf2355b98
5
5
  SHA512:
6
- metadata.gz: ef13097e5f083e7741ecfddbce6b994b84697f1a5bc593e43cdce93bd41bab6b94da9864555a12908a419d4e4ff0836b2230085f495f30d155810bac87f0ef0b
7
- data.tar.gz: 1c0e9c4a2e1f8d3cc6fdf3cd58103261f6271e445629e5f4e661f5d402f3525cc36fe717f1b86204640a0ffaeb25cb5171086645df15bab958781226f5fb2e75
6
+ metadata.gz: 157e81581c2e84cf398ab699902f1a3a17023e26b4fdbc27e76cba5013f1f7e2f5609cafe2dc65cf9bad36c9027bdb520d0ab22891324c9dfc63c3aabda76abc
7
+ data.tar.gz: b016e51119eadedc1ae7b063800580dfa04519cc84f0f9cae4237a4a9e4a356c8d329ac44d8d9da4220e48bab33b68a74c891e59e50a07c947685750d178377f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
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
+
3
33
  ## [1.8.4] — 2026-10-12
4
34
 
5
35
  ### Fixed
@@ -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
 
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">
@@ -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
  }
@@ -1933,8 +1951,9 @@ html[data-theme="light"] .home-featured .ledger-poster {
1933
1951
  ends flush with the viewport and adds no scrollable width; this must stay
1934
1952
  below 768px, which is where .home-band-inner swaps that token for --gutter.
1935
1953
  The side hairlines go with it: a border pinned to the screen edge reads as a
1936
- frame cut off rather than as the plate's edge. Orientation still owns the
1937
- 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. */
1938
1957
  @media (max-width: 767px) {
1939
1958
  .home-hero-portrait {
1940
1959
  max-width: none;
@@ -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.4"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.9.0"
28
28
  gem "csv"
29
29
  gem "base64"
30
30
  RUBY
@@ -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.4"
4
+ VERSION = "1.9.0"
5
5
  end
@@ -4,13 +4,20 @@
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
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.
7
11
 
8
12
  require "pathname"
9
13
 
10
14
  module HeroPortraits
11
15
  IMAGE_EXTENSIONS = %w[.jpg .jpeg .png .webp .gif].freeze
12
16
  WIDTHS = [480, 960, 1440].freeze
13
- SIZES = "(max-width: 767px) 100vw, (max-width: 1023px) 14rem, 16rem"
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"
14
21
 
15
22
  module_function
16
23
 
@@ -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.4
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors