pandorga 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c33cb11f60569d5280aa7079b6f59ad7776f90705f54f67d958d8b149390714d
4
- data.tar.gz: 4a52dcb27be1f4d2a809417bc9b98e89e3c8aa8d92e642fca9ebab156942a192
3
+ metadata.gz: 2bb5b2e689bf56f24930270b0f6dff89d91674594b67267a7e971a96252f1ac1
4
+ data.tar.gz: f5ded06e9a148e5095437afae98852a63aaf994737e38a91c5b5c9d4f6a9ada9
5
5
  SHA512:
6
- metadata.gz: ecf8762ab684b723db57bb8d291609a3decf8ecae02f21b0158c3fd34afce67b9a34347255e790e26d480e8ef0b2eaab577dbe3d6ed571a02ce713cc5a2e09d9
7
- data.tar.gz: 0c30da38132b4b87f2caf982a73f7c8eea1cf157600ecffb9b6f87e24fe289a47952fe3f9c8291c11719531a90cabd0149db89ba13b076c341a4eafabd6577b5
6
+ metadata.gz: 0e18926a06e09cab67eea5ff3df47893e70abda71e2dad6ef7686f64a69f2b0d7527a65efe4069ec55342c51f6d7aa86cbb47f5005ded2650a1abe7c9ef55724
7
+ data.tar.gz: fda5ff032a0dcf24995bdfa280b80e3b2470c2e8e5bf455cfbbcac149df30d02637fd69e947562c0c7fe1f09a0834ae15dd6976c15411e92dea5a9a95dca2d00
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0] — 2026-10-10
4
+
5
+ ### Added
6
+
7
+ - `/sitemap.xml` lists the generated pages. `/robots.txt` allows the public
8
+ site, skips `/studio/`, and names the sitemap. Both use
9
+ `pandorga.identity.url` when it is set, otherwise `site.url`.
10
+ - JSON-LD `WebSite` and `Person` in the shell, from `identity.name` and the
11
+ optional `identity.url`. `site.description` is included when it is set.
12
+ - Hero portrait entries may include optional `srcset` and `sizes`. A portrait
13
+ that only has `src` is unchanged.
14
+
15
+ ### Changed
16
+
17
+ - The home subtitle and summary are requested before the stylesheets and
18
+ painted without a "Loading..." swap. Article rows, CV sections, and empty
19
+ home bands keep a reserved box until the real content replaces it.
20
+ - The hero subtitle stays at `--type-body`, the same size as the summary.
21
+ - `.writing-revised` uses the solid variant ink. The transparent mix was
22
+ 3.69 and 3.90 on the light paper.
23
+ - A selectable project inventory row is a `button`.
24
+ - Utilities ship as `assets/css/tailwind.css`. The shell no longer loads the
25
+ Tailwind Play CDN. Each layout links the shared chrome and its own ledger
26
+ sheet, not every sheet.
27
+
28
+ No `pandorga:` config keys were added or renamed. `srcset` and `sizes` are
29
+ optional fields on objects in `pages/home/portraits.json`.
30
+
3
31
  ## [1.5.1] — 2026-10-10
4
32
 
5
33
  ### 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 -%}
@@ -90,8 +90,14 @@ layout: shell
90
90
  </div>
91
91
 
92
92
  <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>
93
+ <section id="empty-state" class="writing-archive-reserve" aria-busy="true">
94
+ <p class="sr-only">Loading articles...</p>
95
+ <span class="writing-reserve-row" aria-hidden="true"></span>
96
+ <span class="writing-reserve-row" aria-hidden="true"></span>
97
+ <span class="writing-reserve-row" aria-hidden="true"></span>
98
+ <span class="writing-reserve-row" aria-hidden="true"></span>
99
+ <span class="writing-reserve-row" aria-hidden="true"></span>
100
+ <span class="writing-reserve-row" aria-hidden="true"></span>
95
101
  </section>
96
102
  <section id="no-results" class="writing-archive-state hidden">
97
103
  <p class="font-body text-on-surface-variant italic">Nothing matches these filters yet.</p>
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
  }
@@ -239,6 +239,10 @@
239
239
  margin: 0;
240
240
  }
241
241
 
242
+ .home-hero-tagline:empty {
243
+ min-height: calc(var(--type-body) * 1.35);
244
+ }
245
+
242
246
  /* Name spans the copy column (container query on .home-hero-copy). The floor
243
247
  is the band-title step — Articles, Rascunhos, Projects — so a phone does
244
248
  not drop the name to the card size to keep it on one line. From 640px the
@@ -308,6 +312,12 @@
308
312
  line-height: var(--leading-body);
309
313
  }
310
314
 
315
+ /* Hold the summary's box until the fragment paints, so the CTA under it
316
+ does not jump when the paragraph arrives. */
317
+ .home-hero-summary:empty {
318
+ min-height: calc(var(--type-body) * var(--leading-body) * 6);
319
+ }
320
+
311
321
  .home-hero-summary p {
312
322
  margin: 0 0 var(--space-md);
313
323
  }
@@ -781,6 +791,16 @@ html[data-theme="light"] .home-hero-plate {
781
791
  }
782
792
 
783
793
  /* ── Writing cards ── */
794
+ .home-card-grid:empty,
795
+ .home-draft-list:empty,
796
+ [data-home-featured-slot]:empty {
797
+ min-height: 16rem;
798
+ }
799
+
800
+ .home-project-inventory:empty {
801
+ min-height: 8rem;
802
+ }
803
+
784
804
  .home-card-grid {
785
805
  display: grid;
786
806
  grid-template-columns: minmax(0, 1fr);
@@ -1232,6 +1252,13 @@ html[data-theme="light"] .home-hero-plate {
1232
1252
  padding: 0;
1233
1253
  }
1234
1254
 
1255
+ button.home-project-inventory-row {
1256
+ font: inherit;
1257
+ color: inherit;
1258
+ text-align: start;
1259
+ width: 100%;
1260
+ }
1261
+
1235
1262
  .home-project-inventory-row {
1236
1263
  position: relative;
1237
1264
  display: flex;
@@ -272,10 +272,34 @@ html[data-theme="light"] .writing-index .writing-lead-media .ledger-poster {
272
272
  }
273
273
  }
274
274
 
275
- /* The revision date is the quieter half of the ledger line: it explains the
276
- default order without competing with the publication date. */
275
+ /* The revision date is the quieter half of the ledger line. It uses the
276
+ variant ink solid: a 78% mix with transparent measured 3.69 and 3.90
277
+ against the light paper, under the 4.5:1 text floor. */
277
278
  .writing-index .writing-revised {
278
- color: color-mix(in srgb, var(--color-on-surface-variant) 78%, transparent);
279
+ color: var(--color-on-surface-variant);
280
+ }
281
+
282
+ .writing-index .writing-archive-reserve {
283
+ display: flex;
284
+ flex-direction: column;
285
+ gap: var(--space-md);
286
+ max-width: none;
287
+ padding: 0;
288
+ background: transparent;
289
+ border: 0;
290
+ }
291
+
292
+ .writing-index .writing-reserve-row {
293
+ display: block;
294
+ min-height: 7.25rem;
295
+ border: 1.5px solid var(--border-ink);
296
+ border-radius: var(--radius-card);
297
+ background: var(--color-surface-card);
298
+ }
299
+
300
+ #articles-lead:empty {
301
+ min-height: 16rem;
302
+ margin-bottom: var(--space-xl);
279
303
  }
280
304
 
281
305
  /* ══ Archive and rail ══