pandorga 1.9.1 → 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 +22 -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 +5 -5
- data/assets/css/ledger/detail.css +75 -5
- 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,27 @@
|
|
|
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
|
+
|
|
3
25
|
## [1.9.1] — 2026-10-14
|
|
4
26
|
|
|
5
27
|
### 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
|
-
|
|
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 {
|
|
@@ -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
|
|
|
@@ -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 {
|
|
@@ -638,6 +652,22 @@ aside:has(> .text-detail-sidebar) {
|
|
|
638
652
|
background: var(--color-surface-container-low);
|
|
639
653
|
}
|
|
640
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
|
+
|
|
641
671
|
.article-toc-summary {
|
|
642
672
|
padding: var(--space-sm) var(--space-md);
|
|
643
673
|
font-weight: 400;
|
|
@@ -692,13 +722,13 @@ aside:has(> .text-detail-sidebar) {
|
|
|
692
722
|
to the paragraph under it to stop the eye on its own, and the hairline was
|
|
693
723
|
doing most of the work. h3 and the sidebar h1 move with it below.
|
|
694
724
|
|
|
695
|
-
##
|
|
696
|
-
### 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
|
|
697
727
|
size itself (`--type-prose`), so it matches the paragraph and still reads
|
|
698
728
|
as a heading by weight. */
|
|
699
729
|
.article-content h2 {
|
|
700
730
|
font-family: var(--font-content-title);
|
|
701
|
-
font-size: var(--type-card-lg);
|
|
731
|
+
font-size: calc(var(--type-card-lg) + 2pt);
|
|
702
732
|
font-weight: var(--weight-title);
|
|
703
733
|
font-synthesis: none;
|
|
704
734
|
line-height: 1.25;
|
|
@@ -724,8 +754,8 @@ aside:has(> .text-detail-sidebar) {
|
|
|
724
754
|
over a sentence-shaped heading they shout. */
|
|
725
755
|
.article-content h3 {
|
|
726
756
|
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);
|
|
757
|
+
/* Midpoint of ## (--type-card-lg) and #### (--type-prose), plus 1pt. */
|
|
758
|
+
font-size: calc((var(--type-card-lg) + var(--type-prose)) / 2 + 1pt);
|
|
729
759
|
font-weight: var(--weight-title);
|
|
730
760
|
font-synthesis: none;
|
|
731
761
|
letter-spacing: normal;
|
|
@@ -1106,6 +1136,29 @@ nav.text-page-nav:not(:has(a)) {
|
|
|
1106
1136
|
/* ══ Column rhythm at the breakpoints ══ */
|
|
1107
1137
|
@media (max-width: 767px) {
|
|
1108
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
|
+
|
|
1109
1162
|
.text-page-main,
|
|
1110
1163
|
.text-reading-column,
|
|
1111
1164
|
.article-content {
|
|
@@ -1119,6 +1172,23 @@ nav.text-page-nav:not(:has(a)) {
|
|
|
1119
1172
|
padding-right: var(--space-md);
|
|
1120
1173
|
}
|
|
1121
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
|
+
|
|
1122
1192
|
/* A four-column benchmark table wanted 468px inside a 355px column at 390px
|
|
1123
1193
|
and pushed the whole document sideways — every page on the site got a
|
|
1124
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")
|