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 +4 -4
- data/CHANGELOG.md +33 -0
- data/_includes/content-runtime/50-detail.html +17 -14
- data/_includes/content-runtime/60-fragments.html +126 -13
- data/_layouts/projects.html +2 -3
- data/_layouts/text.html +20 -13
- data/assets/css/article.css +6 -6
- data/assets/css/base.css +10 -0
- data/assets/css/ledger/detail.css +77 -8
- data/assets/css/ledger/home.css +11 -5
- data/assets/css/ledger/projects.css +24 -0
- data/lib/pandorga/commands/new.rb +1 -1
- data/lib/pandorga/registry/schema.yml +1 -1
- data/lib/pandorga/version.rb +1 -1
- data/scripts/content/hero_portraits.rb +3 -2
- data/scripts/test/test-hero-portraits.mjs +27 -2
- data/scripts/test/test-hero-portraits.rb +10 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 57520c1d6ff472368d36e60fe8b2cfa73bd3a10ae64e5201d7ff2aae2d05d93f
|
|
4
|
+
data.tar.gz: 373041d44db6e27eff5cfb42e6086c5d78b54c1a57e682906ed74d4167f3efe7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
632
|
+
function configuredLandscape(img) {
|
|
617
633
|
var url = img && img.getAttribute('data-hero-landscape');
|
|
618
|
-
|
|
619
|
-
return
|
|
634
|
+
url = url ? String(url).trim() : '';
|
|
635
|
+
return url;
|
|
620
636
|
}
|
|
621
637
|
|
|
622
|
-
function
|
|
623
|
-
img.
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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) {
|
data/_layouts/projects.html
CHANGED
|
@@ -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
|
|
91
|
-
|
|
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
|
-
{%
|
|
94
|
-
|
|
95
|
-
|
|
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 %}
|
data/assets/css/article.css
CHANGED
|
@@ -73,13 +73,13 @@
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
.article-content h2 {
|
|
76
|
-
/*
|
|
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-
|
|
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:
|
|
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
|
|
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-
|
|
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
|
-
##
|
|
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
|
data/assets/css/ledger/home.css
CHANGED
|
@@ -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,
|
|
376
|
-
|
|
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
|
|
476
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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`"
|
data/lib/pandorga/version.rb
CHANGED
|
@@ -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
|
|
10
|
-
#
|
|
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:
|
|
139
|
-
"the 16:9
|
|
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?("
|
|
162
|
-
|
|
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")
|