pandorga 1.7.3 → 1.7.5

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: c5c04a8266084060b4aa4810ef57d5d9c25a29302ebd8d946183ba2b95a2daeb
4
- data.tar.gz: b0aceffe59d1febefc9a308db429f7da42856463b1b7143bdb23f125729de192
3
+ metadata.gz: 50cefbd5818ff3cacd667823f637d8dd3aca3a0dd06fb78a01f44d68954b8a28
4
+ data.tar.gz: a602af73319ba4acc679ffdc30d75b555663dfb0ce54febf32823ce8e213cc03
5
5
  SHA512:
6
- metadata.gz: 22939efa0329adbf9cc11e0ba771a9fd11d0e154777df7918a1a7bbf821295f3592f4ca1233e43448578deb8a9c69955696f3df282c9f435389e371c69815c1d
7
- data.tar.gz: 70590f4574d43b101ce9e4148d53df33fc1f6f6e2225df5368d0b11a636d425b1300a10d150038c8d2494e004aaa78f7efd495d56a5cadadee1f671f6ae58158
6
+ metadata.gz: 97f05ec16f99648f22c74146bb9f8ce497e3afa759727b8cc5d09f50dcdceee955e3c993e1ada003cd1492a5cd0c9975dbfa9d05bd56668aaf97eec945236425
7
+ data.tar.gz: a6a9494a944b8807f594e1f3c1dd6bc8337aade5ba8c53a7b29e3a905575d39f6b5fd310ad99ac00d7d017d44b182e1d78db85e52c218057d430848455d0135f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.5] — 2026-10-12
4
+
5
+ ### Fixed
6
+
7
+ - A display equation scrolls inside the article column. On a 390px
8
+ viewport a MathJax display box about 455px wide was stretching the
9
+ document to a scrollWidth of 667, so the page moved sideways and the
10
+ sticky header scrolled with it. `mjx-container[display="true"]` and
11
+ `mjx-container[jax][display="true"]` are `max-width: 100%` with
12
+ `overflow-x: auto` and `overflow-y: hidden`. Tables, figures, and
13
+ article SVG are bounded to the column. Mermaid still scrolls inside
14
+ `.mermaid`. `html` and `body` do not set overflow, so the sticky
15
+ header still sticks.
16
+ - A detail URL served by the listing shell had two `h1` elements: the
17
+ listing title (Articles, Rascunhos, Projects, Curriculum) and the
18
+ entry title. While the entry is showing, the listing heading is not
19
+ an `h1`. The document title is the entry title, and `html` `lang`
20
+ follows the entry (`en`, `pt-BR`, `fr`, `es`). Articles, posts,
21
+ projects, and products.
22
+
23
+ No `pandorga:` config keys were added or renamed. Font roles and the
24
+ ledger fallbacks are unchanged.
25
+
26
+ ## [1.7.4] — 2026-10-12
27
+
28
+ ### Fixed
29
+
30
+ - The footer imprint separator uses `outline` on the panel step.
31
+ `outline-variant` on that panel is 1.80:1 in light (`#b8b8b8` on
32
+ `#f4f4f4`) and 1.93:1 in dark (`#45454c` on `#141418`). `outline`
33
+ on the same panels is 6.08:1 and 5.95:1.
34
+
35
+ ### Changed
36
+
37
+ - Unused selectors are gone. `assets/css/ledger/home.css` drops
38
+ `.home-hero-pandorga-svg` (25 bytes). The hero SVG uses
39
+ `home-hero-art-svg`. `assets/css/ledger/detail.css` drops 916 bytes:
40
+ the author byline suppressor and the `.table-csv` companions. No
41
+ template or runtime emits either class. 941 bytes in all.
42
+ - The one-shot key backfill is gone (`scripts/content/backfill-content-keys.rb`,
43
+ 3,432 bytes). Nothing called it.
44
+ - The watch-path test that read `docs/ops/cloudflare.md` is gone
45
+ (1,230 bytes). That path is not in the tree.
46
+ - Deploy notes pin `~> 1.3` and tag `v1.7.4`. The Pages watch paths
47
+ include `*` and exclude `content/*` and `docs/*`.
48
+
49
+ No `pandorga:` config keys were added or renamed. Font roles and the
50
+ ledger fallbacks are unchanged.
51
+
3
52
  ## [1.7.3] — 2026-10-12
4
53
 
5
54
  ### Fixed
@@ -526,11 +526,71 @@
526
526
  el.setAttribute('href', url);
527
527
  }
528
528
 
529
+ /* The listing shell is one document for the index and every detail URL.
530
+ The static h1 is the index title ("Articles"). The runtime then paints
531
+ the entry title as a second h1. Demote the index heading while a detail
532
+ is showing so the document has one h1, and put it back for the index. */
533
+ var listingDocumentSnapshot = null;
534
+
535
+ function rememberListingDocument() {
536
+ if (listingDocumentSnapshot) return;
537
+ listingDocumentSnapshot = {
538
+ title: document.title,
539
+ lang: document.documentElement.getAttribute('lang') || ''
540
+ };
541
+ }
542
+
543
+ function concealListingHeader() {
544
+ var header = document.querySelector('[data-content-page-header]');
545
+ if (!header) return;
546
+ header.setAttribute('hidden', '');
547
+ var heading = header.querySelector('h1');
548
+ if (!heading) return;
549
+ var replacement = document.createElement('p');
550
+ replacement.className = heading.className;
551
+ replacement.setAttribute('data-listing-heading', 'demoted');
552
+ while (heading.firstChild) replacement.appendChild(heading.firstChild);
553
+ heading.replaceWith(replacement);
554
+ }
555
+
556
+ function restoreListingDocument() {
557
+ var header = document.querySelector('[data-content-page-header]');
558
+ if (header) {
559
+ header.removeAttribute('hidden');
560
+ var demoted = header.querySelector('[data-listing-heading="demoted"]');
561
+ if (demoted) {
562
+ var heading = document.createElement('h1');
563
+ heading.className = demoted.className;
564
+ while (demoted.firstChild) heading.appendChild(demoted.firstChild);
565
+ demoted.replaceWith(heading);
566
+ }
567
+ }
568
+ if (!listingDocumentSnapshot) return;
569
+ document.title = listingDocumentSnapshot.title;
570
+ document.documentElement.lang = listingDocumentSnapshot.lang;
571
+ listingDocumentSnapshot = null;
572
+ }
573
+
574
+ /* Site codes (enus, ptbr, en_us, pt_br) are not BCP-47. The html lang
575
+ attribute has to be. */
576
+ function htmlLangFromContent(language) {
577
+ var compact = String(language || '').trim().toLowerCase().replace(/[_-]/g, '');
578
+ if (!compact) return '';
579
+ var primary = compact.slice(0, 2);
580
+ if (primary === 'en') return 'en';
581
+ if (primary === 'pt') return 'pt-BR';
582
+ if (primary === 'fr') return 'fr';
583
+ if (primary === 'es') return 'es';
584
+ return '';
585
+ }
586
+
529
587
  function applyDocumentMeta(meta) {
530
588
  meta = meta || {};
531
589
  var siteSuffix = (window.ContentRuntimeConfig && window.ContentRuntimeConfig.siteSuffix) || '';
532
590
  var title = meta.title ? String(meta.title) + siteSuffix : document.title;
533
591
  document.title = title;
592
+ var htmlLang = htmlLangFromContent(meta.lang);
593
+ if (htmlLang) document.documentElement.lang = htmlLang;
534
594
  if (meta.description) setMetaTag('description', meta.description);
535
595
  setMetaTag('og:title', title, 'property');
536
596
  if (meta.description) setMetaTag('og:description', meta.description, 'property');
@@ -736,12 +796,15 @@
736
796
 
737
797
  async function renderRuntimeDetailSection(options) {
738
798
  var fm = options.frontMatter || {};
799
+ rememberListingDocument();
800
+ concealListingHeader();
739
801
  if (options.documentMeta !== false) {
740
802
  applyDocumentMeta({
741
803
  title: fm.title,
742
804
  description: fm.description,
743
805
  url: window.location.pathname,
744
- image: fm.og_image || fm.thumbnail
806
+ image: fm.og_image || fm.thumbnail,
807
+ lang: fm.language || ((fm.layout === 'post' || options.detailAttribute === 'data-dynamic-post-detail') ? 'ptbr' : '')
745
808
  });
746
809
  }
747
810
  var section = document.createElement('section');
@@ -385,7 +385,7 @@
385
385
  }
386
386
 
387
387
  /* The home page's reference band opens each panel with a plate drawn from
388
- * that section's own content by the export (docs/features/home-band-art.md).
388
+ * that section's own content by the export (docs/deploy/cloudflare.md).
389
389
  * The markup arrives as an SVG document and is injected inline, never as an
390
390
  * <img>: every stroke and fill in it is a custom property, and an external
391
391
  * document cannot see the palette the page is currently wearing.
@@ -39,6 +39,7 @@
39
39
  insertRuntimeBackLink: insertRuntimeBackLink,
40
40
  removeRuntimeBackLink: removeRuntimeBackLink,
41
41
  renderRuntimeDetailSection: renderRuntimeDetailSection,
42
+ restoreListingDocument: restoreListingDocument,
42
43
  reportDetailLoadError: reportDetailLoadError,
43
44
  buildDetailActions: buildDetailActions,
44
45
  buildTextSidebar: buildTextSidebar,
@@ -434,6 +434,7 @@ layout: shell
434
434
  if (detailSidebar) detailSidebar.innerHTML = '';
435
435
  runtime.removeRuntimeBackLink();
436
436
  runtime.removeArticleToc();
437
+ runtime.restoreListingDocument();
437
438
  }
438
439
 
439
440
  async function renderArticleDetail(article, articles) {
data/_layouts/blog.html CHANGED
@@ -425,6 +425,7 @@ layout: shell
425
425
  if (detailSidebar) detailSidebar.innerHTML = '';
426
426
  runtime.removeRuntimeBackLink();
427
427
  runtime.removeArticleToc();
428
+ runtime.restoreListingDocument();
428
429
  }
429
430
 
430
431
  async function renderPostDetail(post, posts) {
data/_layouts/cv.html CHANGED
@@ -280,6 +280,7 @@ layout: shell
280
280
  if (detailMain) detailMain.innerHTML = '';
281
281
  if (detailSidebar) detailSidebar.innerHTML = '';
282
282
  runtime.removeRuntimeBackLink();
283
+ runtime.restoreListingDocument();
283
284
  }
284
285
 
285
286
  async function renderProductDetail(item) {
@@ -354,6 +354,7 @@ layout: shell
354
354
  if (detailHero) detailHero.innerHTML = '';
355
355
  runtime.removeArticleToc();
356
356
  runtime.removeRuntimeBackLink();
357
+ runtime.restoreListingDocument();
357
358
  }
358
359
 
359
360
  function renderListing(projects) {
@@ -112,44 +112,58 @@
112
112
  which outranks `.article-content mjx-container[display="true"]` on
113
113
  specificity. The 0.5rem this file asked for was never what the page drew.
114
114
 
115
- So the wrapper owns the gap, the scroll and the padding; the container
116
- inside it owns none of them. */
115
+ The wrapper owns the paragraph gap. The container owns the scroll. */
117
116
  .article-content .math-display {
118
117
  display: block;
118
+ max-width: 100%;
119
119
  /* The bottom gap is the paragraph gap `.article-content p` sets in
120
120
  ledger/detail.css; the top gap is left to the previous block, exactly as
121
121
  it is between two paragraphs. The negative top margin hands back the
122
- padding below, which a margin collapse cannot absorb — the two are one
123
- number, so change them together or the rhythm moves. */
122
+ container padding below, which a margin collapse cannot absorb. The two
123
+ are one number, so change them together or the rhythm moves. */
124
124
  margin: -1.25rem 0 calc(1.15em - 1.25rem);
125
- /* overflow-x lets an over-wide equation scroll sideways. CSS forces
126
- overflow-y to auto alongside it, so a bracket, radical or matrix rule that
127
- MathJax draws past its own line box gets clipped, or grows a vertical
128
- scrollbar it has no use for. The padding is the allowance for that
129
- overshoot: 1.25rem clears the worst case on the object-centric article
130
- (a 3-row bmatrix, measured at 12px past a 0.5rem allowance). It costs
131
- nothing visually, because the margin above gives it straight back. */
132
- padding: 1.25rem 0;
125
+ padding: 0;
133
126
  overflow-x: auto;
127
+ overflow-y: hidden;
134
128
  text-align: center;
135
129
  }
136
130
 
137
- /* `[jax]` is redundant as a selector — every MathJax container carries it —
138
- but it lifts this rule past MathJax's own injected margin, which is the
139
- whole point of the rule. */
131
+ /* `[jax]` lifts this rule past MathJax's injected margin
132
+ (`mjx-container[jax="CHTML"][display="true"]`). The container is the clip
133
+ edge: on a 390px phone a display `mjx-math` about 455px wide was stretching
134
+ the document to a scrollWidth of 667, because this rule was
135
+ `overflow: visible`. The page then scrolled sideways and the sticky header
136
+ moved with it. `max-width: 100%` plus `overflow-x: auto` and
137
+ `overflow-y: hidden` scroll the equation inside the column, the way `pre`
138
+ already scrolls code. `overflow-y: hidden` is stated so a radical does not
139
+ grow a vertical scrollbar, and so a specified `visible` cannot let the
140
+ equation's min-content widen the column. The 1.25rem padding is the
141
+ allowance for a bracket, radical, or matrix rule MathJax draws past its
142
+ line box (a 3-row bmatrix, measured at 12px past a 0.5rem allowance).
143
+ Do not move this overflow onto `html` or `body`. That scrollport breaks
144
+ `position: sticky` on the header. */
140
145
  .article-content .math-display mjx-container[jax][display="true"] {
146
+ display: block;
141
147
  margin: 0;
142
- padding: 0;
143
- overflow: visible;
148
+ padding: 1.25rem 0;
149
+ max-width: 100%;
150
+ overflow-x: auto;
151
+ overflow-y: hidden;
152
+ text-align: center;
144
153
  }
145
154
 
146
155
  /* Display math the wrapper regex cannot reach, because the `$$` sits inside a
147
156
  list item or a table cell rather than alone in a paragraph. It still has to
148
157
  break the line; its vertical margin stays MathJax's own 1em, which lands
149
- within a pixel of the paragraph gap. */
150
- .article-content mjx-container[display="true"] {
158
+ within a pixel of the paragraph gap. `[jax]` names the same box. Every
159
+ MathJax container carries it, and the attribute keeps this rule ahead of
160
+ the injected stylesheet. */
161
+ .article-content mjx-container[display="true"],
162
+ .article-content mjx-container[jax][display="true"] {
151
163
  display: block;
164
+ max-width: 100%;
152
165
  overflow-x: auto;
166
+ overflow-y: hidden;
153
167
  text-align: center;
154
168
  }
155
169
 
@@ -248,7 +262,9 @@
248
262
  :is(.article-content, .article-cite-block) pre {
249
263
  background: var(--color-surface-container);
250
264
  padding: 1rem;
265
+ max-width: 100%;
251
266
  overflow-x: auto;
267
+ overflow-y: hidden;
252
268
  margin-bottom: 1rem;
253
269
  font-family: var(--font-code);
254
270
  }
@@ -359,11 +375,30 @@
359
375
  margin: 1.5rem auto;
360
376
  text-align: center;
361
377
  width: 100%;
378
+ max-width: 100%;
362
379
  display: block;
363
380
  background: var(--color-surface-container-low);
364
381
  padding: var(--space-md);
365
382
  }
366
383
 
384
+ /* A wide SVG, video, or iframe must stay inside the plate. Cut-in plates
385
+ and YouTube shorts set a narrower max-width on a more specific rule.
386
+ Mermaid and display-math SVG opt back out below so those boxes scroll
387
+ instead of shrinking the drawing. Iframes keep their own height: `height:
388
+ auto` collapses a YouTube or Plotly frame that has no intrinsic ratio. */
389
+ .article-content figure svg,
390
+ .article-content svg {
391
+ max-width: 100%;
392
+ height: auto;
393
+ }
394
+
395
+ .article-content figure video,
396
+ .article-content figure iframe,
397
+ .article-content figure canvas,
398
+ .article-content iframe {
399
+ max-width: 100%;
400
+ }
401
+
367
402
  .article-content figure img {
368
403
  margin-inline: auto;
369
404
  }
@@ -554,7 +589,9 @@ body.figure-lightbox-open {
554
589
  padding: var(--space-xl) var(--space-lg);
555
590
  background: var(--color-surface-container);
556
591
  text-align: center;
592
+ max-width: 100%;
557
593
  overflow-x: auto;
594
+ overflow-y: hidden;
558
595
  -webkit-overflow-scrolling: touch;
559
596
  }
560
597
 
@@ -567,6 +604,15 @@ body.figure-lightbox-open {
567
604
  height: auto;
568
605
  }
569
606
 
607
+ /* SVG output of a display equation keeps its own width and scrolls inside
608
+ `mjx-container`. Inline math is not included: `display: block` here would
609
+ pull an inline equation out of the line. */
610
+ .article-content mjx-container[display="true"] svg {
611
+ max-width: none;
612
+ width: auto;
613
+ height: auto;
614
+ }
615
+
570
616
  /* Captions are sentences. The include centres the whole figure, and a centred
571
617
  paragraph of provenance is harder to read than a left-set one; the rule on
572
618
  the element itself beats the `text-center` utility the runtime emits. The
@@ -628,6 +674,7 @@ body.figure-lightbox-open {
628
674
  /* ── Tables ── */
629
675
  .article-content table {
630
676
  width: 100%;
677
+ max-width: 100%;
631
678
  border-collapse: collapse;
632
679
  margin-bottom: 1.5rem;
633
680
  font-size: var(--type-dek);
@@ -323,6 +323,17 @@ a.back-link .material-symbols-outlined {
323
323
 
324
324
  Every detail page uses this one rule, including the projects page, which
325
325
  only differs in holding the split back to 1024px (ledger/projects.css). */
326
+ /* A grid item's automatic minimum is its widest child. A display equation
327
+ used to be that child, so the column grew past the viewport, the document
328
+ scrolled sideways, and the sticky header scrolled with it. The track is
329
+ already allowed to shrink (`minmax(0, 1fr)` from 768px, and `grid-cols-1`
330
+ below that). The item has to be allowed to shrink with it. Wide children
331
+ scroll inside their own boxes; this does not put overflow on `html` or
332
+ `body`. */
333
+ .grid.text-detail-grid > * {
334
+ min-width: 0;
335
+ }
336
+
326
337
  @media (min-width: 768px) {
327
338
  .grid.text-detail-grid {
328
339
  --detail-column-gap: 2rem;
@@ -485,11 +496,9 @@ aside:has(> .text-detail-sidebar) {
485
496
  font-variant-numeric: tabular-nums;
486
497
  }
487
498
 
488
- /* Projects and products can carry no meta at all, and on articles and posts
489
- the byline below is the block's only child. Either way an all-empty meta
499
+ /* Projects and products can carry no meta at all. An all-empty meta
490
500
  block must not hang a stub of padding off the bottom of the panel. */
491
- .text-detail-sidebar .article-meta:has(> div:empty),
492
- .text-detail-sidebar .article-meta:has(> div > span.text-secondary:not(.font-bold):only-child) {
501
+ .text-detail-sidebar .article-meta:has(> div:empty) {
493
502
  display: none;
494
503
  }
495
504
 
@@ -497,14 +506,6 @@ aside:has(> .text-detail-sidebar) {
497
506
  gap: var(--space-sm);
498
507
  }
499
508
 
500
- /* Author byline. Dropped: single-author site. The runtime still emits it, so
501
- it is suppressed here until `buildTextSidebar` stops rendering it — see the
502
- runtime-change note in this branch's commit message. The product/company
503
- line is the same colour but carries `font-bold`, hence the :not(). */
504
- .text-detail-sidebar .article-meta>div>span.text-secondary:not(.font-bold) {
505
- display: none;
506
- }
507
-
508
509
  .text-detail-sidebar .article-meta .text-tertiary,
509
510
  .text-detail-sidebar .article-meta span.font-bold {
510
511
  font-family: var(--font-label);
@@ -861,8 +862,7 @@ aside:has(> .text-detail-sidebar) {
861
862
  header, and figures that line up. */
862
863
  /* No table surface: the header band and the hover row are the only fills, so
863
864
  both read against the page canvas in either palette. */
864
- .article-content table,
865
- .article-content .table-csv {
865
+ .article-content table {
866
866
  margin: var(--space-xl) 0;
867
867
  font-size: var(--type-dek);
868
868
  font-variant-numeric: tabular-nums;
@@ -870,9 +870,7 @@ aside:has(> .text-detail-sidebar) {
870
870
  }
871
871
 
872
872
  .article-content th,
873
- .article-content td,
874
- .article-content .table-csv th,
875
- .article-content .table-csv td {
873
+ .article-content td {
876
874
  border: 0;
877
875
  border-bottom: 1px solid var(--border-gentle-subtle);
878
876
  padding: var(--space-sm) var(--space-md);
@@ -899,8 +897,7 @@ aside:has(> .text-detail-sidebar) {
899
897
  font-variant-numeric: tabular-nums;
900
898
  }
901
899
 
902
- .article-content thead th,
903
- .article-content .table-csv thead th {
900
+ .article-content thead th {
904
901
  background: var(--color-surface-container);
905
902
  font-family: var(--font-body);
906
903
  font-size: var(--type-body);
@@ -910,13 +907,11 @@ aside:has(> .text-detail-sidebar) {
910
907
  }
911
908
 
912
909
  /* Zebra striping fights the row rules; the hover row is the reading aid. */
913
- .article-content tbody tr:nth-child(even),
914
- .article-content .table-csv tbody tr:nth-child(even) td {
910
+ .article-content tbody tr:nth-child(even) {
915
911
  background: transparent;
916
912
  }
917
913
 
918
- .article-content tbody tr:hover,
919
- .article-content .table-csv tbody tr:hover {
914
+ .article-content tbody tr:hover {
920
915
  background: var(--color-surface-container-low);
921
916
  }
922
917
 
@@ -1131,18 +1126,16 @@ nav.text-page-nav:not(:has(a)) {
1131
1126
  too wide then scrolls inside the table rather than moving the page. The
1132
1127
  runtime emits the table with no wrapper, so the table itself has to be the
1133
1128
  scroll container. */
1134
- .article-content table,
1135
- .article-content .table-csv {
1129
+ .article-content table {
1136
1130
  display: block;
1137
1131
  width: 100%;
1138
1132
  max-width: 100%;
1139
1133
  overflow-x: auto;
1134
+ overflow-y: hidden;
1140
1135
  }
1141
1136
 
1142
1137
  .article-content th,
1143
- .article-content td,
1144
- .article-content .table-csv th,
1145
- .article-content .table-csv td {
1138
+ .article-content td {
1146
1139
  padding: var(--space-xs) var(--space-sm);
1147
1140
  }
1148
1141
  }
@@ -230,11 +230,14 @@
230
230
  }
231
231
 
232
232
  /* A middot between items rather than a separator element, so an absent
233
- license leaves no orphaned punctuation behind. */
233
+ license leaves no orphaned punctuation behind. outline-variant is the
234
+ hairline token: #b8b8b8 on #f4f4f4 is 1.80:1, and #45454c on #141418
235
+ is 1.93:1. outline clears 4.5:1 on the same panel (6.08:1 light,
236
+ 5.95:1 dark). */
234
237
  .site-footer-imprint-item+.site-footer-imprint-item::before {
235
238
  content: "·";
236
239
  margin-right: var(--space-md);
237
- color: var(--color-outline-variant);
240
+ color: var(--color-outline);
238
241
  }
239
242
 
240
243
  .site-footer-imprint-link {
@@ -91,8 +91,7 @@
91
91
  opacity: 0.42;
92
92
  }
93
93
 
94
- .home-hero-art-svg,
95
- .home-hero-pandorga-svg {
94
+ .home-hero-art-svg {
96
95
  width: 100%;
97
96
  height: 100%;
98
97
  display: block;
@@ -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.5.0"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.7.5"
28
28
  gem "csv"
29
29
  gem "base64"
30
30
  RUBY
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.7.3"
4
+ VERSION = "1.7.5"
5
5
  end
@@ -27,7 +27,7 @@
27
27
  # because the plates went stale otherwise. Cloudflare Pages excludes `content/*`
28
28
  # from its build watch paths, so a Studio save never rebuilds the shell, and art
29
29
  # committed into a layout is art that stops counting the day it is written. See
30
- # docs/features/home-band-art.md.
30
+ # docs/deploy/cloudflare.md.
31
31
  #
32
32
  # The output is SVG whose every stroke and fill is a custom property, because the
33
33
  # site has two complete palettes and switches between them on a `data-theme`
@@ -653,7 +653,7 @@ def export_derived_data(content_root, output_root)
653
653
  end
654
654
 
655
655
  # The home page's reference band, drawn from the corpus this run just exported
656
- # rather than from committed markup. See docs/features/home-band-art.md for why
656
+ # rather than from committed markup. See docs/deploy/cloudflare.md for why
657
657
  # it lives here: a Studio save never rebuilds the shell, so art baked into a
658
658
  # layout stops counting the day it is written.
659
659
  def export_band_art(output_root, writing_index)
@@ -86,7 +86,12 @@ sheets.each do |path|
86
86
  home-index-shard--gamma
87
87
  home-index-note
88
88
  post-content
89
+ home-hero-pandorga-svg
90
+ table-csv
89
91
  ]
92
+ if cleaned.include?("span.text-secondary:not(.font-bold)")
93
+ errors << "#{relative}: author byline suppressor matches no markup"
94
+ end
90
95
  retired.each do |name|
91
96
  next unless cleaned.match?(/[.#]#{Regexp.escape(name)}\b/)
92
97
 
@@ -94,6 +99,111 @@ sheets.each do |path|
94
99
  end
95
100
  end
96
101
 
102
+ # PLT-AC-36. A display equation scrolls inside the article column. Overflow
103
+ # on html or body would scroll the sticky header off the viewport.
104
+ def declaration_bodies(css, selector)
105
+ wanted = selector.gsub(/\s+/, " ").strip
106
+ bodies = []
107
+ css.scan(/([^{}]+)\{([^{}]*)\}/m) do |sel, body|
108
+ parts = sel.split(",").map { |part| part.gsub(/\s+/, " ").strip }
109
+ bodies << body if parts.include?(wanted)
110
+ end
111
+ bodies
112
+ end
113
+
114
+ def declarations(body)
115
+ body.downcase.gsub(/\s+/, "")
116
+ end
117
+
118
+ def require_declarations(errors, label, css, selector, required)
119
+ bodies = declaration_bodies(css, selector)
120
+ if bodies.empty?
121
+ errors << "#{label}: missing #{selector}"
122
+ return
123
+ end
124
+
125
+ blob = bodies.map { |body| declarations(body) }.join(";")
126
+ required.each do |decl|
127
+ needle = decl.downcase.gsub(/\s+/, "")
128
+ next if blob.include?(needle)
129
+
130
+ errors << "#{label}: #{selector} missing #{decl}"
131
+ end
132
+ end
133
+
134
+ def strip_comments(source)
135
+ source.gsub(%r{/\*.*?\*/}m, "")
136
+ end
137
+
138
+ article_sheet = strip_comments(File.read(CSS_ROOT.join("article.css")))
139
+ detail_sheet = strip_comments(File.read(CSS_ROOT.join("ledger/detail.css")))
140
+ math_box = ["max-width: 100%", "overflow-x: auto", "overflow-y: hidden"]
141
+
142
+ require_declarations(
143
+ errors, "assets/css/article.css", article_sheet,
144
+ ".article-content .math-display", math_box
145
+ )
146
+ require_declarations(
147
+ errors, "assets/css/article.css", article_sheet,
148
+ '.article-content .math-display mjx-container[jax][display="true"]', math_box
149
+ )
150
+ require_declarations(
151
+ errors, "assets/css/article.css", article_sheet,
152
+ '.article-content mjx-container[display="true"]', math_box
153
+ )
154
+ require_declarations(
155
+ errors, "assets/css/article.css", article_sheet,
156
+ '.article-content mjx-container[jax][display="true"]', math_box
157
+ )
158
+
159
+ if article_sheet.match?(/mjx-container[^{]*\{[^}]*overflow\s*:\s*visible/m)
160
+ errors << "assets/css/article.css: display math sets overflow: visible"
161
+ end
162
+
163
+ require_declarations(
164
+ errors, "assets/css/article.css", article_sheet,
165
+ ".article-content figure", ["max-width: 100%"]
166
+ )
167
+ require_declarations(
168
+ errors, "assets/css/article.css", article_sheet,
169
+ ".article-content svg", ["max-width: 100%"]
170
+ )
171
+ require_declarations(
172
+ errors, "assets/css/article.css", article_sheet,
173
+ ".article-content .mermaid", math_box
174
+ )
175
+ require_declarations(
176
+ errors, "assets/css/article.css", article_sheet,
177
+ ".article-content .mermaid svg", ["max-width: none"]
178
+ )
179
+ require_declarations(
180
+ errors, "assets/css/article.css", article_sheet,
181
+ ".article-content table", ["max-width: 100%"]
182
+ )
183
+ require_declarations(
184
+ errors, "assets/css/ledger/detail.css", detail_sheet,
185
+ ".article-content table", ["max-width: 100%", "overflow-x: auto", "overflow-y: hidden"]
186
+ )
187
+ require_declarations(
188
+ errors, "assets/css/ledger/detail.css", detail_sheet,
189
+ ".grid.text-detail-grid > *", ["min-width: 0"]
190
+ )
191
+
192
+ sheets.each do |path|
193
+ relative = Pathname.new(path).relative_path_from(REPO_ROOT)
194
+ cleaned = strip_noise(File.read(path))
195
+ cleaned.scan(/([^{}]+)\{([^{}]*)\}/m) do |sel, body|
196
+ next unless body.match?(/overflow/i)
197
+
198
+ sel.split(",").each do |part|
199
+ name = part.gsub(/\s+/, " ").strip
200
+ next unless name == "html" || name == "body" || name == "html body"
201
+
202
+ errors << "#{relative}: #{name} sets overflow and would break the sticky header"
203
+ end
204
+ end
205
+ end
206
+
97
207
  fail!("CSS structure problems:\n - #{errors.join("\n - ")}") unless errors.empty?
98
208
 
99
209
  puts " #{sheets.size} stylesheet(s) balanced, no conflict markers."
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Home band art (docs/features/home-band-art.md).
4
+ # Home band art. Watch paths: docs/deploy/cloudflare.md.
5
5
  #
6
6
  # AC-BAND-01 the export writes both plates under data/
7
7
  # AC-BAND-03 a second export with no content change reports them unchanged
@@ -32,6 +32,46 @@ if readme.include?("once 1.1 is on RubyGems")
32
32
  exit 1
33
33
  end
34
34
 
35
+ version = ROOT.join("lib/pandorga/version.rb").read[/VERSION = "([^"]+)"/, 1]
36
+ unless version
37
+ warn "FAIL test-new: Pandorga::VERSION missing"
38
+ exit 1
39
+ end
40
+
41
+ deploy = ROOT.join("docs/deploy/cloudflare.md").read
42
+ upgrading = ROOT.join("docs/upgrading.md").read
43
+ scaffold = ROOT.join("lib/pandorga/commands/new.rb").read
44
+ [
45
+ [deploy, "docs/deploy/cloudflare.md"],
46
+ [upgrading, "docs/upgrading.md"],
47
+ [scaffold, "lib/pandorga/commands/new.rb"]
48
+ ].each do |text, label|
49
+ if text.include?("v1.0.0") || text.include?("v1.3.11") || text.include?("v1.5.0")
50
+ warn "FAIL test-new: #{label} still names a stale release pin"
51
+ exit 1
52
+ end
53
+ end
54
+ unless deploy.include?('~> 1.3') && deploy.include?(%(tag: "v#{version}"))
55
+ warn "FAIL test-new: deploy doc must pin ~> 1.3 and tag v#{version}"
56
+ exit 1
57
+ end
58
+ unless deploy.include?("content/*") && deploy.include?("docs/*") && deploy.include?("Gemfile.lock")
59
+ warn "FAIL test-new: deploy doc lost the watch-path include and excludes"
60
+ exit 1
61
+ end
62
+ unless deploy.include?("3.4.4") && deploy.include?("SKIP_DEPENDENCY_INSTALL")
63
+ warn "FAIL test-new: deploy doc lost the Pages Ruby pin or SKIP_DEPENDENCY_INSTALL"
64
+ exit 1
65
+ end
66
+ unless upgrading.include?(%(tag: "v#{version}")) && upgrading.include?("pandorga-#{version}.gem")
67
+ warn "FAIL test-new: upgrading examples are not v#{version}"
68
+ exit 1
69
+ end
70
+ unless scaffold.include?(%(tag: "v#{version}"))
71
+ warn "FAIL test-new: scaffold comment is not tag v#{version}"
72
+ exit 1
73
+ end
74
+
35
75
  FileUtils.rm_rf(DEST)
36
76
 
37
77
  stdout, stderr, status = run_new(DEST.to_s)
@@ -36,7 +36,7 @@ end
36
36
 
37
37
  if ContentStorageConfig.placeholder?(base)
38
38
  puts "SKIP: R2 URL alignment checks (content_api_base_url is still placeholder: #{base})"
39
- puts " Fill the URL per docs/ops/cloudflare.md before production cutover."
39
+ puts " Fill the URL per docs/deploy/cloudflare.md before production cutover."
40
40
  exit 0
41
41
  end
42
42
 
@@ -54,4 +54,20 @@ ICON_FILES.each do |path|
54
54
  end
55
55
  end
56
56
 
57
+ # PLT-AC-37. The listing shell keeps its index h1 in the document. A detail
58
+ # URL then adds the entry title, so the page has two h1 elements. The runtime
59
+ # demotes the index heading and sets the document title and lang from the entry.
60
+ DETAIL = ROOT.join("_includes/content-runtime/50-detail.html").read
61
+ BOOT = ROOT.join("_includes/content-runtime/90-bootstrap.html").read
62
+ fail!("detail runtime does not demote the listing h1") unless DETAIL.include?("data-listing-heading") && DETAIL.include?("concealListingHeader")
63
+ fail!("detail runtime does not restore the listing document") unless DETAIL.include?("function restoreListingDocument")
64
+ fail!("detail runtime does not set html lang") unless DETAIL.include?("document.documentElement.lang")
65
+ fail!("detail runtime dropped the entry title h1") unless DETAIL.include?('<h1 class="text-title">')
66
+ fail!("restoreListingDocument is not on the runtime") unless BOOT.include?("restoreListingDocument: restoreListingDocument")
67
+
68
+ %w[articles blog projects cv].each do |layout|
69
+ source = ROOT.join("_layouts/#{layout}.html").read
70
+ fail!("#{layout} listing does not restore the document") unless source.include?("restoreListingDocument()")
71
+ end
72
+
57
73
  puts "PASS test-shell-a11y"
@@ -40,4 +40,24 @@ fail!("dark tertiary container moved") unless background.casecmp("#6a684f").zero
40
40
  ratio = contrast(foreground, background)
41
41
  fail!("#{foreground} on #{background} is #{ratio.round(2)}:1") unless ratio >= 4.5
42
42
 
43
- puts "PASS test-theme-contrast (#{foreground} on #{background} = #{ratio.round(2)}:1)"
43
+ # Footer imprint middot. outline-variant is a hairline and fails as ink
44
+ # on the panel step. The separator uses outline, which clears 4.5:1.
45
+ footer = ROOT.join("assets/css/ledger/footer.css").read
46
+ fail!("footer imprint separator still uses outline-variant") if footer.include?("color: var(--color-outline-variant)")
47
+ fail!("footer imprint separator lost outline") unless footer.include?("color: var(--color-outline)")
48
+
49
+ light = YAML.safe_load(ROOT.join("_data/themes/light.yml").read) || {}
50
+ pairs = {
51
+ "light" => [light.dig("neutrals", "outline"), light.dig("neutrals", "panel_contrast")],
52
+ "dark" => [dark.dig("neutrals", "outline"), dark.dig("neutrals", "panel_contrast")]
53
+ }
54
+ reports = []
55
+ pairs.each do |name, (ink, panel)|
56
+ fail!("#{name} outline or panel missing") if ink.to_s.empty? || panel.to_s.empty?
57
+
58
+ pair_ratio = contrast(ink, panel)
59
+ fail!("#{name} outline #{ink} on panel #{panel} is #{pair_ratio.round(2)}:1") unless pair_ratio >= 4.5
60
+ reports << "#{name} #{ink} on #{panel} = #{pair_ratio.round(2)}:1"
61
+ end
62
+
63
+ puts "PASS test-theme-contrast (#{foreground} on #{background} = #{ratio.round(2)}:1; #{reports.join('; ')})"
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.7.3
4
+ version: 1.7.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -328,7 +328,6 @@ files:
328
328
  - lib/pandorga/version.rb
329
329
  - scripts/cdn-allowlist.json
330
330
  - scripts/check_home_lists.mjs
331
- - scripts/content/backfill-content-keys.rb
332
331
  - scripts/content/band_art.rb
333
332
  - scripts/content/export-content-json.rb
334
333
  - scripts/content/flatten-ansi-capture.mjs
@@ -405,7 +404,6 @@ files:
405
404
  - scripts/test/render-parity/youtube.json
406
405
  - scripts/test/test-absent-field-absent-element.rb
407
406
  - scripts/test/test-cdn-pins.rb
408
- - scripts/test/test-cloudflare-watch-paths-doc.rb
409
407
  - scripts/test/test-configuration-doc.rb
410
408
  - scripts/test/test-content-backend.rb
411
409
  - scripts/test/test-content-detail-urls.rb
@@ -1,99 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require "pathname"
5
- require "yaml"
6
-
7
- require_relative "../../_plugins/content_keys"
8
- require_relative "../../_plugins/content_detail_paths"
9
- require_relative "../../_plugins/git_chronology"
10
-
11
- REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
12
- CONTENT_ROOT = REPO_ROOT.join("content")
13
-
14
- KEY_OVERRIDES = {
15
- "content/collections/posts/2026-03-06-horse-auto-follow.md" => "horse-auto-follow-pt",
16
- "content/collections/posts/2026-03-02-mcmc.md" => "mcmc-pt"
17
- }.freeze
18
-
19
- def split_front_matter(text)
20
- ContentKeys.split_front_matter(text)
21
- end
22
-
23
- def write_front_matter(path, front_matter, body)
24
- yaml = front_matter.to_yaml.sub(/\A---\n/, "").strip
25
- path.write("---\n#{yaml}\n---\n\n#{body}")
26
- end
27
-
28
- def infer_key(entry)
29
- override = KEY_OVERRIDES[entry["repo_relative_path"]]
30
- return override if override
31
-
32
- key = ContentKeys.normalize_key(entry["key"])
33
- return key unless key.empty?
34
-
35
- ContentKeys.infer_key_from_filename(entry["path"], entry["type"])
36
- end
37
-
38
- def legacy_detail_path(type, legacy_segment)
39
- case type
40
- when "article" then ContentDetailPaths.article_detail_path(legacy_segment)
41
- when "post" then ContentDetailPaths.post_detail_path(legacy_segment)
42
- when "project" then ContentDetailPaths.project_detail_path(legacy_segment)
43
- when "product" then ContentDetailPaths.product_detail_path(legacy_segment)
44
- else
45
- raise "unknown type #{type}"
46
- end
47
- end
48
-
49
- def migrate_redirect_fields!(front_matter)
50
- redirects = ContentKeys.normalize_redirects(front_matter)
51
- front_matter.delete("redirect_from")
52
- front_matter["redirects"] = redirects unless redirects.empty?
53
- front_matter
54
- end
55
-
56
- def ensure_legacy_redirect!(front_matter, type, legacy_segment, canonical_path)
57
- redirects = ContentKeys.normalize_redirects(front_matter)
58
- legacy = legacy_detail_path(type, legacy_segment).chomp("/")
59
- canonical = canonical_path.chomp("/")
60
- redirects << legacy unless legacy == canonical
61
- redirects = redirects.map { |path| ContentKeys.normalize_redirect_path(path) }.compact.uniq
62
- front_matter["redirects"] = redirects unless redirects.empty?
63
- front_matter.delete("redirects") if redirects.empty?
64
- front_matter
65
- end
66
-
67
- updated = 0
68
- ContentKeys::CONTENT_PATHS.each do |type, relative_dir|
69
- directory = CONTENT_ROOT.join(relative_dir.sub(%r{\Acontent/}, ""))
70
- next unless directory.directory?
71
-
72
- Dir.glob(directory.join("*.{md,markdown}")).sort.each do |absolute_path|
73
- entry = ContentKeys.load_entry(absolute_path, type, repo_root: REPO_ROOT)
74
- text = File.read(absolute_path).sub(/\A\xEF\xBB\xBF/, "")
75
- front_matter, body = split_front_matter(text)
76
- original_yaml = front_matter.to_yaml
77
-
78
- key = infer_key(entry)
79
- front_matter["key"] = key
80
- migrate_redirect_fields!(front_matter)
81
-
82
- if %w[article post].include?(type)
83
- GitChronology.apply!(front_matter, entry["repo_relative_path"], type)
84
- end
85
-
86
- canonical = ContentKeys.detail_path(type, key, date: front_matter["date"])
87
- ensure_legacy_redirect!(front_matter, type, entry["legacy_segment"], canonical)
88
-
89
- next if front_matter.to_yaml == original_yaml
90
-
91
- write_front_matter(Pathname(absolute_path), front_matter, body.sub(/\A\n+/, ""))
92
- updated += 1
93
- puts "updated #{entry['repo_relative_path']} (key=#{key})"
94
- end
95
- end
96
-
97
- ContentKeys.validate!(REPO_ROOT)
98
- ContentKeys.sync_redirects_file!(REPO_ROOT)
99
- puts "Backfill complete (#{updated} file(s) updated). Redirects synced to _redirects."
@@ -1,31 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require "pathname"
5
-
6
- ROOT = Pathname.new(__dir__).join("..", "..").expand_path
7
- DOC = ROOT.join("docs/ops/cloudflare.md")
8
-
9
- def check!(condition, message = "condition was false")
10
- raise "Assertion failed: #{message}" unless condition
11
- end
12
-
13
- text = DOC.read
14
-
15
- puts "Testing Cloudflare watch paths documentation..."
16
-
17
- check! text.include?("`*`"), "missing include pattern `*`"
18
- check! text.include?("`content/*`"), "missing exclude pattern content/*"
19
- check! text.include?("`docs/*`"), "missing exclude pattern docs/*"
20
- check! text.include?("Settings → Build → Build watch paths"),
21
- "missing dashboard navigation path"
22
- check! text.include?("SKIP_DEPENDENCY_INSTALL"),
23
- "missing SKIP_DEPENDENCY_INSTALL (avoids Pages pip install of study deps)"
24
- check! text.include?("bundle install") && text.include?("jekyll build"),
25
- "missing lean Pages build command with explicit bundle install"
26
- check! text.include?("pandorga install-functions"),
27
- "missing pandorga install-functions in Pages build command (PLT-0.2)"
28
- check! text.include?("3.4.4"),
29
- "missing Pages v3 Ruby 3.4.4 pin (avoids compiling Ruby from source)"
30
-
31
- puts "Cloudflare watch paths documentation OK."