From 4b2452fa32b785c9cec5f111695684028fe3e377 Mon Sep 17 00:00:00 2001 From: mrsll Date: Sat, 16 May 2026 14:45:30 -0500 Subject: [PATCH 01/20] brand: conformance pass against dynamical-org/brand design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --radius-sm/md/lg tokens mirroring colors_and_type.css - .hero-cta: drop uppercase, letter-spacing, and color transition (BRAND.md bans uppercase + wide tracking; hover is instant); align padding/font-size/radius to the ghost-button spec - #latest-popup: 8px → --radius-md (4px) per spec - Add og:image meta (icon-1024.png) and drop duplicate og:type - Remove vendored brand assets in public/{favicon.ico,apple-touch-icon.png, assets/{favicon,lockup,lockup-white,icon}.svg} — BRAND.md says link to CDN, don't rehost. Site already references the CDN-hosted copies. Nav lockup left on the light/dark swap: the brand spec suggests neutral lockup.svg with currentColor, but currentColor doesn't propagate into , so the swap is the correct working pattern. --- _includes/base.njk | 2 +- public/apple-touch-icon.png | Bin 1528 -> 0 bytes public/assets/favicon.svg | 1 - public/assets/icon.svg | 1 - public/assets/lockup-white.svg | 1 - public/assets/lockup.svg | 1 - public/favicon.ico | Bin 15086 -> 0 bytes public/main.css | 16 ++++++++++------ 8 files changed, 11 insertions(+), 11 deletions(-) delete mode 100644 public/apple-touch-icon.png delete mode 100644 public/assets/favicon.svg delete mode 100644 public/assets/icon.svg delete mode 100644 public/assets/lockup-white.svg delete mode 100644 public/assets/lockup.svg delete mode 100644 public/favicon.ico diff --git a/_includes/base.njk b/_includes/base.njk index bcffe8431..5ab5f75ad 100644 --- a/_includes/base.njk +++ b/_includes/base.njk @@ -12,7 +12,7 @@ - + diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png deleted file mode 100644 index d187e4af76a92d2c573188f34c3ea44073d469fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1528 zcmeAS@N?(olHy`uVBq!ia0vp^TR>O&pI$MSz7tbGByiYM_v6iEBhjN@7W>RdP`(kYX@0Ff!9MFwr$M3o$UUGBB_* zG1LZ<1_lea{dtd~AvZrIGp!Q0h6}GOc5@=z@T=GY_w! z$r0>~S1olnzb$f?+OoId<0dQlKm7aeS(?Uhs^5zHqwT-xY5BVSKYeeXmRKM5;;sF? z|7EZ5{)xKXeP7&uc5KxC+BX5WUrXe#I_G+L>RSE!-zN8Ssz3gI^l<0%=JbdAC9LM0 zSpRhPPyJl|y7)b_b5s6)hy^M?zniaPp<-K`gj7-nCc|T||NB{+rS;pZukQQ%vAICg zI`;Elw!~RK5A71IGGBA+cWjpNrkU&Yz8#iKag^tuX1~+! zZ&-x<=4UOlrQWIUdXx6V=STMqx0KJ-`|bA$?vwbQt@w0V8+UA-@u4@@lFa7&R(T%s z?JbwZ6Lk}w+3l^)o!8{&A7q)q6`cI+Qpk%(Hm!5!EeV=)=*qmECL&cwAlw;;uFT(Q z@>STjY95oXe~_kM;;Zv9v$$$6*gTD&V%Yc)0o8EUvj9en!3Z{JCaxo73`|ePP`~cCXiLynpfKy!BTXwVb#5yp9=L zqJbu(Lvt7L%-4xOlzn-!ptoN|8IQP>U1Z&&M_-m`JiKWblkp?vr>C2bf0xia+2+<| z{?q1Xd;v;bylMF0ea5$xpPhWO=835OJXQ@4iyiiDsZ;J>`Quz?D$|+%xY;Y8RX*kB z+6$>>rmR1&u6@7!-I=?m&V^U$|4=xc*Q&R1rR2jCSLSXF?l%AaYwEft{!MQ}UKq`u zwWy-(-L*$&AFCfec`BbnF6HJ*kO|6qRTJjke|`Og3{%B~rTF8zYW_XjPVq+%E4IHq z`hIb`>enk=b9K+_owYr02Fj-&)-0K)bL^G)+kM|Zhc~B3hVR>Pwx)Drea3}vM#okQ t@0xx7&Ve+breB?5rT0o52*lcxe6K0}GxCBe9Dqd{gQu&X%Q~loCIG}UoDKj0 diff --git a/public/assets/favicon.svg b/public/assets/favicon.svg deleted file mode 100644 index 4964fb135..000000000 --- a/public/assets/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -dynamical.org \ No newline at end of file diff --git a/public/assets/icon.svg b/public/assets/icon.svg deleted file mode 100644 index 4964fb135..000000000 --- a/public/assets/icon.svg +++ /dev/null @@ -1 +0,0 @@ -dynamical.org \ No newline at end of file diff --git a/public/assets/lockup-white.svg b/public/assets/lockup-white.svg deleted file mode 100644 index 158ddffd0..000000000 --- a/public/assets/lockup-white.svg +++ /dev/null @@ -1 +0,0 @@ -dynamical.orgdynamical.org \ No newline at end of file diff --git a/public/assets/lockup.svg b/public/assets/lockup.svg deleted file mode 100644 index d87b3a216..000000000 --- a/public/assets/lockup.svg +++ /dev/null @@ -1 +0,0 @@ -dynamical.orgdynamical.org \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index 95023f685f6dbe858d6fddb5651ed06b92b08edb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 zcmeI3S%@4(7{`0v#3YJtBE}P(RTE5tN5BKbV^+|h7{RFVNr(tSq7P9)1g~8c6cjx0 zd@&+k2?hi~@Pv&K4-^l4@ha475#`@j@G-tlAX z%l;484tK*Da20HET#P&%Qa{RreZ%ZOp=}u`p3a>Ge}a5Z!b>n0%+93pSH5?`Ebn}s z{heTa`-=UQ-g&csg0>xSOy*qHUVb)YbmYVAkIkG5**}@l>9u_*f2-d;Bhp7U1ypnX2#z#HOtej(i7)XV|pMAm0&+-TP3{BlaulF4txY37C`Ib zUC^5Q-6nw#4f6z z+Byt1I1E(lv@4RJI%MPjLfd!n13U`R`H7Bvh2pEOY5u-c0`52z-_J*5W4gIR;B@@> z|IoGzbX^SQ?`&f6H=(EXL~Bc#_&=e)29A?m$kw&T;_LeQKImFjCcef`b11iV?p!Rs z#zNQJi=>~oIbVML6@N(qn09DxZIS{GG4exfp)^mA7~rs;s{+V6S_$LC6blYg{P)GqTGfN3Jcl{&ai& zvHt6tzZA3<4nvXauf}cyzJ>=OSU`88qW)z z?mD{>3!FWNoqIU~K=U2AB*<*ZYyfnAxm1JLMn{`?0*nCuxr2@_X<%ZiP zlX_z^sW}Fb1C7bSu{zWy>&V*J5OQd8G=g-Rx=ma8kgqBJ>lU`5_Y}M$O|FF2dx}Lf z`#lAz_P75*1*&|`W4660kIO;#G-tzQaA`&4L-!fINZ+o_TI=5eT}$<>T?P5L4SJow zu9bE#P*slh^Dj1hsuO7bOnd#EZ=0iSvRHi@h-Qt8}8}duI0D^hYuCd6= z|8>skS^5Nrz7IPDO66_JvV&Ou_oHLaVKw%Tg7o>#{7BMfbjn}PC3>H0=o#}y*>DU$>3uhOqzuxug zyR)}pjr3xhbR1>p&qnN4!MTt=hsI*rdGr-Cl)v8Bp9*teHke7Sl#Zip{#uXqx%e0k zl8>UBbRm?#)(5R|mQ&QFr&~6EG3G+eMx4oIa z?sfHC_5o;%#xe!zLg@OdcW__8=b-QQ%p_Mz$5FQaPhqFurJMpCe;<<#?&5{=x6iTU znDkuy;0+L$dWZ9XmSZ{~J1bhcw~Xj$9AEtHS%TgV_90OnYA=xdT>$<(rkA z@_z-pqoL-}R*Rw?a5=>C*Js#gH^-};t-dSv(YsB3_wVcb`!71XY@ds!JVnp>Dx1Ha zx8H?LpnQBbbNmo!&0Uw_T8>wM`Zy(zZV%)K%I5FaGqj)kzRtxZ zvHg$o*Y8Di53;i&@^LftqWrc7X#LP{UPho^5&5V=FY~v4Osnik2l_$!6)0GN<`0JS zzfIcO<*tMuFVS8=qA0zg@w$@(z8q+br^f-A)Ul&Im2>v8v^bs&=0!iI Date: Tue, 19 May 2026 10:22:14 -0500 Subject: [PATCH 02/20] catalog: render per-dataset validation reports on-site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports reformatters' src/scripts/validation/render.py pipeline into an 11ty page so /catalog//validation/ serves the same content as the standalone HTML on R2 — markdown-it (commonmark+tables) plus the five post-process transforms, sidebar TOC with per-variable checkbox toggle, image refs resolved against the report's baseUrl. Adds _data/validationReports.js that fetches validation_summary.md for each STAC dataset (skipping 404s silently), and surfaces both the on-site link and a "raw" fallback on the catalog dataset page. --- _data/catalog.js | 7 + _data/validationReports.js | 49 ++++ content/catalog-pages.njk | 7 + content/catalog/validation.11ty.js | 389 +++++++++++++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 _data/validationReports.js create mode 100644 content/catalog/validation.11ty.js diff --git a/_data/catalog.js b/_data/catalog.js index 83903847a..d3d978fa1 100644 --- a/_data/catalog.js +++ b/_data/catalog.js @@ -82,6 +82,12 @@ function reshapeStacCollection(collection) { const licenseLinks = (collection.links || []).filter((l) => l.rel === "license"); + // Optional validation-report asset published by reformatters. Surface its + // href so the catalog page can offer a fallback raw-HTML link for datasets + // where the on-site rendering can't fetch the markdown source. + const validationAsset = (collection.assets || {})["validation_report"]; + const validation_report_href = validationAsset ? validationAsset.href : null; + return { license_md: licenseMd(licenseLinks), name: collection.title, @@ -103,6 +109,7 @@ function reshapeStacCollection(collection) { dimensions, variables, notebooks, + validation_report_href, }; } diff --git a/_data/validationReports.js b/_data/validationReports.js new file mode 100644 index 000000000..28292443a --- /dev/null +++ b/_data/validationReports.js @@ -0,0 +1,49 @@ +const fetch = require("@11ty/eleventy-fetch"); + +const STAC_BASE_URL = process.env.STAC_BASE_URL || "https://stac.dynamical.org"; +const VALIDATION_BASE_URL = + process.env.VALIDATION_REPORTS_BASE_URL || + "https://dataset-validation-reports.dynamical.org"; +const STAC_CACHE_DURATION = process.env.STAC_CACHE_DURATION || "1d"; + +module.exports = async function () { + const rootCatalog = await fetch(`${STAC_BASE_URL}/catalog.json`, { + type: "json", + duration: STAC_CACHE_DURATION, + }); + + const childLinks = (rootCatalog.links || []).filter((l) => l.rel === "child"); + const datasetIds = childLinks + .map((l) => { + const m = l.href.match(/\/([^/]+)\/collection\.json(?:$|\?|#)?$/); + return m ? m[1] : null; + }) + .filter(Boolean); + + const results = await Promise.all( + datasetIds.map(async (datasetId) => { + const baseUrl = `${VALIDATION_BASE_URL}/${datasetId}/latest/`; + const mdUrl = `${baseUrl}validation_summary.md`; + try { + const markdown = await fetch(mdUrl, { + type: "text", + duration: STAC_CACHE_DURATION, + }); + return { datasetId, baseUrl, markdown }; + } catch (err) { + // 404s are expected — not every dataset has a published validation + // report yet. Surface any other failure so build errors don't get + // silently swallowed (network, DNS, 5xx, etc). + if (/\b404\b/.test(String(err && err.message))) return null; + console.warn( + `[validationReports] ${datasetId}: failed to fetch summary — ${err.message}`, + ); + return null; + } + }), + ); + + const entries = results.filter(Boolean); + entries.sort((a, b) => a.datasetId.localeCompare(b.datasetId)); + return { entries }; +}; diff --git a/content/catalog-pages.njk b/content/catalog-pages.njk index d560ef018..e3017a2b2 100644 --- a/content/catalog-pages.njk +++ b/content/catalog-pages.njk @@ -64,6 +64,13 @@ eleventyComputed:

STAC (browse) + {%- set validationReport = validationReports.entries | find('datasetId', entry.dataset_id) %} + {% if validationReport %} + · validation report + (raw) + {% elif entry.validation_report_href %} + · validation report + {% endif %}

{% if model.description %}

{{ model.description | markdown | safe }}

diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js new file mode 100644 index 000000000..d7d63ce77 --- /dev/null +++ b/content/catalog/validation.11ty.js @@ -0,0 +1,389 @@ +// Per-dataset validation report page. Renders validation_summary.md +// (published by dynamical-org/reformatters to R2) into a near-identical +// layout to the standalone HTML report at +// https://dataset-validation-reports.dynamical.org//latest/validation_report.html. +// +// Mirrors reformatters' src/scripts/validation/render.py — the same five +// post-process transforms over markdown-it's HTML output, the same sidebar +// TOC, the same CSS, the same checkbox-toggle JS. Image references in the +// markdown are bare filenames and get resolved against the report's baseUrl +// so the rendered page can load them directly from R2. + +const MarkdownIt = require("markdown-it"); + +const md = new MarkdownIt("commonmark").enable("table"); + +function slugify(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function escapeAttr(s) { + return s.replace(/&/g, "&").replace(/"/g, """); +} + +// Per-variable headings render as

name

; other ### don't. +function extractPerVarNames(html) { + return [...html.matchAll(/

([^<]+)<\/code><\/h3>/g)].map((m) => m[1]); +} + +function wrapVariableSections(html) { + // Match a variable heading and everything up to the next h2/h3 or EOF. + const re = /

([^<]+)<\/code><\/h3>([\s\S]*?)(?=]|$)/g; + return html.replace(re, (_full, varName, body) => { + const v = varName; + const plots = + `
` + + `` + + `${escapeAttr(v)} — null fraction` + + `` + + `${escapeAttr(v)} — spatial comparison` + + `` + + `${escapeAttr(v)} — time series comparison` + + `
`; + return ( + `
` + + `

${v}

` + + `${body}${plots}
` + ); + }); +} + +function annotateH2(html) { + const sections = []; + const out = html.replace(/

([\s\S]*?)<\/h2>/g, (_full, inner) => { + const plain = inner.replace(/<[^>]+>/g, "").trim(); + const slug = slugify(plain); + sections.push({ slug, title: plain }); + return `

${inner}

`; + }); + return { html: out, sections }; +} + +function annotateNonVarH3(html) { + return html.replace(/

([\s\S]*?)<\/h3>/g, (full, inner) => { + if (inner.startsWith("")) return full; + const plain = inner.replace(/<[^>]+>/g, "").trim(); + const slug = slugify(plain); + return `

${inner}

`; + }); +} + +function pngLinksOpenInNewTab(html) { + return html.replace(//g, ''); +} + +function wrapTables(html) { + return html.replace( + /([\s\S]*?<\/table>)/g, + '
$1
', + ); +} + +// Resolve bare-filename src/href on / against baseUrl. Anything that +// already looks absolute (scheme, protocol-relative, root-relative, or a bare +// fragment) is left alone — only relative refs get rewritten, matching how a +// browser would resolve them when loading the standalone HTML next to the +// markdown. +function rewriteAssetUrls(html, baseUrl) { + const isAbsolute = (u) => + /^[a-z][a-z0-9+.-]*:/i.test(u) || + u.startsWith("//") || + u.startsWith("/") || + u.startsWith("#"); + const rewriteAttr = (input, attr) => + input.replace( + new RegExp(`(<(?:img|a)\\b[^>]*\\s${attr}=")([^"]+)(")`, "g"), + (full, pre, url, post) => + isAbsolute(url) ? full : `${pre}${baseUrl}${url}${post}`, + ); + return rewriteAttr(rewriteAttr(html, "src"), "href"); +} + +function extractDatasetName(mdText, fallback) { + const m = mdText.match(/^\|\s*Validation\s*\|\s*([^|]+?)\s*\|/m); + return m ? m[1] : fallback; +} + +function buildToc(sections, variables, datasetName, datasetId) { + const sectionItems = sections + .map((s) => `
  • ${s.title}
  • `) + .join(""); + const varItems = variables + .map( + (v) => + `
  • ${v}
  • `, + ) + .join(""); + return ` + +`; +} + +// CSS ported verbatim from reformatters' render.py _CSS, with one addition: +// a `.toc-back` rule for the breadcrumb back to /catalog//. +const CSS = ` +:root { + color-scheme: light dark; + --bg-color: #ffffff; + --text-color: #111111; + --header-color: #111111; + --link-color: #0b57d0; + --link-visited-color: #6f42c1; + --border-color: #111111; + --border-muted-color: #444444; + --muted-text: #666666; + --muted-text-2: #999999; + --pill-muted-bg: #f0f0f0; + --pill-muted-fg: #111111; + --pill-muted-border: #d0d0d0; + --sidebar: 28rem; +} +@media (prefers-color-scheme: dark) { + :root { + --bg-color: #0f0f10; + --text-color: #e8e8ea; + --header-color: #ffffff; + --link-color: #8ab4f8; + --link-visited-color: #c58af9; + --border-color: #e8e8ea; + --border-muted-color: #b5b5b5; + --muted-text: #b5b5b5; + --muted-text-2: #8f8f93; + --pill-muted-bg: #2a2a2d; + --pill-muted-fg: #e8e8ea; + --pill-muted-border: #3a3a3d; + } +} + +*, *::before, *::after { box-sizing: border-box; } +html { font-size: 62.5%; } +body, input, button, h1, h2, h3, h4, h5, h6 { + font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; +} +body { + margin: 0; + font-size: 1.4rem; + line-height: 1.6; + color: var(--text-color); + background-color: var(--bg-color); +} +a { color: var(--link-color); } +a:visited { color: var(--link-visited-color); } +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + margin-top: 2rem; + margin-bottom: 1rem; + color: var(--header-color); +} +p { margin-bottom: 1.2rem; } + +table { border-collapse: collapse; border: 1px solid var(--border-color); + margin: 1rem 0 2rem; max-width: 100%; } +th, td { padding: 0.8rem 1.6rem; text-align: left; vertical-align: top; + border-right: 1px dotted var(--border-muted-color); } +th { border-bottom: 1px solid var(--border-color); font-weight: 700; } +.table-scroll { overflow-x: auto; margin: 1rem 0 2rem; } +.table-scroll table { margin: 0; } + +ul, ol { padding-left: 2rem; } +li { margin: 0.2rem 0; } + +.toc-toggle { + position: fixed; top: 1rem; left: 1rem; z-index: 30; + border: 1px solid var(--border-color); background: var(--bg-color); + color: var(--header-color); + width: 3.6rem; height: 3.6rem; font-size: 1.6rem; cursor: pointer; + display: none; padding: 0; line-height: 1; + align-items: center; justify-content: center; +} +.toc-toggle:hover { background: var(--header-color); color: var(--bg-color); } + +.toc { + position: fixed; top: 0; left: 0; bottom: 0; width: var(--sidebar); + border-right: 1px solid var(--border-color); padding: 2rem; + overflow-y: auto; background: var(--bg-color); z-index: 20; +} +.toc-back { font-size: 1.2rem; margin-bottom: 1.6rem; } +.toc-back a { color: var(--muted-text); text-decoration: none; } +.toc-back a:visited { color: var(--muted-text); } +.toc-back a:hover { color: var(--link-color); } +.toc-heading { + font-size: 1.4rem; color: var(--header-color); + margin: 2rem 0 0.8rem; font-weight: 700; +} +.toc-heading:first-child { margin-top: 0; } +.toc ul { list-style: none; padding: 0; margin: 0; } +.toc li { margin: 0.3rem 0; } +.toc a { color: var(--text-color); text-decoration: none; display: block; } +.toc a:visited { color: var(--text-color); } +.toc a:hover { color: var(--link-color); } +.toc .var-row { display: flex; align-items: center; gap: 0.6rem; } +.toc .var-row input { margin: 0; flex-shrink: 0; } +.toc .var-row a { flex: 1; min-width: 0; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; + font-size: 1.3rem; } +.toc .var-actions { + display: flex; gap: 0.6rem; margin-bottom: 0.8rem; + font-size: 1.1rem; text-transform: uppercase; letter-spacing: 1px; +} +.toc .var-actions button { + background: var(--bg-color); border: 1px solid var(--border-color); + color: var(--header-color); padding: 0.2rem 0.8rem; cursor: pointer; + font-weight: 700; letter-spacing: 1px; +} +.toc .var-actions button:hover { background: var(--header-color); color: var(--bg-color); } + +main { + margin-left: var(--sidebar); padding: 2rem 4rem 6rem; + max-width: calc(78rem + var(--sidebar)); +} +main h1 { margin-top: 0; } +main h2 { margin-top: 3.2rem; padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border-color); } +main h3 { margin-top: 2.4rem; } + +section.variable { + margin-top: 2.4rem; padding-top: 0.6rem; + border-top: 1px solid var(--border-color); +} +section.variable.hidden { display: none; } +.plots { display: flex; flex-direction: column; gap: 1rem; margin: 1rem 0 2rem; } +.plots a { display: block; } +.plots img { + display: block; max-width: 100%; height: auto; + border: 1px solid var(--border-color); + background: var(--bg-color); +} + +@media (max-width: 880px) { + .toc-toggle { display: flex; } + .toc { transform: translateX(-100%); transition: transform 180ms ease; + padding-top: 5.6rem; + box-shadow: 0.4rem 0 1.2rem var(--shadow-color, rgba(0,0,0,0.4)); } + body.toc-open .toc { transform: translateX(0); } + body.toc-open::after { content: ""; position: fixed; inset: 0; + background: rgba(0,0,0,0.4); z-index: 15; } + main { margin-left: 0; padding: 6rem 2rem 3rem; } + table { font-size: 1.2rem; } + th, td { padding: 0.4rem 0.8rem; } +} +`; + +// JS ported verbatim from reformatters' render.py _JS. +const JS = String.raw` +(function () { + var body = document.body; + var toggle = document.querySelector('.toc-toggle'); + if (toggle) { + toggle.addEventListener('click', function () { + body.classList.toggle('toc-open'); + }); + } + document.querySelectorAll('.toc a').forEach(function (a) { + a.addEventListener('click', function () { + body.classList.remove('toc-open'); + var href = a.getAttribute('href') || ''; + if (href.indexOf('#var-') === 0) { + var v = href.slice(5); + var cb = document.querySelector('input[data-var="' + cssEscape(v) + '"]'); + if (cb && !cb.checked) { cb.checked = true; applyVar(v, true); } + } + }); + }); + + function cssEscape(s) { + return s.replace(/(["\\])/g, '\\$1'); + } + function applyVar(v, on) { + document.querySelectorAll('section.variable[data-var="' + cssEscape(v) + '"]') + .forEach(function (s) { s.classList.toggle('hidden', !on); }); + } + document.querySelectorAll('input[data-var]').forEach(function (cb) { + cb.addEventListener('change', function () { applyVar(cb.dataset.var, cb.checked); }); + }); + var allBtn = document.querySelector('[data-action="all"]'); + var noneBtn = document.querySelector('[data-action="none"]'); + if (allBtn) allBtn.addEventListener('click', function () { setAll(true); }); + if (noneBtn) noneBtn.addEventListener('click', function () { setAll(false); }); + function setAll(on) { + document.querySelectorAll('input[data-var]').forEach(function (cb) { + cb.checked = on; applyVar(cb.dataset.var, on); + }); + } +})(); +`; + +function renderHtml({ datasetId, baseUrl, markdown }) { + let html = md.render(markdown); + html = pngLinksOpenInNewTab(html); + const variables = extractPerVarNames(html); + const annotated = annotateH2(html); + html = annotated.html; + html = annotateNonVarH3(html); + html = wrapVariableSections(html); + html = wrapTables(html); + html = rewriteAssetUrls(html, baseUrl); + + const datasetName = extractDatasetName(markdown, datasetId); + const toc = buildToc(annotated.sections, variables, datasetName, datasetId); + const title = `Validation report — ${datasetId}`; + + return ` + + + + +${title} + + + + + + + + +${toc} +
    ${html}
    + + + +`; +} + +class ValidationReportPage { + data() { + return { + pagination: { + data: "validationReports.entries", + size: 1, + alias: "entry", + }, + permalink: ({ entry }) => `catalog/${entry.datasetId}/validation/`, + eleventyComputed: { + title: ({ entry }) => `Validation report — ${entry.datasetId}`, + }, + layout: false, + }; + } + + render({ entry }) { + return renderHtml(entry); + } +} + +module.exports = ValidationReportPage; +module.exports.renderHtml = renderHtml; From 8a0763f49f1fb94b39f32261ea51eb974f0b5d77 Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 10:25:34 -0500 Subject: [PATCH 03/20] validation: inherit site chrome, restyle TOC as inline sticky sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validation report page now extends base.njk and uses main.css for typography, links, and base table styling. The TOC moves from a fixed full-height overlay into the page flow as a sticky sidebar to the left of the report body — it stays on-screen as you scroll, and collapses to an in-flow block on narrow viewports. Drops the bespoke font/body/link CSS that duplicated main.css. --- content/catalog/validation.11ty.js | 329 +++++++++++++---------------- 1 file changed, 143 insertions(+), 186 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index d7d63ce77..dbaa4384c 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -4,10 +4,14 @@ // https://dataset-validation-reports.dynamical.org//latest/validation_report.html. // // Mirrors reformatters' src/scripts/validation/render.py — the same five -// post-process transforms over markdown-it's HTML output, the same sidebar -// TOC, the same CSS, the same checkbox-toggle JS. Image references in the -// markdown are bare filenames and get resolved against the report's baseUrl -// so the rendered page can load them directly from R2. +// post-process transforms over markdown-it's HTML output, the same per- +// variable checkbox toggle. Image references in the markdown are bare +// filenames and get resolved against the report's baseUrl so the rendered +// page can load them directly from R2. +// +// The page inherits site chrome (base.njk + main.css). The TOC sits in a +// sticky sidebar to the left of the content on wide screens and collapses +// to an in-flow block on narrow ones. const MarkdownIt = require("markdown-it"); @@ -30,7 +34,6 @@ function extractPerVarNames(html) { } function wrapVariableSections(html) { - // Match a variable heading and everything up to the next h2/h3 or EOF. const re = /

    ([^<]+)<\/code><\/h3>([\s\S]*?)(?=]|$)/g; return html.replace(re, (_full, varName, body) => { const v = varName; @@ -84,9 +87,7 @@ function wrapTables(html) { // Resolve bare-filename src/href on / against baseUrl. Anything that // already looks absolute (scheme, protocol-relative, root-relative, or a bare -// fragment) is left alone — only relative refs get rewritten, matching how a -// browser would resolve them when loading the standalone HTML next to the -// markdown. +// fragment) is left alone — only relative refs get rewritten. function rewriteAssetUrls(html, baseUrl) { const isAbsolute = (u) => /^[a-z][a-z0-9+.-]*:/i.test(u) || @@ -107,7 +108,7 @@ function extractDatasetName(mdText, fallback) { return m ? m[1] : fallback; } -function buildToc(sections, variables, datasetName, datasetId) { +function buildToc(sections, variables) { const sectionItems = sections .map((s) => `
  • ${s.title}
  • `) .join(""); @@ -119,9 +120,8 @@ function buildToc(sections, variables, datasetName, datasetId) { ) .join(""); return ` - + `; } -// CSS ported verbatim from reformatters' render.py _CSS, with one addition: -// a `.toc-back` rule for the breadcrumb back to /catalog//. +// Only the styles unique to the validation report. Base typography, colors, +// link colors, headings, body table styling are inherited from main.css. const CSS = ` -:root { - color-scheme: light dark; - --bg-color: #ffffff; - --text-color: #111111; - --header-color: #111111; - --link-color: #0b57d0; - --link-visited-color: #6f42c1; - --border-color: #111111; - --border-muted-color: #444444; - --muted-text: #666666; - --muted-text-2: #999999; - --pill-muted-bg: #f0f0f0; - --pill-muted-fg: #111111; - --pill-muted-border: #d0d0d0; - --sidebar: 28rem; +.validation-report { font-size: 1.4rem; } +.validation-breadcrumb { margin-bottom: 2rem; } +.validation-grid { + display: grid; + grid-template-columns: 22rem 1fr; + gap: 3rem; + align-items: start; + min-width: 0; } -@media (prefers-color-scheme: dark) { - :root { - --bg-color: #0f0f10; - --text-color: #e8e8ea; - --header-color: #ffffff; - --link-color: #8ab4f8; - --link-visited-color: #c58af9; - --border-color: #e8e8ea; - --border-muted-color: #b5b5b5; - --muted-text: #b5b5b5; - --muted-text-2: #8f8f93; - --pill-muted-bg: #2a2a2d; - --pill-muted-fg: #e8e8ea; - --pill-muted-border: #3a3a3d; - } +.validation-body { min-width: 0; } +.validation-body h2 { + margin-top: 3.2rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border-color); } - -*, *::before, *::after { box-sizing: border-box; } -html { font-size: 62.5%; } -body, input, button, h1, h2, h3, h4, h5, h6 { - font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; +.validation-body h3 { margin-top: 2.4rem; } +.validation-body section.variable { + margin-top: 2.4rem; + padding-top: 0.6rem; + border-top: 1px solid var(--border-color); +} +.validation-body section.variable.hidden { display: none; } +.validation-body .plots { + display: flex; flex-direction: column; + gap: 1rem; margin: 1rem 0 2rem; +} +.validation-body .plots a { display: block; } +.validation-body .plots img { + display: block; max-width: 100%; height: auto; + border: 1px solid var(--border-color); + background: var(--bg-color); } -body { +.validation-body .table-scroll { + overflow-x: auto; margin: 1rem 0 2rem; +} +.validation-body .table-scroll table { + border-collapse: collapse; + border: 1px solid var(--border-color); margin: 0; - font-size: 1.4rem; - line-height: 1.6; - color: var(--text-color); - background-color: var(--bg-color); + max-width: 100%; } -a { color: var(--link-color); } -a:visited { color: var(--link-visited-color); } -h1, h2, h3, h4, h5, h6 { +.validation-body .table-scroll th, +.validation-body .table-scroll td { + padding: 0.8rem 1.6rem; + vertical-align: top; + border-right: 1px dotted var(--border-muted-color); +} +.validation-body .table-scroll th { + border-bottom: 1px solid var(--border-color); font-weight: 700; - margin-top: 2rem; - margin-bottom: 1rem; - color: var(--header-color); } -p { margin-bottom: 1.2rem; } - -table { border-collapse: collapse; border: 1px solid var(--border-color); - margin: 1rem 0 2rem; max-width: 100%; } -th, td { padding: 0.8rem 1.6rem; text-align: left; vertical-align: top; - border-right: 1px dotted var(--border-muted-color); } -th { border-bottom: 1px solid var(--border-color); font-weight: 700; } -.table-scroll { overflow-x: auto; margin: 1rem 0 2rem; } -.table-scroll table { margin: 0; } -ul, ol { padding-left: 2rem; } -li { margin: 0.2rem 0; } - -.toc-toggle { - position: fixed; top: 1rem; left: 1rem; z-index: 30; - border: 1px solid var(--border-color); background: var(--bg-color); +.validation-toc { + position: sticky; + top: 1rem; + align-self: start; + font-size: 1.2rem; + border-left: 1px solid var(--border-muted-color); + padding-left: 1.6rem; + max-height: calc(100vh - 2rem); + overflow-y: auto; +} +.validation-toc .toc-heading { + font-size: 1.3rem; color: var(--header-color); - width: 3.6rem; height: 3.6rem; font-size: 1.6rem; cursor: pointer; - display: none; padding: 0; line-height: 1; - align-items: center; justify-content: center; + margin: 1.6rem 0 0.6rem; + font-weight: 700; } -.toc-toggle:hover { background: var(--header-color); color: var(--bg-color); } - -.toc { - position: fixed; top: 0; left: 0; bottom: 0; width: var(--sidebar); - border-right: 1px solid var(--border-color); padding: 2rem; - overflow-y: auto; background: var(--bg-color); z-index: 20; +.validation-toc .toc-heading:first-child { margin-top: 0; } +.validation-toc ul { list-style: none; padding: 0; margin: 0; } +.validation-toc li { margin: 0.2rem 0; } +.validation-toc a { + color: var(--text-color); + text-decoration: none; + display: block; } -.toc-back { font-size: 1.2rem; margin-bottom: 1.6rem; } -.toc-back a { color: var(--muted-text); text-decoration: none; } -.toc-back a:visited { color: var(--muted-text); } -.toc-back a:hover { color: var(--link-color); } -.toc-heading { - font-size: 1.4rem; color: var(--header-color); - margin: 2rem 0 0.8rem; font-weight: 700; +.validation-toc a:visited { color: var(--text-color); } +.validation-toc a:hover { color: var(--link-color); } +.validation-toc .var-row { display: flex; align-items: center; gap: 0.6rem; } +.validation-toc .var-row input { margin: 0; flex-shrink: 0; } +.validation-toc .var-row a { + flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.toc-heading:first-child { margin-top: 0; } -.toc ul { list-style: none; padding: 0; margin: 0; } -.toc li { margin: 0.3rem 0; } -.toc a { color: var(--text-color); text-decoration: none; display: block; } -.toc a:visited { color: var(--text-color); } -.toc a:hover { color: var(--link-color); } -.toc .var-row { display: flex; align-items: center; gap: 0.6rem; } -.toc .var-row input { margin: 0; flex-shrink: 0; } -.toc .var-row a { flex: 1; min-width: 0; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; - font-size: 1.3rem; } -.toc .var-actions { - display: flex; gap: 0.6rem; margin-bottom: 0.8rem; - font-size: 1.1rem; text-transform: uppercase; letter-spacing: 1px; +.validation-toc .var-actions { + display: flex; gap: 0.6rem; margin-bottom: 0.6rem; + font-size: 1.05rem; text-transform: uppercase; letter-spacing: 1px; } -.toc .var-actions button { - background: var(--bg-color); border: 1px solid var(--border-color); - color: var(--header-color); padding: 0.2rem 0.8rem; cursor: pointer; +.validation-toc .var-actions button { + background: var(--bg-color); + border: 1px solid var(--border-color); + color: var(--header-color); + padding: 0.1rem 0.6rem; + cursor: pointer; font-weight: 700; letter-spacing: 1px; + font-family: inherit; } -.toc .var-actions button:hover { background: var(--header-color); color: var(--bg-color); } - -main { - margin-left: var(--sidebar); padding: 2rem 4rem 6rem; - max-width: calc(78rem + var(--sidebar)); -} -main h1 { margin-top: 0; } -main h2 { margin-top: 3.2rem; padding-bottom: 0.4rem; - border-bottom: 1px solid var(--border-color); } -main h3 { margin-top: 2.4rem; } - -section.variable { - margin-top: 2.4rem; padding-top: 0.6rem; - border-top: 1px solid var(--border-color); -} -section.variable.hidden { display: none; } -.plots { display: flex; flex-direction: column; gap: 1rem; margin: 1rem 0 2rem; } -.plots a { display: block; } -.plots img { - display: block; max-width: 100%; height: auto; - border: 1px solid var(--border-color); - background: var(--bg-color); +.validation-toc .var-actions button:hover { + background: var(--header-color); color: var(--bg-color); } @media (max-width: 880px) { - .toc-toggle { display: flex; } - .toc { transform: translateX(-100%); transition: transform 180ms ease; - padding-top: 5.6rem; - box-shadow: 0.4rem 0 1.2rem var(--shadow-color, rgba(0,0,0,0.4)); } - body.toc-open .toc { transform: translateX(0); } - body.toc-open::after { content: ""; position: fixed; inset: 0; - background: rgba(0,0,0,0.4); z-index: 15; } - main { margin-left: 0; padding: 6rem 2rem 3rem; } - table { font-size: 1.2rem; } - th, td { padding: 0.4rem 0.8rem; } + .validation-grid { grid-template-columns: 1fr; gap: 1.6rem; } + .validation-toc { + position: static; max-height: none; overflow: visible; + border-left: none; border-top: 1px solid var(--border-muted-color); + border-bottom: 1px solid var(--border-muted-color); + padding: 1rem 0; + } + .validation-body .table-scroll table { font-size: 1.2rem; } + .validation-body .table-scroll th, + .validation-body .table-scroll td { + padding: 0.4rem 0.8rem; + } } `; -// JS ported verbatim from reformatters' render.py _JS. const JS = String.raw` (function () { - var body = document.body; - var toggle = document.querySelector('.toc-toggle'); - if (toggle) { - toggle.addEventListener('click', function () { - body.classList.toggle('toc-open'); - }); + function cssEscape(s) { return s.replace(/(["\\])/g, '\\$1'); } + function applyVar(v, on) { + document.querySelectorAll('section.variable[data-var="' + cssEscape(v) + '"]') + .forEach(function (s) { s.classList.toggle('hidden', !on); }); } - document.querySelectorAll('.toc a').forEach(function (a) { + document.querySelectorAll('.validation-toc a').forEach(function (a) { a.addEventListener('click', function () { - body.classList.remove('toc-open'); var href = a.getAttribute('href') || ''; if (href.indexOf('#var-') === 0) { var v = href.slice(5); @@ -304,14 +270,6 @@ const JS = String.raw` } }); }); - - function cssEscape(s) { - return s.replace(/(["\\])/g, '\\$1'); - } - function applyVar(v, on) { - document.querySelectorAll('section.variable[data-var="' + cssEscape(v) + '"]') - .forEach(function (s) { s.classList.toggle('hidden', !on); }); - } document.querySelectorAll('input[data-var]').forEach(function (cb) { cb.addEventListener('change', function () { applyVar(cb.dataset.var, cb.checked); }); }); @@ -327,7 +285,7 @@ const JS = String.raw` })(); `; -function renderHtml({ datasetId, baseUrl, markdown }) { +function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { let html = md.render(markdown); html = pngLinksOpenInNewTab(html); const variables = extractPerVarNames(html); @@ -338,35 +296,28 @@ function renderHtml({ datasetId, baseUrl, markdown }) { html = wrapTables(html); html = rewriteAssetUrls(html, baseUrl); - const datasetName = extractDatasetName(markdown, datasetId); - const toc = buildToc(annotated.sections, variables, datasetName, datasetId); - const title = `Validation report — ${datasetId}`; + const toc = buildToc(annotated.sections, variables); + const breadcrumbName = datasetName || datasetId; - return ` - - - - -${title} - - - - - - - - -${toc} -
    ${html}
    - - - -`; + return `
    +
    + Catalog > + ${breadcrumbName} > + Validation report +
    +
    + ${toc} +
    ${html}
    +
    + + +
    `; } class ValidationReportPage { data() { return { + layout: "base.njk", pagination: { data: "validationReports.entries", size: 1, @@ -376,14 +327,20 @@ class ValidationReportPage { eleventyComputed: { title: ({ entry }) => `Validation report — ${entry.datasetId}`, }, - layout: false, }; } - render({ entry }) { - return renderHtml(entry); + render({ entry, catalog }) { + const catalogEntry = + catalog && catalog.entries + ? catalog.entries.find((e) => e.dataset_id === entry.datasetId) + : null; + const datasetName = + (catalogEntry && catalogEntry.name) || + extractDatasetName(entry.markdown, entry.datasetId); + return renderFragment(entry, datasetName); } } module.exports = ValidationReportPage; -module.exports.renderHtml = renderHtml; +module.exports.renderFragment = renderFragment; From 87998274dd14b01fe0b7dd837bcdad7d7b6afb17 Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 10:30:02 -0500 Subject: [PATCH 04/20] validation: drop variable filters, float TOC off left of content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TOC now sits to the left of the centered max-width report body on wide viewports and stacks above it on narrow ones. Variable checkboxes and all/none controls are removed — the variable list in the TOC stays as plain anchor links. The toggle JS is gone with them. --- content/catalog/validation.11ty.js | 207 +++++++++++------------------ 1 file changed, 80 insertions(+), 127 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index dbaa4384c..3ae2ca2ca 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -3,15 +3,14 @@ // layout to the standalone HTML report at // https://dataset-validation-reports.dynamical.org//latest/validation_report.html. // -// Mirrors reformatters' src/scripts/validation/render.py — the same five -// post-process transforms over markdown-it's HTML output, the same per- -// variable checkbox toggle. Image references in the markdown are bare -// filenames and get resolved against the report's baseUrl so the rendered -// page can load them directly from R2. +// Mirrors reformatters' src/scripts/validation/render.py output — the +// same post-process transforms over markdown-it's HTML. Image refs in +// the markdown are bare filenames and get resolved against the report's +// baseUrl so the rendered page can load them directly from R2. // -// The page inherits site chrome (base.njk + main.css). The TOC sits in a -// sticky sidebar to the left of the content on wide screens and collapses -// to an in-flow block on narrow ones. +// The page inherits site chrome (base.njk + main.css). The TOC sits to +// the left of the centered max-width content on wide viewports and +// stacks above it on narrow ones. const MarkdownIt = require("markdown-it"); @@ -47,7 +46,7 @@ function wrapVariableSections(html) { `${escapeAttr(v)} — time series comparison` + ``; return ( - `
    ` + + `
    ` + `

    ${v}

    ` + `${body}${plots}
    ` ); @@ -85,9 +84,9 @@ function wrapTables(html) { ); } -// Resolve bare-filename src/href on / against baseUrl. Anything that -// already looks absolute (scheme, protocol-relative, root-relative, or a bare -// fragment) is left alone — only relative refs get rewritten. +// Resolve bare-filename src/href on / against baseUrl. Anything +// that already looks absolute (scheme, protocol-relative, root-relative, +// or a bare fragment) is left alone — only relative refs get rewritten. function rewriteAssetUrls(html, baseUrl) { const isAbsolute = (u) => /^[a-z][a-z0-9+.-]*:/i.test(u) || @@ -113,39 +112,68 @@ function buildToc(sections, variables) { .map((s) => `
  • ${s.title}
  • `) .join(""); const varItems = variables - .map( - (v) => - `
  • ${v}
  • `, - ) + .map((v) => `
  • ${v}
  • `) .join(""); return ` `; } -// Only the styles unique to the validation report. Base typography, colors, -// link colors, headings, body table styling are inherited from main.css. +// Layout: a flex wrapper centered at content-width + TOC-width. The TOC +// floats off the left edge of the centered max-width report body on +// wide viewports; below ~1100px both stack into a single column. +// +// Typography, link colors, base table style come from main.css. const CSS = ` -.validation-report { font-size: 1.4rem; } -.validation-breadcrumb { margin-bottom: 2rem; } -.validation-grid { - display: grid; - grid-template-columns: 22rem 1fr; +.validation-wrapper { + display: flex; + align-items: flex-start; gap: 3rem; - align-items: start; + max-width: 99rem; + margin: 0 auto; +} +.validation-toc { + position: sticky; + top: 1rem; + flex: 0 0 18rem; + font-size: 1.2rem; + max-height: calc(100vh - 2rem); + overflow-y: auto; + border-left: 1px solid var(--border-muted-color); + padding-left: 1.6rem; +} +.validation-toc .toc-heading { + font-size: 1.3rem; + color: var(--header-color); + margin: 1.4rem 0 0.6rem; + font-weight: 700; +} +.validation-toc .toc-heading:first-child { margin-top: 0; } +.validation-toc ul { list-style: none; padding: 0; margin: 0; } +.validation-toc li { margin: 0.2rem 0; } +.validation-toc a { + color: var(--text-color); + text-decoration: none; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.validation-toc a:visited { color: var(--text-color); } +.validation-toc a:hover { color: var(--link-color); } + +.validation-body { + flex: 1 1 auto; min-width: 0; + max-width: 78rem; + font-size: 1.4rem; } -.validation-body { min-width: 0; } +.validation-breadcrumb { margin-bottom: 2rem; } .validation-body h2 { margin-top: 3.2rem; padding-bottom: 0.4rem; @@ -157,7 +185,6 @@ const CSS = ` padding-top: 0.6rem; border-top: 1px solid var(--border-color); } -.validation-body section.variable.hidden { display: none; } .validation-body .plots { display: flex; flex-direction: column; gap: 1rem; margin: 1rem 0 2rem; @@ -188,60 +215,19 @@ const CSS = ` font-weight: 700; } -.validation-toc { - position: sticky; - top: 1rem; - align-self: start; - font-size: 1.2rem; - border-left: 1px solid var(--border-muted-color); - padding-left: 1.6rem; - max-height: calc(100vh - 2rem); - overflow-y: auto; -} -.validation-toc .toc-heading { - font-size: 1.3rem; - color: var(--header-color); - margin: 1.6rem 0 0.6rem; - font-weight: 700; -} -.validation-toc .toc-heading:first-child { margin-top: 0; } -.validation-toc ul { list-style: none; padding: 0; margin: 0; } -.validation-toc li { margin: 0.2rem 0; } -.validation-toc a { - color: var(--text-color); - text-decoration: none; - display: block; -} -.validation-toc a:visited { color: var(--text-color); } -.validation-toc a:hover { color: var(--link-color); } -.validation-toc .var-row { display: flex; align-items: center; gap: 0.6rem; } -.validation-toc .var-row input { margin: 0; flex-shrink: 0; } -.validation-toc .var-row a { - flex: 1; min-width: 0; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.validation-toc .var-actions { - display: flex; gap: 0.6rem; margin-bottom: 0.6rem; - font-size: 1.05rem; text-transform: uppercase; letter-spacing: 1px; -} -.validation-toc .var-actions button { - background: var(--bg-color); - border: 1px solid var(--border-color); - color: var(--header-color); - padding: 0.1rem 0.6rem; - cursor: pointer; - font-weight: 700; letter-spacing: 1px; - font-family: inherit; -} -.validation-toc .var-actions button:hover { - background: var(--header-color); color: var(--bg-color); -} - -@media (max-width: 880px) { - .validation-grid { grid-template-columns: 1fr; gap: 1.6rem; } +@media (max-width: 1100px) { + .validation-wrapper { + flex-direction: column; + max-width: 78rem; + gap: 1.6rem; + } .validation-toc { - position: static; max-height: none; overflow: visible; - border-left: none; border-top: 1px solid var(--border-muted-color); + position: static; + flex: none; + max-height: none; + overflow: visible; + border-left: none; + border-top: 1px solid var(--border-muted-color); border-bottom: 1px solid var(--border-muted-color); padding: 1rem 0; } @@ -253,38 +239,6 @@ const CSS = ` } `; -const JS = String.raw` -(function () { - function cssEscape(s) { return s.replace(/(["\\])/g, '\\$1'); } - function applyVar(v, on) { - document.querySelectorAll('section.variable[data-var="' + cssEscape(v) + '"]') - .forEach(function (s) { s.classList.toggle('hidden', !on); }); - } - document.querySelectorAll('.validation-toc a').forEach(function (a) { - a.addEventListener('click', function () { - var href = a.getAttribute('href') || ''; - if (href.indexOf('#var-') === 0) { - var v = href.slice(5); - var cb = document.querySelector('input[data-var="' + cssEscape(v) + '"]'); - if (cb && !cb.checked) { cb.checked = true; applyVar(v, true); } - } - }); - }); - document.querySelectorAll('input[data-var]').forEach(function (cb) { - cb.addEventListener('change', function () { applyVar(cb.dataset.var, cb.checked); }); - }); - var allBtn = document.querySelector('[data-action="all"]'); - var noneBtn = document.querySelector('[data-action="none"]'); - if (allBtn) allBtn.addEventListener('click', function () { setAll(true); }); - if (noneBtn) noneBtn.addEventListener('click', function () { setAll(false); }); - function setAll(on) { - document.querySelectorAll('input[data-var]').forEach(function (cb) { - cb.checked = on; applyVar(cb.dataset.var, on); - }); - } -})(); -`; - function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { let html = md.render(markdown); html = pngLinksOpenInNewTab(html); @@ -299,18 +253,17 @@ function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { const toc = buildToc(annotated.sections, variables); const breadcrumbName = datasetName || datasetId; - return `
    -
    - Catalog > - ${breadcrumbName} > - Validation report -
    -
    - ${toc} -
    ${html}
    -
    + return `
    + ${toc} + -
    `; } From 6fd21fac348f8907b7c14f1a4285cc0336cd34f7 Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 10:39:37 -0500 Subject: [PATCH 05/20] validation: move TOC to right rail, drop TOC borders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content body now uses the same max-width as every other catalog page (78rem, centered) so it sits in the same horizontal position. The TOC lives in a 'rail' anchored just outside the wrapper's right edge — it takes no space in the main column, stays sticky as you scroll, and collapses to an in-flow block above the content when there isn't room for it beside the body (~1180px breakpoint). --- content/catalog/validation.11ty.js | 49 +++++++++++++++--------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index 3ae2ca2ca..fa7a37897 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -124,28 +124,33 @@ function buildToc(sections, variables) { `; } -// Layout: a flex wrapper centered at content-width + TOC-width. The TOC -// floats off the left edge of the centered max-width report body on -// wide viewports; below ~1100px both stack into a single column. +// Layout: the report body is centered at the same max-width as the +// rest of the site (78rem), unaffected by the TOC. The TOC lives in a +// rail anchored just outside the wrapper's right edge (so it sits in +// what would otherwise be empty page margin), and stays sticky as the +// page scrolls. Below ~1180px the rail collapses back into the flow +// and renders above the content. // // Typography, link colors, base table style come from main.css. const CSS = ` .validation-wrapper { - display: flex; - align-items: flex-start; - gap: 3rem; - max-width: 99rem; + position: relative; + max-width: 78rem; margin: 0 auto; } +.validation-toc-rail { + position: absolute; + top: 0; + left: calc(100% + 3rem); + width: 18rem; + height: 100%; +} .validation-toc { position: sticky; - top: 1rem; - flex: 0 0 18rem; + top: 2rem; font-size: 1.2rem; - max-height: calc(100vh - 2rem); + max-height: calc(100vh - 4rem); overflow-y: auto; - border-left: 1px solid var(--border-muted-color); - padding-left: 1.6rem; } .validation-toc .toc-heading { font-size: 1.3rem; @@ -168,9 +173,6 @@ const CSS = ` .validation-toc a:hover { color: var(--link-color); } .validation-body { - flex: 1 1 auto; - min-width: 0; - max-width: 78rem; font-size: 1.4rem; } .validation-breadcrumb { margin-bottom: 2rem; } @@ -215,20 +217,17 @@ const CSS = ` font-weight: 700; } -@media (max-width: 1100px) { - .validation-wrapper { - flex-direction: column; - max-width: 78rem; - gap: 1.6rem; +@media (max-width: 1180px) { + .validation-toc-rail { + position: static; + width: auto; + height: auto; + margin-bottom: 1.6rem; } .validation-toc { position: static; - flex: none; max-height: none; overflow: visible; - border-left: none; - border-top: 1px solid var(--border-muted-color); - border-bottom: 1px solid var(--border-muted-color); padding: 1rem 0; } .validation-body .table-scroll table { font-size: 1.2rem; } @@ -254,7 +253,7 @@ function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { const breadcrumbName = datasetName || datasetId; return `
    - ${toc} +
    ${toc}
    Catalog > From 1885c7549fe8029da7d7832c2671ac8f5bcf505e Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 11:28:43 -0500 Subject: [PATCH 06/20] validation: TOC back to left, right-align; drop section borders Rail anchors outside the wrapper's left edge again; TOC items are right-aligned so they sit flush against the content's left edge. Drops the H2 bottom-border underline and the variable section top-border that were running across the content area. --- content/catalog/validation.11ty.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index fa7a37897..e055bd224 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -126,10 +126,11 @@ function buildToc(sections, variables) { // Layout: the report body is centered at the same max-width as the // rest of the site (78rem), unaffected by the TOC. The TOC lives in a -// rail anchored just outside the wrapper's right edge (so it sits in -// what would otherwise be empty page margin), and stays sticky as the -// page scrolls. Below ~1180px the rail collapses back into the flow -// and renders above the content. +// rail anchored just outside the wrapper's left edge (so it sits in +// what would otherwise be empty page margin) and is right-aligned so +// its items sit flush against the content's left edge. It stays sticky +// as the page scrolls. Below ~1180px the rail collapses back into the +// flow and renders above the content. // // Typography, link colors, base table style come from main.css. const CSS = ` @@ -141,7 +142,7 @@ const CSS = ` .validation-toc-rail { position: absolute; top: 0; - left: calc(100% + 3rem); + right: calc(100% + 3rem); width: 18rem; height: 100%; } @@ -151,6 +152,7 @@ const CSS = ` font-size: 1.2rem; max-height: calc(100vh - 4rem); overflow-y: auto; + text-align: right; } .validation-toc .toc-heading { font-size: 1.3rem; @@ -176,17 +178,9 @@ const CSS = ` font-size: 1.4rem; } .validation-breadcrumb { margin-bottom: 2rem; } -.validation-body h2 { - margin-top: 3.2rem; - padding-bottom: 0.4rem; - border-bottom: 1px solid var(--border-color); -} +.validation-body h2 { margin-top: 3.2rem; } .validation-body h3 { margin-top: 2.4rem; } -.validation-body section.variable { - margin-top: 2.4rem; - padding-top: 0.6rem; - border-top: 1px solid var(--border-color); -} +.validation-body section.variable { margin-top: 2.4rem; } .validation-body .plots { display: flex; flex-direction: column; gap: 1rem; margin: 1rem 0 2rem; @@ -229,6 +223,7 @@ const CSS = ` max-height: none; overflow: visible; padding: 1rem 0; + text-align: left; } .validation-body .table-scroll table { font-size: 1.2rem; } .validation-body .table-scroll th, From 86b7b43cc9737a3bea2db46a0cb0f586b4b5e5f1 Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 11:32:18 -0500 Subject: [PATCH 07/20] validation: switch body wrapper to div, left-align TOC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site's main.css applies a perimeter border + horizontal margins to
    (intended for blog/update posts), so the report body was picking up an unintended box. Use a plain
    instead. Also drops the right-align on the TOC text — items render left-aligned as the user prefers. --- content/catalog/validation.11ty.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index e055bd224..c718ee37c 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -127,10 +127,9 @@ function buildToc(sections, variables) { // Layout: the report body is centered at the same max-width as the // rest of the site (78rem), unaffected by the TOC. The TOC lives in a // rail anchored just outside the wrapper's left edge (so it sits in -// what would otherwise be empty page margin) and is right-aligned so -// its items sit flush against the content's left edge. It stays sticky -// as the page scrolls. Below ~1180px the rail collapses back into the -// flow and renders above the content. +// what would otherwise be empty page margin) and stays sticky as the +// page scrolls. Below ~1180px the rail collapses back into the flow +// and renders above the content. // // Typography, link colors, base table style come from main.css. const CSS = ` @@ -152,7 +151,6 @@ const CSS = ` font-size: 1.2rem; max-height: calc(100vh - 4rem); overflow-y: auto; - text-align: right; } .validation-toc .toc-heading { font-size: 1.3rem; @@ -223,7 +221,6 @@ const CSS = ` max-height: none; overflow: visible; padding: 1rem 0; - text-align: left; } .validation-body .table-scroll table { font-size: 1.2rem; } .validation-body .table-scroll th, @@ -249,14 +246,14 @@ function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { return `
    ${toc}
    - +
    `; } From cb00daa64afcae98d278800a80ce8125d67e921d Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 12:04:44 -0500 Subject: [PATCH 08/20] =?UTF-8?q?lib:=20add=20markdown-toc=20=E2=80=94=20g?= =?UTF-8?q?eneric=20scroll-spy=20TOC=20for=20rendered=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exports annotateHeadings(), buildTocHtml(), CSS, and JS. Given HTML already produced by a markdown renderer, annotateHeadings adds ids to every h2/h3 and returns the headings list in document order; buildTocHtml turns that into a nested
      with h3s grouped under their preceding h2. CSS handles the nested tree, collapse-when-inactive on h3 children, and a ▸ indicator on the active link with a reserved padding slot so toggling .active doesn't reflow neighbours. JS uses an IntersectionObserver with a top-30% active band to mark the deepest in-view heading active and expand its parent h2. Page templates put the article body inside .md-toc-content and the TOC inside .md-toc; the JS keys off those classes. --- lib/markdown-toc.js | 192 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 lib/markdown-toc.js diff --git a/lib/markdown-toc.js b/lib/markdown-toc.js new file mode 100644 index 000000000..1261569db --- /dev/null +++ b/lib/markdown-toc.js @@ -0,0 +1,192 @@ +// Generic scroll-spy TOC for markdown-rendered HTML. +// +// Given HTML already produced by a markdown renderer, annotateHeadings() +// adds id="…" to every

      and

      and returns the heading list in +// document order. buildTocHtml() turns that list into a nested
        +// with H3s grouped under their preceding H2. CSS + JS provide the +// visual + interaction layer: nested children collapse until their +// parent H2 is the active section, and a ▸ indicator marks the heading +// currently in view as the page scrolls. +// +// Page templates that consume this module are expected to: +// - put the rendered article body inside an element with class +// `md-toc-content` +// - put the TOC inside an element with class `md-toc` +// +// Both selectors are baked into the JS so multiple instances on a +// single page Just Work and the public API stays minimal. + +function slugify(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function plainText(htmlFragment) { + return htmlFragment.replace(/<[^>]+>/g, "").trim(); +} + +// Annotate every

        and

        in `html` with an id derived from its +// plain-text content. Returns the updated html and a flat list of +// { level, slug, title } in document order. +function annotateHeadings(html) { + const headings = []; + const out = html.replace(/<(h2|h3)>([\s\S]*?)<\/\1>/g, (_full, tag, inner) => { + const title = plainText(inner); + const slug = slugify(title); + headings.push({ level: tag === "h2" ? 2 : 3, slug, title }); + return `<${tag} id="${slug}">${inner}`; + }); + return { html: out, headings }; +} + +// Build the nested TOC. H3s are grouped under the most recent preceding +// H2. H3s that appear before any H2 are emitted at the top level so +// nothing silently disappears. +function buildTocHtml(headings) { + let html = `
          `; + let openH2 = false; + let openH3List = false; + + const closeH2 = () => { + if (openH3List) { + html += `
        `; + openH3List = false; + } + if (openH2) { + html += ``; + openH2 = false; + } + }; + + for (const h of headings) { + if (h.level === 2) { + closeH2(); + html += `
      • ${h.title}`; + openH2 = true; + } else if (h.level === 3) { + if (!openH2) { + // Stray H3 before any H2 — emit at top level. + html += `
      • ${h.title}
      • `; + continue; + } + if (!openH3List) { + html += `
          `; + openH3List = true; + } + html += `
        • ${h.title}
        • `; + } + } + closeH2(); + html += `
        `; + return html; +} + +// Styles for the toc tree itself. Page-level layout (positioning the +// toc rail, font sizes, colors) stays with the consumer's CSS — these +// rules are scoped to `.md-toc` so they only target the TOC contents. +const CSS = ` +.md-toc .toc-tree, +.md-toc .toc-tree ul { + display: block; + list-style: none; + padding: 0; + margin: 0; +} +.md-toc .toc-h2, +.md-toc .toc-h3 { + display: list-item; + margin: 0.2rem 0; +} +.md-toc .toc-h3 { padding-left: 1em; } + +.md-toc .toc-h2 > ul { display: none; } +.md-toc .toc-h2.expanded > ul { display: block; } + +/* Reserve 1.2em on the left of every link for the ▸ indicator. The + space stays even when no indicator is shown so toggling .active + doesn't reflow neighbouring items. The indicator lives inside the + link's box rather than hanging off the side because the TOC is + typically a scroll container (overflow-y: auto), which clips + horizontal overflow in most browsers. */ +.md-toc a { + position: relative; + display: inline-block; + padding-left: 1.2em; +} +.md-toc a::before { + content: "▸"; + position: absolute; + left: 0; + opacity: 0; + transition: opacity 120ms; +} +.md-toc a.active::before { opacity: 1; } +`; + +// Scroll-spy. Tracks which headings inside `.md-toc-content` have +// crossed the top-30% "active line" and marks the deepest such heading +// active. If the active heading is an H3, its containing H2's TOC
      • +// also gets `expanded` so the children list stays visible. +const JS = String.raw` +(function () { + var content = document.querySelector('.md-toc-content'); + var toc = document.querySelector('.md-toc'); + if (!content || !toc) return; + + var headings = [].slice.call(content.querySelectorAll('h2[id], h3[id]')); + if (!headings.length) return; + + var entriesById = {}; + toc.querySelectorAll('a[href^="#"]').forEach(function (a) { + var id = a.getAttribute('href').slice(1); + var li = a.closest('li'); + entriesById[id] = { + link: a, + li: li, + parentH2Li: li.closest('.toc-h2'), + }; + }); + + var passed = {}; + + function update() { + var activeId = null; + for (var i = 0; i < headings.length; i++) { + if (passed[headings[i].id]) activeId = headings[i].id; + } + if (!activeId) activeId = headings[0].id; + + for (var id in entriesById) { + entriesById[id].link.classList.remove('active'); + if (entriesById[id].parentH2Li) { + entriesById[id].parentH2Li.classList.remove('expanded'); + } + } + + var active = entriesById[activeId]; + if (!active) return; + active.link.classList.add('active'); + if (active.parentH2Li) active.parentH2Li.classList.add('expanded'); + } + + var io = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + if (e.isIntersecting) { + passed[e.target.id] = true; + } else if (e.boundingClientRect.top < 0) { + passed[e.target.id] = true; + } else { + delete passed[e.target.id]; + } + }); + update(); + }, { rootMargin: '0px 0px -70% 0px', threshold: 0 }); + + headings.forEach(function (h) { io.observe(h); }); + update(); +})(); +`; + +module.exports = { annotateHeadings, buildTocHtml, CSS, JS }; From f03095e1c116e2b9a4f140e7b01c57167739357c Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 12:04:54 -0500 Subject: [PATCH 09/20] validation: use lib/markdown-toc, nest h3s, drop variable special case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slims validation.11ty.js to validation-specific work — png link target=_blank, per-variable plot injection, table-scroll wrapping, asset-URL rewriting against the report's baseUrl — and delegates the TOC build + scroll-spy + ▸ indicator to lib/markdown-toc. Variable headings are now annotated with normal H3 ids (the slug of the variable name), so they show up nested under 'Per-variable details' in the TOC like any other H3. The old special 'Variables' group and its custom anchor scheme are gone. --- content/catalog/validation.11ty.js | 121 +++++++---------------------- 1 file changed, 29 insertions(+), 92 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index c718ee37c..6799275d1 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -4,37 +4,30 @@ // https://dataset-validation-reports.dynamical.org//latest/validation_report.html. // // Mirrors reformatters' src/scripts/validation/render.py output — the -// same post-process transforms over markdown-it's HTML. Image refs in -// the markdown are bare filenames and get resolved against the report's -// baseUrl so the rendered page can load them directly from R2. +// same post-process transforms over markdown-it's HTML, plus per- +// variable plot injection. Image refs in the markdown are bare +// filenames and get resolved against the report's baseUrl so the +// rendered page can load them directly from R2. // -// The page inherits site chrome (base.njk + main.css). The TOC sits to -// the left of the centered max-width content on wide viewports and -// stacks above it on narrow ones. +// Page chrome and the scroll-spy TOC come from base.njk + main.css + +// lib/markdown-toc.js. Everything in this file is validation-specific. const MarkdownIt = require("markdown-it"); +const markdownToc = require("../../lib/markdown-toc.js"); const md = new MarkdownIt("commonmark").enable("table"); -function slugify(text) { - return text - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); } -// Per-variable headings render as

        name

        ; other ### don't. -function extractPerVarNames(html) { - return [...html.matchAll(/

        ([^<]+)<\/code><\/h3>/g)].map((m) => m[1]); -} - -function wrapVariableSections(html) { - const re = /

        ([^<]+)<\/code><\/h3>([\s\S]*?)(?=]|$)/g; - return html.replace(re, (_full, varName, body) => { +// Per-variable heading rows in the markdown are `### \`name\``, which +// markdown-it renders as `

        name

        `. For each one, +// append a `
        ` block of the three R2 plots that share +// the variable's name as a filename suffix. +function injectVariablePlots(html) { + const re = /(

        ([^<]+)<\/code><\/h3>)([\s\S]*?)(?=]|$)/g; + return html.replace(re, (_full, heading, varName, body) => { const v = varName; const plots = `
        ` + @@ -45,31 +38,7 @@ function wrapVariableSections(html) { `` + `${escapeAttr(v)} — time series comparison` + `
        `; - return ( - `
        ` + - `

        ${v}

        ` + - `${body}${plots}
        ` - ); - }); -} - -function annotateH2(html) { - const sections = []; - const out = html.replace(/

        ([\s\S]*?)<\/h2>/g, (_full, inner) => { - const plain = inner.replace(/<[^>]+>/g, "").trim(); - const slug = slugify(plain); - sections.push({ slug, title: plain }); - return `

        ${inner}

        `; - }); - return { html: out, sections }; -} - -function annotateNonVarH3(html) { - return html.replace(/

        ([\s\S]*?)<\/h3>/g, (full, inner) => { - if (inner.startsWith("")) return full; - const plain = inner.replace(/<[^>]+>/g, "").trim(); - const slug = slugify(plain); - return `

        ${inner}

        `; + return `${heading}${body}${plots}`; }); } @@ -107,23 +76,6 @@ function extractDatasetName(mdText, fallback) { return m ? m[1] : fallback; } -function buildToc(sections, variables) { - const sectionItems = sections - .map((s) => `
      • ${s.title}
      • `) - .join(""); - const varItems = variables - .map((v) => `
      • ${v}
      • `) - .join(""); - return ` - -`; -} - // Layout: the report body is centered at the same max-width as the // rest of the site (78rem), unaffected by the TOC. The TOC lives in a // rail anchored just outside the wrapper's left edge (so it sits in @@ -132,6 +84,7 @@ function buildToc(sections, variables) { // and renders above the content. // // Typography, link colors, base table style come from main.css. +// Nested-tree styling + ▸ indicator come from lib/markdown-toc CSS. const CSS = ` .validation-wrapper { position: relative; @@ -145,32 +98,13 @@ const CSS = ` width: 18rem; height: 100%; } -.validation-toc { +.md-toc { position: sticky; top: 2rem; font-size: 1.2rem; max-height: calc(100vh - 4rem); overflow-y: auto; } -.validation-toc .toc-heading { - font-size: 1.3rem; - color: var(--header-color); - margin: 1.4rem 0 0.6rem; - font-weight: 700; -} -.validation-toc .toc-heading:first-child { margin-top: 0; } -.validation-toc ul { list-style: none; padding: 0; margin: 0; } -.validation-toc li { margin: 0.2rem 0; } -.validation-toc a { - color: var(--text-color); - text-decoration: none; - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.validation-toc a:visited { color: var(--text-color); } -.validation-toc a:hover { color: var(--link-color); } .validation-body { font-size: 1.4rem; @@ -178,7 +112,6 @@ const CSS = ` .validation-breadcrumb { margin-bottom: 2rem; } .validation-body h2 { margin-top: 3.2rem; } .validation-body h3 { margin-top: 2.4rem; } -.validation-body section.variable { margin-top: 2.4rem; } .validation-body .plots { display: flex; flex-direction: column; gap: 1rem; margin: 1rem 0 2rem; @@ -216,7 +149,7 @@ const CSS = ` height: auto; margin-bottom: 1.6rem; } - .validation-toc { + .md-toc { position: static; max-height: none; overflow: visible; @@ -228,25 +161,28 @@ const CSS = ` padding: 0.4rem 0.8rem; } } +${markdownToc.CSS} `; function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { let html = md.render(markdown); html = pngLinksOpenInNewTab(html); - const variables = extractPerVarNames(html); - const annotated = annotateH2(html); + + const annotated = markdownToc.annotateHeadings(html); html = annotated.html; - html = annotateNonVarH3(html); - html = wrapVariableSections(html); + + html = injectVariablePlots(html); html = wrapTables(html); html = rewriteAssetUrls(html, baseUrl); - const toc = buildToc(annotated.sections, variables); + const tocHtml = markdownToc.buildTocHtml(annotated.headings); const breadcrumbName = datasetName || datasetId; return `
        -
        ${toc}
        -
        +
        + +
        +
        Catalog > ${breadcrumbName} > @@ -255,6 +191,7 @@ function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { ${html}
        +
        `; } From fea14e3b9317b4c347e8969b5d646bff248481fb Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 13:10:55 -0500 Subject: [PATCH 10/20] validation: own H1, narrow-mode TOC under title, ellipsis long links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generate the page H1 from the catalog entry name (' validation report') and strip any H1 the rendered markdown produces. Anticipates reformatters#614 which drops the H1 from validation_summary.md; still looks right against the current (pre-#614) bucket output. - Move the TOC rail inside .validation-body, after the breadcrumb and H1. On wide viewports the rail still absolute-positions out of flow into the left margin (containing block stays .validation-wrapper). On narrow viewports it goes static and renders directly below the H1 — not above the breadcrumb as before. - Narrow mode drops the indicator and the nested subheaders: the TOC becomes a flat list of H2 links right under the title. The scroll- spy keeps running but its visual effects are CSS-suppressed; the TOC is off-screen once you scroll past it anyway, so the spy isn't useful there. - TOC rail gets a right border to separate it from the body content. - Long heading names ellipsis-truncate inside the narrow TOC column (e.g., 'downward_short_wav…' instead of overflowing). --- content/catalog/validation.11ty.js | 43 +++++++++++++++++++++++++----- lib/markdown-toc.js | 11 ++++++-- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index 6799275d1..897c38607 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -53,6 +53,15 @@ function wrapTables(html) { ); } +// The page generates its own H1 from the catalog entry name, so any H1 +// emitted by the rendered markdown is redundant. reformatters PR #614 +// drops the H1 from validation_summary.md; this strip keeps the page +// looking right against both the current bucket output (still includes +// the H1) and the post-#614 output (no H1). +function stripH1(html) { + return html.replace(/

        [\s\S]*?<\/h1>\s*/g, ""); +} + // Resolve bare-filename src/href on / against baseUrl. Anything // that already looks absolute (scheme, protocol-relative, root-relative, // or a bare fragment) is left alone — only relative refs get rewritten. @@ -94,9 +103,11 @@ const CSS = ` .validation-toc-rail { position: absolute; top: 0; - right: calc(100% + 3rem); + right: calc(100% + 2rem); width: 18rem; height: 100%; + padding-right: 1rem; + border-right: 1px solid var(--border-muted-color); } .md-toc { position: sticky; @@ -143,18 +154,31 @@ const CSS = ` } @media (max-width: 1180px) { + /* Rail collapses out of the left margin and renders in document flow + instead — inside validation-body, after the breadcrumb + H1. */ .validation-toc-rail { position: static; width: auto; height: auto; - margin-bottom: 1.6rem; + margin: 0 0 2.4rem; + padding-right: 0; + border-right: none; } .md-toc { position: static; max-height: none; overflow: visible; - padding: 1rem 0; + padding: 0; } + /* No indicator and no nested subheaders when the TOC is in-flow — + it's just a flat list of section links right under the title. + Once the user scrolls past it, it's off-screen and the scroll-spy + wouldn't be useful, so we skip the visual effects entirely. */ + .md-toc a { padding-left: 0; } + .md-toc a::before { display: none; } + .md-toc .toc-h3 { display: none; } + .md-toc .toc-h2 > ul { display: none; } + .validation-body .table-scroll table { font-size: 1.2rem; } .validation-body .table-scroll th, .validation-body .table-scroll td { @@ -166,6 +190,7 @@ ${markdownToc.CSS} function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { let html = md.render(markdown); + html = stripH1(html); html = pngLinksOpenInNewTab(html); const annotated = markdownToc.annotateHeadings(html); @@ -177,17 +202,23 @@ function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { const tocHtml = markdownToc.buildTocHtml(annotated.headings); const breadcrumbName = datasetName || datasetId; + const pageTitle = `${breadcrumbName} validation report`; + // The TOC rail lives inside .validation-body, after the breadcrumb + // and H1. On wide viewports it absolute-positions itself out of flow + // into the left margin (containing block = .validation-wrapper). On + // narrow viewports it goes static and renders right under the H1. return `
        -
        - -
        Catalog > ${breadcrumbName} > Validation report
        +

        ${pageTitle}

        +
        + +
        ${html}
        diff --git a/lib/markdown-toc.js b/lib/markdown-toc.js index 1261569db..5a9149875 100644 --- a/lib/markdown-toc.js +++ b/lib/markdown-toc.js @@ -109,11 +109,18 @@ const CSS = ` doesn't reflow neighbouring items. The indicator lives inside the link's box rather than hanging off the side because the TOC is typically a scroll container (overflow-y: auto), which clips - horizontal overflow in most browsers. */ + horizontal overflow in most browsers. + + Long heading text gets a single-line ellipsis — the TOC is a fixed + narrow column and any heading wider than that should be truncated + visually rather than wrap. */ .md-toc a { position: relative; - display: inline-block; + display: block; padding-left: 1.2em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .md-toc a::before { content: "▸"; From 1cab924d6f0eaa83927906ecb12017089f8079f4 Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 14:24:10 -0500 Subject: [PATCH 11/20] validation: shrink TOC border to content height; use site table classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TOC right border moves from .validation-toc-rail (full wrapper height) to .md-toc itself, so the vertical line is only as tall as the visible TOC content. - TOC list items get font-weight: inherit so the global 'nav ul li:first-child { font-weight: 700 }' rule from main.css (intended for the brand lockup in the site nav) no longer leaks into the TOC and bold the first heading. - Markdown tables now wrap in

    — same pattern as the catalog dataset page. Drops the bespoke .table-scroll CSS in favour of main.css's shared table styling, so validation tables look consistent with the rest of the site. --- content/catalog/validation.11ty.js | 43 ++++++++---------------------- lib/markdown-toc.js | 4 +++ 2 files changed, 15 insertions(+), 32 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index 897c38607..cd1042326 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -46,10 +46,14 @@ function pngLinksOpenInNewTab(html) { return html.replace(//g, ''); } +// Match the site's catalog dataset page — wrap markdown tables in the +// shared .table-container scroll wrapper and apply the .data class so +// they pick up main.css's table styling (border, cell padding, header +// underline) instead of duplicating it here. function wrapTables(html) { return html.replace( - /(
    [\s\S]*?<\/table>)/g, - '
    $1
    ', + /
    ([\s\S]*?)<\/table>/g, + '
    $1
    ', ); } @@ -106,8 +110,6 @@ const CSS = ` right: calc(100% + 2rem); width: 18rem; height: 100%; - padding-right: 1rem; - border-right: 1px solid var(--border-muted-color); } .md-toc { position: sticky; @@ -115,6 +117,10 @@ const CSS = ` font-size: 1.2rem; max-height: calc(100vh - 4rem); overflow-y: auto; + /* Border lives on the toc itself (not the full-height rail) so the + vertical line is only as tall as the visible TOC content. */ + padding-right: 1rem; + border-right: 1px solid var(--border-muted-color); } .validation-body { @@ -133,26 +139,6 @@ const CSS = ` border: 1px solid var(--border-color); background: var(--bg-color); } -.validation-body .table-scroll { - overflow-x: auto; margin: 1rem 0 2rem; -} -.validation-body .table-scroll table { - border-collapse: collapse; - border: 1px solid var(--border-color); - margin: 0; - max-width: 100%; -} -.validation-body .table-scroll th, -.validation-body .table-scroll td { - padding: 0.8rem 1.6rem; - vertical-align: top; - border-right: 1px dotted var(--border-muted-color); -} -.validation-body .table-scroll th { - border-bottom: 1px solid var(--border-color); - font-weight: 700; -} - @media (max-width: 1180px) { /* Rail collapses out of the left margin and renders in document flow instead — inside validation-body, after the breadcrumb + H1. */ @@ -161,14 +147,13 @@ const CSS = ` width: auto; height: auto; margin: 0 0 2.4rem; - padding-right: 0; - border-right: none; } .md-toc { position: static; max-height: none; overflow: visible; padding: 0; + border-right: none; } /* No indicator and no nested subheaders when the TOC is in-flow — it's just a flat list of section links right under the title. @@ -178,12 +163,6 @@ const CSS = ` .md-toc a::before { display: none; } .md-toc .toc-h3 { display: none; } .md-toc .toc-h2 > ul { display: none; } - - .validation-body .table-scroll table { font-size: 1.2rem; } - .validation-body .table-scroll th, - .validation-body .table-scroll td { - padding: 0.4rem 0.8rem; - } } ${markdownToc.CSS} `; diff --git a/lib/markdown-toc.js b/lib/markdown-toc.js index 5a9149875..0b0151487 100644 --- a/lib/markdown-toc.js +++ b/lib/markdown-toc.js @@ -98,6 +98,10 @@ const CSS = ` .md-toc .toc-h3 { display: list-item; margin: 0.2rem 0; + /* Override any 'nav ul li:first-child' font-weight:700 style leaking + from site CSS — the TOC's first item shouldn't render bold just + because of where it sits in the DOM. */ + font-weight: inherit; } .md-toc .toc-h3 { padding-left: 1em; } From c26702ce170140d1bea1dc0cfa5d6e358eda7a19 Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 16:35:05 -0500 Subject: [PATCH 12/20] validation: cull page-specific CSS; reuse .content and generalize toc rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit + minimize CSS introduced for the validation page: - Replace .validation-wrapper with the site's existing .content class (already supplies max-width:78rem + centered margin). - Drop .validation-body, .validation-breadcrumb, and the .validation-body h2/h3 margin overrides — the site's base type rules handle these. - Move .md-toc-rail + .md-toc sticky/border/font-size + narrow-viewport collapse rules from validation.11ty.js into lib/markdown-toc.js so any future markdown-driven page that opts into .md-toc-content gets the rail behavior for free. - Stop wrapping markdown tables in .table-container (main.css gives it overflow-x:auto, which was clipping/scrolling the validation tables); just apply class="data" to the so tables overflow naturally. Page-specific CSS shrinks to just the .plots block under per-variable H3s, which is the only truly validation-specific styling. --- content/catalog/validation.11ty.js | 122 +++++++---------------------- lib/markdown-toc.js | 69 ++++++++++++---- 2 files changed, 82 insertions(+), 109 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index cd1042326..7020112eb 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -46,15 +46,12 @@ function pngLinksOpenInNewTab(html) { return html.replace(//g, ''); } -// Match the site's catalog dataset page — wrap markdown tables in the -// shared .table-container scroll wrapper and apply the .data class so -// they pick up main.css's table styling (border, cell padding, header -// underline) instead of duplicating it here. -function wrapTables(html) { - return html.replace( - /
    ([\s\S]*?)<\/table>/g, - '
    $1
    ', - ); +// Apply the site's .data table class so markdown tables pick up +// main.css's table styling (border, cell padding, header underline). +// No .table-container wrapper — tables overflow naturally instead of +// being clipped/scrolled inside a fixed box. +function styleTables(html) { + return html.replace(//g, '
    '); } // The page generates its own H1 from the catalog entry name, so any H1 @@ -89,81 +86,22 @@ function extractDatasetName(mdText, fallback) { return m ? m[1] : fallback; } -// Layout: the report body is centered at the same max-width as the -// rest of the site (78rem), unaffected by the TOC. The TOC lives in a -// rail anchored just outside the wrapper's left edge (so it sits in -// what would otherwise be empty page margin) and stays sticky as the -// page scrolls. Below ~1180px the rail collapses back into the flow -// and renders above the content. -// -// Typography, link colors, base table style come from main.css. -// Nested-tree styling + ▸ indicator come from lib/markdown-toc CSS. +// Validation-specific styles only: the per-variable plot block under +// each

    name

    . Layout (wrapper, toc rail, sticky, +// narrow-mode collapse) comes from lib/markdown-toc CSS; everything +// else (max-width, typography, tables, link colors) comes from +// main.css's .content + table.data + base type rules. const CSS = ` -.validation-wrapper { - position: relative; - max-width: 78rem; - margin: 0 auto; -} -.validation-toc-rail { - position: absolute; - top: 0; - right: calc(100% + 2rem); - width: 18rem; - height: 100%; -} -.md-toc { - position: sticky; - top: 2rem; - font-size: 1.2rem; - max-height: calc(100vh - 4rem); - overflow-y: auto; - /* Border lives on the toc itself (not the full-height rail) so the - vertical line is only as tall as the visible TOC content. */ - padding-right: 1rem; - border-right: 1px solid var(--border-muted-color); -} - -.validation-body { - font-size: 1.4rem; -} -.validation-breadcrumb { margin-bottom: 2rem; } -.validation-body h2 { margin-top: 3.2rem; } -.validation-body h3 { margin-top: 2.4rem; } -.validation-body .plots { +.plots { display: flex; flex-direction: column; gap: 1rem; margin: 1rem 0 2rem; } -.validation-body .plots a { display: block; } -.validation-body .plots img { +.plots a { display: block; } +.plots img { display: block; max-width: 100%; height: auto; border: 1px solid var(--border-color); background: var(--bg-color); } -@media (max-width: 1180px) { - /* Rail collapses out of the left margin and renders in document flow - instead — inside validation-body, after the breadcrumb + H1. */ - .validation-toc-rail { - position: static; - width: auto; - height: auto; - margin: 0 0 2.4rem; - } - .md-toc { - position: static; - max-height: none; - overflow: visible; - padding: 0; - border-right: none; - } - /* No indicator and no nested subheaders when the TOC is in-flow — - it's just a flat list of section links right under the title. - Once the user scrolls past it, it's off-screen and the scroll-spy - wouldn't be useful, so we skip the visual effects entirely. */ - .md-toc a { padding-left: 0; } - .md-toc a::before { display: none; } - .md-toc .toc-h3 { display: none; } - .md-toc .toc-h2 > ul { display: none; } -} ${markdownToc.CSS} `; @@ -176,30 +114,28 @@ function renderFragment({ datasetId, baseUrl, markdown }, datasetName) { html = annotated.html; html = injectVariablePlots(html); - html = wrapTables(html); + html = styleTables(html); html = rewriteAssetUrls(html, baseUrl); const tocHtml = markdownToc.buildTocHtml(annotated.headings); const breadcrumbName = datasetName || datasetId; const pageTitle = `${breadcrumbName} validation report`; - // The TOC rail lives inside .validation-body, after the breadcrumb - // and H1. On wide viewports it absolute-positions itself out of flow - // into the left margin (containing block = .validation-wrapper). On - // narrow viewports it goes static and renders right under the H1. - return `
    -
    -
    - Catalog > - ${breadcrumbName} > - Validation report -
    -

    ${pageTitle}

    -
    - -
    - ${html} + // .content gives the standard 78rem max-width + centering. The TOC + // rail absolute-positions itself out of flow into the left margin + // (containing block = .md-toc-content). On narrow viewports the rail + // goes static and renders here in flow, under the H1. + return `
    +
    + Catalog > + ${breadcrumbName} > + Validation report +
    +

    ${pageTitle}

    +
    +
    + ${html}
    `; diff --git a/lib/markdown-toc.js b/lib/markdown-toc.js index 0b0151487..55f79c128 100644 --- a/lib/markdown-toc.js +++ b/lib/markdown-toc.js @@ -83,10 +83,30 @@ function buildTocHtml(headings) { return html; } -// Styles for the toc tree itself. Page-level layout (positioning the -// toc rail, font sizes, colors) stays with the consumer's CSS — these -// rules are scoped to `.md-toc` so they only target the TOC contents. +// Styles for both the toc tree itself and the rail that floats it into +// the left margin of the content. Consumer just needs to give the body +// the class `md-toc-content` and drop a `
    +//
    ` inside it. const CSS = ` +.md-toc-content { position: relative; } + +.md-toc-rail { + position: absolute; + top: 0; + right: calc(100% + 2rem); + width: 18rem; + height: 100%; +} +.md-toc { + position: sticky; + top: 2rem; + font-size: 1.2rem; + max-height: calc(100vh - 4rem); + overflow-y: auto; + padding-right: 1rem; + border-right: 1px solid var(--border-muted-color); +} + .md-toc .toc-tree, .md-toc .toc-tree ul { display: block; @@ -98,9 +118,7 @@ const CSS = ` .md-toc .toc-h3 { display: list-item; margin: 0.2rem 0; - /* Override any 'nav ul li:first-child' font-weight:700 style leaking - from site CSS — the TOC's first item shouldn't render bold just - because of where it sits in the DOM. */ + /* Override 'nav ul li:first-child' font-weight:700 leaking from site CSS. */ font-weight: inherit; } .md-toc .toc-h3 { padding-left: 1em; } @@ -108,16 +126,11 @@ const CSS = ` .md-toc .toc-h2 > ul { display: none; } .md-toc .toc-h2.expanded > ul { display: block; } -/* Reserve 1.2em on the left of every link for the ▸ indicator. The - space stays even when no indicator is shown so toggling .active - doesn't reflow neighbouring items. The indicator lives inside the - link's box rather than hanging off the side because the TOC is - typically a scroll container (overflow-y: auto), which clips - horizontal overflow in most browsers. - - Long heading text gets a single-line ellipsis — the TOC is a fixed - narrow column and any heading wider than that should be truncated - visually rather than wrap. */ +/* Reserve 1.2em on the left of every link for the ▸ indicator so + toggling .active doesn't reflow neighbours. The indicator lives + inside the link's box because the TOC is a scroll container + (overflow-y: auto), which clips horizontal overflow in most browsers. + Long headings ellipsis-truncate. */ .md-toc a { position: relative; display: block; @@ -134,6 +147,30 @@ const CSS = ` transition: opacity 120ms; } .md-toc a.active::before { opacity: 1; } + +/* Narrow viewports: rail collapses out of the left margin and renders + in document flow. No indicator, no nested H3s — the TOC is just a + flat list of section links, and once scrolled past it's off-screen + so scroll-spy is useless. */ +@media (max-width: 1180px) { + .md-toc-rail { + position: static; + width: auto; + height: auto; + margin: 0 0 2.4rem; + } + .md-toc { + position: static; + max-height: none; + overflow: visible; + padding: 0; + border-right: none; + } + .md-toc a { padding-left: 0; } + .md-toc a::before { display: none; } + .md-toc .toc-h3 { display: none; } + .md-toc .toc-h2 > ul { display: none; } +} `; // Scroll-spy. Tracks which headings inside `.md-toc-content` have From 3202f0a2e748233aa83779150b62fe00f5c18965 Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 16:48:08 -0500 Subject: [PATCH 13/20] tables: overflow into page margins when viewport allows; toc rail polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .table-container previously clipped wide tables to .content's 78rem max-width and scrolled inside even on wide viewports where the table could be shown in full. Switch to width:max-content + viewport-based max-width + relative+translateX(-50%) centering so: - Narrow viewport (table > viewport): scroll horizontally inside the container, same as before. - Wide viewport (table fits in viewport but not in .content): table renders at full width, visibly overflowing .content into the page margins. Restore the .table-container wrapper on validation report tables — the new wrapper behavior is what we want there too. TOC rail tweaks per request: - width 18rem -> 22rem - gap to article 2rem -> 4rem - right border uses --popup-border (lighter grey) instead of --border-muted-color so it reads as a quiet divider rather than a hard rule against the body text; padding-right 1rem -> 2rem to keep the toc text from sitting on top of the border. --- content/catalog/validation.11ty.js | 14 +++++++++----- lib/markdown-toc.js | 10 ++++++---- public/main.css | 25 +++++++++++++++++++++---- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/content/catalog/validation.11ty.js b/content/catalog/validation.11ty.js index 7020112eb..dbc8f2016 100644 --- a/content/catalog/validation.11ty.js +++ b/content/catalog/validation.11ty.js @@ -46,12 +46,16 @@ function pngLinksOpenInNewTab(html) { return html.replace(//g, ''); } -// Apply the site's .data table class so markdown tables pick up -// main.css's table styling (border, cell padding, header underline). -// No .table-container wrapper — tables overflow naturally instead of -// being clipped/scrolled inside a fixed box. +// Apply the site's .table-container + .data table classes so markdown +// tables match the catalog page. The site-wide .table-container rule +// lets tables overflow their parent into the page margins when the +// viewport has room and only scrolls horizontally when the viewport is +// narrower than the table. function styleTables(html) { - return html.replace(/
    /g, '
    '); + return html.replace( + /
    ([\s\S]*?)<\/table>/g, + '
    $1
    ', + ); } // The page generates its own H1 from the catalog entry name, so any H1 diff --git a/lib/markdown-toc.js b/lib/markdown-toc.js index 55f79c128..5731a4808 100644 --- a/lib/markdown-toc.js +++ b/lib/markdown-toc.js @@ -93,8 +93,8 @@ const CSS = ` .md-toc-rail { position: absolute; top: 0; - right: calc(100% + 2rem); - width: 18rem; + right: calc(100% + 4rem); + width: 22rem; height: 100%; } .md-toc { @@ -103,8 +103,10 @@ const CSS = ` font-size: 1.2rem; max-height: calc(100vh - 4rem); overflow-y: auto; - padding-right: 1rem; - border-right: 1px solid var(--border-muted-color); + padding-right: 2rem; + /* Fainter than --border-muted-color so the rail reads as a quiet + divider rather than a hard rule against the article body. */ + border-right: 1px solid var(--popup-border); } .md-toc .toc-tree, diff --git a/public/main.css b/public/main.css index 1b454e0a1..b6696140b 100644 --- a/public/main.css +++ b/public/main.css @@ -115,10 +115,6 @@ body { background-color: var(--bg-color); } -.content { - font-size: 1.8rem; -} - .status-pill { display: inline-block; padding: 0.05em 0.55em; @@ -189,9 +185,30 @@ p { } .table-container { + /* Tables render at full width when the viewport has room (visibly + overflowing .content's max-width into the page margins), and scroll + horizontally inside the container only when the viewport itself is + narrower than the table. + + Shrink-wrap the wrapper to its table (width: max-content), cap at + the body's content area (max-width: 100vw - body padding), and + re-center on the viewport via the relative+transform trick so the + overflow distributes symmetrically. Without re-centering, a table + wider than .content would extend rightward from .content's left + edge and push past the viewport's right edge, triggering a page + horizontal scrollbar. */ + width: max-content; + max-width: calc(100vw - 8rem); + position: relative; + left: 50%; + transform: translateX(-50%); overflow-x: auto; } +@media screen and (max-width: 1000px) { + .table-container { max-width: calc(100vw - 2rem); } +} + table { border-collapse: collapse; } From ab73ceb93e8ce99f01e31266e1ac5495ecf4bdc1 Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 16:52:27 -0500 Subject: [PATCH 14/20] revert: drop the .table-container width:max-content + center trick It produced a worse outcome than the original problem: max-content sizing forced tables to render at their unwrapped natural width, so markdown tables with long URL values stretched all the way across the viewport instead of wrapping to fit the column. Back to plain overflow-x:auto. Tables wrap inside .content's 78rem and scroll horizontally when their content truly doesn't fit. --- public/main.css | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/public/main.css b/public/main.css index b6696140b..4c70b82ca 100644 --- a/public/main.css +++ b/public/main.css @@ -185,30 +185,9 @@ p { } .table-container { - /* Tables render at full width when the viewport has room (visibly - overflowing .content's max-width into the page margins), and scroll - horizontally inside the container only when the viewport itself is - narrower than the table. - - Shrink-wrap the wrapper to its table (width: max-content), cap at - the body's content area (max-width: 100vw - body padding), and - re-center on the viewport via the relative+transform trick so the - overflow distributes symmetrically. Without re-centering, a table - wider than .content would extend rightward from .content's left - edge and push past the viewport's right edge, triggering a page - horizontal scrollbar. */ - width: max-content; - max-width: calc(100vw - 8rem); - position: relative; - left: 50%; - transform: translateX(-50%); overflow-x: auto; } -@media screen and (max-width: 1000px) { - .table-container { max-width: calc(100vw - 2rem); } -} - table { border-collapse: collapse; } From 4b2c3bfe48900a3c758bb03a46a97844636cad86 Mon Sep 17 00:00:00 2001 From: mrsll Date: Tue, 19 May 2026 18:54:15 -0500 Subject: [PATCH 15/20] tables: make .table-container's content-column cap explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit max-width: 100% is the same value default block sizing already gives, written explicitly so the intent — wrapper stays anchored to the article column (.content), scrolls horizontally when the table inside is wider — is obvious at the rule. This is the catalog small-screen scroll aesthetic for any table that's wider than .content: the wrapper clips at the column edge with native horizontal scroll, rather than the table breaking out past .content into the page margins. --- public/main.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/public/main.css b/public/main.css index 4c70b82ca..161df043f 100644 --- a/public/main.css +++ b/public/main.css @@ -185,6 +185,12 @@ p { } .table-container { + /* Caps the wrapper at the article column width (100% of .content = + 78rem on wide viewports, body-content-area on narrow ones) and + scrolls horizontally when the table inside is wider. Default + block sizing already produces max-width:100% — written explicitly + so the intent is obvious. */ + max-width: 100%; overflow-x: auto; } From e677a542610e3df97e4b00e566629a4d18fd1021 Mon Sep 17 00:00:00 2001 From: mrsll Date: Wed, 20 May 2026 12:07:06 -0500 Subject: [PATCH 16/20] tables: add overflow-edge indicators with dithered shadow .table-container now shows a 1px border + Bayer-style 1-bit dither shadow at any edge where content overflows. Uses the local/scroll background-attachment trick: content-anchored covers hide the viewport-anchored border and dither image when the content edge sits at the viewport edge. Shadow is baked into an inline SVG (14x4 tile, mirrored via SVG transform for the right edge) so the same trick applies without masks. Themed for light/dark via shadow-dither-l/-r custom properties. --- public/main.css | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/public/main.css b/public/main.css index 161df043f..26ea0168b 100644 --- a/public/main.css +++ b/public/main.css @@ -26,6 +26,14 @@ --muted-text-2: #999999; --shadow-color: rgba(0, 0, 0, 0.15); + /* 1-bit ordered-dither shadow images: a 14x4 SVG tile with Bayer-style + coverage that fades dense → sparse left-to-right (the -l variant is + for left edges, the -r variant is the same pattern mirrored for + right edges via SVG transform). Tiled vertically via repeat-y at + 14px x 4px. shape-rendering=crispEdges keeps cells pixel-aligned. */ + --shadow-dither-l: url("data:image/svg+xml;utf8,"); + --shadow-dither-r: url("data:image/svg+xml;utf8,"); + --pill-available-bg: #5bc54a; --pill-available-fg: #081a08; --pill-muted-bg: #f0f0f0; @@ -73,6 +81,9 @@ --muted-text-2: #8f8f93; --shadow-color: rgba(0, 0, 0, 0.5); + --shadow-dither-l: url("data:image/svg+xml;utf8,"); + --shadow-dither-r: url("data:image/svg+xml;utf8,"); + --pill-muted-bg: #2a2a2d; --pill-muted-fg: #e8e8ea; --pill-muted-border: #3a3a3d; @@ -192,6 +203,33 @@ p { so the intent is obvious. */ max-width: 100%; overflow-x: auto; + /* Edge indicators that appear only when content overflows on that + side. Three layers per side: a solid-color "cover" anchored to the + scrolled content (background-attachment: local) sits at the + content's left/right edge; behind it, a 1px border line and a + dithered fade shadow are anchored to the scroll viewport + (background-attachment: scroll) at the container's left/right edge. + When the content edge lines up with the viewport edge (scrolled to + that side, or no overflow), the cover hides both the border and + the shadow. Once scrolled off, both show through — the border + marks the cut, the dithered shadow signals "more content this way". */ + background-image: + linear-gradient(var(--bg-color), var(--bg-color)), + linear-gradient(var(--bg-color), var(--bg-color)), + linear-gradient(var(--border-color), var(--border-color)), + linear-gradient(var(--border-color), var(--border-color)), + var(--shadow-dither-l), + var(--shadow-dither-r); + background-position: 0 0, 100% 0, 0 0, 100% 0, 0 0, 100% 0; + background-size: + 16px 100%, 16px 100%, + 1px 100%, 1px 100%, + 14px 4px, 14px 4px; + background-repeat: + no-repeat, no-repeat, + no-repeat, no-repeat, + repeat-y, repeat-y; + background-attachment: local, local, scroll, scroll, scroll, scroll; } table { From eee8ed6a056a8ba5e4caf20a8e0344028d91886f Mon Sep 17 00:00:00 2001 From: mrsll Date: Wed, 20 May 2026 12:07:42 -0500 Subject: [PATCH 17/20] validation: drop the (raw) link beside the report link --- content/catalog-pages.njk | 1 - 1 file changed, 1 deletion(-) diff --git a/content/catalog-pages.njk b/content/catalog-pages.njk index e3017a2b2..fa3b87197 100644 --- a/content/catalog-pages.njk +++ b/content/catalog-pages.njk @@ -67,7 +67,6 @@ eleventyComputed: {%- set validationReport = validationReports.entries | find('datasetId', entry.dataset_id) %} {% if validationReport %} · validation report - (raw) {% elif entry.validation_report_href %} · validation report {% endif %} From 4b9602b85553ff745dc22e460abeca3c2254b68c Mon Sep 17 00:00:00 2001 From: mrsll Date: Wed, 20 May 2026 12:13:47 -0500 Subject: [PATCH 18/20] markdown-toc: loop tag-strip regex to a fixed point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-pass regex could reconstitute a tag from nested input like ipt> — flagged by CodeQL js/incomplete-multi-character- sanitization. Loop until no more replacements happen so the stripped text can't reintroduce a tag. No real exposure today (markdown-it commonmark mode disables raw HTML and the validation input is trusted), but the fix is cheap defense-in-depth. --- lib/markdown-toc.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/markdown-toc.js b/lib/markdown-toc.js index 5731a4808..d34031ab9 100644 --- a/lib/markdown-toc.js +++ b/lib/markdown-toc.js @@ -24,7 +24,16 @@ function slugify(text) { } function plainText(htmlFragment) { - return htmlFragment.replace(/<[^>]+>/g, "").trim(); + // Loop until the replace reaches a fixed point so nested constructs + // like `ipt>` don't reconstitute a `