From 1e4e755b23cc7bf8bfa9a9cadd4e17ceaadf88a5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micka=C3=ABl=20Canouil?=
<8896044+mcanouil@users.noreply.github.com>
Date: Wed, 12 Aug 2026 15:50:02 +0200
Subject: [PATCH 1/3] feat(gallery): redesign gallery with intent-driven
landing
Replace the single carousel gallery with an intent-driven landing and six
category spoke pages. The landing offers a category chooser, a section rail,
and curated featured examples with animated hover previews; each spoke page
lists its examples with keyboard-operable category filters and working sort
and search.
Drive every card from YAML under docs/gallery/data/, with a curated
featured.yml and per-category lists, and single-source the feature links and
browse chips from that data. Consolidate thumbnails under thumbnails/ and add
capture tooling for still, PDF, and video previews, documented in READMEs.
Meet WCAG AA on the interactive chips and heading order, keep the legacy
implementation for reference, and skip the freeze check for the intentionally
deleted freeze entry.
---
.githooks/check-freeze.sh | 13 +
.github/workflows/preview.yml | 11 +-
.../gallery/index/execute-results/html.json | 15 -
_quarto.yml | 17 +-
_tools/screenshots/README.md | 44 +++
_tools/screenshots/SETUP.md | 6 +
_tools/screenshots/package-lock.json | 29 ++
_tools/screenshots/package.json | 7 +
_tools/screenshots/scripts/PREVIEWS.md | 88 +++++
.../scripts/capture-card-thumbs.mjs | 143 ++++++++
.../scripts/capture-pdf-thumbs.mjs | 170 ++++++++++
.../screenshots/scripts/capture-previews.mjs | 267 +++++++++++++++
_tools/screenshots/scripts/help.js | 7 +-
docs/gallery/README.md | 65 ++++
docs/gallery/_metadata.yml | 15 +
docs/gallery/articles/advanced-layout-pdf.png | Bin 191186 -> 0 bytes
docs/gallery/articles/articles-pdf.png | Bin 259104 -> 0 bytes
docs/gallery/assets/_browse-chips.ejs.md | 19 ++
docs/gallery/assets/_caps.ejs.md | 9 +
docs/gallery/assets/_card.ejs.md | 35 ++
docs/gallery/assets/_chooser.ejs.md | 15 +
docs/gallery/assets/_featured.ejs.md | 110 +++++++
docs/gallery/assets/_gallery.scss | 300 +++++++++++++++++
docs/gallery/assets/_jumpnav.ejs.md | 109 +++++++
docs/gallery/assets/_preview.html | 188 +++++++++++
docs/gallery/browse.qmd | 26 ++
docs/gallery/carousel.R | 64 ----
docs/gallery/carousel.yml | 25 --
docs/gallery/dashboards-monitoring.qmd | 28 ++
.../gallery/dashboards/ggplotly-dashboard.png | Bin 283166 -> 0 bytes
docs/gallery/dashboards/ojs-flights.jpg | Bin 75812 -> 0 bytes
.../dashboards/waste-sites-dashboard.png | Bin 1455706 -> 0 bytes
docs/gallery/data/dashboards-monitoring.yml | 191 +++++++++++
docs/gallery/data/featured.yml | 293 +++++++++++++++++
docs/gallery/data/personal-community.yml | 211 ++++++++++++
docs/gallery/data/reports-analytics.yml | 97 ++++++
docs/gallery/data/research-scholarship.yml | 104 ++++++
docs/gallery/data/software-documentation.yml | 127 ++++++++
docs/gallery/data/teaching-learning.yml | 304 ++++++++++++++++++
docs/gallery/index.qmd | 142 ++------
docs/gallery/legacy/README.md | 8 +
docs/gallery/{ => legacy}/gallery.ejs | 0
docs/gallery/{ => legacy}/gallery.yml | 180 +++++------
docs/gallery/personal-community.qmd | 28 ++
.../presentations/presentations-beamer.png | Bin 92547 -> 0 bytes
docs/gallery/presentations/presentations.png | Bin 148515 -> 0 bytes
docs/gallery/reports-analytics.qmd | 28 ++
docs/gallery/research-scholarship.qmd | 28 ++
docs/gallery/software-documentation.qmd | 28 ++
docs/gallery/teaching-learning.qmd | 28 ++
.../customer-churn-dashboard.png | Bin
.../dashing-through-snow.png | Bin
.../diamonds-dashboard.png | Bin
.../gapminder-dashboard.png | Bin
.../dashboards-monitoring/gh-dashboard.png | Bin 0 -> 151508 bytes
.../housing-market-dashboard.png | Bin
.../dashboards-monitoring}/ld-dashboard.png | Bin
.../dashboards-monitoring/mynorfolk.png | Bin 0 -> 147256 bytes
.../nfl-injuries-dashboard.png | Bin
.../nz-quakes-dashboard.png | Bin
.../dashboards-monitoring}/ojs-flights.png | Bin
.../ojs-penguins-dashboard.png | Bin
.../dashboards-monitoring}/olympics-game.png | Bin
.../penguins-dashboard.png | Bin
.../pyopensci-software-review.png | Bin 0 -> 149662 bytes
.../restaurant-dashboard.png | Bin
.../retirement-gallery.png | Bin
.../dashboards-monitoring/stock-explorer.png | Bin 0 -> 156911 bytes
.../weather-ojs-dashboard.jpg | Bin
.../personal-community-isabelizimm.mp4 | Bin 0 -> 118470 bytes
.../personal-community-isabelizimm.webm | Bin 0 -> 112587 bytes
.../personal-community-noahweidig.mp4 | Bin 0 -> 415835 bytes
.../personal-community-noahweidig.webm | Bin 0 -> 296078 bytes
.../featured/personal-community-rainbowr.mp4 | Bin 0 -> 88253 bytes
.../featured/personal-community-rainbowr.webm | Bin 0 -> 90667 bytes
.../featured/reports-analytics-closeread.mp4 | Bin 0 -> 332789 bytes
.../featured/reports-analytics-closeread.webm | Bin 0 -> 274459 bytes
.../featured/research-scholarship-affcom.mp4 | Bin 0 -> 48668 bytes
.../featured/research-scholarship-affcom.webm | Bin 0 -> 51849 bytes
.../research-scholarship-causal-inference.mp4 | Bin 0 -> 480786 bytes
...research-scholarship-causal-inference.webm | Bin 0 -> 342597 bytes
.../software-documentation-great-docs.mp4 | Bin 0 -> 447249 bytes
.../software-documentation-great-docs.webm | Bin 0 -> 390077 bytes
.../software-documentation-gribouille.mp4 | Bin 0 -> 205050 bytes
.../software-documentation-gribouille.webm | Bin 0 -> 158290 bytes
.../software-documentation-positron.mp4 | Bin 0 -> 90783 bytes
.../software-documentation-positron.webm | Bin 0 -> 93289 bytes
.../teaching-learning-dataviz-course.mp4 | Bin 0 -> 215498 bytes
.../teaching-learning-dataviz-course.webm | Bin 0 -> 192722 bytes
.../teaching-learning-feature-eng.mp4 | Bin 0 -> 48931 bytes
.../teaching-learning-feature-eng.webm | Bin 0 -> 54288 bytes
.../teaching-learning-textrecipes.mp4 | Bin 0 -> 110071 bytes
.../teaching-learning-textrecipes.webm | Bin 0 -> 108576 bytes
.../legacy}/books-telling-stories.png | Bin
.../{books => thumbnails/legacy}/ci-in-r.png | Bin
.../legacy}/mynorfolk-dash.png | Bin
.../legacy}/presentations-textrecipes.png | Bin
.../legacy}/stock-explorer-dashboard.png | Bin
.../legacy}/websites-rainbowr.png | Bin
.../personal-community/bea-milz.png} | Bin
.../chris-von-csefalvay.png} | Bin
.../personal-community/dkapitan.png} | Bin
.../personal-community/fluidsyndicate.png} | Bin
.../personal-community/forbo7-landing.png} | Bin
.../personal-community/hamel.png} | Bin
.../personal-community/ipardalis.png} | Bin
.../personal-community/isabelizimm.png | Bin 0 -> 402446 bytes
.../personal-community/masiello-group.png} | Bin
.../personal-community/mcanouil.png | Bin 0 -> 172247 bytes
.../personal-community/mike-mahoney.png} | Bin
.../personal-community/mockup.png} | Bin
.../personal-community/nasa-openscapes.png} | Bin
.../personal-community/nascent-peru.png | Bin 0 -> 332092 bytes
.../personal-community}/nhs-r-website.png | Bin
.../personal-community/noahweidig.png | Bin 0 -> 350019 bytes
.../personal-community/quartotip.png} | Bin
.../personal-community/rainbowr.png | Bin 0 -> 262649 bytes
.../real-world-data-science.png} | Bin
.../personal-community/vistales.png} | Bin
.../personal-community/wannabe-rstats-fu.png} | Bin
.../personal-community}/website-ala-labs.png | Bin
.../advanced-layout-html.png | Bin
.../reports-analytics/advanced-layout-pdf.png | Bin 0 -> 194466 bytes
.../reports-analytics/closeread.png | Bin 0 -> 238313 bytes
.../reports-analytics/html.png} | Bin
.../reports-analytics/jupyter.png} | Bin
.../reports-analytics/msword.png} | Bin
.../reports-analytics/ojs.png} | Bin
.../reports-analytics/shiny.png} | Bin
.../thumbnails/reports-analytics/tfff.png | Bin 0 -> 259143 bytes
.../thumbnails/reports-analytics/whr.png | Bin 0 -> 430970 bytes
.../research-scholarship/affcom.png | Bin 0 -> 129060 bytes
.../research-scholarship/causal-inference.png | Bin 0 -> 388420 bytes
.../cleaning-bd-in-r.png} | Bin
.../heiss-publications.png | Bin 0 -> 297748 bytes
.../mcanouil-publications.png | Bin 0 -> 103439 bytes
.../thumbnails/research-scholarship/pdf.png | Bin 0 -> 136162 bytes
.../research-scholarship/r4csr.png} | Bin
.../research-scholarship}/r4pde.png | Bin
.../research-scholarship/telling-stories.png | Bin 0 -> 256209 bytes
.../research-scholarship}/tsar.png | Bin
.../software-documentation/devito.png} | Bin
.../software-documentation/great-docs.png | Bin 0 -> 371540 bytes
.../software-documentation/gribouille.png | Bin 0 -> 147927 bytes
.../software-documentation/ibis.png} | Bin
.../software-documentation/mrgsolve.png} | Bin
.../software-documentation/nbdev.png} | Bin
.../software-documentation/positron.png | Bin 0 -> 222772 bytes
.../quarto-extensions.png | Bin 0 -> 149640 bytes
.../software-documentation/quarto.png} | Bin
.../r-development-guide.png} | Bin
.../software-documentation/r-manuals.png} | Bin
.../software-documentation/r-pkgs.png} | Bin
.../software-documentation}/utilitr.png | Bin
.../2021-cloud-hackathon.png} | Bin
.../anthology-experimental-designs.png} | Bin
.../teaching-learning/arrow-dplyr.png} | Bin
.../teaching-learning/arrow-workshop.png} | Bin
.../thumbnails/teaching-learning/beamer.png | Bin 0 -> 61502 bytes
.../book-analyse-conceptions-french.png | Bin
.../teaching-learning}/books_lsj.png | Bin
.../teaching-learning/cookbook-rpolars.png} | Bin
.../teaching-learning/datascienceinabox.png} | Bin
.../teaching-learning/dataviz-course.png | Bin 0 -> 291575 bytes
.../teaching-learning/des-rap-book.png | Bin 0 -> 829291 bytes
.../educators-perspective-tidyverse.png} | Bin
.../teaching-learning/feature-eng.png | Bin 0 -> 113745 bytes
.../teaching-learning}/geocompy.png | Bin
.../teaching-learning/hertie-coding-club.png} | Bin
.../teaching-learning/hopr.png} | Bin
.../teaching-learning/julia-workshop.png} | Bin
.../teaching-learning/jupyter.png} | Bin
.../teaching-learning/linogaliana.png} | Bin
.../teaching-learning/mlr3book.png} | Bin
.../teaching-learning}/modern-polars.png | Bin
.../nfl-analytics-with-r.PNG | Bin
.../opescapes-quarto-tutorial.png} | Bin
.../teaching-learning/penguins-story.png} | Bin
.../teaching-learning/powerpoint.png} | Bin
.../practical-deep-learning.png} | Bin
.../teaching-learning/python-for-data.png} | Bin
.../quarto-the-practical-guide.png | Bin 0 -> 411524 bytes
.../teaching-learning/r4ds.png} | Bin
.../teaching-learning/revealjs.png} | Bin
.../teaching-learning/sta210.png} | Bin
.../streamlining-with-r.png} | Bin
.../teaching-learning/textrecipes.png | Bin 0 -> 147278 bytes
...ebsite-m.canouil.dev_quarto-extensions.png | Bin 653636 -> 0 bytes
.../websites/website-mickael.canouil.fr.png | Bin 993538 -> 0 bytes
docs/websites/website-listings-custom.qmd | 6 +-
190 files changed, 3309 insertions(+), 319 deletions(-)
delete mode 100644 _freeze/docs/gallery/index/execute-results/html.json
create mode 100644 _tools/screenshots/README.md
create mode 100644 _tools/screenshots/scripts/PREVIEWS.md
create mode 100644 _tools/screenshots/scripts/capture-card-thumbs.mjs
create mode 100644 _tools/screenshots/scripts/capture-pdf-thumbs.mjs
create mode 100644 _tools/screenshots/scripts/capture-previews.mjs
create mode 100644 docs/gallery/README.md
create mode 100644 docs/gallery/_metadata.yml
delete mode 100644 docs/gallery/articles/advanced-layout-pdf.png
delete mode 100644 docs/gallery/articles/articles-pdf.png
create mode 100644 docs/gallery/assets/_browse-chips.ejs.md
create mode 100644 docs/gallery/assets/_caps.ejs.md
create mode 100644 docs/gallery/assets/_card.ejs.md
create mode 100644 docs/gallery/assets/_chooser.ejs.md
create mode 100644 docs/gallery/assets/_featured.ejs.md
create mode 100644 docs/gallery/assets/_gallery.scss
create mode 100644 docs/gallery/assets/_jumpnav.ejs.md
create mode 100644 docs/gallery/assets/_preview.html
create mode 100644 docs/gallery/browse.qmd
delete mode 100644 docs/gallery/carousel.R
delete mode 100644 docs/gallery/carousel.yml
create mode 100644 docs/gallery/dashboards-monitoring.qmd
delete mode 100644 docs/gallery/dashboards/ggplotly-dashboard.png
delete mode 100644 docs/gallery/dashboards/ojs-flights.jpg
delete mode 100644 docs/gallery/dashboards/waste-sites-dashboard.png
create mode 100644 docs/gallery/data/dashboards-monitoring.yml
create mode 100644 docs/gallery/data/featured.yml
create mode 100644 docs/gallery/data/personal-community.yml
create mode 100644 docs/gallery/data/reports-analytics.yml
create mode 100644 docs/gallery/data/research-scholarship.yml
create mode 100644 docs/gallery/data/software-documentation.yml
create mode 100644 docs/gallery/data/teaching-learning.yml
create mode 100644 docs/gallery/legacy/README.md
rename docs/gallery/{ => legacy}/gallery.ejs (100%)
rename docs/gallery/{ => legacy}/gallery.yml (81%)
create mode 100644 docs/gallery/personal-community.qmd
delete mode 100644 docs/gallery/presentations/presentations-beamer.png
delete mode 100644 docs/gallery/presentations/presentations.png
create mode 100644 docs/gallery/reports-analytics.qmd
create mode 100644 docs/gallery/research-scholarship.qmd
create mode 100644 docs/gallery/software-documentation.qmd
create mode 100644 docs/gallery/teaching-learning.qmd
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/customer-churn-dashboard.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/dashing-through-snow.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/diamonds-dashboard.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/gapminder-dashboard.png (100%)
create mode 100644 docs/gallery/thumbnails/dashboards-monitoring/gh-dashboard.png
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/housing-market-dashboard.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/ld-dashboard.png (100%)
create mode 100644 docs/gallery/thumbnails/dashboards-monitoring/mynorfolk.png
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/nfl-injuries-dashboard.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/nz-quakes-dashboard.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/ojs-flights.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/ojs-penguins-dashboard.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/olympics-game.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/penguins-dashboard.png (100%)
create mode 100644 docs/gallery/thumbnails/dashboards-monitoring/pyopensci-software-review.png
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/restaurant-dashboard.png (100%)
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/retirement-gallery.png (100%)
create mode 100644 docs/gallery/thumbnails/dashboards-monitoring/stock-explorer.png
rename docs/gallery/{dashboards => thumbnails/dashboards-monitoring}/weather-ojs-dashboard.jpg (100%)
create mode 100644 docs/gallery/thumbnails/featured/personal-community-isabelizimm.mp4
create mode 100644 docs/gallery/thumbnails/featured/personal-community-isabelizimm.webm
create mode 100644 docs/gallery/thumbnails/featured/personal-community-noahweidig.mp4
create mode 100644 docs/gallery/thumbnails/featured/personal-community-noahweidig.webm
create mode 100644 docs/gallery/thumbnails/featured/personal-community-rainbowr.mp4
create mode 100644 docs/gallery/thumbnails/featured/personal-community-rainbowr.webm
create mode 100644 docs/gallery/thumbnails/featured/reports-analytics-closeread.mp4
create mode 100644 docs/gallery/thumbnails/featured/reports-analytics-closeread.webm
create mode 100644 docs/gallery/thumbnails/featured/research-scholarship-affcom.mp4
create mode 100644 docs/gallery/thumbnails/featured/research-scholarship-affcom.webm
create mode 100644 docs/gallery/thumbnails/featured/research-scholarship-causal-inference.mp4
create mode 100644 docs/gallery/thumbnails/featured/research-scholarship-causal-inference.webm
create mode 100644 docs/gallery/thumbnails/featured/software-documentation-great-docs.mp4
create mode 100644 docs/gallery/thumbnails/featured/software-documentation-great-docs.webm
create mode 100644 docs/gallery/thumbnails/featured/software-documentation-gribouille.mp4
create mode 100644 docs/gallery/thumbnails/featured/software-documentation-gribouille.webm
create mode 100644 docs/gallery/thumbnails/featured/software-documentation-positron.mp4
create mode 100644 docs/gallery/thumbnails/featured/software-documentation-positron.webm
create mode 100644 docs/gallery/thumbnails/featured/teaching-learning-dataviz-course.mp4
create mode 100644 docs/gallery/thumbnails/featured/teaching-learning-dataviz-course.webm
create mode 100644 docs/gallery/thumbnails/featured/teaching-learning-feature-eng.mp4
create mode 100644 docs/gallery/thumbnails/featured/teaching-learning-feature-eng.webm
create mode 100644 docs/gallery/thumbnails/featured/teaching-learning-textrecipes.mp4
create mode 100644 docs/gallery/thumbnails/featured/teaching-learning-textrecipes.webm
rename docs/gallery/{books => thumbnails/legacy}/books-telling-stories.png (100%)
rename docs/gallery/{books => thumbnails/legacy}/ci-in-r.png (100%)
rename docs/gallery/{dashboards => thumbnails/legacy}/mynorfolk-dash.png (100%)
rename docs/gallery/{presentations => thumbnails/legacy}/presentations-textrecipes.png (100%)
rename docs/gallery/{dashboards => thumbnails/legacy}/stock-explorer-dashboard.png (100%)
rename docs/gallery/{websites => thumbnails/legacy}/websites-rainbowr.png (100%)
rename docs/gallery/{websites/websites-bea-milz.png => thumbnails/personal-community/bea-milz.png} (100%)
rename docs/gallery/{websites/websites-chris-von-csefalvay.png => thumbnails/personal-community/chris-von-csefalvay.png} (100%)
rename docs/gallery/{websites/websites-dkapitan.png => thumbnails/personal-community/dkapitan.png} (100%)
rename docs/gallery/{websites/websites-fluidsyndicate.png => thumbnails/personal-community/fluidsyndicate.png} (100%)
rename docs/gallery/{websites/websites-forbo7-landing.png => thumbnails/personal-community/forbo7-landing.png} (100%)
rename docs/gallery/{websites/websites-hamel.png => thumbnails/personal-community/hamel.png} (100%)
rename docs/gallery/{websites/websites-ipardalis.png => thumbnails/personal-community/ipardalis.png} (100%)
create mode 100644 docs/gallery/thumbnails/personal-community/isabelizimm.png
rename docs/gallery/{websites/websites-masiello-group.png => thumbnails/personal-community/masiello-group.png} (100%)
create mode 100644 docs/gallery/thumbnails/personal-community/mcanouil.png
rename docs/gallery/{websites/websites-mike-mahoney.png => thumbnails/personal-community/mike-mahoney.png} (100%)
rename docs/gallery/{websites/websites-mockup.png => thumbnails/personal-community/mockup.png} (100%)
rename docs/gallery/{websites/websites-nasa-openscapes.png => thumbnails/personal-community/nasa-openscapes.png} (100%)
create mode 100644 docs/gallery/thumbnails/personal-community/nascent-peru.png
rename docs/gallery/{websites => thumbnails/personal-community}/nhs-r-website.png (100%)
create mode 100644 docs/gallery/thumbnails/personal-community/noahweidig.png
rename docs/gallery/{websites/websites-quartotip.png => thumbnails/personal-community/quartotip.png} (100%)
create mode 100644 docs/gallery/thumbnails/personal-community/rainbowr.png
rename docs/gallery/{websites/websites-real-world-data-science.png => thumbnails/personal-community/real-world-data-science.png} (100%)
rename docs/gallery/{websites/websites-vistales.png => thumbnails/personal-community/vistales.png} (100%)
rename docs/gallery/{websites/websites-wannabe-rstats-fu.png => thumbnails/personal-community/wannabe-rstats-fu.png} (100%)
rename docs/gallery/{websites => thumbnails/personal-community}/website-ala-labs.png (100%)
rename docs/gallery/{articles => thumbnails/reports-analytics}/advanced-layout-html.png (100%)
create mode 100644 docs/gallery/thumbnails/reports-analytics/advanced-layout-pdf.png
create mode 100644 docs/gallery/thumbnails/reports-analytics/closeread.png
rename docs/gallery/{articles/articles-html.png => thumbnails/reports-analytics/html.png} (100%)
rename docs/gallery/{interactive/interactive-jupyter.png => thumbnails/reports-analytics/jupyter.png} (100%)
rename docs/gallery/{articles/articles-msword.png => thumbnails/reports-analytics/msword.png} (100%)
rename docs/gallery/{interactive/interactive-ojs.png => thumbnails/reports-analytics/ojs.png} (100%)
rename docs/gallery/{interactive/interactive-shiny.png => thumbnails/reports-analytics/shiny.png} (100%)
create mode 100644 docs/gallery/thumbnails/reports-analytics/tfff.png
create mode 100644 docs/gallery/thumbnails/reports-analytics/whr.png
create mode 100644 docs/gallery/thumbnails/research-scholarship/affcom.png
create mode 100644 docs/gallery/thumbnails/research-scholarship/causal-inference.png
rename docs/gallery/{books/books-cleaning-bd-in-r.png => thumbnails/research-scholarship/cleaning-bd-in-r.png} (100%)
create mode 100644 docs/gallery/thumbnails/research-scholarship/heiss-publications.png
create mode 100644 docs/gallery/thumbnails/research-scholarship/mcanouil-publications.png
create mode 100644 docs/gallery/thumbnails/research-scholarship/pdf.png
rename docs/gallery/{books/books-r4csr.png => thumbnails/research-scholarship/r4csr.png} (100%)
rename docs/gallery/{books => thumbnails/research-scholarship}/r4pde.png (100%)
create mode 100644 docs/gallery/thumbnails/research-scholarship/telling-stories.png
rename docs/gallery/{books => thumbnails/research-scholarship}/tsar.png (100%)
rename docs/gallery/{websites/websites-devito.png => thumbnails/software-documentation/devito.png} (100%)
create mode 100644 docs/gallery/thumbnails/software-documentation/great-docs.png
create mode 100644 docs/gallery/thumbnails/software-documentation/gribouille.png
rename docs/gallery/{websites/websites-ibis.png => thumbnails/software-documentation/ibis.png} (100%)
rename docs/gallery/{websites/websites-mrgsolve.png => thumbnails/software-documentation/mrgsolve.png} (100%)
rename docs/gallery/{websites/websites-nbdev.png => thumbnails/software-documentation/nbdev.png} (100%)
create mode 100644 docs/gallery/thumbnails/software-documentation/positron.png
create mode 100644 docs/gallery/thumbnails/software-documentation/quarto-extensions.png
rename docs/gallery/{websites/websites-quarto.png => thumbnails/software-documentation/quarto.png} (100%)
rename docs/gallery/{books/books-r-development-guide.png => thumbnails/software-documentation/r-development-guide.png} (100%)
rename docs/gallery/{websites/websites-r-manuals.png => thumbnails/software-documentation/r-manuals.png} (100%)
rename docs/gallery/{books/books-r-pkgs.png => thumbnails/software-documentation/r-pkgs.png} (100%)
rename docs/gallery/{books => thumbnails/software-documentation}/utilitr.png (100%)
rename docs/gallery/{websites/websites-2021-cloud-hackathon.png => thumbnails/teaching-learning/2021-cloud-hackathon.png} (100%)
rename docs/gallery/{presentations/presentations-anthology-experimental-designs.png => thumbnails/teaching-learning/anthology-experimental-designs.png} (100%)
rename docs/gallery/{presentations/presentations-arrow-dplyr.png => thumbnails/teaching-learning/arrow-dplyr.png} (100%)
rename docs/gallery/{websites/websites-arrow-workshop.png => thumbnails/teaching-learning/arrow-workshop.png} (100%)
create mode 100644 docs/gallery/thumbnails/teaching-learning/beamer.png
rename docs/gallery/{books => thumbnails/teaching-learning}/book-analyse-conceptions-french.png (100%)
rename docs/gallery/{books => thumbnails/teaching-learning}/books_lsj.png (100%)
rename docs/gallery/{books/books-cookbook-rpolars.png => thumbnails/teaching-learning/cookbook-rpolars.png} (100%)
rename docs/gallery/{websites/websites-datascienceinabox.png => thumbnails/teaching-learning/datascienceinabox.png} (100%)
create mode 100644 docs/gallery/thumbnails/teaching-learning/dataviz-course.png
create mode 100644 docs/gallery/thumbnails/teaching-learning/des-rap-book.png
rename docs/gallery/{presentations/presentations-educators-perspective-tidyverse.png => thumbnails/teaching-learning/educators-perspective-tidyverse.png} (100%)
create mode 100644 docs/gallery/thumbnails/teaching-learning/feature-eng.png
rename docs/gallery/{books => thumbnails/teaching-learning}/geocompy.png (100%)
rename docs/gallery/{websites/websites-hertie-coding-club.png => thumbnails/teaching-learning/hertie-coding-club.png} (100%)
rename docs/gallery/{websites/websites-hopr.png => thumbnails/teaching-learning/hopr.png} (100%)
rename docs/gallery/{websites/websites-julia-workshop.png => thumbnails/teaching-learning/julia-workshop.png} (100%)
rename docs/gallery/{websites/websites-jupyter.png => thumbnails/teaching-learning/jupyter.png} (100%)
rename docs/gallery/{websites/websites-linogaliana.png => thumbnails/teaching-learning/linogaliana.png} (100%)
rename docs/gallery/{books/books-mlr3book.png => thumbnails/teaching-learning/mlr3book.png} (100%)
rename docs/gallery/{books => thumbnails/teaching-learning}/modern-polars.png (100%)
rename docs/gallery/{books => thumbnails/teaching-learning}/nfl-analytics-with-r.PNG (100%)
rename docs/gallery/{websites/websites-opescapes-quarto-tutorial.png => thumbnails/teaching-learning/opescapes-quarto-tutorial.png} (100%)
rename docs/gallery/{presentations/presentations-penguins-story.png => thumbnails/teaching-learning/penguins-story.png} (100%)
rename docs/gallery/{presentations/presentations-powerpoint.png => thumbnails/teaching-learning/powerpoint.png} (100%)
rename docs/gallery/{websites/websites-practical-deep-learning.png => thumbnails/teaching-learning/practical-deep-learning.png} (100%)
rename docs/gallery/{books/books-python-for-data.png => thumbnails/teaching-learning/python-for-data.png} (100%)
create mode 100644 docs/gallery/thumbnails/teaching-learning/quarto-the-practical-guide.png
rename docs/gallery/{books/books-r4ds.png => thumbnails/teaching-learning/r4ds.png} (100%)
rename docs/gallery/{presentations/presentations-revealjs.png => thumbnails/teaching-learning/revealjs.png} (100%)
rename docs/gallery/{websites/websites-sta210.png => thumbnails/teaching-learning/sta210.png} (100%)
rename docs/gallery/{presentations/presentations-streamlining-with-r.png => thumbnails/teaching-learning/streamlining-with-r.png} (100%)
create mode 100644 docs/gallery/thumbnails/teaching-learning/textrecipes.png
delete mode 100644 docs/gallery/websites/website-m.canouil.dev_quarto-extensions.png
delete mode 100644 docs/gallery/websites/website-mickael.canouil.fr.png
diff --git a/.githooks/check-freeze.sh b/.githooks/check-freeze.sh
index a5ddd16231..6c39d76895 100755
--- a/.githooks/check-freeze.sh
+++ b/.githooks/check-freeze.sh
@@ -39,6 +39,19 @@ while IFS= read -r qmd; do
[ -n "$qmd" ] || continue
freeze_rel="_freeze/${qmd%.qmd}/execute-results/html.json"
+ # Skip when the freeze file is intentionally removed in this change: the
+ # page no longer executes code (e.g. converted to the markdown engine), so
+ # its freeze entry is vestigial and correctly deleted, not stale.
+ if [ "$MODE" = "commit" ]; then
+ if git diff --cached --diff-filter=D --name-only 2>/dev/null | grep -Fxq "$freeze_rel"; then
+ continue
+ fi
+ else
+ if git diff "${REMOTE}..HEAD" --diff-filter=D --name-only 2>/dev/null | grep -Fxq "$freeze_rel"; then
+ continue
+ fi
+ fi
+
# Read freeze JSON from git objects, not working tree, to avoid false passes
# when the freeze file is updated on disk but not yet staged.
if [ "$MODE" = "commit" ]; then
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
index 3c617d6488..20be8f3b66 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/preview.yml
@@ -210,9 +210,14 @@ jobs:
'docs/extensions/listings/custom-formats.yml': 'docs/extensions/index.html',
'docs/extensions/listings/revealjs-formats.yml': 'docs/extensions/index.html',
'docs/extensions/listings/revealjs.yml': 'docs/extensions/index.html',
- // Gallery data files → gallery index
- 'docs/gallery/carousel.yml': 'docs/gallery/index.html',
- 'docs/gallery/gallery.yml': 'docs/gallery/index.html',
+ // Gallery data files → gallery pages
+ 'docs/gallery/data/featured.yml': 'docs/gallery/index.html',
+ 'docs/gallery/data/reports-analytics.yml': 'docs/gallery/reports-analytics.html',
+ 'docs/gallery/data/dashboards-monitoring.yml': 'docs/gallery/dashboards-monitoring.html',
+ 'docs/gallery/data/research-scholarship.yml': 'docs/gallery/research-scholarship.html',
+ 'docs/gallery/data/teaching-learning.yml': 'docs/gallery/teaching-learning.html',
+ 'docs/gallery/data/software-documentation.yml': 'docs/gallery/software-documentation.html',
+ 'docs/gallery/data/personal-community.yml': 'docs/gallery/personal-community.html',
// Guide listing → guide index
'docs/guide/guide.yml': 'docs/guide/index.html',
// Download data → download index
diff --git a/_freeze/docs/gallery/index/execute-results/html.json b/_freeze/docs/gallery/index/execute-results/html.json
deleted file mode 100644
index f9f6bfda06..0000000000
--- a/_freeze/docs/gallery/index/execute-results/html.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "hash": "838b86bcdf1e147ddbf3ace68d6405b8",
- "result": {
- "engine": "knitr",
- "markdown": "---\npagetitle: \"Gallery\"\nformat:\n html:\n toc: false\nimage: articles/advanced-layout-pdf.png\nsearch: false\nexecute:\n echo: false\nlisting:\n - id: gallery\n template: gallery.ejs\n contents: gallery.yml\ndescription: |\n A gallery of example documents, websites, books, and presentations created with Quarto.\naliases:\n - /docs/dashboards/examples/index.html\n - /docs/dashboards/examples/\n---\n\n::: {#hero-banner .column-screen}\n::: {.grid .column-page}\n::: {.headline .g-col-lg-6 .g-col-12 .g-col-md-12}\n::: {.h1 role=\"heading\" aria-level=\"1\"}\nGallery\n:::\n\n\nQuarto can produce a wide variety of output formats. Here are some examples:\n\n- [Articles & Reports](#articles-reports)\n- [Presentations](#presentations)\n- [Dashboards](#dashboards)\n- [Websites](#websites)\n- [Books](#books)\n- [Interactive Docs](#interactive-docs)\n:::\n\n\n::: {.cell .g-col-lg-6 .g-col-12 .g-col-md-12}\n::: {.cell-output-display}\n\n```{=html}\n
\n
\n \n \n \n \n \n
\n
\n
\n
\n \n \n
\n
Articles and reports with Python and R
\n
\n
\n
\n
\n \n \n
\n
Create data-driven presentations
\n
\n
\n
\n
\n \n \n
\n
Engage readers with interactivity
\n
\n
\n
\n
\n \n \n
\n
Publish collections of articles as a website
\n
\n
\n
\n
\n \n \n
\n
Create multi-format books
\n
\n
\n
\n
\n \nPrevoius \n \n
\n \nNext \n \n
\n```\n\n:::\n:::\n\n:::\n:::\n\n::: {#gallery .column-page}\n:::\n\n```{=html}\n\n```\n",
- "supporting": [],
- "filters": [
- "rmarkdown/pagebreak.lua"
- ],
- "includes": {},
- "engineDependencies": {},
- "preserve": {},
- "postProcess": true
- }
-}
\ No newline at end of file
diff --git a/_quarto.yml b/_quarto.yml
index b52f28767d..dd66373bca 100644
--- a/_quarto.yml
+++ b/_quarto.yml
@@ -111,6 +111,19 @@ website:
- text: "Tutorial: Authoring"
href: docs/get-started/authoring/
+ - id: gallery
+ contents:
+ - section: "Gallery"
+ href: docs/gallery/index.qmd
+ contents:
+ - docs/gallery/reports-analytics.qmd
+ - docs/gallery/dashboards-monitoring.qmd
+ - docs/gallery/research-scholarship.qmd
+ - docs/gallery/teaching-learning.qmd
+ - docs/gallery/software-documentation.qmd
+ - docs/gallery/personal-community.qmd
+ - docs/gallery/browse.qmd
+
- id: guide
collapse-level: 1
contents:
@@ -684,8 +697,8 @@ format:
respect-user-color-scheme: true
toc: true
theme:
- light: [cosmo, theme.scss]
- dark: [cosmo, theme-dark.scss]
+ light: [cosmo, theme.scss, docs/gallery/assets/_gallery.scss]
+ dark: [cosmo, theme-dark.scss, docs/gallery/assets/_gallery.scss]
code-copy: true
code-overflow: wrap
css: styles.css
diff --git a/_tools/screenshots/README.md b/_tools/screenshots/README.md
new file mode 100644
index 0000000000..2a575e43ca
--- /dev/null
+++ b/_tools/screenshots/README.md
@@ -0,0 +1,44 @@
+# Screenshot and preview tooling
+
+Scripts to capture and update quarto-web's documentation images and gallery media.
+
+There are two families.
+Doc screenshots come from `manifest.json` through `capture.js`.
+Gallery assets come from `docs/gallery/data/*.yml` through the `scripts/capture-*.mjs` scripts.
+
+Run every command from `_tools/screenshots/`.
+
+## Which tool makes what
+
+| Asset | Command | Script | Output |
+| ----------------------------------------- | ----------------------------- | ---------------------------------- | ----------------------------------------------- |
+| Doc screenshots (PNG, light and dark) | `npm run capture` | `capture.js` (manifest) | `docs/**/images/*.png` |
+| Gallery hover previews (webm and mp4) | `npm run capture:previews` | `scripts/capture-previews.mjs` | `docs/gallery/thumbnails/featured/*.{webm,mp4}` |
+| Gallery PDF card thumbnails (PNG) | `npm run capture:pdf-thumbs` | `scripts/capture-pdf-thumbs.mjs` | `docs/gallery/thumbnails/**/*.png` |
+| Gallery card thumbnails, image-less (PNG) | `npm run capture:card-thumbs` | `scripts/capture-card-thumbs.mjs` | `docs/gallery/thumbnails//.png` |
+| axe DevTools console (PNG, manual, macOS) | see script header | `scripts/capture-axe-devtools.mjs` | `docs/output-formats/images/axe-console.png` |
+
+Only `npm run capture:previews` makes the mp4 and webm hover clips.
+
+Each `capture:*` gallery command takes `-- --name ` to match a subset.
+Each also takes `-- --limit ` to stage the first `n` matches.
+
+## Prerequisites at a glance
+
+All commands need Node.js 18+ and this tool's dependencies (`npm install`, then `npx playwright install chromium`).
+
+`capture:previews` also needs ffmpeg on `PATH` and a full Playwright Chromium; set `PREVIEW_CHROME` to point at it because the headless shell cannot record video.
+
+`capture:pdf-thumbs` also needs poppler (`pdfinfo`, `pdftoppm`) and ImageMagick (`magick`).
+
+Full setup steps are in `SETUP.md`.
+The gallery prerequisites are detailed in `scripts/PREVIEWS.md`.
+
+## Where to read next
+
+`SETUP.md` covers install steps and the still-screenshot workflow.
+`scripts/PREVIEWS.md` covers gallery video previews plus PDF and card thumbnails in detail.
+`docs/gallery/README.md` covers how to add the gallery entries these thumbnails serve.
+`CLAUDE.md` covers the AI-assisted workflow and the `manifest.json` field reference.
+`DECISIONS.md` covers why the tooling is built the way it is.
+`npm run help` prints the full command list.
diff --git a/_tools/screenshots/SETUP.md b/_tools/screenshots/SETUP.md
index de12eb4bcd..20c8aced2f 100644
--- a/_tools/screenshots/SETUP.md
+++ b/_tools/screenshots/SETUP.md
@@ -61,6 +61,12 @@ playwright-cli -s=screenshot screenshot --filename=docs/websites/images/about-jo
playwright-cli -s=screenshot close
```
+## Gallery featured video previews
+
+`scripts/capture-previews.mjs` (`npm run capture:previews`) records the animated preview
+clips for featured gallery cards. It is separate from the still-screenshot manifest and
+needs ffmpeg plus a full Playwright Chromium. See **`scripts/PREVIEWS.md`** for details.
+
## Optional: PNG Compression
```bash
diff --git a/_tools/screenshots/package-lock.json b/_tools/screenshots/package-lock.json
index ce19c156c7..92534f5647 100644
--- a/_tools/screenshots/package-lock.json
+++ b/_tools/screenshots/package-lock.json
@@ -6,6 +6,7 @@
"": {
"dependencies": {
"ajv": "^8.18.0",
+ "js-yaml": "^5.2.3",
"open": "^10.0.0",
"playwright": "^1.50.0",
"sharp": "^0.33.0"
@@ -398,6 +399,12 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -593,6 +600,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/js-yaml": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz",
+ "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.mjs"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
diff --git a/_tools/screenshots/package.json b/_tools/screenshots/package.json
index 770a0cbfdf..d14dbc1243 100644
--- a/_tools/screenshots/package.json
+++ b/_tools/screenshots/package.json
@@ -8,14 +8,21 @@
"serve": "node scripts/serve.js",
"compress": "node scripts/compress.js",
"capture": "node capture.js",
+ "capture:previews": "node scripts/capture-previews.mjs",
+ "capture:pdf-thumbs": "node scripts/capture-pdf-thumbs.mjs",
+ "capture:card-thumbs": "node scripts/capture-card-thumbs.mjs",
"open": "node scripts/open.js",
"validate": "node scripts/validate.js",
"update-demo": "bash scripts/update-demo.sh"
},
"dependencies": {
"ajv": "^8.18.0",
+ "js-yaml": "^5.2.3",
"open": "^10.0.0",
"playwright": "^1.50.0",
"sharp": "^0.33.0"
+ },
+ "allowScripts": {
+ "sharp@0.33.5": true
}
}
diff --git a/_tools/screenshots/scripts/PREVIEWS.md b/_tools/screenshots/scripts/PREVIEWS.md
new file mode 100644
index 0000000000..10885f324a
--- /dev/null
+++ b/_tools/screenshots/scripts/PREVIEWS.md
@@ -0,0 +1,88 @@
+# Gallery featured video previews
+
+`capture-previews.mjs` generates the short, muted, looping clips that featured
+gallery cards play on hover or keyboard focus. The rendering,
+interaction, and reduced-motion handling live in the gallery, not here:
+`docs/gallery/assets/_featured.ejs.md` and `docs/gallery/assets/_gallery.scss`.
+
+This script is separate from the still-screenshot manifest (`capture.js`): it
+records motion rather than a single frame, and is driven by gallery data rather
+than `manifest.json`.
+
+## How it works
+
+- Driven by `docs/gallery/data/featured.yml`: every card with both a `live` URL
+ and a `preview` base path gets a clip. `preview:` is the output path without
+ extension, e.g. `thumbnails/featured/software-documentation-gribouille`.
+- Optional `preview-url:` overrides the page that is recorded when the card's
+ `live` link is not the best source (e.g. a landing page vs a richer subpage). The
+ card's `live` link is unchanged; only the capture source differs.
+- Output: `.webm` (VP9) and `.mp4` (H.264) under
+ `docs/gallery/thumbnails/featured/` (animated previews are a featured-only
+ asset), beside the still PNG (`image:`), which stays the `` poster.
+- Capture mode comes from the card's `categories`:
+ - **reveal.js** (`revealjs`/`presentation`) → step through a few slides with
+ ArrowRight.
+ - **everything else** → scroll the page top to bottom.
+- The blank page-load lead-in is measured and trimmed from the encode, so clips
+ open on real content and loop without a white flash.
+- Each file is size-budgeted; the script warns above 500 KB.
+
+Cards without a `preview:` field keep the static PNG. Leave it off for pages that
+do not benefit: single-screen dashboards, PDF/blob `live` URLs, or sites that block
+headless capture.
+
+## Prerequisites
+
+- **ffmpeg** on `PATH` (`brew install ffmpeg`). Playwright records VP8 webm only;
+ ffmpeg re-encodes to a smaller VP9 webm and an H.264 mp4 for Safari/broad support.
+- **A full Playwright Chromium** — the headless shell cannot record video. The
+ script defaults to the installed `chromium-*/chrome-mac-arm64` build; set
+ `PREVIEW_CHROME` to override the path (`npx playwright install chromium`).
+- **This tool's dependencies installed** — `npm install` in `_tools/screenshots`
+ (the script uses the tool's `playwright` and `js-yaml`).
+
+## Usage
+
+From `_tools/screenshots`:
+
+```bash
+npm run capture:previews # every preview card
+npm run capture:previews -- --name whr # match by name or preview path
+npm run capture:previews -- --limit 2 # first N matches (staging)
+```
+
+After capturing, re-render the landing (`quarto render docs/gallery/index.qmd`) and
+check the clips in the browser in both themes.
+
+# PDF card thumbnails
+
+`capture-pdf-thumbs.mjs` builds the still `image` for cards whose `live` URL is a
+PDF, rendering the PDF's first two pages (or the first page alone when it has only
+one) into a side-by-side spread that overwrites the card's existing thumbnail.
+
+- Driven by `docs/gallery/data/*.yml`: every card with a `.pdf` `live` URL. GitHub
+ `blob` URLs are rewritten to `raw.githubusercontent.com` before download.
+- Output: overwrites the card's `image` PNG under `docs/gallery/thumbnails/`.
+- Prerequisites: **poppler** (`pdfinfo`, `pdftoppm`) and **ImageMagick** (`magick`)
+ on `PATH` (`brew install poppler imagemagick`).
+
+```bash
+npm run capture:pdf-thumbs # every PDF-linked card
+npm run capture:pdf-thumbs -- --name whr # match by name or image path
+npm run capture:pdf-thumbs -- --limit 1 # first N matches (staging)
+```
+
+# Card thumbnails for image-less entries
+
+`capture-card-thumbs.mjs` fills the `image` for cards that have none yet and whose
+`live` URL is a normal web page (PDF-linked cards go through `capture-pdf-thumbs.mjs`).
+It screenshots the page top to `docs/gallery/thumbnails//.png`,
+dismissing a cookie/consent banner first, then prints the `image:` and a placeholder
+`alt:` line to add to the card (it does not edit the YAML).
+
+```bash
+npm run capture:card-thumbs # every image-less web card
+npm run capture:card-thumbs -- --name rap # match by name
+npm run capture:card-thumbs -- --limit 1 # first N matches (staging)
+```
diff --git a/_tools/screenshots/scripts/capture-card-thumbs.mjs b/_tools/screenshots/scripts/capture-card-thumbs.mjs
new file mode 100644
index 0000000000..d2fa56f921
--- /dev/null
+++ b/_tools/screenshots/scripts/capture-card-thumbs.mjs
@@ -0,0 +1,143 @@
+// Capture still card thumbnails for gallery entries that have no `image` yet.
+//
+// Data-driven from docs/gallery/data/*.yml: every card that is missing an `image`
+// and whose `live` URL is a normal web page (not a PDF) gets a screenshot of that
+// page, written to docs/gallery/thumbnails//.png. The script prints
+// the `image:` (and a placeholder `alt:`) line to add to the card; it does not edit
+// the YAML itself.
+//
+// PDF-linked cards are handled by capture-pdf-thumbs.mjs instead.
+//
+// Usage (from _tools/screenshots):
+// npm run capture:card-thumbs # every image-less web card
+// npm run capture:card-thumbs -- --name rap # match by name
+// npm run capture:card-thumbs -- --limit 1 # first N matches (staging)
+//
+// Prerequisites: this tool's dependencies (`npm install`); Playwright's chromium.
+
+import { readFileSync, existsSync, mkdirSync } from "node:fs";
+import { join, dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { chromium } from "playwright";
+import { load as yamlLoad } from "js-yaml";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(HERE, "..", "..", "..");
+const GALLERY_DIR = join(REPO_ROOT, "docs", "gallery");
+const DATA_DIR = join(GALLERY_DIR, "data");
+
+const VIEWPORT = { width: 1200, height: 760 };
+
+// Reuse the installed full chrome-for-testing build (same as capture-previews);
+// override with PREVIEW_CHROME. Falls back to Playwright's default resolution.
+const CHROME =
+ process.env.PREVIEW_CHROME ||
+ join(process.env.HOME || "", "Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64", "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
+
+function parseArgs(argv) {
+ const args = { name: null, limit: Infinity };
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i] === "--name") args.name = argv[++i];
+ else if (argv[i] === "--limit") args.limit = Number(argv[++i]);
+ }
+ return args;
+}
+
+function isPdf(url) {
+ return typeof url === "string" && /\.pdf(\?|#|$)/i.test(url);
+}
+
+function slugify(s) {
+ return String(s)
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+function missingCards() {
+ const out = [];
+ const files = ["dashboards-monitoring.yml", "reports-analytics.yml", "research-scholarship.yml", "teaching-learning.yml", "software-documentation.yml", "personal-community.yml"];
+ for (const file of files) {
+ const path = join(DATA_DIR, file);
+ if (!existsSync(path)) continue;
+ const category = file.replace(/\.yml$/, "");
+ const doc = yamlLoad(readFileSync(path, "utf8"));
+ for (const card of doc || []) {
+ if (!card || card.image || !card.live || isPdf(card.live)) continue;
+ out.push({ name: card.name || card.title, url: card.live, category, image: `thumbnails/${category}/${slugify(card.name || card.title)}.png` });
+ }
+ }
+ return out;
+}
+
+// Dismiss a cookie/consent banner if one is covering the page, so it does not
+// end up in the thumbnail. Clicks the first visible consent button (buttons only,
+// never links, to avoid navigating away).
+async function dismissConsent(page) {
+ const clicked = await page
+ .evaluate(() => {
+ const re = /^(i agree|agree|accept all|accept|got it|allow all|allow|i decline|decline|reject all|reject)\b/i;
+ const els = [...document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]')];
+ const hit = els.find((e) => e.offsetParent !== null && re.test((e.textContent || e.value || "").trim()));
+ if (hit) {
+ hit.click();
+ return true;
+ }
+ return false;
+ })
+ .catch(() => false);
+ if (clicked) await page.waitForTimeout(600);
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ let cards = missingCards();
+ if (args.name) {
+ const q = args.name.toLowerCase();
+ cards = cards.filter((c) => (c.name || "").toLowerCase().includes(q));
+ }
+ cards = cards.slice(0, args.limit);
+ if (cards.length === 0) {
+ console.log("No image-less web cards found.");
+ return;
+ }
+ console.log(`Capturing ${cards.length} card thumbnail(s).`);
+
+ const browser = await chromium.launch(existsSync(CHROME) ? { executablePath: CHROME } : {});
+ const toAdd = [];
+ for (const card of cards) {
+ process.stdout.write(`- ${card.name} … `);
+ const ctx = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 2 });
+ const page = await ctx.newPage();
+ try {
+ await page.goto(card.url, { waitUntil: "domcontentloaded", timeout: 60000 });
+ await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
+ await page.waitForTimeout(1200);
+ await dismissConsent(page);
+ const out = join(GALLERY_DIR, card.image);
+ mkdirSync(dirname(out), { recursive: true });
+ await page.screenshot({ path: out }); // viewport only (top of the page)
+ console.log(card.image);
+ toAdd.push(card);
+ } catch (err) {
+ console.log(`FAILED: ${err.message}`);
+ } finally {
+ await ctx.close();
+ }
+ }
+ await browser.close();
+
+ if (toAdd.length) {
+ console.log("\nAdd to each card's YAML (fill in a real alt):");
+ for (const c of toAdd) {
+ console.log(` # ${c.name} (${c.category})`);
+ console.log(` image: ${c.image}`);
+ console.log(` alt: TODO describe the ${c.name} page.`);
+ }
+ }
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/_tools/screenshots/scripts/capture-pdf-thumbs.mjs b/_tools/screenshots/scripts/capture-pdf-thumbs.mjs
new file mode 100644
index 0000000000..8e7675cd9e
--- /dev/null
+++ b/_tools/screenshots/scripts/capture-pdf-thumbs.mjs
@@ -0,0 +1,170 @@
+// Build gallery card thumbnails for PDF-linked examples from the PDF itself.
+//
+// Data-driven from docs/gallery/data/*.yml: every card whose `live` URL is a PDF
+// gets its `image` regenerated as a spread of the first two pages (or the first
+// page alone when the PDF has a single page). The output overwrites the card's
+// existing `image` PNG under docs/gallery/thumbnails/, so nothing else changes.
+//
+// Pipeline per card: resolve the raw PDF URL (GitHub blob -> raw.githubusercontent),
+// download it, read the page count with pdfinfo, render the first one or two pages
+// with pdftoppm, then composite them side by side with ImageMagick.
+//
+// Usage (from _tools/screenshots):
+// npm run capture:pdf-thumbs # every PDF-linked card
+// npm run capture:pdf-thumbs -- --name whr # match by name or image path
+// npm run capture:pdf-thumbs -- --limit 1 # first N matches (staging)
+//
+// Prerequisites: poppler (`pdfinfo`, `pdftoppm`) and ImageMagick (`magick`) on PATH
+// (`brew install poppler imagemagick`). See scripts/PREVIEWS.md.
+
+import { readFileSync, existsSync, mkdtempSync, rmSync, statSync, writeFileSync, readdirSync } from "node:fs";
+import { join, dirname, resolve } from "node:path";
+import { tmpdir } from "node:os";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { load as yamlLoad } from "js-yaml";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(HERE, "..", "..", "..");
+const GALLERY_DIR = join(REPO_ROOT, "docs", "gallery");
+const DATA_DIR = join(GALLERY_DIR, "data");
+
+const RENDER_DPI = 150; // pdftoppm render resolution before downscaling
+const OUT_WIDTH = 1000; // final composite width (keeps PNG small)
+const GUTTER = 12; // px gap between the two page images
+const SIZE_BUDGET = 500 * 1024;
+
+function parseArgs(argv) {
+ const args = { name: null, limit: Infinity };
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i] === "--name") args.name = argv[++i];
+ else if (argv[i] === "--limit") args.limit = Number(argv[++i]);
+ }
+ return args;
+}
+
+function isPdf(url) {
+ return typeof url === "string" && /\.pdf(\?|#|$)/i.test(url);
+}
+
+// GitHub "blob" viewer URLs are HTML; rewrite to the raw file host.
+function rawUrl(url) {
+ const m = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/(.+)$/i);
+ if (m) return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}`;
+ return url;
+}
+
+function pdfCards() {
+ const out = new Map(); // dedupe by output image path (shared across featured + list)
+ for (const file of ["featured.yml", "reports-analytics.yml", "dashboards-monitoring.yml", "research-scholarship.yml", "teaching-learning.yml", "software-documentation.yml", "personal-community.yml"]) {
+ const path = join(DATA_DIR, file);
+ if (!existsSync(path)) continue;
+ const doc = yamlLoad(readFileSync(path, "utf8"));
+ const cards = [];
+ for (const node of doc || []) {
+ if (node && Array.isArray(node.cards)) cards.push(...node.cards);
+ else cards.push(node);
+ }
+ for (const card of cards) {
+ if (!card || !isPdf(card.live) || !card.image) continue;
+ if (!out.has(card.image)) out.set(card.image, { name: card.name || card.title, url: card.live, image: card.image });
+ }
+ }
+ return [...out.values()];
+}
+
+async function download(url, dest) {
+ const res = await fetch(url, { redirect: "follow" });
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ writeFileSync(dest, buf);
+}
+
+function pageCount(pdf) {
+ const out = execFileSync("pdfinfo", [pdf], { encoding: "utf8" });
+ const m = out.match(/^Pages:\s*(\d+)/m);
+ return m ? Number(m[1]) : 1;
+}
+
+function renderPages(pdf, count, dir) {
+ // pdftoppm zero-pads the page suffix to the document's page-count width
+ // (page-1.png for short PDFs, page-01.png for longer ones), so glob whatever
+ // it actually wrote and take the first two in order.
+ const prefix = join(dir, "page");
+ execFileSync("pdftoppm", ["-png", "-r", String(RENDER_DPI), "-f", "1", "-l", String(Math.min(2, count)), pdf, prefix]);
+ return readdirSync(dir)
+ .filter((f) => /^page-\d+\.png$/.test(f))
+ .sort()
+ .slice(0, 2)
+ .map((f) => join(dir, f));
+}
+
+function composite(pages, out) {
+ if (pages.length === 1) {
+ execFileSync("magick", [pages[0], "-resize", `${OUT_WIDTH}x`, "-strip", out]);
+ } else {
+ // Side-by-side spread with a thin white gutter, then downscale the whole thing.
+ execFileSync("magick", [pages[0], pages[1], "-background", "white", "+smush", String(GUTTER), "-resize", `${OUT_WIDTH}x`, "-strip", out]);
+ }
+}
+
+function kb(bytes) {
+ return `${(bytes / 1024).toFixed(0)} KB`;
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ for (const bin of ["pdfinfo", "pdftoppm", "magick"]) {
+ try {
+ execFileSync(bin, ["-h"], { stdio: "ignore" }); // usage exits non-zero; only ENOENT means missing
+ } catch (e) {
+ if (e && e.code === "ENOENT") {
+ console.error(`Missing '${bin}'. Install poppler + imagemagick. See scripts/PREVIEWS.md.`);
+ process.exit(1);
+ }
+ }
+ }
+
+ let cards = pdfCards();
+ if (args.name) {
+ const q = args.name.toLowerCase();
+ cards = cards.filter((c) => (c.name || "").toLowerCase().includes(q) || c.image.toLowerCase().includes(q));
+ }
+ cards = cards.slice(0, args.limit);
+ if (cards.length === 0) {
+ console.log("No matching PDF-linked cards.");
+ return;
+ }
+ console.log(`Building ${cards.length} PDF thumbnail(s).`);
+
+ const report = [];
+ for (const card of cards) {
+ process.stdout.write(`- ${card.name} … `);
+ const dir = mkdtempSync(join(tmpdir(), "gal-pdf-"));
+ try {
+ const pdf = join(dir, "in.pdf");
+ await download(rawUrl(card.url), pdf);
+ const count = pageCount(pdf);
+ const pages = renderPages(pdf, count, dir);
+ const out = join(GALLERY_DIR, card.image);
+ composite(pages, out);
+ const size = statSync(out).size;
+ report.push({ name: card.name, size });
+ const warn = size > SIZE_BUDGET ? " ⚠ over budget" : "";
+ console.log(`${pages.length} page(s), ${kb(size)}${warn}`);
+ } catch (err) {
+ console.log(`FAILED: ${err.message}`);
+ report.push({ name: card.name, error: err.message });
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }
+ const ok = report.filter((r) => !r.error);
+ console.log(`\nDone: ${ok.length}/${report.length} built.`);
+ if (report.some((r) => r.error)) process.exitCode = 1;
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/_tools/screenshots/scripts/capture-previews.mjs b/_tools/screenshots/scripts/capture-previews.mjs
new file mode 100644
index 0000000000..f977592759
--- /dev/null
+++ b/_tools/screenshots/scripts/capture-previews.mjs
@@ -0,0 +1,267 @@
+// Capture short, muted, looping preview clips for the gallery featured cards.
+//
+// Data-driven from docs/gallery/data/featured.yml: every featured card with both
+// a `live` URL and a `preview` base path gets a short recording of its live page,
+// encoded to .webm (VP9) and .mp4 (H.264) under docs/gallery/.
+// The `preview` path points at thumbnails/featured/ (animated previews are a
+// featured-only asset); the still PNG (`image`) stays the poster, untouched.
+//
+// Capture mode is chosen from the card's `categories`: reveal.js decks step through
+// a few slides; everything else scrolls the page top to bottom. The blank page-load
+// lead-in is measured and trimmed from the encode so clips open on real content (no
+// white flash, including on loop).
+//
+// Usage (from _tools/screenshots):
+// npm run capture:previews # every preview card
+// npm run capture:previews -- --name whr # match by name or preview path
+// npm run capture:previews -- --limit 2 # first N matches (staging)
+//
+// Prerequisites and details: see scripts/PREVIEWS.md.
+
+import { readFileSync, existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
+import { join, dirname, resolve } from "node:path";
+import { tmpdir } from "node:os";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { chromium } from "playwright";
+import { load as yamlLoad } from "js-yaml";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const TOOLS_DIR = resolve(HERE, "..");
+const REPO_ROOT = resolve(TOOLS_DIR, "..", "..");
+const GALLERY_DIR = join(REPO_ROOT, "docs", "gallery");
+const FEATURED_YML = join(GALLERY_DIR, "data", "featured.yml");
+
+// A full browser build is required for recordVideo; the headless shell cannot
+// screencast. Point at the installed chrome-for-testing (override per platform).
+const CHROME =
+ process.env.PREVIEW_CHROME ||
+ join(
+ process.env.HOME,
+ "Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64",
+ "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
+ );
+
+const RECORD = { width: 1280, height: 720 };
+const SCROLL_MS = 9000;
+const SLIDE_STEPS = 5; // reveal.js: number of ArrowRight presses
+const SLIDE_HOLD_MS = 1500; // dwell on each slide
+const SIZE_BUDGET = 500 * 1024; // warn above 500 KB per file
+
+function parseArgs(argv) {
+ const args = { name: null, limit: Infinity };
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i] === "--name") args.name = argv[++i];
+ else if (argv[i] === "--limit") args.limit = Number(argv[++i]);
+ }
+ return args;
+}
+
+function modeFor(card) {
+ const cats = (card.categories || []).map((c) => String(c).toLowerCase());
+ if (cats.includes("revealjs") || cats.includes("presentation")) return "slides";
+ return "scroll";
+}
+
+function featuredCards() {
+ const doc = yamlLoad(readFileSync(FEATURED_YML, "utf8"));
+ const cards = [];
+ for (const cat of doc || []) {
+ for (const card of cat.cards || []) {
+ if (card.live && card.preview) {
+ cards.push({
+ name: card.name || card.title,
+ // `preview-url` overrides the capture source when the card's `live`
+ // link is not the best page to record (e.g. a landing vs a rich subpage).
+ captureUrl: card["preview-url"] || card.live,
+ preview: card.preview,
+ mode: modeFor(card),
+ });
+ }
+ }
+ }
+ return cards;
+}
+
+function ffmpeg(args) {
+ execFileSync("ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", ...args], { stdio: "inherit" });
+}
+
+function kb(bytes) {
+ return `${(bytes / 1024).toFixed(0)} KB`;
+}
+
+function scrollMax(page) {
+ // The real scroll distance, read from the scrolling root. Works whether the
+ // document itself scrolls or a smooth-scroll library drives a taller body.
+ return page.evaluate(() => {
+ const root = document.scrollingElement || document.documentElement;
+ return Math.max(0, root.scrollHeight - window.innerHeight);
+ });
+}
+
+async function scrollThrough(page) {
+ // Drive the scroll with trusted wheel events rather than `window.scrollTo`:
+ // some sites hijack scrolling with a smooth-scroll library (Lenis/Locomotive)
+ // that ignores programmatic scroll and only reacts to real wheel input.
+ const max = await scrollMax(page);
+ if (max === 0) {
+ await page.waitForTimeout(1500);
+ return;
+ }
+ await page.mouse.move(RECORD.width / 2, RECORD.height / 2);
+ const FRAMES = 60;
+ const dt = SCROLL_MS / FRAMES;
+ let prev = 0;
+ for (let i = 1; i <= FRAMES; i++) {
+ const t = i / FRAMES;
+ const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
+ const target = max * eased;
+ await page.mouse.wheel(0, target - prev);
+ prev = target;
+ await page.waitForTimeout(dt);
+ }
+ await page.mouse.wheel(0, 600); // nudge past any smooth-scroll easing shortfall
+ await page.waitForTimeout(400);
+}
+
+async function warmUp(page) {
+ // Wheel through once to trigger lazy-loaded images, wait for them to decode,
+ // then hard-reset to the top. Runs before the recorded scroll (and before the
+ // lead offset) so images are painted and we open at the top when it begins.
+ // Uses trusted wheel events for the same reason scrollThrough does.
+ const max = await scrollMax(page);
+ if (max === 0) {
+ await page.waitForTimeout(500);
+ return;
+ }
+ await page.mouse.move(RECORD.width / 2, RECORD.height / 2);
+ const step = Math.round(RECORD.height * 0.8) || 1;
+ const steps = Math.min(40, Math.ceil(max / step));
+ for (let i = 0; i < steps; i++) {
+ await page.mouse.wheel(0, step);
+ await page.waitForTimeout(150);
+ }
+ await page.waitForTimeout(400);
+ await page.evaluate(async () => {
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const untilLoaded = Promise.all(
+ Array.from(document.images).map((img) =>
+ img.complete
+ ? Promise.resolve()
+ : new Promise((r) => {
+ img.addEventListener("load", r, { once: true });
+ img.addEventListener("error", r, { once: true });
+ }),
+ ),
+ );
+ await Promise.race([untilLoaded, sleep(4000)]); // don't hang on a stuck image
+ });
+ // Overshoot upward so a smooth-scroll library settles hard at the very top.
+ await page.mouse.wheel(0, -(max + steps * step + 2000));
+ await page.waitForTimeout(700);
+}
+
+async function advanceSlides(page) {
+ // Reveal.js: step through a few slides with ArrowRight (slide-level, not fragments).
+ for (let i = 0; i < SLIDE_STEPS; i++) {
+ await page.waitForTimeout(SLIDE_HOLD_MS);
+ await page.keyboard.press("ArrowRight");
+ }
+ await page.waitForTimeout(SLIDE_HOLD_MS);
+}
+
+async function record(browser, url, mode) {
+ const dir = mkdtempSync(join(tmpdir(), "gal-preview-"));
+ const context = await browser.newContext({
+ viewport: { width: RECORD.width, height: RECORD.height },
+ recordVideo: { dir, size: RECORD },
+ });
+ const recordStart = Date.now();
+ const page = await context.newPage();
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
+ // Best-effort settle; heavy sites may never reach full load, so don't block on it.
+ await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
+ await page.waitForTimeout(800); // let fonts/images paint on the first frame
+
+ // Pre-load lazy images with a warm-up scroll (scroll cards only), then reset to top.
+ if (mode !== "slides") await warmUp(page).catch(() => {});
+
+ // Everything before this point is the blank/white page load and warm-up; record the
+ // offset so the encode trims it and opens on real content (no white flash, incl. on loop).
+ const lead = Math.max(0, (Date.now() - recordStart) / 1000 - 0.2);
+
+ if (mode === "slides") await advanceSlides(page);
+ else await scrollThrough(page);
+
+ await page.close();
+ await context.close(); // flushes the .webm
+
+ const raw = readdirSync(dir)
+ .filter((f) => f.endsWith(".webm"))
+ .map((f) => join(dir, f))[0];
+ return { raw, dir, lead };
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ if (!existsSync(CHROME)) {
+ console.error(`Full Chromium not found at:\n ${CHROME}\nInstall it or set PREVIEW_CHROME. See scripts/PREVIEWS.md.`);
+ process.exit(1);
+ }
+
+ let cards = featuredCards();
+ if (args.name) {
+ const q = args.name.toLowerCase();
+ cards = cards.filter((c) => c.name.toLowerCase().includes(q) || c.preview.toLowerCase().includes(q));
+ }
+ cards = cards.slice(0, args.limit);
+
+ if (cards.length === 0) {
+ console.log("No matching featured cards with both `live` and `preview`.");
+ return;
+ }
+ console.log(`Capturing ${cards.length} preview(s).`);
+
+ const browser = await chromium.launch({ executablePath: CHROME });
+ const report = [];
+ for (const card of cards) {
+ const webmOut = join(GALLERY_DIR, `${card.preview}.webm`);
+ const mp4Out = join(GALLERY_DIR, `${card.preview}.mp4`);
+ mkdirSync(dirname(webmOut), { recursive: true }); // e.g. thumbnails/featured/
+
+ process.stdout.write(`- ${card.name} … `);
+ let tmp;
+ try {
+ const rec = await record(browser, card.captureUrl, card.mode);
+ tmp = rec.dir;
+ if (!rec.raw) throw new Error("no video produced");
+ // Re-encode from the raw capture, trimming the blank page-load lead-in so the
+ // clip opens on real content (fast `-ss` before `-i`).
+ const ss = ["-ss", rec.lead.toFixed(2)];
+ ffmpeg([...ss, "-i", rec.raw, "-an", "-vf", "scale=640:-2", "-c:v", "libvpx-vp9", "-crf", "40", "-b:v", "0", webmOut]);
+ ffmpeg([...ss, "-i", rec.raw, "-an", "-vf", "scale=640:-2", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "30", "-movflags", "+faststart", mp4Out]);
+ const wb = statSync(webmOut).size;
+ const mb = statSync(mp4Out).size;
+ report.push({ name: card.name, webm: wb, mp4: mb });
+ const warn = wb > SIZE_BUDGET || mb > SIZE_BUDGET ? " ⚠ over budget" : "";
+ console.log(`webm ${kb(wb)}, mp4 ${kb(mb)}${warn}`);
+ } catch (err) {
+ console.log(`FAILED: ${err.message}`);
+ report.push({ name: card.name, error: err.message });
+ } finally {
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
+ }
+ }
+ await browser.close();
+
+ const ok = report.filter((r) => !r.error);
+ const total = ok.reduce((s, r) => s + r.webm + r.mp4, 0);
+ console.log(`\nDone: ${ok.length}/${report.length} captured, total ${kb(total)} added.`);
+ if (report.some((r) => r.error)) process.exitCode = 1;
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/_tools/screenshots/scripts/help.js b/_tools/screenshots/scripts/help.js
index bce3554352..ab18c1a403 100644
--- a/_tools/screenshots/scripts/help.js
+++ b/_tools/screenshots/scripts/help.js
@@ -14,6 +14,10 @@ Commands (run from _tools/screenshots/):
npm run capture -- --no-compress Skip oxipng compression
npm run capture -- --verify Open each image for visual review
+ npm run capture:previews Gallery hover previews (webm + mp4)
+ npm run capture:pdf-thumbs Gallery thumbnails for PDF-linked cards
+ npm run capture:card-thumbs Gallery thumbnails for image-less cards
+
npm run list List manifest entries (formatted)
npm run render -- Render a Quarto example project
npm run serve -- Serve a rendered site locally
@@ -25,5 +29,6 @@ Commands (run from _tools/screenshots/):
npm run help Show this help
Source of truth: manifest.json
-Docs: CLAUDE.md (workflow), SETUP.md (setup guide)
+Docs: README.md (overview), SETUP.md (setup guide), CLAUDE.md (workflow),
+ scripts/PREVIEWS.md (gallery video/image capture)
`.trim());
diff --git a/docs/gallery/README.md b/docs/gallery/README.md
new file mode 100644
index 0000000000..db142b321a
--- /dev/null
+++ b/docs/gallery/README.md
@@ -0,0 +1,65 @@
+# Gallery data
+
+How to add examples to the Quarto gallery.
+
+The gallery has one landing page (`index.qmd`) and six category spoke pages (`reports-analytics.qmd`, `dashboards-monitoring.qmd`, `research-scholarship.qmd`, `teaching-learning.qmd`, `software-documentation.qmd`, `personal-community.qmd`).
+Every card is driven by YAML under `data/` and rendered through the EJS templates in `assets/`.
+
+## Data model
+
+`data/.yml` is the full list for one category, a flat sequence of cards.
+The spoke page renders it through `assets/_card.ejs.md` with filtering, sorting, and category facets.
+The first three cards in each list are the category's landing featured set.
+
+`data/featured.yml` is the curated landing file, grouped by category.
+Each category is an object with meta (`category`, `slug`, `anchor`, `page`, `icon`, `gap`, `tagline`, `capabilities`) and a `cards:` list.
+`assets/_featured.ejs.md` renders up to three cards per category on the landing.
+
+Both files use the same card shape.
+
+| Field | Required | Purpose |
+| ------------- | -------- | ------------------------------------------------------------------------------------------------ |
+| `name` | yes | Internal identifier, also the fallback title. |
+| `slug` | yes | Category slug (`reports`, `dashboards`, `research`, `teaching`, `docs`, `personal`). |
+| `title` | yes | Headline shown on the card. |
+| `author` | no | Credit line. |
+| `live` | yes | URL to the live example or PDF. |
+| `code` | no | URL to the source. |
+| `categories` | yes | Facet chips: format, project type, language; also the clickable filters. |
+| `features` | no | Display-only capability chips, for example `Listings` or `brand.yml`. |
+| `image` | yes | Thumbnail path, relative to `docs/gallery/`, for example `thumbnails/reports-analytics/whr.png`. |
+| `alt` | no | Alt text for the thumbnail. |
+| `description` | yes | One-line summary. |
+| `preview` | no | Featured only: hover-video base path with no extension. |
+| `preview-url` | no | Featured only: page to record when `live` is not the best source. |
+
+Thumbnails live under `thumbnails//.png`.
+Featured hover clips live under `thumbnails/featured/-.{webm,mp4}`.
+
+## Add a normal entry
+
+Append a card to the matching `data/.yml` and set `slug` to that category's slug.
+
+Add a thumbnail at `thumbnails//.png`, or generate one from `_tools/screenshots`.
+Use `npm run capture:card-thumbs -- --name ` for a normal web page.
+Use `npm run capture:pdf-thumbs -- --name ` when `live` points at a PDF.
+
+The card appears on the spoke page automatically.
+If it is one of the first three in the list it also becomes that category's landing featured card.
+
+## Add a featured entry
+
+Add or curate a card under the category's `cards:` in `data/featured.yml`; only the first three render on the landing.
+
+For a hover video, set `preview:` to `thumbnails/featured/-`, add `preview-url:` if the `live` link is not the richest page to record, then run `npm run capture:previews -- --name ` from `_tools/screenshots`.
+Leave `preview:` off to keep the still `image` as the poster with no motion.
+
+The `capabilities` list on each category is the single source for the "Learn the Quarto features used" links, shown on the landing and pulled onto the spoke page.
+
+Featured cards currently mirror the first entries of the matching `data/.yml`, so keep the two in sync when curating.
+
+## Related
+
+`../../_tools/screenshots/README.md` is the capture tooling for thumbnails and previews.
+`assets/` holds the EJS templates that render these YAML files.
+`legacy/` holds the previous gallery implementation, kept for reference.
diff --git a/docs/gallery/_metadata.yml b/docs/gallery/_metadata.yml
new file mode 100644
index 0000000000..75aba4fb03
--- /dev/null
+++ b/docs/gallery/_metadata.yml
@@ -0,0 +1,15 @@
+# Applies to every page under docs/gallery/.
+#
+# Live-link hover preview:
+# DISABLE — delete (or rename) this file; the include stops site-wide.
+# REMOVE — delete this file and assets/_preview.html. Nothing else references them.
+format:
+ html:
+ toc: false
+ include-after-body: assets/_preview.html
+ # Widen the card grid and shrink the listing category facet (default
+ # margin-width is 300px). Replaces per-page `page-layout: full`: grid
+ # body-width governs the content band on its own.
+ grid:
+ body-width: 1200px
+ margin-width: 180px
diff --git a/docs/gallery/articles/advanced-layout-pdf.png b/docs/gallery/articles/advanced-layout-pdf.png
deleted file mode 100644
index ad4acc1e4a27839a22f7885061cb0b698e08d347..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 191186
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