pandorga 1.6.3 → 1.6.4

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: 107838b82bee49181ddb10435ed0ab5726579fda06fe0bc0e5399ce0f17f86ad
4
- data.tar.gz: f8e893a43d87ed1b846a522cef98b2314c56efdfd304804354458d6c5ae9e0be
3
+ metadata.gz: c10e8353c0760ae19918f1a54deacb0b6d408975f11b83a0a8162c359d08a9bc
4
+ data.tar.gz: b5e4968d6049c318c1cd9cdde355e2ca1099e55f1aa5b85889622a3f31943f1d
5
5
  SHA512:
6
- metadata.gz: df29223d7d858b285e1b1e9cf991cd3362e192895fdbcbded93c3f2cc7991f7594779b45ee5a04d7c840ac7279cf26eb23bb704b25b55f17ab7b95d4a483743d
7
- data.tar.gz: 1e1ac9acc5c296025106b85da0bc4535ca67a3bf72061932dbffc1273f027f66d69666dc17f265eb40d9298b37b99cdf23cef2476c510a9cd1e4862a5b965bd0
6
+ metadata.gz: 43ea5a53a28f44d966d7a87b8bc9f6734e4e1fb029319f278e4ca2a3d0ee4c53bb1338486ab3c777eebfe9a0efb08262b757c8b1e4f17d0c5b44db50fb51fad2
7
+ data.tar.gz: 308655d3e64b7821d54a77ee4b203a60d15353f7464e5de772f2a11efb901b2e977bead7112ebb212cc5542a14668eb27886cfe9949cda388a2626ad8c50ddb7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.4] — 2026-10-10
4
+
5
+ ### Fixed
6
+
7
+ - The Material Symbols stylesheet no longer blocks first paint. It loads
8
+ as print media and is applied on load. The icon box stays 1em, and its
9
+ size stays inherited, so the Google sheet cannot restyle it after paint.
10
+ - The content runtime, including marked, comes after the page so the
11
+ hero is in the document before that script. A home band's kicker, title,
12
+ and note from `content/pages/headers.yml` are in the HTML.
13
+ - The hero subtitle from `content/pages/headers.yml` is in the HTML.
14
+ Leaving that line empty until script runs moved the portrait.
15
+ - On a phone the hero band is shorter, so the first section title stays in
16
+ the first viewport. That title was the late LCP element.
17
+
18
+ ### Added
19
+
20
+ - Lighthouse mobile is a release rule. A stable release scores 90 or above.
21
+ A score from 75 to 89 is acceptable only to ship a bug fix, and an urgent
22
+ performance campaign must then bring the score back to 90 or above.
23
+ Below 75 is never acceptable. CI runs one mobile pass on the example home.
24
+
25
+ No `pandorga:` config keys were added or renamed.
26
+
3
27
  ## [1.6.3] — 2026-10-10
4
28
 
5
29
  ### Fixed
data/README.md CHANGED
@@ -75,6 +75,15 @@ bundle exec pandorga serve
75
75
  | [docs/deploy/cloudflare.md](docs/deploy/cloudflare.md) | Pages + object store |
76
76
  | [docs/spec.md](docs/spec.md) | Migration plan and acceptance criteria |
77
77
  | [docs/decisions.md](docs/decisions.md) | Frozen decisions |
78
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Gates, and the Lighthouse mobile release rule |
79
+
80
+ ## Releases
81
+
82
+ Lighthouse mobile on the home page is mandatory. A stable release scores
83
+ 90 or above. A score from 75 to 89 is acceptable only to ship a bug fix,
84
+ and an urgent performance campaign must then bring it back to 90 or above.
85
+ Below 75 is never acceptable. The checklist is in
86
+ [CONTRIBUTING.md](CONTRIBUTING.md).
78
87
 
79
88
  ## License
80
89
 
@@ -329,8 +329,9 @@
329
329
  return;
330
330
  }
331
331
  if (inline) {
332
- el.innerHTML = stripSingleParagraph(parseInlineMarkdown(text));
333
- } else {
332
+ var next = stripSingleParagraph(parseInlineMarkdown(text));
333
+ if (el.innerHTML.trim() !== next.trim()) el.innerHTML = next;
334
+ } else if (el.textContent.trim() !== text) {
334
335
  el.textContent = text;
335
336
  }
336
337
  el.hidden = false;
@@ -23,11 +23,18 @@
23
23
  data-home-language="{{ _lang }}"
24
24
  >
25
25
  <div class="home-band-inner">
26
+ {%- assign _section = site.data.page_headers[band.section_key] -%}
27
+ {%- assign _title = band.title -%}
28
+ {%- if _section.title != blank -%}{%- assign _title = _section.title -%}{%- endif -%}
26
29
  <div class="ledger-section-banner" data-content-home-section="{{ band.section_key }}">
27
30
  <div class="ledger-section-lead">
28
- <span class="ledger-kicker" data-content-section-kicker></span>
29
- <h2 class="section-title" data-content-section-title></h2>
30
- <p class="home-section-note font-body" data-content-section-note></p>
31
+ {%- if _section.kicker != blank -%}
32
+ <span class="ledger-kicker" data-content-section-kicker>{{ _section.kicker }}</span>
33
+ {%- endif -%}
34
+ <h2 class="section-title" data-content-section-title>{{ _title }}</h2>
35
+ {%- if _section.note != blank -%}
36
+ <p class="home-section-note font-body" data-content-section-note>{{ _section.note }}</p>
37
+ {%- endif -%}
31
38
  </div>
32
39
  <div class="home-section-aside">
33
40
  <a class="ledger-cta" href="{{ _path }}">
data/_layouts/home.html CHANGED
@@ -9,9 +9,9 @@ layout: shell
9
9
  plus content fragments. Decorative art is a slot
10
10
  (`pandorga.home.hero_art`, default page/hero-default.html). Listing and
11
11
  index bands come from site.data.pandorga_home_bands in registry order.
12
- Band copy (kicker, title, note) arrives from content/pages/headers.yml
13
- via hydrateHomeSections(). No Portuguese or owner-specific strings live
14
- in this layout.
12
+ Band copy (kicker, title, note) and the hero subtitle come from
13
+ content/pages/headers.yml at build time. No Portuguese or owner-specific
14
+ strings live in this layout.
15
15
  {%- endcomment -%}
16
16
 
17
17
  {%- assign _hero_name = site.pandorga.identity | pandorga_hero_name: site.author, site.title -%}
@@ -28,8 +28,13 @@ layout: shell
28
28
  <div class="home-band-inner">
29
29
  <div class="home-hero">
30
30
  <div class="home-hero-copy">
31
+ {%- assign _home_header = site.data.page_headers.home -%}
32
+ {%- if _home_header.subtitle != blank -%}
33
+ <div id="home-subtitle-content" class="home-hero-tagline font-label" data-content-static>{{ _home_header.subtitle }}</div>
34
+ {%- else -%}
31
35
  <div id="home-subtitle-content" class="home-hero-tagline font-label"
32
36
  data-content-fragment="home/subtitle" data-content-quiet></div>
37
+ {%- endif -%}
33
38
  <h1 class="home-hero-title">
34
39
  <span class="home-hero-given">{{ _hero_name.given }}</span>{%- if _hero_name.family != "" %} <span class="home-hero-family">{{ _hero_name.family }}</span>{%- endif %}
35
40
  </h1>
data/_layouts/shell.html CHANGED
@@ -116,8 +116,15 @@
116
116
  <link rel="stylesheet" href="/assets/css/ledger/{{ _sheet }}.css" />
117
117
  {%- endfor -%}
118
118
  {% include theme/font-loader.html %}
119
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
120
- rel="stylesheet" />
119
+ {%- comment -%}
120
+ Icons are not first paint. A stylesheet link here blocks rendering for
121
+ a round trip to fonts.googleapis.com. Print media keeps that sheet off
122
+ the critical path, and onload applies it for screen.
123
+ {%- endcomment -%}
124
+ {%- assign _symbols_href = "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" -%}
125
+ <link rel="preload" as="style" href="{{ _symbols_href }}" />
126
+ <link rel="stylesheet" href="{{ _symbols_href }}" media="print" onload="this.media='all'" />
127
+ <noscript><link rel="stylesheet" href="{{ _symbols_href }}" /></noscript>
121
128
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css" />
122
129
  <style>
123
130
  :root { --drop-cap-lines: {{ site.drop_cap_lines | default: 2 }}; }
@@ -233,8 +240,6 @@
233
240
  })();
234
241
  </script>
235
242
 
236
- {% include page/content-runtime.html %}
237
-
238
243
  <div id="page-content" tabindex="-1">
239
244
  {{ content }}
240
245
  </div>
@@ -244,6 +249,8 @@
244
249
  <!-- Footer -->
245
250
  {% include page/site-footer.html nav_active=nav_active %}
246
251
 
252
+ {% include page/content-runtime.html %}
253
+
247
254
  </body>
248
255
 
249
256
  </html>
@@ -25,6 +25,7 @@ module Jekyll
25
25
  site.data["content_fragments"] ||= {}
26
26
 
27
27
  load_page_content(site, content_path)
28
+ load_page_headers(site, content_path)
28
29
  load_structured_data(site, content_path)
29
30
  end
30
31
 
@@ -40,6 +41,17 @@ module Jekyll
40
41
  end
41
42
  end
42
43
 
44
+ # content/pages/headers.yml is also exported as pages/headers.json.
45
+ # The home bands read it here so a section title and note are in the
46
+ # HTML. A heading painted later by the runtime becomes the LCP element.
47
+ def load_page_headers(site, content_path)
48
+ path = File.join(content_path, "pages", "headers.yml")
49
+ return unless File.file?(path)
50
+
51
+ loaded = load_yaml(path)
52
+ site.data["page_headers"] = loaded.is_a?(Hash) ? loaded : {}
53
+ end
54
+
43
55
  def load_structured_data(site, content_path)
44
56
  data_path = File.join(content_path, "collections", "data")
45
57
  return unless Dir.exist?(data_path)
data/assets/css/base.css CHANGED
@@ -1,12 +1,40 @@
1
1
  /* ── Material Symbols ── */
2
- .material-symbols-outlined {
2
+ /* Doubled class so this beats the Google stylesheet, which arrives later
3
+ and sets font-size: 24px. That late size change moves the hero. */
4
+ .material-symbols-outlined.material-symbols-outlined {
3
5
  font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
6
+ font-weight: normal;
7
+ font-style: normal;
8
+ font-size: inherit;
9
+ letter-spacing: normal;
4
10
  display: inline-flex;
5
11
  align-items: center;
6
12
  justify-content: center;
7
13
  vertical-align: middle;
8
14
  line-height: 1;
9
15
  text-transform: none;
16
+ white-space: nowrap;
17
+ /* One em square. Until the icon face arrives the ligature is a word,
18
+ and an unsized word widens the control. */
19
+ width: 1em;
20
+ height: 1em;
21
+ min-width: 1em;
22
+ overflow: hidden;
23
+ flex-shrink: 0;
24
+ }
25
+
26
+ .material-symbols-outlined.text-sm { font-size: 0.875rem; }
27
+ .material-symbols-outlined.text-lg { font-size: 1.125rem; }
28
+ .material-symbols-outlined.text-xl { font-size: 1.25rem; }
29
+ .material-symbols-outlined.text-5xl { font-size: 3rem; }
30
+
31
+ @media (min-width: 768px) {
32
+ .material-symbols-outlined.text-6xl { font-size: 3.75rem; }
33
+ }
34
+
35
+ .ledger-cta-arrow.material-symbols-outlined,
36
+ .ledger-cta-icon.material-symbols-outlined {
37
+ font-size: 1.35em;
10
38
  }
11
39
 
12
40
  /* Cubist triad — ~+25% over the 1.25rem theme-toggle box (rounded to 1.5rem). */
@@ -22,6 +50,8 @@
22
50
  font-size: var(--type-card);
23
51
  width: 1.25rem;
24
52
  height: 1.25rem;
53
+ min-width: 1.25rem;
54
+ max-width: 1.25rem;
25
55
  line-height: 1;
26
56
  }
27
57
 
@@ -74,6 +74,19 @@
74
74
  overflow: hidden;
75
75
  }
76
76
 
77
+ /* The first band title is the LCP text. On a phone the hero padding and the
78
+ gap under the copy were pushing that title just past the first viewport,
79
+ so the paint waited on a later frame. */
80
+ @media (max-width: 767px) {
81
+ .home-band--hero {
82
+ padding-block: 1.75rem 1.25rem;
83
+ }
84
+
85
+ .home-hero {
86
+ gap: var(--space-md);
87
+ }
88
+ }
89
+
77
90
  .home-band--hero .home-band-inner {
78
91
  position: relative;
79
92
  z-index: 1;
@@ -25,6 +25,7 @@ module Jekyll
25
25
  site.data["content_fragments"] ||= {}
26
26
 
27
27
  load_page_content(site, content_path)
28
+ load_page_headers(site, content_path)
28
29
  load_structured_data(site, content_path)
29
30
  end
30
31
 
@@ -40,6 +41,17 @@ module Jekyll
40
41
  end
41
42
  end
42
43
 
44
+ # content/pages/headers.yml is also exported as pages/headers.json.
45
+ # The home bands read it here so a section title and note are in the
46
+ # HTML. A heading painted later by the runtime becomes the LCP element.
47
+ def load_page_headers(site, content_path)
48
+ path = File.join(content_path, "pages", "headers.yml")
49
+ return unless File.file?(path)
50
+
51
+ loaded = load_yaml(path)
52
+ site.data["page_headers"] = loaded.is_a?(Hash) ? loaded : {}
53
+ end
54
+
43
55
  def load_structured_data(site, content_path)
44
56
  data_path = File.join(content_path, "collections", "data")
45
57
  return unless Dir.exist?(data_path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.6.3"
4
+ VERSION = "1.6.4"
5
5
  end
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # One Lighthouse mobile run against the built example home.
3
+ # Fails below 75. Warns below 90. PLT-AC-24.
4
+ set -euo pipefail
5
+
6
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+ SITE="${ROOT}/examples/minimal/_site"
8
+ PORT="${LIGHTHOUSE_PORT:-4173}"
9
+ OUT="${LIGHTHOUSE_REPORT:-/tmp/lighthouse-home.json}"
10
+ FAIL_BELOW=75
11
+ WARN_BELOW=90
12
+
13
+ fail() {
14
+ echo "ERROR: $1" >&2
15
+ exit 1
16
+ }
17
+
18
+ step() { echo "==> $1"; }
19
+
20
+ [[ -f "${SITE}/index.html" ]] || fail "missing ${SITE}/index.html (run scripts/validate.sh first)"
21
+
22
+ if [[ -n "${CHROME_PATH:-}" && -x "${CHROME_PATH}" ]]; then
23
+ CHROME="${CHROME_PATH}"
24
+ elif command -v google-chrome >/dev/null 2>&1; then
25
+ CHROME="$(command -v google-chrome)"
26
+ elif command -v google-chrome-stable >/dev/null 2>&1; then
27
+ CHROME="$(command -v google-chrome-stable)"
28
+ else
29
+ fail "Chrome is not installed"
30
+ fi
31
+
32
+ step "Serving ${SITE}"
33
+ python3 -m http.server "${PORT}" --bind 127.0.0.1 --directory "${SITE}" >/tmp/lighthouse-home-server.log 2>&1 &
34
+ SERVER_PID=$!
35
+ cleanup() {
36
+ kill "${SERVER_PID}" >/dev/null 2>&1 || true
37
+ }
38
+ trap cleanup EXIT
39
+
40
+ ready=0
41
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
42
+ if curl -sf "http://127.0.0.1:${PORT}/" >/dev/null; then
43
+ ready=1
44
+ break
45
+ fi
46
+ sleep 0.25
47
+ done
48
+ [[ "${ready}" == "1" ]] || fail "example server did not answer on port ${PORT}"
49
+
50
+ step "Lighthouse mobile (one run)"
51
+ rm -f "${OUT}"
52
+ npx --yes lighthouse@13.5.0 "http://127.0.0.1:${PORT}/" \
53
+ --only-categories=performance \
54
+ --form-factor=mobile \
55
+ --output=json \
56
+ --output-path="${OUT}" \
57
+ --quiet \
58
+ --no-enable-error-reporting \
59
+ --chrome-path="${CHROME}" \
60
+ --chrome-flags="--headless=new --no-sandbox --disable-gpu"
61
+
62
+ python3 - "${OUT}" "${FAIL_BELOW}" "${WARN_BELOW}" <<'PY'
63
+ import json
64
+ import sys
65
+
66
+ path, fail_below, warn_below = sys.argv[1:]
67
+ fail_below = int(fail_below)
68
+ warn_below = int(warn_below)
69
+ report = json.load(open(path, encoding="utf-8"))
70
+ raw = report.get("categories", {}).get("performance", {}).get("score")
71
+ if raw is None:
72
+ print("ERROR: Lighthouse returned no performance score", file=sys.stderr)
73
+ sys.exit(1)
74
+ score = int(round(float(raw) * 100))
75
+ print(f"Lighthouse mobile performance: {score}")
76
+ if score < fail_below:
77
+ print(f"::error::Lighthouse mobile performance {score} is below {fail_below}")
78
+ sys.exit(1)
79
+ if score < warn_below:
80
+ print(
81
+ f"::warning::Lighthouse mobile performance {score} is below {warn_below}. "
82
+ "A score from 75 to 89 is acceptable only to ship a bug fix. "
83
+ "An urgent performance campaign must bring it back to 90 or above."
84
+ )
85
+ PY
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Lighthouse mobile release rule and the non-blocking icon sheet.
5
+ # PLT-AC-24, PLT-AC-25.
6
+
7
+ require "pathname"
8
+
9
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
10
+
11
+ def fail!(message)
12
+ warn "FAIL test-lighthouse-policy: #{message}"
13
+ exit 1
14
+ end
15
+
16
+ docs = {
17
+ "spec" => ROOT.join("docs/spec.md").read,
18
+ "contributing" => ROOT.join("CONTRIBUTING.md").read,
19
+ "readme" => ROOT.join("README.md").read
20
+ }
21
+ shell = ROOT.join("_layouts/shell.html").read
22
+ base = ROOT.join("assets/css/base.css").read
23
+ script = ROOT.join("scripts/lighthouse-home.sh").read
24
+ workflow = ROOT.join(".github/workflows/validate.yml").read
25
+
26
+ docs.each do |name, text|
27
+ fail!("#{name} does not require 90 or above") unless text.include?("90 or above")
28
+ fail!("#{name} does not limit 75 to 89 to a bug fix") unless text.include?("only to ship a bug fix")
29
+ fail!("#{name} does not require the performance campaign") unless text.include?("urgent performance campaign")
30
+ fail!("#{name} does not forbid a score below 75") unless text.include?("Below 75 is never acceptable")
31
+ end
32
+
33
+ spec = docs.fetch("spec")
34
+ contributing = docs.fetch("contributing")
35
+
36
+ fail!("spec dropped the slower-release reason") unless spec.include?("slower release is better than a broken one")
37
+ fail!("contributing dropped the slower-release reason") unless contributing.include?("slower release is better than a broken one")
38
+
39
+ fail!("lighthouse script is not the CI check") unless workflow.include?("./scripts/lighthouse-home.sh")
40
+ fail!("lighthouse script lost the fail floor") unless script.include?("FAIL_BELOW=75")
41
+ fail!("lighthouse script lost the warn floor") unless script.include?("WARN_BELOW=90")
42
+ fail!("lighthouse script is not a single mobile run") unless script.include?("--form-factor=mobile")
43
+ fail!("lighthouse script repeats runs") if script.include?("--repeat")
44
+
45
+ screen = shell.sub(%r{<noscript>.*?</noscript>}m, "")
46
+ fail!("Material Symbols sheet is missing") unless shell.include?("Material+Symbols+Outlined")
47
+ fail!("Material Symbols sheet still blocks first paint") unless screen.include?('media="print" onload="this.media=\'all\'"')
48
+ fail!("screen still has a blocking Material Symbols stylesheet") if screen.match?(/rel="stylesheet"[^>]*Material\+Symbols|Material\+Symbols[^>]*rel="stylesheet"/m)
49
+
50
+ icon = base[/\.material-symbols-outlined\.material-symbols-outlined \{.*?\n\}/m]
51
+ fail!("icon rule missing") unless icon
52
+ fail!("icon box is not 1em") unless icon.include?("width: 1em;") && icon.include?("height: 1em;")
53
+ fail!("icon size can still jump when the Google sheet arrives") unless icon.include?("font-size: inherit;")
54
+ fail!("icon box does not clip the fallback word") unless icon.include?("overflow: hidden;")
55
+ fail!("content runtime still precedes the page") unless shell.index("{{ content }}") && shell.index("page/content-runtime.html") > shell.index("{{ content }}")
56
+ band = File.read(File.expand_path("../../_includes/home/band-listing.html", __dir__))
57
+ fail!("band title is still painted by script") unless band.include?('data-content-section-title>{{ _title }}')
58
+ fail!("band note is still painted by script") unless band.include?("page_headers")
59
+ headers = File.read(File.expand_path("../../_plugins/content_headers.rb", __dir__))
60
+ fail!("page headers are not loaded at build time") unless headers.include?('site.data["page_headers"]')
61
+ home = File.read(File.expand_path("../../_layouts/home.html", __dir__))
62
+ fail!("hero subtitle is still painted by script") unless home.include?("page_headers.home") && home.include?("data-content-static")
63
+
64
+ puts "PASS test-lighthouse-policy"
data/scripts/validate.sh CHANGED
@@ -59,6 +59,7 @@ TESTS=(
59
59
  scripts/test/test-shell-a11y.rb
60
60
  scripts/test/test-hydrate-reserve.rb
61
61
  scripts/test/test-hero-summary.rb
62
+ scripts/test/test-lighthouse-policy.rb
62
63
  scripts/test/test-css-delivery.rb
63
64
  scripts/test/test-discovery.rb
64
65
  )
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pandorga
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.3
4
+ version: 1.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -322,6 +322,7 @@ files:
322
322
  - scripts/lib/r2_json_sync.rb
323
323
  - scripts/lib/withdrawn_public_objects.rb
324
324
  - scripts/lib/writing_index_contract.rb
325
+ - scripts/lighthouse-home.sh
325
326
  - scripts/pdf/md-to-pdf.mjs
326
327
  - scripts/pdf/md-to-pdf.sh
327
328
  - scripts/tailwind/build.sh
@@ -412,6 +413,7 @@ files:
412
413
  - scripts/test/test-install-functions.rb
413
414
  - scripts/test/test-language-export.rb
414
415
  - scripts/test/test-ledger-blocks.rb
416
+ - scripts/test/test-lighthouse-policy.rb
415
417
  - scripts/test/test-listing-filter-contract.rb
416
418
  - scripts/test/test-math-row-breaks.mjs
417
419
  - scripts/test/test-math-row-breaks.rb