Cannabis Freedom Index

Changelog af4a206

poster

9 files of code, design or docs +434 −18 lines

Code, design and documents 9 files

source-archive.json +13 −13

Not shown: a picture, a generated file, or past the length shown per change.

src/components/HomePage.astro +5 −1
@@ -212,5 +212,8 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
             <h2>World map</h2>
           </div>
-          <a class="map-expand" id="mapExpand" href={viewHref(current)} title="Open this map on a page of its own">Expand <span aria-hidden="true">↗</span></a>
+          <div class="map-links">
+            <a class="map-expand" href={`${base}map/poster/`}>Print or share →</a>
+            <a class="map-expand" id="mapExpand" href={viewHref(current)} title="Open this map on a page of its own">Expand <span aria-hidden="true">↗</span></a>
+          </div>
         </div>
       )}
@@ -262,4 +265,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
           {legend.map((t) => <span><i class="sw" style={`background:var(--t${t.i})`}></i>{t.name} · {t.min}–{t.max}</span>)}
           <span><i class="sw sw-nodata"></i>No data</span>
+          {view && <a class="legend-poster" href={`${base}map/poster/`}>Print or share this map →</a>}
         </div>
         {Object.entries(USE_SCALES).map(([key, scale]) => (

src/layouts/Base.astro +6 −4
@@ -11,5 +11,6 @@ import Leaf from "../components/Leaf.astro";
 // object or a list of them; `type` is the Open Graph type of the page.
 // `noindex` keeps a page out of search engines (hidden utility pages)
-const { title, seoTitle, description, jsonld, type = "website", noindex = false } = Astro.props;
+// `image` replaces the drawn preview with another image of the site: [path after the base, width, height]
+const { title, seoTitle, description, jsonld, type = "website", noindex = false, image } = Astro.props;
 const base = import.meta.env.BASE_URL;
 const repo = "https://github.com/burningtree/cannabis-freedom-index";
@@ -21,5 +22,6 @@ const canonical = new URL(Astro.url.pathname, Astro.site ?? "https://cannabisfre
 // /country/de/ → /og/country/de.png, the home page → /og/index.png
 const ogPath = Astro.url.pathname.slice(base.length).replace(/\/$/, "") || "index";
-const ogImage = new URL(`${base}og/${ogPath}.png`, Astro.site ?? "https://cannabisfreedom.fyi").href;
+const ogImage = new URL(image ? `${base}${image[0]}` : `${base}og/${ogPath}.png`, Astro.site ?? "https://cannabisfreedom.fyi").href;
+const [ogW, ogH] = image ? image.slice(1) : [1200, 630];
 const schemas = jsonld ? [jsonld].flat() : [];
 ---
@@ -40,6 +42,6 @@ const schemas = jsonld ? [jsonld].flat() : [];
   <meta property="og:locale" content="en">
   <meta property="og:image" content={ogImage}>
-  <meta property="og:image:width" content="1200">
-  <meta property="og:image:height" content="630">
+  <meta property="og:image:width" content={ogW}>
+  <meta property="og:image:height" content={ogH}>
   <meta property="og:image:alt" content={seoTitle ?? title ?? fullTitle}>
   <meta name="twitter:card" content="summary_large_image">

src/lib/world-map.js +81 −0
@@ -0,0 +1,81 @@
+// The world map drawn at build time, for the printable poster (/map/poster/) and its images.
+// The same shapes and projection as the map on the home page (HomePage.astro), coloured by each
+// country's tier. Each shape's path is returned as a string, so a page can inline it and the
+// image renderer can wrap it in an SVG of its own.
+import { geoNaturalEarth1, geoPath } from "d3-geo";
+import { feature } from "topojson-client";
+import world from "world-atlas/countries-50m.json";
+import territories from "../data/territories.geo.json";
+import { places, countries, worldAvg, lastUpdated, formatDate } from "./countries.js";
+import { TIERS, tierIndex } from "./tiers.js";
+
+// the tier colours as plain values, as in src/og/render.js (an image has no CSS variables)
+export const TIER_HEX = ["#a3121f", "#dc4a2a", "#f08c3a", "#f0cf4a", "#9ccc55", "#2e9e4f"];
+export const PAPER = { bg: "#f4f2eb", ink: "#11110f", muted: "#4b4a43", nodata: "#d9d5c8", land: "#e6e2d6" };
+
+const byId = new Map(places.map((c) => [c.id, c]));
+const byNum = new Map(places.filter((c) => c.num != null).map((c) => [c.num, c]));
+// atlas shapes that take another entry's score, as on the home page
+const ALIASES = { Kosovo: "XK", Somaliland: "XS", "N. Cyprus": "XN", 304: "DK" };
+const ANTARCTICA = "010";
+
+const shapes = feature(world, world.objects.countries).features.map((f) => {
+  const alias = ALIASES[f.properties.name] || ALIASES[+f.id];
+  return { f, c: alias ? byId.get(alias) : byNum.get(+f.id), antarctica: f.id === ANTARCTICA };
+});
+// disputed territories the atlas has no shape for, drawn last, on top
+shapes.push(...territories.features.map((f) => ({ f, c: byId.get(f.id) })).filter((s) => s.c));
+
+const land = shapes.filter((s) => !s.antarctica);
+// the box the map fits: its aspect ratio, so callers can size a frame for it
+export const MAP_RATIO = (() => {
+  const [[x0, y0], [x1, y1]] = geoPath(geoNaturalEarth1()).bounds({ type: "FeatureCollection", features: land.map((s) => s.f) });
+  return (x1 - x0) / (y1 - y0);
+})();
+
+// width × height: the box the land (without Antarctica) is fitted into; `digits`, the decimals
+// kept in the paths (none is plenty for a page, and keeps it light)
+export function worldMap(width, height, digits = 1) {
+  const projection = geoNaturalEarth1().fitSize([width, height], { type: "FeatureCollection", features: land.map((s) => s.f) });
+  const path = geoPath(projection).digits(digits);
+  const fill = (c) => (c ? TIER_HEX[tierIndex(c.raw)] : PAPER.nodata);
+  // a scored place too small to see at this size (Singapore, Malta, the islands) also gets a dot
+  const tiny = Math.max(4, (width * height) / 60000);
+  const dots = land.filter((s) => s.c && path.area(s.f) < tiny).map((s) => {
+    const [x, y] = path.centroid(s.f);
+    return { x: +x.toFixed(1), y: +y.toFixed(1), fill: fill(s.c), c: s.c };
+  }).filter((d) => Number.isFinite(d.x));
+  return {
+    shapes: land.map((s) => ({ d: path(s.f), fill: fill(s.c), c: s.c })).filter((s) => s.d),
+    dots,
+    r: Math.max(2, width / 520),
+  };
+}
+
+// The map as a standalone SVG document, for the PNG images.
+export function worldMapSvg(width, height) {
+  const m = worldMap(width, height);
+  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`
+    + m.shapes.map((s) => `<path d="${s.d}" fill="${s.fill}" stroke="${PAPER.bg}" stroke-width="0.5" stroke-linejoin="round"/>`).join("")
+    + m.dots.map((d) => `<circle cx="${d.x}" cy="${d.y}" r="${m.r}" fill="${d.fill}" stroke="${PAPER.bg}" stroke-width="0.8"/>`).join("")
+    + "</svg>";
+}
+
+// What the poster says beside the map: the tiers with how many countries are in each (the best
+// first), the `n` most and least free, the world average and the date of the data.
+export function posterFacts(n = 5) {
+  const tiers = TIERS.map((t, i) => {
+    const k = TIERS.length - 1 - i;
+    const hi = i ? TIERS[i - 1].min - 1 : 100;
+    return { name: t.name, range: `${t.min}–${hi}`, color: TIER_HEX[k], count: countries.filter((c) => tierIndex(c.raw) === k).length };
+  });
+  const row = (c) => ({ c, rank: c.rank, name: c.name, score: c.score, color: TIER_HEX[tierIndex(c.raw)] });
+  return {
+    tiers,
+    top: countries.slice(0, n).map(row),
+    bottom: countries.slice(-n).map(row),
+    avg: worldAvg.toFixed(1),
+    total: countries.length,
+    date: formatDate(lastUpdated),
+  };
+}

src/og/poster.astro +61 −0
@@ -0,0 +1,61 @@
+---
+// The world map as an image to share (/map/poster/<size>.png): title, the map coloured by tier,
+// the tiers with how many countries are in each. A square or portrait image also lists the
+// most and least free; the wide one has no room for them.
+const { W, H, wide, eyebrow, title, sub, map, mapW, mapH, tiers, top, bottom, avg, site, note } = Astro.props;
+const pad = wide ? 56 : 52;
+const titleSize = wide ? 64 : 62;
+---
+<div style={`display:flex;flex-direction:column;width:${W}px;height:${H}px;background:#f4f2eb;color:#11110f;padding:${pad - 10}px ${pad}px ${pad - 12}px;font-family:Inter`}>
+  <div style="display:flex;justify-content:space-between;align-items:center;font-family:'JetBrains Mono';font-size:17px;letter-spacing:2px;color:#4b4a43">
+    <div style="display:flex">{eyebrow}</div>
+    <div style="display:flex">{site}</div>
+  </div>
+  <div style="display:flex;justify-content:space-between;align-items:flex-end;margin-top:22px">
+    <div style="display:flex;flex-direction:column;flex:1;min-width:0">
+      <div style={`display:flex;font-family:'Instrument Serif';font-size:${titleSize}px;line-height:1;letter-spacing:-1.5px`}>{title}</div>
+      <div style={`display:flex;margin-top:10px;font-size:21px;line-height:1.35;color:#4b4a43;max-width:${wide ? 1100 : 640}px`}>{sub}</div>
+    </div>
+    <div style="display:flex;flex-direction:column;align-items:flex-end;margin-left:32px">
+      <div style="display:flex;font-family:'JetBrains Mono';font-size:54px;font-weight:500;letter-spacing:-3px;line-height:1">{avg}</div>
+      <div style="display:flex;margin-top:6px;font-family:'JetBrains Mono';font-size:14px;letter-spacing:2px;color:#4b4a43">WORLD AVERAGE OF 100</div>
+    </div>
+  </div>
+  <div style="display:flex;flex:1;align-items:center;justify-content:center">
+    <img src={map} style={`width:${mapW}px;height:${mapH}px`} />
+  </div>
+  <div style="display:flex;flex-wrap:wrap;border-top:1px solid #11110f;padding-top:16px">
+    {tiers.map((t) => (
+      <div style={`display:flex;align-items:flex-start;width:${wide ? "16.66%" : "33.33%"};margin-bottom:${wide ? 0 : 14}px`}>
+        <div style={`display:flex;width:16px;height:16px;margin-top:3px;margin-right:9px;background:${t.color}`}></div>
+        <div style="display:flex;flex-direction:column">
+          <div style="display:flex;font-size:17px;font-weight:600">{t.name}</div>
+          <div style="display:flex;margin-top:3px;font-family:'JetBrains Mono';font-size:14px;color:#4b4a43">{t.range} · {t.count}</div>
+        </div>
+      </div>
+    ))}
+  </div>
+  {!wide && (
+    <div style="display:flex;margin-top:12px;border-top:1px solid #d9d5c8;padding-top:16px">
+      {[["MOST FREE", top], ["LEAST FREE", bottom]].map(([label, list]) => (
+        <div style="display:flex;flex-direction:column;width:50%;padding-right:24px">
+          <div style="display:flex;font-family:'JetBrains Mono';font-size:14px;letter-spacing:2px;color:#4b4a43;margin-bottom:8px">{label}</div>
+          {list.map((c) => (
+            <div style="display:flex;align-items:center;justify-content:space-between;padding:5px 0">
+              <div style="display:flex;align-items:center">
+                <div style="display:flex;width:30px;font-family:'JetBrains Mono';font-size:14px;color:#4b4a43">{c.rank}</div>
+                {c.flag && <img src={c.flag} style="width:26px;height:19px;margin-right:10px" />}
+                <div style="display:flex;font-family:'Instrument Serif';font-size:27px;line-height:1">{c.name}</div>
+              </div>
+              <div style={`display:flex;font-family:'JetBrains Mono';font-size:20px;font-weight:500;border-bottom:3px solid ${c.color}`}>{c.score}</div>
+            </div>
+          ))}
+        </div>
+      ))}
+    </div>
+  )}
+  <div style="display:flex;justify-content:space-between;margin-top:14px;font-family:'JetBrains Mono';font-size:13px;letter-spacing:1px;color:#4b4a43">
+    <div style="display:flex">{note}</div>
+    <div style="display:flex">CC BY 4.0</div>
+  </div>
+</div>

src/og/render-poster.js +28 −0
@@ -0,0 +1,28 @@
+// Draws the map poster images (/map/poster/<size>.png) at build time, like render.js draws the
+// social previews but at sizes of their own, from src/og/poster.astro. Kept apart from
+// render.js because every image that file draws is cached under a hash of the file itself.
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { satoriAstro } from "x-satori/astro";
+import { Resvg } from "@resvg/resvg-js";
+
+const ROOT = process.cwd();
+const font = (pkg, file) => readFileSync(path.join(ROOT, "node_modules", "@fontsource", pkg, "files", file));
+const fonts = [
+  ["Instrument Serif", "instrument-serif", 400], ["Inter", "inter", 400], ["Inter", "inter", 600], ["JetBrains Mono", "jetbrains-mono", 400], ["JetBrains Mono", "jetbrains-mono", 500],
+].flatMap(([name, pkg, weight]) => ["latin", "latin-ext"].map((set) => ({ name, data: font(pkg, `${pkg}-${set}-${weight}-normal.woff`), weight, style: "normal" })));
+
+// The map goes through the template as this one-pixel stand-in and is put into the finished SVG
+// afterwards: satori would take long to parse a megabyte of base64 in the template's HTML.
+const STAND_IN = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
+
+// The text reaches satori as HTML, where ' and & arrive as entities it draws letter for letter
+// ("d&#39;Ivoire"), so they go in as their typographic forms, as in render.js.
+const plain = (v, key) => key === "flag" ? v : typeof v === "string" ? v.replace(/'/g, "\u2019").replace(/ & /g, " and ").replace(/&/g, "+")
+  : Array.isArray(v) ? v.map((x) => plain(x)) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x, k)])) : v;
+
+export async function renderPoster(props, map, width, height) {
+  const source = readFileSync(path.join(ROOT, "src", "og", "poster.astro"), "utf8");
+  const svg = (await satoriAstro({ width, height, fonts, props: { ...plain(props), map: STAND_IN } }, source)).replaceAll(STAND_IN, map);
+  return new Resvg(svg, { fitTo: { mode: "width", value: width } }).render().asPng();
+}

src/pages/map/poster/[size].png.js +42 −0
@@ -0,0 +1,42 @@
+// The world map as an image to share, in three sizes: /map/poster/wide.png (1600 × 900, for
+// X, LinkedIn and slides), square.png (1080 × 1080) and portrait.png (1080 × 1350, for
+// Instagram). Drawn by src/og/render-poster.js from src/og/poster.astro.
+import { DATA_SNAPSHOT } from "../../../lib/countries.js";
+import { worldMapSvg, posterFacts, MAP_RATIO } from "../../../lib/world-map.js";
+import { flagOf } from "../../../og/render.js";
+import { renderPoster } from "../../../og/render-poster.js";
+
+export const SIZES = {
+  wide: { W: 1600, H: 900, wide: true, mapH: 560, rows: 0 },
+  square: { W: 1080, H: 1080, wide: false, mapH: 385, rows: 5 },
+  portrait: { W: 1080, H: 1350, wide: false, mapH: 500, rows: 8 },
+};
+
+export function getStaticPaths() {
+  return Object.keys(SIZES).map((size) => ({ params: { size } }));
+}
+
+export async function GET({ params, site }) {
+  const { W, H, wide, mapH: maxH, rows } = SIZES[params.size];
+  const pad = wide ? 56 : 52;
+  // the map as large as fits the space left for it, in its own proportions
+  const mapW = Math.round(Math.min(W - 2 * pad, maxH * MAP_RATIO));
+  const mapH = Math.round(mapW / MAP_RATIO);
+  // drawn at twice the size, so the borders stay fine when the image is scaled
+  const svg = worldMapSvg(mapW * 2, mapH * 2);
+  const f = posterFacts(rows || 5);
+  const host = new URL(site).host.toUpperCase();
+  const png = await renderPoster({
+    W, H, wide, mapW, mapH,
+    eyebrow: `CANNABIS FREEDOM INDEX · ${DATA_SNAPSHOT} EDITION`,
+    title: "How free is cannabis?",
+    sub: `${f.total} countries scored from 0 to 100 for how free you are to carry, grow, share and use it.`,
+    tiers: f.tiers.map((t) => ({ ...t, count: `${t.count} ${t.count === 1 ? "country" : "countries"}` })),
+    top: f.top.map(({ c, ...r }) => ({ ...r, flag: flagOf(c.id) })),
+    bottom: f.bottom.map(({ c, ...r }) => ({ ...r, flag: flagOf(c.id) })),
+    avg: f.avg,
+    site: host,
+    note: `DATA AS OF ${f.date.toUpperCase()} · ${host}/MAP/`,
+  }, `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`, W, H);
+  return new Response(png, { headers: { "Content-Type": "image/png" } });
+}

src/pages/map/poster/index.astro +111 −0
@@ -0,0 +1,111 @@
+---
+// The world map as a poster: one sheet that prints on A4 or A3 landscape, with the tiers, the
+// most and least free and the date of the data. The same poster comes as images to share, drawn
+// by ./[size].png.js. The sheet keeps the paper colours in the dark theme too, as printed.
+import Base from "../../../layouts/Base.astro";
+import { DATA_SNAPSHOT } from "../../../lib/countries.js";
+import { worldMap, posterFacts, MAP_RATIO } from "../../../lib/world-map.js";
+
+const base = import.meta.env.BASE_URL;
+const MAP_W = 1000;
+const MAP_H = Math.round(MAP_W / MAP_RATIO);
+const map = worldMap(MAP_W, MAP_H, 0);
+const f = posterFacts();
+const host = new URL(Astro.site ?? "https://cannabisfreedom.fyi").host;
+// the image sizes: id, name, pixels, where they fit
+const images = [
+  ["wide", "Wide", "1600 × 900", "X, LinkedIn, slides"],
+  ["square", "Square", "1080 × 1080", "Instagram, Mastodon"],
+  ["portrait", "Portrait", "1080 × 1350", "Instagram"],
+];
+---
+<Base title="World map poster" description={`The Cannabis Freedom Index world map as a poster to print or share: ${f.total} countries scored from 0 to 100, by tier.`} image={["map/poster/wide.png", 1600, 900]}>
+  <main class="poster-page">
+    <div class="poster-bar">
+      <a class="back" href={`${base}map/`}>← The interactive map</a>
+      <div class="poster-pick" role="group" aria-label="Format">
+        <button type="button" aria-pressed="true" data-format="print">Print <small>A4 landscape</small></button>
+        {images.map(([id, label, size]) => (
+          <button type="button" aria-pressed="false" data-format={id} data-href={`${base}map/poster/${id}.png`}
+            data-file={`cannabis-freedom-index-map-${DATA_SNAPSHOT}-${id}.png`} data-size={size}>{label} <small>{size}</small></button>
+        ))}
+      </div>
+      <a class="btn btn-primary poster-go" id="posterGo" href={`${base}map/poster/wide.png`}>Print</a>
+    </div>
+    <p class="poster-use" id="posterUse">Prints on one A4 landscape sheet. Choose an image size instead to share it.</p>
+
+    {images.map(([id, label, size, use]) => (
+      <figure class="poster-image" data-format={id} hidden>
+        <img src={`${base}map/poster/${id}.png`} alt={`The poster as a ${label.toLowerCase()} image, ${size} pixels`} loading="lazy"
+          width={size.split(" × ")[0]} height={size.split(" × ")[1]} />
+        <figcaption>{size} pixels · for {use}</figcaption>
+      </figure>
+    ))}
+
+    <article class="poster" data-format="print" aria-labelledby="posterTitle">
+      <header class="poster-head">
+        <p class="poster-eyebrow"><span>Cannabis Freedom Index · {DATA_SNAPSHOT} edition</span><span>{host}</span></p>
+        <div class="poster-title">
+          <div>
+            <h1 id="posterTitle">How free is cannabis?</h1>
+            <p>{f.total} countries scored from 0 to 100 for how free you are to carry, grow, share and use it.</p>
+          </div>
+          <p class="poster-avg"><b>{f.avg}</b><span>World average of 100</span></p>
+        </div>
+      </header>
+
+      <svg class="poster-map" viewBox={`0 0 ${MAP_W} ${MAP_H}`} role="img" aria-label="World map of the countries coloured by their tier on the index">
+        {map.shapes.map((s) => <path d={s.d} fill={s.fill} />)}
+        {map.dots.map((d) => <circle cx={d.x} cy={d.y} r={map.r} fill={d.fill} />)}
+      </svg>
+
+      <ul class="poster-tiers">
+        {f.tiers.map((t) => (
+          <li><i style={`background:${t.color}`}></i><span><b>{t.name}</b><small>{t.range} · {t.count} {t.count === 1 ? "country" : "countries"}</small></span></li>
+        ))}
+      </ul>
+
+      <div class="poster-lists">
+        {[["Most free", f.top], ["Least free", f.bottom]].map(([label, list]) => (
+          <section>
+            <h2>{label}</h2>
+            <ol>
+              {list.map((r) => (
+                <li><span class="poster-rank">{r.rank}</span><span class="poster-name">{r.c.flag} {r.c.name}</span><b style={`border-color:${r.color}`}>{r.score}</b></li>
+              ))}
+            </ol>
+          </section>
+        ))}
+      </div>
+
+      <footer class="poster-foot">
+        <span>Data as of {f.date} · {host}/map/ · each country's rules and sources are on its page</span>
+        <span>CC BY 4.0</span>
+      </footer>
+    </article>
+  </main>
+  <script>
+    // Pick a format, see it, then print or download it: the sheet is the print version, the
+    // images are the drawn PNGs.
+    const go = document.getElementById("posterGo");
+    const use = document.getElementById("posterUse");
+    const picks = [...document.querySelectorAll(".poster-pick button")];
+    const views = [...document.querySelectorAll(".poster, .poster-image")];
+    function choose(btn) {
+      picks.forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
+      views.forEach((v) => (v.hidden = v.dataset.format !== btn.dataset.format));
+      const print = btn.dataset.format === "print";
+      go.textContent = print ? "Print" : "Download image";
+      if (print) go.removeAttribute("download");
+      else { go.href = btn.dataset.href; go.download = btn.dataset.file; }
+      use.textContent = print ? "Prints on one A4 landscape sheet. Choose an image size instead to share it."
+        : `A ${btn.dataset.size} PNG, ready to post.`;
+    }
+    picks.forEach((b) => b.addEventListener("click", () => choose(b)));
+    go.addEventListener("click", (e) => {
+      if (document.querySelector('.poster-pick [aria-pressed="true"]')?.dataset.format !== "print") return;
+      e.preventDefault();
+      window.print();
+    });
+  </script>
+</Base>

src/styles/global.css +87 −0
@@ -762,4 +762,5 @@ table.cani tr[hidden] { display: none; }
 .map-sub { margin: 0 0 14px; }
 /* "Expand" beside the World map title opens the current view on a page of its own */
+.map-links { display: flex; gap: 22px; align-items: baseline; }
 .map-expand { font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-2); }
 .map-expand:hover { color: var(--text); text-decoration: underline; text-underline-offset: 5px; }
@@ -1950,2 +1951,88 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase
 .avg-tier { font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; padding-left: 8px; border-left: 3px solid var(--c); color: var(--text-2); }
 @media (max-width: 600px) { .avg-band { font-size: 9.5px; } }
+
+/* ══ Map poster (/map/poster/) ═════════════════════════════════════════════════
+   One sheet in paper colours in both themes, as it prints; the bar above it is for the screen. */
+.poster-page { max-width: 1240px; margin: 0 auto; padding: 24px clamp(16px, 4vw, 40px) 64px; }
+.poster-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; }
+.poster-bar .back { width: 100%; }
+.poster-pick { display: flex; flex-wrap: wrap; border: 1px solid var(--border); }
+.poster-pick button { padding: 9px 14px; border: 0; border-left: 1px solid var(--border); background: none; color: var(--text-2); font: inherit; font-size: 14px; cursor: pointer; }
+.poster-pick button:first-child { border-left: 0; }
+.poster-pick button:hover { color: var(--text); }
+.poster-pick button[aria-pressed="true"] { background: var(--text); color: var(--bg); }
+.poster-pick small { margin-left: 6px; font: 400 11px var(--mono); opacity: .7; }
+.poster-go { min-width: 10em; text-align: center; justify-content: center; }
+.poster-use { margin: 10px 0 20px; font-size: 13px; color: var(--muted); }
+.poster-image { margin: 0; text-align: center; }
+.poster-image img { display: block; max-width: 100%; max-height: 78vh; width: auto; height: auto; margin: 0 auto; border: 1px solid var(--border); box-shadow: 0 18px 50px rgba(0, 0, 0, .12); }
+.poster-image figcaption { margin-top: 10px; font: 400 11.5px var(--mono); color: var(--muted); }
+.poster[hidden], .poster-image[hidden] { display: none; }
+.poster {
+  --ink: #11110f; --ink-2: #4b4a43; --rule: #d9d5c8;
+  display: flex; flex-direction: column; gap: clamp(14px, 2vw, 22px);
+  padding: clamp(18px, 3.4vw, 44px) clamp(18px, 3.6vw, 48px) clamp(16px, 2.6vw, 32px);
+  background: #f4f2eb; color: var(--ink); border: 1px solid var(--border);
+  box-shadow: 0 18px 50px rgba(0, 0, 0, .12);
+  -webkit-print-color-adjust: exact; print-color-adjust: exact;
+}
+.poster-eyebrow { display: flex; justify-content: space-between; gap: 16px; margin: 0; font: 400 clamp(10px, 1.1vw, 12.5px) var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--ink-2); }
+.poster-title { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-top: clamp(10px, 1.6vw, 18px); }
+.poster-title h1 { margin: 0; font: 400 clamp(40px, 6vw, 76px)/1 var(--display); letter-spacing: -.02em; }
+.poster-title p { margin: 10px 0 0; max-width: 56ch; font-size: clamp(14px, 1.4vw, 17px); color: var(--ink-2); }
+.poster-title .poster-avg { display: flex; flex-direction: column; align-items: flex-end; flex: none; margin: 0; }
+.poster-avg b { font: 400 clamp(34px, 5vw, 64px)/1 var(--mono); letter-spacing: -.05em; }
+.poster-avg span { margin-top: 6px; font: 400 11px var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--ink-2); }
+.poster-map { display: block; width: 100%; height: auto; }
+.poster-map path { stroke: #f4f2eb; stroke-width: .5; stroke-linejoin: round; }
+.poster-map circle { stroke: #f4f2eb; stroke-width: .8; }
+.poster-tiers { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px 16px; margin: 0; padding: 14px 0 0; list-style: none; border-top: 1px solid var(--ink); }
+.poster-tiers li { display: flex; gap: 9px; align-items: flex-start; }
+.poster-tiers i { flex: none; width: 14px; height: 14px; margin-top: 3px; }
+.poster-tiers b { display: block; font-size: 14.5px; font-weight: 600; line-height: 1.25; }
+.poster-tiers small { display: block; margin-top: 2px; font: 400 11.5px var(--mono); color: var(--ink-2); }
+.poster-lists { display: grid; grid-template-columns: 1fr 1fr; gap: 0 clamp(24px, 4vw, 56px); padding-top: 14px; border-top: 1px solid var(--rule); }
+.poster-lists h2 { margin: 0 0 6px; font: 400 11px var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--ink-2); }
+.poster-lists ol { margin: 0; padding: 0; list-style: none; }
+.poster-lists li { display: flex; align-items: baseline; gap: 10px; padding: 4px 0; }
+.poster-rank { width: 2.4em; font: 400 12px var(--mono); color: var(--ink-2); }
+.poster-name { flex: 1; min-width: 0; font: 400 clamp(17px, 1.8vw, 22px)/1.2 var(--display); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.poster-lists b { font: 500 15px var(--mono); border-bottom: 3px solid; }
+.poster-foot { display: flex; justify-content: space-between; gap: 16px; font: 400 11px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-2); }
+@media (max-width: 760px) {
+  .poster-pick, .poster-go { width: 100%; }
+  .poster-pick button { flex: 1 1 45%; border-top: 1px solid var(--border); }
+  .poster-tiers { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .poster-title { flex-direction: column; align-items: flex-start; }
+  .poster-title .poster-avg { align-items: flex-start; }
+  .poster-lists { grid-template-columns: 1fr; gap: 14px; }
+  .poster-foot { flex-direction: column; gap: 4px; }
+}
+@media print {
+  /* one A4 landscape sheet: the poster fills the page and keeps its paper colour to the edge */
+  @page { size: A4 landscape; margin: 0; }
+  html:has(.poster), body:has(.poster) { background: #f4f2eb !important; }
+  body:has(.poster) .topbar, body:has(.poster) .footer, .poster-bar, .poster-use, .poster-image { display: none !important; }
+  .poster[hidden] { display: flex !important; }
+  .poster-page { max-width: none; margin: 0; padding: 0; }
+  .poster { box-sizing: border-box; width: 297mm; height: 210mm; padding: 9mm 11mm 7mm; gap: 3mm; border: 0; box-shadow: none; overflow: hidden; }
+  .poster-eyebrow { font-size: 8pt; }
+  .poster-title { flex-direction: row; align-items: flex-end; margin-top: 2mm; }
+  .poster-title h1 { font-size: 36pt; }
+  .poster-title p { font-size: 10.5pt; margin-top: 1.5mm; }
+  .poster-title .poster-avg { align-items: flex-end; }
+  .poster-avg b { font-size: 30pt; }
+  .poster-avg span { font-size: 7pt; }
+  .poster-map { width: auto; height: 100mm; margin: 0 auto; }
+  .poster-tiers { grid-template-columns: repeat(6, minmax(0, 1fr)); padding-top: 2.5mm; }
+  .poster-tiers b { font-size: 9.5pt; }
+  .poster-tiers small { font-size: 7.5pt; }
+  .poster-lists { grid-template-columns: 1fr 1fr; gap: 0 12mm; padding-top: 2.5mm; }
+  .poster-lists h2 { font-size: 7pt; margin-bottom: 1mm; }
+  .poster-lists li { padding: .5mm 0; }
+  .poster-name { font-size: 12pt; }
+  .poster-rank, .poster-lists b { font-size: 8.5pt; }
+  .poster-foot { flex-direction: row; font-size: 7pt; margin-top: auto; }
+}
+.legend-poster { margin-left: auto; color: var(--text-2); text-decoration: underline; text-decoration-color: var(--muted); text-underline-offset: 3px; }
+.legend-poster:hover { color: var(--text); }

    ↑↓ to move, ↵ to open.