pandorga 1.5.1 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c33cb11f60569d5280aa7079b6f59ad7776f90705f54f67d958d8b149390714d
4
- data.tar.gz: 4a52dcb27be1f4d2a809417bc9b98e89e3c8aa8d92e642fca9ebab156942a192
3
+ metadata.gz: 5686edc300ca78ccb310e5f59909212720c54656998508c697f56e2689e4c21b
4
+ data.tar.gz: 8b6a24524c80d7e9917c784d6230016406fc5e2791f5349ca09aa509ef650fd3
5
5
  SHA512:
6
- metadata.gz: ecf8762ab684b723db57bb8d291609a3decf8ecae02f21b0158c3fd34afce67b9a34347255e790e26d480e8ef0b2eaab577dbe3d6ed571a02ce713cc5a2e09d9
7
- data.tar.gz: 0c30da38132b4b87f2caf982a73f7c8eea1cf157600ecffb9b6f87e24fe289a47952fe3f9c8291c11719531a90cabd0149db89ba13b076c341a4eafabd6577b5
6
+ metadata.gz: c649b33b2fa0afdc8f93c3558a9e07ba8d56f18e4dff63064d9eecdca0701587c7d64fcb030ddcfc51f71f6169ba3ef8745e1c555ad8cbcc10215baf7a82d81a
7
+ data.tar.gz: 79ad62fc9e7cfca6a70416fed7865ce1a4c70be646c05262ad2fe37579f4281b0ad4eaf1a5732b8fb70879c2269759b566750a6b49ee7e4f6dda19f36e282195
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.1] — 2026-10-10
4
+
5
+ ### Fixed
6
+
7
+ - The articles featured slot reserved 16rem whenever it was empty, so page 2
8
+ and later kept a blank band between the header rule and the filter. The
9
+ reserve now lasts only until the listing decides. A page with no specimen
10
+ collapses that slot.
11
+
12
+ No `pandorga:` config keys were added or renamed.
13
+
14
+ ## [1.6.0] — 2026-10-10
15
+
16
+ ### Added
17
+
18
+ - `/sitemap.xml` lists the generated pages. `/robots.txt` allows the public
19
+ site, skips `/studio/`, and names the sitemap. Both use
20
+ `pandorga.identity.url` when it is set, otherwise `site.url`.
21
+ - JSON-LD `WebSite` and `Person` in the shell, from `identity.name` and the
22
+ optional `identity.url`. `site.description` is included when it is set.
23
+ - Hero portrait entries may include optional `srcset` and `sizes`. A portrait
24
+ that only has `src` is unchanged.
25
+
26
+ ### Changed
27
+
28
+ - The home subtitle and summary are requested before the stylesheets and
29
+ painted without a "Loading..." swap. Article rows, CV sections, and empty
30
+ home bands keep a reserved box until the real content replaces it.
31
+ - The hero subtitle stays at `--type-body`, the same size as the summary.
32
+ - `.writing-revised` uses the solid variant ink. The transparent mix was
33
+ 3.69 and 3.90 on the light paper.
34
+ - A selectable project inventory row is a `button`.
35
+ - Utilities ship as `assets/css/tailwind.css`. The shell no longer loads the
36
+ Tailwind Play CDN. Each layout links the shared chrome and its own ledger
37
+ sheet, not every sheet.
38
+
39
+ No `pandorga:` config keys were added or renamed. `srcset` and `sizes` are
40
+ optional fields on objects in `pages/home/portraits.json`.
41
+
3
42
  ## [1.5.1] — 2026-10-10
4
43
 
5
44
  ### Fixed
@@ -60,6 +60,13 @@
60
60
  },
61
61
  showLoadingState: function (element, message) {
62
62
  if (!element) return;
63
+ // Reserved skeletons already occupy the box. Replacing them with a
64
+ // one-line message is the shift the listing then repeats when the
65
+ // cards arrive.
66
+ if (element.hasAttribute('data-content-reserve')) {
67
+ element.setAttribute('aria-busy', 'true');
68
+ return;
69
+ }
63
70
  element.innerHTML = '<p class="font-body text-on-surface-variant italic col-span-full">' + escapeHtml(message) + '</p>';
64
71
  },
65
72
  showErrorState: function (element, message) {
@@ -344,14 +344,15 @@
344
344
  var slug = item.slug || f.slug || '';
345
345
  var index = options.index;
346
346
 
347
- var row = document.createElement('article');
347
+ // A selectable row is a real button. role=button on an article is
348
+ // what the audit flags, and the button already handles Enter and Space.
349
+ var row = document.createElement(selectable ? 'button' : 'article');
348
350
  row.className = 'home-project-inventory-row group' +
349
351
  (selected ? ' is-selected' : '');
352
+ if (selectable) row.type = 'button';
350
353
  if (slug) row.setAttribute('data-slug', slug);
351
354
 
352
355
  if (selectable) {
353
- row.setAttribute('role', 'button');
354
- row.setAttribute('tabindex', '0');
355
356
  row.setAttribute('aria-pressed', selected ? 'true' : 'false');
356
357
  row.setAttribute('aria-label', name);
357
358
  }
@@ -13,6 +13,19 @@
13
13
  }
14
14
 
15
15
  async function listPaths(collection, manifestKey) {
16
+ var pre = window.__pandorgaPreload && window.__pandorgaPreload['manifest.json'];
17
+ if (pre && typeof pre.then === 'function') {
18
+ try {
19
+ var preloaded = await pre;
20
+ assertManifestSchema(preloaded);
21
+ var preloadedFiles = preloaded.files && preloaded.files[manifestKey];
22
+ if (Array.isArray(preloadedFiles)) return preloadedFiles;
23
+ } catch (error) {
24
+ if (error && String(error.message || '').indexOf('Unsupported content schema') === 0) throw error;
25
+ delete window.__pandorgaPreload['manifest.json'];
26
+ }
27
+ }
28
+
16
29
  var manifestResponse = await fetch(buildUrl('manifest.json'));
17
30
  if (manifestResponse.ok) {
18
31
  var manifest = await manifestResponse.json();
@@ -63,16 +76,31 @@
63
76
  return collectionCache[cacheKey];
64
77
  }
65
78
 
66
- async function loadJson(path) {
67
- var key = String(path || '');
68
- if (jsonCache[key]) return jsonCache[key];
69
- jsonCache[key] = fetch(buildUrl(path)).then(function (response) {
79
+ function fetchJson(path) {
80
+ return fetch(buildUrl(path)).then(function (response) {
70
81
  if (!response.ok) throw new Error('Content JSON fetch failed: ' + path);
71
82
  return response.json();
72
- }).catch(function (error) {
73
- delete jsonCache[key];
74
- throw error;
75
83
  });
84
+ }
85
+
86
+ async function loadJson(path) {
87
+ var key = String(path || '');
88
+ if (jsonCache[key]) return jsonCache[key];
89
+ var pre = window.__pandorgaPreload && window.__pandorgaPreload[key];
90
+ jsonCache[key] = Promise.resolve()
91
+ .then(function () {
92
+ if (pre && typeof pre.then === 'function') return pre;
93
+ return fetchJson(path);
94
+ })
95
+ .catch(function () {
96
+ if (!(pre && typeof pre.then === 'function')) throw new Error('Content JSON fetch failed: ' + path);
97
+ delete window.__pandorgaPreload[key];
98
+ return fetchJson(path);
99
+ })
100
+ .catch(function (error) {
101
+ delete jsonCache[key];
102
+ throw error;
103
+ });
76
104
  return jsonCache[key];
77
105
  }
78
106
 
@@ -192,10 +220,21 @@
192
220
  options = options || {};
193
221
  var el = typeof elementOrId === 'string' ? document.getElementById(elementOrId) : elementOrId;
194
222
  if (!el) return Promise.resolve();
195
- var loadingText = options.loadingText || 'Loading...';
196
- el.innerHTML = '<p class="font-body text-on-surface-variant italic">' + escapeHtml(loadingText) + '</p>';
223
+ // A quiet slot keeps its reserved box. Writing "Loading..." into the
224
+ // hero summary makes that placeholder the LCP, then swaps it away.
225
+ var quiet = el.hasAttribute('data-content-quiet');
226
+ var loadingText = quiet ? '' : (options.loadingText || 'Loading...');
227
+ if (loadingText) {
228
+ el.innerHTML = '<p class="font-body text-on-surface-variant italic">' + escapeHtml(loadingText) + '</p>';
229
+ }
197
230
  return loadJson(pageFragmentPath(relativePath)).then(function (fragment) {
198
- el.innerHTML = renderMarkdown(fragment.body_markdown || '');
231
+ var html = renderMarkdown(fragment.body_markdown || '');
232
+ if (el.getAttribute('data-content-painted') === '1' && el.innerHTML === html) {
233
+ el.removeAttribute('data-content-painted');
234
+ return;
235
+ }
236
+ el.innerHTML = html;
237
+ el.removeAttribute('data-content-painted');
199
238
  }).catch(function (error) {
200
239
  console.error(error);
201
240
  el.innerHTML = '<p class="font-body text-on-surface-variant italic">' + escapeHtml(options.errorText || 'Content could not be loaded right now.') + '</p>';
@@ -473,11 +512,36 @@
473
512
  var src = typeof entry.src === 'string' ? entry.src.trim() : '';
474
513
  if (!src) continue;
475
514
  var alt = typeof entry.alt === 'string' ? entry.alt.trim() : '';
476
- entries.push({ src: src, alt: alt });
515
+ var record = { src: src, alt: alt };
516
+ // Optional responsive fields. Absent keys stay off the record so a
517
+ // pool that only has src keeps the previous shape.
518
+ var srcset = typeof entry.srcset === 'string' ? entry.srcset.trim() : '';
519
+ var sizes = typeof entry.sizes === 'string' ? entry.sizes.trim() : '';
520
+ if (srcset) record.srcset = srcset;
521
+ if (sizes) record.sizes = sizes;
522
+ entries.push(record);
477
523
  }
478
524
  return entries;
479
525
  }
480
526
 
527
+ function resolveSrcset(srcset) {
528
+ return String(srcset || '').split(',').map(function (part) {
529
+ var bits = part.trim().split(/\s+/);
530
+ if (!bits[0]) return '';
531
+ bits[0] = resolveAssetUrl(bits[0]);
532
+ return bits.filter(Boolean).join(' ');
533
+ }).filter(Boolean).join(', ');
534
+ }
535
+
536
+ function applyHeroPortraitSources(img, chosen) {
537
+ var srcset = resolveSrcset(chosen && chosen.srcset);
538
+ var sizes = chosen && typeof chosen.sizes === 'string' ? chosen.sizes.trim() : '';
539
+ if (srcset) img.setAttribute('srcset', srcset);
540
+ else img.removeAttribute('srcset');
541
+ if (sizes) img.setAttribute('sizes', sizes);
542
+ else img.removeAttribute('sizes');
543
+ }
544
+
481
545
  function pickHeroPortrait(entries, random) {
482
546
  if (!entries || !entries.length) return null;
483
547
  var roll = typeof random === 'function' ? random() : Math.random();
@@ -491,6 +555,7 @@
491
555
  if (!nextSrc) return Promise.resolve(false);
492
556
  if (img.getAttribute('src') === nextSrc) {
493
557
  img.alt = nextAlt;
558
+ applyHeroPortraitSources(img, chosen);
494
559
  img.classList.add('is-shown');
495
560
  return Promise.resolve(true);
496
561
  }
@@ -499,6 +564,7 @@
499
564
  probe.onload = function () {
500
565
  img.src = nextSrc;
501
566
  img.alt = nextAlt;
567
+ applyHeroPortraitSources(img, chosen);
502
568
  img.classList.add('is-shown');
503
569
  resolve(true);
504
570
  };
@@ -0,0 +1,35 @@
1
+ {%- comment -%}
2
+ Start the JSON fetches the first paint depends on, before the stylesheets
3
+ and the content runtime. loadJson and listPaths reuse window.__pandorgaPreload.
4
+ {%- endcomment -%}
5
+ {%- case page.layout -%}
6
+ {%- when "home" -%}
7
+ {%- assign _preload_paths = "pages/cv/summary.json|pages/home/subtitle.json|pages/home/portraits.json|pages/headers.json|data/writing_index.json" -%}
8
+ {%- when "articles" -%}
9
+ {%- assign _preload_paths = "data/writing_index.json|pages/headers.json|manifest.json" -%}
10
+ {%- when "blog" -%}
11
+ {%- assign _preload_paths = "data/writing_index.json|pages/headers.json|manifest.json" -%}
12
+ {%- when "cv" -%}
13
+ {%- assign _preload_paths = "pages/cv/summary.json|pages/cv/disclaimer.json|data/education.json|data/languages.json|pages/headers.json|manifest.json" -%}
14
+ {%- when "projects" -%}
15
+ {%- assign _preload_paths = "data/writing_index.json|pages/headers.json|manifest.json" -%}
16
+ {%- else -%}
17
+ {%- assign _preload_paths = "" -%}
18
+ {%- endcase -%}
19
+ {%- if _preload_paths != blank -%}
20
+ <script>
21
+ (function () {
22
+ var base = {{ site.content_api_base_url | default: "" | jsonify }};
23
+ base = String(base || "").replace(/\/$/, "");
24
+ var paths = {{ _preload_paths | split: "|" | jsonify }};
25
+ window.__pandorgaPreload = window.__pandorgaPreload || {};
26
+ paths.forEach(function (path) {
27
+ var url = base ? base + "/" + path : "/" + path;
28
+ window.__pandorgaPreload[path] = fetch(url, { credentials: "same-origin" }).then(function (response) {
29
+ if (!response.ok) throw new Error(path);
30
+ return response.json();
31
+ });
32
+ });
33
+ })();
34
+ </script>
35
+ {%- endif -%}
@@ -0,0 +1,35 @@
1
+ {%- comment -%}
2
+ WebSite and Person from identity. No new required keys: name is already
3
+ required, and url is the existing optional canonical origin.
4
+ {%- endcomment -%}
5
+ {%- assign ld_name = site.pandorga.identity.name | default: site.author | default: site.title -%}
6
+ {%- assign ld_url = site.pandorga.identity.url | default: site.url -%}
7
+ {%- if ld_url != blank -%}
8
+ {%- assign ld_website = ld_url -%}
9
+ {%- else -%}
10
+ {%- assign ld_website = "/" | absolute_url -%}
11
+ {%- endif -%}
12
+ {%- if ld_name != blank -%}
13
+ <script type="application/ld+json">
14
+ {
15
+ "@context": "https://schema.org",
16
+ "@graph": [
17
+ {
18
+ "@type": "WebSite",
19
+ "name": {{ ld_name | jsonify }},
20
+ "url": {{ ld_website | jsonify }}
21
+ {%- if site.description != blank -%},
22
+ "description": {{ site.description | jsonify }}
23
+ {%- endif -%}
24
+ },
25
+ {
26
+ "@type": "Person",
27
+ "name": {{ ld_name | jsonify }}
28
+ {%- if ld_url != blank -%},
29
+ "url": {{ ld_url | jsonify }}
30
+ {%- endif -%}
31
+ }
32
+ ]
33
+ }
34
+ </script>
35
+ {%- endif -%}
@@ -30,7 +30,16 @@ layout: shell
30
30
  image column, and one that reserves space for a picture that does not
31
31
  exist is worse than no panel at all.
32
32
  {%- endcomment -%}
33
- <div id="articles-lead"></div>
33
+ <div id="articles-lead" class="is-pending"></div>
34
+ <script>
35
+ (function () {
36
+ var page = parseInt(new URLSearchParams(window.location.search).get('page') || '1', 10);
37
+ var lead = document.getElementById('articles-lead');
38
+ if (!lead || !(page > 1)) return;
39
+ lead.classList.remove('is-pending');
40
+ lead.hidden = true;
41
+ })();
42
+ </script>
34
43
 
35
44
  <div class="writing-body">
36
45
 
@@ -90,8 +99,14 @@ layout: shell
90
99
  </div>
91
100
 
92
101
  <div class="writing-archive" id="articles-list">
93
- <section id="empty-state" class="writing-archive-state">
94
- <p class="font-body text-on-surface-variant italic">Loading articles...</p>
102
+ <section id="empty-state" class="writing-archive-reserve" aria-busy="true">
103
+ <p class="sr-only">Loading articles...</p>
104
+ <span class="writing-reserve-row" aria-hidden="true"></span>
105
+ <span class="writing-reserve-row" aria-hidden="true"></span>
106
+ <span class="writing-reserve-row" aria-hidden="true"></span>
107
+ <span class="writing-reserve-row" aria-hidden="true"></span>
108
+ <span class="writing-reserve-row" aria-hidden="true"></span>
109
+ <span class="writing-reserve-row" aria-hidden="true"></span>
95
110
  </section>
96
111
  <section id="no-results" class="writing-archive-state hidden">
97
112
  <p class="font-body text-on-surface-variant italic">Nothing matches these filters yet.</p>
@@ -253,7 +268,11 @@ layout: shell
253
268
  function renderListing(state) {
254
269
  Array.from(grid.querySelectorAll('[data-dynamic-article]')).forEach(function (el) { el.remove(); });
255
270
  if (emptyState) emptyState.remove();
256
- if (leadRoot) leadRoot.innerHTML = '';
271
+ if (leadRoot) {
272
+ leadRoot.classList.remove('is-pending');
273
+ leadRoot.innerHTML = '';
274
+ leadRoot.hidden = true;
275
+ }
257
276
 
258
277
  var visible = visibleArticles(state);
259
278
  if (orderNote) orderNote.textContent = SORT_LABELS[state.sort] || SORT_LABELS.updated;
@@ -281,7 +300,10 @@ layout: shell
281
300
 
282
301
  var totalPages = Math.max(1, Math.ceil(rest.length / perPage));
283
302
  if (currentPage > totalPages) currentPage = totalPages;
284
- if (currentPage === 1) renderLead(featured);
303
+ if (currentPage === 1) {
304
+ renderLead(featured);
305
+ if (leadRoot && leadRoot.childElementCount) leadRoot.hidden = false;
306
+ }
285
307
 
286
308
  rest.slice((currentPage - 1) * perPage, currentPage * perPage).forEach(renderRow);
287
309
  listing.renderPagination(controls, currentPage, totalPages);
data/_layouts/cv.html CHANGED
@@ -25,43 +25,45 @@ layout: shell
25
25
 
26
26
  <div id="cv-disclaimer" class="cv-disclaimer-box">
27
27
  <span class="material-symbols-outlined cv-dossier-note-icon" aria-hidden="true">info</span>
28
- <div class="cv-dossier-note font-body" data-content-fragment="cv/disclaimer">
29
- <p class="font-body text-on-surface-variant italic">Loading...</p>
30
- </div>
28
+ <div class="cv-dossier-note font-body" data-content-fragment="cv/disclaimer" data-content-quiet></div>
31
29
  </div>
32
30
 
33
31
  <div id="cv-listing">
34
32
  {% capture cv_summary %}
35
- <div class="cv-summary font-body" data-content-fragment="cv/summary">
36
- <p class="font-body text-on-surface-variant italic">Loading...</p>
37
- </div>
33
+ <div class="cv-summary font-body" data-content-fragment="cv/summary" data-content-quiet></div>
38
34
  {% endcapture %}
39
35
  {% include cv/cv-box.html id="summary" title="Summary" content=cv_summary %}
40
36
 
41
37
  {% capture cv_experience %}
42
- <div id="cv-experience-list" class="cv-entry-stack">
43
- <p class="font-body text-on-surface-variant italic">Loading experience...</p>
38
+ <div id="cv-experience-list" class="cv-entry-stack" data-content-reserve aria-busy="true">
39
+ <span class="cv-reserve-card" aria-hidden="true"></span>
40
+ <span class="cv-reserve-card" aria-hidden="true"></span>
41
+ <span class="cv-reserve-card" aria-hidden="true"></span>
44
42
  </div>
45
43
  {% endcapture %}
46
44
  {% include cv/cv-box.html id="experience" title="Experience" content=cv_experience %}
47
45
 
48
46
  {% capture cv_products %}
49
- <div id="cv-products-grid" class="cv-product-grid">
50
- <p class="font-body text-on-surface-variant italic">Loading products...</p>
47
+ <div id="cv-products-grid" class="cv-product-grid" data-content-reserve aria-busy="true">
48
+ <span class="cv-reserve-card" aria-hidden="true"></span>
49
+ <span class="cv-reserve-card" aria-hidden="true"></span>
50
+ <span class="cv-reserve-card" aria-hidden="true"></span>
51
51
  </div>
52
52
  {% endcapture %}
53
53
  {% include cv/cv-box.html id="products" title="Products" content=cv_products %}
54
54
 
55
55
  {% capture cv_education %}
56
- <div id="cv-education-list" class="cv-entry-stack">
57
- <p class="font-body text-on-surface-variant italic">Loading education...</p>
56
+ <div id="cv-education-list" class="cv-entry-stack" data-content-reserve aria-busy="true">
57
+ <span class="cv-reserve-card" aria-hidden="true"></span>
58
+ <span class="cv-reserve-card" aria-hidden="true"></span>
58
59
  </div>
59
60
  {% endcapture %}
60
61
  {% include cv/cv-box.html id="education" title="Education" content=cv_education %}
61
62
 
62
63
  {% capture cv_languages %}
63
- <div id="cv-languages-list" class="cv-language-list">
64
- <p class="font-body text-on-surface-variant italic">Loading languages...</p>
64
+ <div id="cv-languages-list" class="cv-language-list" data-content-reserve aria-busy="true">
65
+ <span class="cv-reserve-card" aria-hidden="true"></span>
66
+ <span class="cv-reserve-card" aria-hidden="true"></span>
65
67
  </div>
66
68
  {% endcapture %}
67
69
  {% include cv/cv-box.html id="languages" title="Languages" content=cv_languages %}
@@ -327,6 +329,7 @@ layout: shell
327
329
  productMap[product.slug] = product;
328
330
  });
329
331
  experienceList.innerHTML = '';
332
+ experienceList.removeAttribute('aria-busy');
330
333
  jobs.sort(function (a, b) {
331
334
  var as = (a.front_matter && a.front_matter.start) || '';
332
335
  var bs = (b.front_matter && b.front_matter.start) || '';
@@ -344,6 +347,7 @@ layout: shell
344
347
  runtime.Listing.showLoadingState(educationList, 'Loading education...');
345
348
  runtime.loadJson('data/education.json').then(function (entries) {
346
349
  educationList.innerHTML = '';
350
+ educationList.removeAttribute('aria-busy');
347
351
  entries.forEach(function (entry) { educationList.appendChild(renderEducation(entry)); });
348
352
  }).catch(function (error) {
349
353
  console.error(error);
@@ -355,6 +359,7 @@ layout: shell
355
359
  runtime.Listing.showLoadingState(languagesList, 'Loading languages...');
356
360
  runtime.loadJson('data/languages.json').then(function (entries) {
357
361
  languagesList.innerHTML = '';
362
+ languagesList.removeAttribute('aria-busy');
358
363
  entries.forEach(function (entry) { languagesList.appendChild(renderLanguage(entry)); });
359
364
  }).catch(function (error) {
360
365
  console.error(error);
@@ -366,6 +371,7 @@ layout: shell
366
371
  runtime.Listing.showLoadingState(productsGrid, 'Loading products...');
367
372
  runtime.loadWritingIndex('products').then(function (products) {
368
373
  productsGrid.innerHTML = '';
374
+ productsGrid.removeAttribute('aria-busy');
369
375
  var ordered = products.slice().sort(compareProductsByEndDate);
370
376
  ordered.forEach(function (product) {
371
377
  productsGrid.appendChild(renderProduct(product));
data/_layouts/home.html CHANGED
@@ -29,12 +29,12 @@ layout: shell
29
29
  <div class="home-hero">
30
30
  <div class="home-hero-copy">
31
31
  <div id="home-subtitle-content" class="home-hero-tagline font-label"
32
- data-content-fragment="home/subtitle"></div>
32
+ data-content-fragment="home/subtitle" data-content-quiet></div>
33
33
  <h1 class="home-hero-title">
34
34
  <span class="home-hero-given">{{ _hero_name.given }}</span>{%- if _hero_name.family != "" %} <span class="home-hero-family">{{ _hero_name.family }}</span>{%- endif %}
35
35
  </h1>
36
36
  <div id="home-cv-summary" class="home-hero-summary font-body"
37
- data-content-fragment="cv/summary"></div>
37
+ data-content-fragment="cv/summary" data-content-quiet></div>
38
38
  <div class="home-hero-actions">
39
39
  {%- if _cv_page -%}
40
40
  <a class="ledger-cta ledger-cta--emphasis home-hero-cta" href="{{ _cv_page.url }}">
@@ -50,7 +50,7 @@ layout: shell
50
50
  <div class="home-hero-plate-wrap">
51
51
  <span class="ledger-frame-crosshairs" aria-hidden="true"></span>
52
52
  <figure class="home-hero-plate">
53
- <img alt="" width="1200" height="800" data-content-hero-portrait />
53
+ <img alt="" width="1200" height="800" fetchpriority="high" data-content-hero-portrait />
54
54
  <span class="ledger-frame-corners" aria-hidden="true"></span>
55
55
  </figure>
56
56
  </div>
@@ -58,6 +58,26 @@ layout: shell
58
58
  </div>
59
59
  </div>
60
60
  </section>
61
+ <script>
62
+ (function () {
63
+ var runtime = window.ContentRuntime;
64
+ var pre = window.__pandorgaPreload || {};
65
+ function paint(id, path) {
66
+ var el = document.getElementById(id);
67
+ var pending = pre[path];
68
+ if (!el || !pending || !runtime || !runtime.renderMarkdown) return;
69
+ pending.then(function (fragment) {
70
+ if (!fragment || el.getAttribute('data-content-painted') === '1') return;
71
+ var html = runtime.renderMarkdown(fragment.body_markdown || '');
72
+ if (!html) return;
73
+ el.innerHTML = html;
74
+ el.setAttribute('data-content-painted', '1');
75
+ }).catch(function () {});
76
+ }
77
+ paint('home-subtitle-content', 'pages/home/subtitle.json');
78
+ paint('home-cv-summary', 'pages/cv/summary.json');
79
+ })();
80
+ </script>
61
81
 
62
82
  {%- for band in _home_bands -%}
63
83
  {%- if band.kind == "index" -%}
data/_layouts/shell.html CHANGED
@@ -13,6 +13,7 @@
13
13
  {% assign canonical_path = page.canonical_url | default: page.url %}
14
14
  <title>{{ meta_title | strip | escape_once }}</title>
15
15
  <meta name="description" content="{{ meta_description | escape_once }}" />
16
+ {% include theme/json-ld.html %}
16
17
  <!-- Open Graph / Social Sharing -->
17
18
  {% assign og_type = page.og_type | default: "website" %}
18
19
  <meta property="og:type" content="{{ og_type }}" />
@@ -57,17 +58,54 @@
57
58
  <meta name="twitter:image" content="{{ og_image_url }}" />
58
59
  <!-- Canonical URL -->
59
60
  <link rel="canonical" href="{{ canonical_path | absolute_url }}" />
61
+ {% include theme/critical-preload.html %}
60
62
  <link rel="stylesheet" href="/assets/css/base.css" />
61
- <link rel="stylesheet" href="/assets/css/article.css" />
62
- {% include theme/theme-vars.html %}
63
- <link rel="stylesheet" href="/assets/css/cv.css" />
64
63
  <link rel="stylesheet" href="/assets/css/global-link.css" />
65
- {%- comment -%} Per-page ledger styles. One file per page so page work stays
66
- independent; shared component rules live in base.css. {%- endcomment -%}
67
- {%- for sheet in site.static_files -%}
68
- {%- if sheet.path contains "/assets/css/ledger/" -%}
69
- <link rel="stylesheet" href="{{ sheet.path }}" />
70
- {%- endif -%}
64
+ {% include theme/theme-vars.html %}
65
+ <link rel="stylesheet" href="/assets/css/ledger/chrome.css" />
66
+ <link rel="stylesheet" href="/assets/css/ledger/footer.css" />
67
+ {%- comment -%}
68
+ Ledger sheets stay with the layout that uses them. article.css is the
69
+ reading column, so only detail-capable layouts load it. An unknown
70
+ layout still receives every sheet.
71
+ {%- endcomment -%}
72
+ {%- case page.layout -%}
73
+ {%- when "home" -%}
74
+ {%- assign _ledger = "home" | split: "|" -%}
75
+ {%- assign _article_css = false -%}
76
+ {%- when "articles" -%}
77
+ {%- assign _ledger = "writing|detail" | split: "|" -%}
78
+ {%- assign _article_css = true -%}
79
+ {%- when "blog" -%}
80
+ {%- assign _ledger = "rascunhos|detail" | split: "|" -%}
81
+ {%- assign _article_css = true -%}
82
+ {%- when "cv" -%}
83
+ {%- assign _ledger = "cv|detail" | split: "|" -%}
84
+ {%- assign _article_css = true -%}
85
+ {%- when "projects" -%}
86
+ {%- assign _ledger = "projects|detail" | split: "|" -%}
87
+ {%- assign _article_css = true -%}
88
+ {%- when "resources" -%}
89
+ {%- assign _ledger = "sources" | split: "|" -%}
90
+ {%- assign _article_css = false -%}
91
+ {%- when "network" -%}
92
+ {%- assign _ledger = "network" | split: "|" -%}
93
+ {%- assign _article_css = false -%}
94
+ {%- when "bibliography" -%}
95
+ {%- assign _ledger = "bibliography" | split: "|" -%}
96
+ {%- assign _article_css = false -%}
97
+ {%- when "text" -%}
98
+ {%- assign _ledger = "detail" | split: "|" -%}
99
+ {%- assign _article_css = true -%}
100
+ {%- else -%}
101
+ {%- assign _ledger = "home|writing|detail|projects|rascunhos|bibliography|network|sources|cv" | split: "|" -%}
102
+ {%- assign _article_css = true -%}
103
+ {%- endcase -%}
104
+ {%- if _article_css -%}
105
+ <link rel="stylesheet" href="/assets/css/article.css" />
106
+ {%- endif -%}
107
+ {%- for _sheet in _ledger -%}
108
+ <link rel="stylesheet" href="/assets/css/ledger/{{ _sheet }}.css" />
71
109
  {%- endfor -%}
72
110
  <link href="https://fonts.googleapis.com" rel="preconnect" />
73
111
  <link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
@@ -78,8 +116,7 @@
78
116
  <style>
79
117
  :root { --drop-cap-lines: {{ site.drop_cap_lines | default: 2 }}; }
80
118
  </style>
81
- <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
82
- <script src="/assets/tailwind-play.js"></script>
119
+ <link rel="stylesheet" href="/assets/css/tailwind.css" />
83
120
  </head>
84
121
 
85
122
  <body class="bg-background text-on-surface">
@@ -419,6 +419,19 @@ html[data-theme="light"] .cv-product {
419
419
  padding: var(--space-xl);
420
420
  }
421
421
 
422
+ .cv-summary:empty,
423
+ .cv-dossier-note:empty {
424
+ min-height: calc(var(--type-body) * var(--leading-body) * 4);
425
+ }
426
+
427
+ .cv-reserve-card {
428
+ display: block;
429
+ min-height: 6.5rem;
430
+ border: 1.5px solid var(--border-ink);
431
+ border-radius: var(--radius-card);
432
+ background: var(--color-surface-card);
433
+ }
434
+
422
435
  .cv-summary p {
423
436
  margin: 0 0 1em;
424
437
  }