From 330bd1860217cfac38cb986412a497a2c3ded950 Mon Sep 17 00:00:00 2001 From: Urmil Chandarana Date: Fri, 14 Aug 2026 17:18:33 -0700 Subject: [PATCH 1/5] Add Export to PDF to the Scoring Methodology modal --- .../ScoringMethodologyModal.module.css | 115 ++++++++++++++++++ src/components/ScoringMethodologyModal.tsx | 60 ++++++++- src/css/custom.css | 27 ++++ 3 files changed, 196 insertions(+), 6 deletions(-) diff --git a/src/components/ScoringMethodologyModal.module.css b/src/components/ScoringMethodologyModal.module.css index 854d503..6fb1992 100644 --- a/src/components/ScoringMethodologyModal.module.css +++ b/src/components/ScoringMethodologyModal.module.css @@ -57,6 +57,35 @@ text-align: justify; } +.headerActions { + display: flex; + align-items: center; + gap: 0.6rem; + flex: 0 0 auto; +} + +.exportButton { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--ifm-color-primary); + border-radius: 999px; + background: var(--agml-accent-soft); + color: var(--ifm-color-primary); + padding: 0.45rem 0.9rem; + font-size: 0.78rem; + font-weight: 600; + font-family: var(--ifm-code-font-family); + cursor: pointer; + white-space: nowrap; +} + +.exportButton:hover, +.exportButton:focus-visible { + border-color: var(--ifm-color-primary-dark); + outline: none; +} + .closeButton { flex: 0 0 auto; width: 32px; @@ -544,3 +573,89 @@ grid-column: span 1; } } + +/* Export to PDF goes through the browser's native print pipeline (real, selectable, searchable + text — not a rasterized DOM screenshot) rather than a client-side PDF library, so this is the + entire "export" implementation: hide the modal chrome, let the panel flow across pages instead + of scrolling in a fixed box, and force the light palette regardless of the current on-screen + theme. The token overrides below are the exact --agml- and --ifm-color-primary light-mode + values from src/css/custom.css :root — redeclared here (not just "swap to light mode") because + html[data-theme='dark'] would otherwise still win the cascade for a dark-mode reader printing + this page, and a dark background either wastes ink or, worse, gets stripped by the print + engine while the light text stays light — invisible text on a white page. */ +@media print { + @page { + margin: 16mm 14mm; + } + + .backdrop { + position: static; + z-index: auto; + display: block; + padding: 0; + background: none; + backdrop-filter: none; + } + + .panel { + --agml-page-bg: oklch(0.985 0.006 145); + --agml-surface-strong: oklch(1 0.002 145); + --agml-surface-soft: oklch(0.965 0.008 145); + --agml-border: oklch(0.85 0.012 145); + --agml-border-strong: oklch(0.78 0.014 145); + --agml-text: oklch(0.2 0.012 145); + --agml-muted: oklch(0.45 0.012 145); + --agml-tag-bg: oklch(0.91 0.02 145); + --agml-accent-soft: oklch(0.92 0.06 150); + --ifm-color-primary: oklch(0.5 0.14 150); + --agml-caution-text: oklch(0.65 0.18 97); + --agml-warning-text: oklch(0.55 0.19 27); + --agml-warning-soft: rgba(199, 60, 41, 0.12); + --agml-axis-difficulty: oklch(0.5 0.15 250); + --agml-axis-diversity: oklch(0.55 0.17 340); + + width: 100%; + max-height: none; + overflow: visible; + border: none; + border-radius: 0; + box-shadow: none; + padding: 0; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .exportButton, + .closeButton, + .tabRow { + display: none !important; + } + + /* Keep each card, tile, and pending-note box from splitting across a page boundary — letting + the *sections* break freely between pages (so a long axis isn't forced onto its own page + and left mostly blank) while individual cards stay intact. */ + .overallSection, + .weightTile, + .metricCard, + .metricCardPenalty, + .placeholderCard, + .pendingNote, + .fieldRefRow { + break-inside: avoid; + } + + .axisTitle { + break-after: avoid; + } + + .fieldRefSummary { + cursor: default; + } + + /* The
marker triangle is a screen affordance for an interaction that doesn't exist + on paper — the export logic force-opens it, so the arrow reads as visual noise here. */ + .fieldRefSummary::-webkit-details-marker, + .fieldRefSummary::marker { + display: none; + } +} diff --git a/src/components/ScoringMethodologyModal.tsx b/src/components/ScoringMethodologyModal.tsx index ed60f33..4c6ca10 100644 --- a/src/components/ScoringMethodologyModal.tsx +++ b/src/components/ScoringMethodologyModal.tsx @@ -1,7 +1,7 @@ // Static reference modal explaining how dataset quality scores are computed. Unlike the rest // of the benchmarking UI, nothing here is driven by a specific dataset's data — it's the same // content for every dataset, documenting the formulas in SCORING_FORMULAS.md in plain language. -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import styles from './ScoringMethodologyModal.module.css'; @@ -259,6 +259,12 @@ export function ScoringMethodologyModal({ onClose: () => void; }) { const [activeTab, setActiveTab] = useState(taskType); + const fieldRefDetailsEl = useRef(null); + // Printing captures whatever's currently in the DOM, but only the active tab's content is + // ever rendered — exporting "the entire reference" while e.g. Object Detection is selected + // would otherwise print just its placeholder. Flip to Image Classification (the only tab + // with published content) first, then print once that re-render has actually painted. + const [pendingExport, setPendingExport] = useState(false); useEffect(() => { if (open) setActiveTab(taskType); @@ -273,8 +279,41 @@ export function ScoringMethodologyModal({ return () => window.removeEventListener('keydown', onKeyDown); }, [open, onClose]); + useEffect(() => { + if (!pendingExport) return; + + // The JSON field reference is a collapsed
by default — force it open so it's + // part of the printed document, and put back whatever state the user had it in after. + const wasDetailsOpen = fieldRefDetailsEl.current?.open ?? false; + if (fieldRefDetailsEl.current) fieldRefDetailsEl.current.open = true; + + // The exported file's suggested name comes from document.title in every major browser's + // print dialog — swap it for the print, then restore it once the dialog closes. + const previousTitle = document.title; + document.title = 'AgML Scoring Methodology'; + + const cleanUp = () => { + document.title = previousTitle; + if (fieldRefDetailsEl.current) fieldRefDetailsEl.current.open = wasDetailsOpen; + window.removeEventListener('afterprint', cleanUp); + setPendingExport(false); + }; + window.addEventListener('afterprint', cleanUp); + + // Two rAFs to be sure the tab-switch re-render above has actually painted before the + // print engine snapshots the DOM — one for React to commit, one for the browser to paint. + requestAnimationFrame(() => requestAnimationFrame(() => window.print())); + + return () => window.removeEventListener('afterprint', cleanUp); + }, [pendingExport]); + if (!open) return null; + const handleExportClick = () => { + if (activeTab !== 'Image Classification') setActiveTab('Image Classification'); + setPendingExport(true); + }; + const axisGroups = buildAxisGroups(activeTab); // Portaled to the document body — this modal can be opened from inside the dataset modal's @@ -282,7 +321,11 @@ export function ScoringMethodologyModal({ // containing block for any `position: fixed` descendant, which would otherwise center this // backdrop inside that tall transformed box instead of the actual viewport. return createPortal( -
+ // The extra, unhashed "print-export-root" class is a stable hook for the global print + // stylesheet (src/css/custom.css) — a CSS Modules class name can't be targeted from + // outside its own file, and hiding every *other* piece of the page during print (the + // Docusaurus site chrome, the dataset modal underneath) has to happen from there. +
- +
+ + +
@@ -401,7 +449,7 @@ export function ScoringMethodologyModal({
))}
-
+
JSON field reference
{FIELD_REFS.map((f) => ( diff --git a/src/css/custom.css b/src/css/custom.css index 85b2ea1..0498f17 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -325,3 +325,30 @@ body { .prism-code { background: var(--agml-surface-strong) !important; } + +/* Any open modal locks body scroll via an inline `overflow: hidden` (and Safari/Chrome can pair + that with a constrained height on ancestor layout containers) while it's open. That's a + screen-only affordance, but Chromium's print pagination can take a hidden-overflow body as + "this document is exactly one viewport tall" and truncate everything below it instead of + flowing it onto additional pages. The !important is required to beat the inline style. */ +@media print { + html, + body { + height: auto !important; + overflow: visible !important; + } + + /* The Scoring Methodology modal's "Export to PDF" is the only thing on this site that ever + triggers a print — everything else (the Docusaurus site chrome, the dataset modal + underneath, both nested inside the site's root div rather than portaled) should be absent + from the printed output entirely, not just visually covered, so the export starts on page + one instead of several blank/irrelevant pages in. print-export-root is portaled directly + onto , so hiding every *other* direct child of body hides all of it in one rule. */ + body > :not(.print-export-root) { + display: none !important; + } + + body > .print-export-root { + display: block !important; + } +} From 73983fb7a661f3791bb67f7eb68bce9495d3423a Mon Sep 17 00:00:00 2001 From: Urmil Chandarana Date: Fri, 14 Aug 2026 18:02:50 -0700 Subject: [PATCH 2/5] Make Export to PDF download directly instead of opening the print dialog --- package-lock.json | 190 ++++++++++- package.json | 2 + .../ScoringMethodologyModal.module.css | 53 +++- src/components/ScoringMethodologyModal.tsx | 294 +++++++++++++++--- src/css/custom.css | 1 + 5 files changed, 489 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 126c821..9724b2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@mdx-js/react": "^3.0.0", "@orama/orama": "^3.1.18", "clsx": "^2.0.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "plotly.js-dist-min": "^3.7.0", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", @@ -7665,6 +7667,12 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/pbf": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", @@ -7684,6 +7692,13 @@ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -7814,6 +7829,13 @@ "@types/geojson": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -8518,7 +8540,6 @@ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6.0" } @@ -8918,6 +8939,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -9906,6 +9947,15 @@ "node": ">=4" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-loader": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", @@ -10721,6 +10771,16 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -11667,6 +11727,17 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", @@ -11729,6 +11800,12 @@ "node": ">=0.4.0" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-loader": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", @@ -13078,6 +13155,19 @@ "node": ">=12" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -13365,6 +13455,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -13923,6 +14019,23 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/kdbush": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", @@ -17574,6 +17687,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -17798,8 +17927,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/pick-by-alias": { "version": "1.2.0", @@ -19671,7 +19799,6 @@ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", "license": "MIT", - "peer": true, "dependencies": { "performance-now": "^2.1.0" } @@ -20028,6 +20155,13 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexpu-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", @@ -20545,6 +20679,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -21375,6 +21519,16 @@ "node": "*" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/static-eval": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", @@ -21699,6 +21853,16 @@ "svg-path-bounds": "^1.0.1" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/svgo": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz", @@ -21872,6 +22036,15 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thingies": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", @@ -22636,6 +22809,15 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/package.json b/package.json index 547a60d..662ea28 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "@mdx-js/react": "^3.0.0", "@orama/orama": "^3.1.18", "clsx": "^2.0.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "plotly.js-dist-min": "^3.7.0", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", diff --git a/src/components/ScoringMethodologyModal.module.css b/src/components/ScoringMethodologyModal.module.css index 6fb1992..493ff14 100644 --- a/src/components/ScoringMethodologyModal.module.css +++ b/src/components/ScoringMethodologyModal.module.css @@ -86,6 +86,11 @@ outline: none; } +.exportButton:disabled { + cursor: default; + opacity: 0.6; +} + .closeButton { flex: 0 0 auto; width: 32px; @@ -574,15 +579,45 @@ } } -/* Export to PDF goes through the browser's native print pipeline (real, selectable, searchable - text — not a rasterized DOM screenshot) rather than a client-side PDF library, so this is the - entire "export" implementation: hide the modal chrome, let the panel flow across pages instead - of scrolling in a fixed box, and force the light palette regardless of the current on-screen - theme. The token overrides below are the exact --agml- and --ifm-color-primary light-mode - values from src/css/custom.css :root — redeclared here (not just "swap to light mode") because - html[data-theme='dark'] would otherwise still win the cascade for a dark-mode reader printing - this page, and a dark background either wastes ink or, worse, gets stripped by the print - engine while the light text stays light — invisible text on a white page. */ +/* Applied via JS for the split-second it takes html2canvas to rasterize the panel for "Export to + PDF" (see ScoringMethodologyModal.tsx) — forces a fixed desktop-width layout with nothing + clipped, regardless of the actual browser window, so the export always looks the same rather + than capturing whatever happens to be on screen at whatever size. The light-palette forcing + that used to live here is applied alongside this class as inline rgb() custom properties + instead (see applyExportColors in the .tsx) — html2canvas can't parse oklch() at all, so the + literal oklch() values that were here previously made every capture throw immediately. */ +.exportSnapshot { + width: 940px !important; + max-height: none !important; + overflow: visible !important; + box-shadow: none !important; +} + +/* The toggle buttons and tabs are an app affordance, not part of the reference document itself — + hidden for the capture the same way the @media print rules hide them for a manual print. */ +.exportSnapshot .headerActions, +.exportSnapshot .tabRow { + display: none !important; +} + +/* html2canvas doesn't replicate a 's default disclosure-triangle marker faithfully — it + renders as a plain "1." list-item number instead. Force-opening the details for the capture + (see handleExportClick) already makes the marker meaningless as an affordance, so hide it. */ +.exportSnapshot .fieldRefSummary { + list-style: none; +} + +.exportSnapshot .fieldRefSummary::-webkit-details-marker, +.exportSnapshot .fieldRefSummary::marker { + display: none; +} + +/* Manual Ctrl+P / Cmd+P printing is a secondary path — "Export to PDF" above is the primary, + one-click way to get this document, generated client-side via html2canvas + jsPDF instead of + this native print pipeline so it downloads directly with no dialog. This block is what a + reader gets if they print the page themselves regardless: hides the modal chrome, lets the + panel flow across pages instead of scrolling in a fixed box, and forces the same light palette + as .exportSnapshot above for the same reason. */ @media print { @page { margin: 16mm 14mm; diff --git a/src/components/ScoringMethodologyModal.tsx b/src/components/ScoringMethodologyModal.tsx index 4c6ca10..50f4572 100644 --- a/src/components/ScoringMethodologyModal.tsx +++ b/src/components/ScoringMethodologyModal.tsx @@ -198,6 +198,151 @@ const FIELD_REFS: { symbol: string; path: string }[] = [ { symbol: 'noise_rate', path: 'metrics.label_noise.estimated_noise_rate' }, ]; +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +// html2canvas (unlike a real browser paint) can't parse oklch() at all — it throws and aborts the +// capture the instant it hits one, and CSS custom properties inherit, so setting only the handful +// this component's own styles reference isn't enough: getComputedStyle(document.body) reflects +// *every* --ifm-*/--agml-*/--docusaurus-* token defined on :root, oklch() or not, because they're +// all inherited whether this component uses them or not, and html2canvas's document-clone step +// touches all of them. This is every :root token that resolves (following var() aliases like +// --ifm-navbar-background-color: var(--agml-surface)) to an oklch() value — generated from +// src/css/custom.css's :root block, not hand-picked, so it can't silently drift out of sync — with +// each one pre-converted to the equivalent rgb()/rgba() string ahead of time (see oklchToRgb for +// the same conversion done at runtime, used here as a one-off script instead since these values +// are fixed). Always the light-mode set regardless of the current theme, matching .exportSnapshot. +const EXPORT_COLOR_TOKENS: [string, string][] = [ + ['--ifm-color-primary', 'rgb(0, 120, 52)'], + ['--ifm-color-primary-dark', 'rgb(0, 105, 37)'], + ['--ifm-color-primary-darker', 'rgb(0, 96, 28)'], + ['--ifm-color-primary-darkest', 'rgb(0, 75, 12)'], + ['--ifm-color-primary-light', 'rgb(28, 135, 66)'], + ['--ifm-color-primary-lighter', 'rgb(49, 151, 81)'], + ['--ifm-color-primary-lightest', 'rgb(92, 181, 114)'], + ['--docusaurus-highlighted-code-line-bg', 'rgba(11, 14, 11, 0.08)'], + ['--agml-page-bg', 'rgb(248, 251, 248)'], + ['--agml-surface', 'rgb(241, 247, 241)'], + ['--agml-surface-strong', 'rgb(254, 255, 254)'], + ['--agml-surface-soft', 'rgb(240, 245, 240)'], + ['--agml-border', 'rgb(201, 208, 201)'], + ['--agml-border-strong', 'rgb(178, 186, 178)'], + ['--agml-shadow', '0 16px 32px rgba(0, 0, 0, 0.06)'], + ['--agml-shadow-strong', '0 24px 48px rgba(0, 0, 0, 0.09)'], + ['--agml-text', 'rgb(19, 24, 19)'], + ['--agml-muted', 'rgb(81, 87, 81)'], + ['--agml-accent-soft', 'rgb(201, 241, 208)'], + ['--agml-warning-text', 'rgb(201, 48, 45)'], + ['--agml-caution-text', 'rgb(175, 140, 0)'], + ['--agml-axis-difficulty', 'rgb(0, 101, 180)'], + ['--agml-axis-diversity', 'rgb(174, 64, 144)'], + ['--agml-modal-overlay', 'rgba(0, 0, 0, 0.3)'], + ['--agml-badge-classification-bg', 'rgb(177, 237, 178)'], + ['--agml-badge-classification-fg', 'rgb(0, 71, 0)'], + ['--agml-badge-detection-bg', 'rgb(162, 237, 216)'], + ['--agml-badge-detection-fg', 'rgb(0, 79, 57)'], + ['--agml-badge-segmentation-bg', 'rgb(221, 223, 170)'], + ['--agml-badge-segmentation-fg', 'rgb(66, 65, 0)'], + ['--agml-badge-other-bg', 'rgb(221, 227, 221)'], + ['--agml-badge-other-fg', 'rgb(68, 73, 68)'], + ['--agml-badge-vlm-bg', 'rgb(226, 207, 255)'], + ['--agml-badge-vlm-fg', 'rgb(73, 38, 118)'], + ['--agml-tag-bg', 'rgb(217, 229, 217)'], + ['--agml-tag-fg', 'rgb(51, 65, 51)'], + ['--ifm-navbar-background-color', 'rgb(241, 247, 241)'], + ['--ifm-footer-background-color', 'rgb(241, 247, 241)'], + ['--ifm-card-background-color', 'rgb(254, 255, 254)'], + ['--ifm-toc-background-color', 'rgb(241, 247, 241)'], + ['--ifm-toc-border-color', 'rgb(201, 208, 201)'], +]; + +function applyExportColors(el: HTMLElement) { + for (const [token, value] of EXPORT_COLOR_TOKENS) el.style.setProperty(token, value); +} + +function clearExportColors(el: HTMLElement) { + for (const [token] of EXPORT_COLOR_TOKENS) el.style.removeProperty(token); +} + +// html2canvas clones the whole document as part of its own pipeline, and — confirmed by testing, +// not just cautious guesswork — it does *not* correctly skip descendants of a `display: none` +// ancestor the way an actual browser paint does: hiding the rest of the page with CSS still left +// it walking into the Docusaurus site chrome and the dataset modal underneath, both full of this +// site's oklch() tokens, and throwing on the first one it hit. Actually detaching those nodes +// from the DOM (not just hiding them) is the only thing that reliably keeps html2canvas out of +// them. Returns a restore callback that puts everything back in its exact original order. +function detachOtherBodyChildren(keepEl: Node): () => void { + const originalOrder = Array.from(document.body.childNodes); + for (const node of originalOrder) { + if (node !== keepEl) node.parentNode?.removeChild(node); + } + return () => { + document.body.replaceChildren(...originalOrder); + }; +} + +// PDF geometry, in points (72pt = 1in) — US Letter with a comfortable half-inch margin. +const PAGE_WIDTH_PT = 612; +const PAGE_HEIGHT_PT = 792; +const MARGIN_PT = 36; +const USABLE_WIDTH_PT = PAGE_WIDTH_PT - MARGIN_PT * 2; +const USABLE_HEIGHT_PT = PAGE_HEIGHT_PT - MARGIN_PT * 2; + +// Cards, tiles, and formula boxes that should never be sliced in half across a page boundary — +// the same set the (still-present, Ctrl+P-only) @media print rules mark break-inside: avoid. +const UNBREAKABLE_SELECTOR = [ + 'overallSection', + 'weightTile', + 'metricCard', + 'metricCardPenalty', + 'placeholderCard', + 'axisFormulaFooter', + 'fieldRefRow', +] + .map((key) => `.${styles[key]}`) + .join(','); + +// A canvas screenshot has no concept of "don't split this element" the way print CSS does, so +// this re-derives it manually: read where each unbreakable block sits (in CSS px, relative to +// the panel's top) before rasterizing, then have the page-break search pull a cut point back to +// the top of whichever block it would otherwise land inside of. +function getUnbreakableRanges(panelEl: HTMLElement): { top: number; bottom: number }[] { + const panelTop = panelEl.getBoundingClientRect().top; + return Array.from(panelEl.querySelectorAll(UNBREAKABLE_SELECTOR)).map((el) => { + const rect = el.getBoundingClientRect(); + return { top: rect.top - panelTop, bottom: rect.bottom - panelTop }; + }); +} + +// Greedily walks down the content in ~one-page steps, snapping each cut point back to the start +// of any unbreakable block it would otherwise fall in the middle of. +function computePageBreaks(totalHeight: number, pageHeight: number, ranges: { top: number; bottom: number }[]): number[] { + const breaks = [0]; + let cursor = 0; + while (cursor < totalHeight) { + let candidate = cursor + pageHeight; + if (candidate >= totalHeight) { + breaks.push(totalHeight); + break; + } + const collision = ranges.find((r) => candidate > r.top && candidate < r.bottom); + if (collision) candidate = collision.top; + // A single block taller than one page can't be avoided — fall back to a hard cut so the + // loop still makes forward progress instead of spinning forever. + if (candidate <= cursor) candidate = cursor + pageHeight; + breaks.push(candidate); + cursor = candidate; + } + // The panel's own bottom padding/border trails past the last real content by a little, which + // can leave a nearly-blank final "page" a few px tall on its own — fold it into the previous + // page instead of shipping a page whose only content is some rounded corner. + while (breaks.length > 2 && breaks[breaks.length - 1] - breaks[breaks.length - 2] < 24) { + breaks.splice(breaks.length - 2, 1); + } + return breaks; +} + function buildAxisGroups(taskType: TaskType): AxisDoc[] { const data = taskType === 'Image Classification' ? IMAGE_CLASSIFICATION_AXES : null; return AXIS_META.map((meta) => ({ @@ -259,12 +404,9 @@ export function ScoringMethodologyModal({ onClose: () => void; }) { const [activeTab, setActiveTab] = useState(taskType); + const [isExporting, setIsExporting] = useState(false); const fieldRefDetailsEl = useRef(null); - // Printing captures whatever's currently in the DOM, but only the active tab's content is - // ever rendered — exporting "the entire reference" while e.g. Object Detection is selected - // would otherwise print just its placeholder. Flip to Image Classification (the only tab - // with published content) first, then print once that re-render has actually painted. - const [pendingExport, setPendingExport] = useState(false); + const panelEl = useRef(null); useEffect(() => { if (open) setActiveTab(taskType); @@ -279,39 +421,114 @@ export function ScoringMethodologyModal({ return () => window.removeEventListener('keydown', onKeyDown); }, [open, onClose]); - useEffect(() => { - if (!pendingExport) return; - - // The JSON field reference is a collapsed
by default — force it open so it's - // part of the printed document, and put back whatever state the user had it in after. - const wasDetailsOpen = fieldRefDetailsEl.current?.open ?? false; - if (fieldRefDetailsEl.current) fieldRefDetailsEl.current.open = true; - - // The exported file's suggested name comes from document.title in every major browser's - // print dialog — swap it for the print, then restore it once the dialog closes. - const previousTitle = document.title; - document.title = 'AgML Scoring Methodology'; - - const cleanUp = () => { - document.title = previousTitle; - if (fieldRefDetailsEl.current) fieldRefDetailsEl.current.open = wasDetailsOpen; - window.removeEventListener('afterprint', cleanUp); - setPendingExport(false); - }; - window.addEventListener('afterprint', cleanUp); - - // Two rAFs to be sure the tab-switch re-render above has actually painted before the - // print engine snapshots the DOM — one for React to commit, one for the browser to paint. - requestAnimationFrame(() => requestAnimationFrame(() => window.print())); - - return () => window.removeEventListener('afterprint', cleanUp); - }, [pendingExport]); - if (!open) return null; - const handleExportClick = () => { - if (activeTab !== 'Image Classification') setActiveTab('Image Classification'); - setPendingExport(true); + // Downloads a PDF directly — no print dialog, no "choose a destination" step. Built with + // html2canvas + jsPDF instead of the browser's native print-to-PDF specifically so the file + // lands in Downloads on click; the trade-off is a rasterized page image rather than + // selectable text, so the capture width/scale below are tuned to still read crisply. + const handleExportClick = async () => { + if (isExporting) return; + setIsExporting(true); + try { + // Only the active tab's content is ever in the DOM — exporting "the entire reference" + // while e.g. Object Detection is selected would otherwise capture just its placeholder. + if (activeTab !== 'Image Classification') { + setActiveTab('Image Classification'); + await nextFrame(); + await nextFrame(); + } + + const detailsEl = fieldRefDetailsEl.current; + const wasDetailsOpen = detailsEl?.open ?? false; + if (detailsEl) detailsEl.open = true; + + const panel = panelEl.current; + if (!panel) return; + + const [{ default: html2canvas }, { jsPDF }] = await Promise.all([import('html2canvas'), import('jspdf')]); + + // 1.5 is a compromise: high enough to still look crisp when zoomed in on a screen, + // low enough (combined with JPEG below) to keep the file a few MB instead of tens of — + // scale 2 with lossless PNG produced a ~55MB download for this document. + const scale = 1.5; + let canvas: HTMLCanvasElement; + let ranges: { top: number; bottom: number }[]; + let panelWidthPx: number; + const restoreBody = detachOtherBodyChildren(panel.parentElement ?? panel); + try { + // Forces the light palette regardless of the current theme (a dark capture would + // either waste ink if someone prints it later or, worse, get its background silently + // dropped while the text stays light), removes the on-screen height cap so nothing is + // clipped, and fixes the width so the export always uses the wide desktop layout — + // otherwise a narrow browser window would export the mobile single-column layout. + panel.classList.add(styles.exportSnapshot); + // , not or just the panel: custom-property var() substitution resolves + // per element, and + // Infima's own internal tokens (--ifm-link-color, --docusaurus-progress-bar-color, + // pagination/tabs/menu active-colors, etc. — none of them mine to enumerate) are + // declared as var(--ifm-color-primary) on the same html[data-theme] rule that sets + // --ifm-color-primary itself. Overriding on body would only reach body's own + // inherited copy of --ifm-color-primary, too late for those aliases — they'd already + // have resolved against html's oklch() value. Overriding on html instead means any + // var() reference declared alongside it picks up this override too, transitively. + applyExportColors(document.documentElement); + await nextFrame(); + await nextFrame(); + + ranges = getUnbreakableRanges(panel); + panelWidthPx = panel.getBoundingClientRect().width; + canvas = await html2canvas(panel, { backgroundColor: '#ffffff', scale, useCORS: true }); + } finally { + // Runs even if html2canvas throws (it does, on anything it can't parse) — otherwise a + // failed export would leave the modal stuck showing the light-forced snapshot state. + restoreBody(); + panel.classList.remove(styles.exportSnapshot); + clearExportColors(document.documentElement); + if (detailsEl) detailsEl.open = wasDetailsOpen; + } + + const ptPerPx = USABLE_WIDTH_PT / panelWidthPx; + const pageHeightPx = USABLE_HEIGHT_PT / ptPerPx; + const totalHeightPx = canvas.height / scale; + const breaks = computePageBreaks(totalHeightPx, pageHeightPx, ranges); + + const doc = new jsPDF({ unit: 'pt', format: 'letter' }); + const sliceCanvas = document.createElement('canvas'); + const sliceCtx = sliceCanvas.getContext('2d'); + sliceCanvas.width = canvas.width; + + for (let i = 0; i < breaks.length - 1; i += 1) { + const sliceTopPx = breaks[i]; + const sliceHeightPx = breaks[i + 1] - sliceTopPx; + if (sliceHeightPx <= 0) continue; + + sliceCanvas.height = sliceHeightPx * scale; + sliceCtx?.clearRect(0, 0, sliceCanvas.width, sliceCanvas.height); + sliceCtx?.drawImage( + canvas, + 0, + sliceTopPx * scale, + canvas.width, + sliceHeightPx * scale, + 0, + 0, + canvas.width, + sliceHeightPx * scale, + ); + + if (i > 0) doc.addPage(); + // JPEG over PNG for the same reason as the reduced scale above — this is a rasterized + // capture either way, so there's no selectable-text quality to lose, and at 0.92 + // quality the compression artifacts aren't visible while the file shrinks drastically + // compared to lossless PNG at this resolution. + doc.addImage(sliceCanvas.toDataURL('image/jpeg', 0.92), 'JPEG', MARGIN_PT, MARGIN_PT, USABLE_WIDTH_PT, sliceHeightPx * ptPerPx); + } + + doc.save('AgML-Scoring-Methodology.pdf'); + } finally { + setIsExporting(false); + } }; const axisGroups = buildAxisGroups(activeTab); @@ -327,6 +544,7 @@ export function ScoringMethodologyModal({ // Docusaurus site chrome, the dataset modal underneath) has to happen from there.
-