Cannabis Freedom Index

Changelog 9bd9dc2tree, with Claude Opus 5.5

Context pages: /context/ and one page per topic

Each context topic compared across every place (src/lib/contexts.js): - /context/ lists the 16 topics with their coverage (written, explained as not applicable, not researched) and, for a topic with a status, how the places divide. - /context/<topic>/ has the figures, a world map coloured by the topic's status or by coverage, with the home map's zoom buttons and hover cards, a status table, and a filterable table of every profile's summary linked to its section. - A profile's context titles link to their topic page; the pages are in the sitemap and the site search.

8 files of code, design or docs +412 −4 lines

Code, design and documents 8 files

src/components/CountryArticle.astro +2 −2
@@ -367,5 +367,5 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x)));
   {ctxList && (
     <section class="c-sec c-context" id="context">
-      <h2 class="d-h">Context <span>not part of the score</span></h2>
+      <h2 class="d-h">Context <span>not part of the score · <a href={`${base}context/`}>every country, by topic&nbsp;→</a></span></h2>
       {sub && <p class="d-hint">Sections written for {sub.name} come first in their place; the others hold for all of {c.name} and are marked so.</p>}
       {[ctxList.filter((x) => x && !x.items), ctxList.filter((x) => x && x.items)].map((list, g) => (list.length > 0 || (g === 0 && travel)) && (
@@ -373,5 +373,5 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x)));
           {list.map((x) => (
             <div class="segrow ctx-tile" data-seg={`ctx-${x.key}`} id={`seg-ctx-${x.key}`}>
-              <div class="segrow-top"><span>{CONTEXT_ICONS[x.key] && <Icon name={CONTEXT_ICONS[x.key]} class="ctx-icon" aria-hidden="true" />}{x.name}<SourceTip label="What this covers" inline>{x.help}</SourceTip></span></div>
+              <div class="segrow-top"><span>{CONTEXT_ICONS[x.key] && <Icon name={CONTEXT_ICONS[x.key]} class="ctx-icon" aria-hidden="true" />}{x.key === "products" ? x.name : <a class="ctx-topic" href={`${base}context/${x.key.replace(/_/g, "-")}/`} title={`${x.name} in every country`}>{x.name}</a>}<SourceTip label="What this covers" inline>{x.help}</SourceTip></span></div>
               {x.national && <p class="ctx ctx-general">For all of {c.name}; nothing written for {sub.name} yet.</p>}
               {x.key === "recreational" && (

src/lib/contexts.js +91 −0
@@ -0,0 +1,91 @@
+// The context topics as pages of their own (/context/ and /context/<key>/): for each topic, every
+// place with what its profile says, the status it carries where the topic has one, how far the
+// research has got, and a colour for the world map. Built from the country files through
+// countries.js (texts and statuses) and research.js (written, not applicable or missing).
+import { CONTEXT, CONTEXT_ICONS, places, REC_LABEL, REC_HELP, MED_LABEL, MED_HELP } from "./countries.js";
+import { STATUS_SECTIONS, PLANTING_STATUS } from "./segment-ids.js";
+import { research } from "./research.js";
+import { viewByMetric } from "./map-views.js";
+
+const researchOf = new Map(research.map((r) => [r.slug, r]));
+// a tone of the status chips (s-legal …) as a map colour, the same scale as the chips' dots
+const TONE = { legal: 5, yes: 5, partial: 4, decrim: 3, limited: 3, illegal: 0, no: 0, death: 0 };
+const toneTier = (tone) => (tone in TONE ? TONE[tone] : null);
+
+// The opening of a section's text, cut at a sentence end near `max` characters: the table's summary.
+const excerpt = (text, max = 230) => {
+  if (!text) return "";
+  const t = text.replace(/\s+/g, " ").trim();
+  if (t.length <= max) return t;
+  const cut = t.slice(0, max);
+  const end = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("; "));
+  return end > max * 0.45 ? cut.slice(0, end + 1) : `${cut.replace(/\s+\S*$/, "")}…`;
+};
+
+// How a topic's places are grouped and coloured. A topic with a status (clubs, driving, the hemp
+// limit…) is mapped by it; recreational and medical by the country's legal status; seeds and
+// clones by the seed status; the death penalty marks where the law provides for it; the rest
+// show how far the research has got.
+function scheme(key) {
+  if (STATUS_SECTIONS[key]) {
+    const sec = STATUS_SECTIONS[key];
+    return {
+      kind: "status", title: sec.name,
+      values: Object.entries(sec.statuses).map(([value, s]) => ({ value, label: s.label, help: s.help, tone: s.tone, tier: sec.tiers[value] ?? null })),
+      of: (c) => c[key] ?? null,
+    };
+  }
+  if (key === "recreational") return {
+    kind: "status", title: "Recreational status",
+    values: Object.entries(REC_LABEL).map(([value, label]) => ({ value, label, help: REC_HELP[value], tone: value, tier: toneTier(value) })),
+    of: (c) => c.rec,
+  };
+  if (key === "medical") return {
+    kind: "status", title: "Medical status",
+    values: Object.entries(MED_LABEL).map(([value, label]) => ({ value, label, help: MED_HELP[value], tone: value, tier: toneTier(value) })),
+    of: (c) => c.med,
+  };
+  if (key === "planting") return {
+    kind: "status", title: "Seeds",
+    values: Object.entries(PLANTING_STATUS).map(([value, s]) => ({ value, label: s.label, help: s.help[0], tone: s.tone, tier: toneTier(s.tone) })),
+    of: (c) => c.planting?.seeds ?? null,
+  };
+  return { kind: "coverage" };
+}
+
+// the interactive map view for a topic, where the home page's map can show it
+const MAP_METRIC = { recreational: "status:rec", medical: "status:med", planting: "plant:seeds", culture: "culture" };
+const mapView = (key) => viewByMetric[MAP_METRIC[key] ?? `status:${key}`] ?? null;
+
+export const contextTopics = CONTEXT.filter(([key]) => key !== "products").map(([key, name, help]) => {
+  const sc = scheme(key);
+  const rows = places.map((c) => {
+    const block = c.details.context.find((x) => x?.key === key) ?? null;
+    const section = researchOf.get(c.slug)?.contextCoverage.sections.find((s) => s.key === key);
+    const state = section?.state === "filled" || section?.state === "not_applicable" ? section.state : block ? "filled" : "missing";
+    const value = sc.kind === "status" ? sc.of(c) : null;
+    const status = value != null ? sc.values.find((v) => v.value === value) ?? null : null;
+    const text = block ? (block.items ? block.items.map((it) => it.title).join(" · ") : block.t) : "";
+    return { c, state, status, summary: excerpt(text), reason: state === "not_applicable" ? section.reason : "" };
+  }).sort((a, b) => a.c.name.localeCompare(b.c.name));
+  const count = (state) => rows.filter((r) => r.state === state).length;
+  const statuses = sc.kind === "status"
+    ? sc.values.map((v) => ({ ...v, rows: rows.filter((r) => r.status?.value === v.value) })).filter((v) => v.rows.length)
+    : [];
+  return {
+    key, name, help, icon: CONTEXT_ICONS[key], slug: key.replace(/_/g, "-"),
+    scheme: sc, statuses, rows,
+    filled: count("filled"), notApplicable: count("not_applicable"), missing: count("missing"), total: rows.length,
+    unknown: sc.kind === "status" ? rows.filter((r) => !r.status).length : 0,
+    mapView: mapView(key),
+  };
+});
+
+// The map colour of a place on a topic's map, as a CSS colour: a status tier on the --t scale, the
+// accent blue where the topic is written, grey where it is explained as not applicable.
+export const mapFill = (topic, row) => {
+  if (!row) return "var(--map-none)";
+  if (topic.key === "death_penalty") return row.state === "filled" ? "var(--t0)" : row.state === "not_applicable" ? "var(--map-na)" : "var(--map-none)";
+  if (topic.scheme.kind === "status") return row.status?.tier != null ? `var(--t${row.status.tier})` : "var(--map-none)";
+  return row.state === "filled" ? "var(--u3)" : row.state === "not_applicable" ? "var(--map-na)" : "var(--map-none)";
+};

src/lib/search-icons.js +1 −1
@@ -13,5 +13,5 @@ export const PAGE_ICONS = {
   "did-you-know/": "iconoir:light-bulb", "survey/": "iconoir:graph-up", "survey/take/": "iconoir:edit-pencil",
   "culture/": "iconoir:palette", "culture/people/": "iconoir:community", "culture/stories/": "iconoir:book", "sources/": "iconoir:bookmark-book", "research/": "iconoir:percentage-circle",
-  "glossary/": "iconoir:book-stack", "charts/": "iconoir:stats-report", "map/": "iconoir:map", "map/poster/": "iconoir:media-image",
+  "glossary/": "iconoir:book-stack", "context/": "iconoir:list", "charts/": "iconoir:stats-report", "map/": "iconoir:map", "map/poster/": "iconoir:media-image",
 };
 // pages that come in families: a part of the cultural atlas, a map view, a history period, a history tag, an exhibition

src/pages/context/[topic].astro +193 −0
@@ -0,0 +1,193 @@
+---
+// One context topic across every place, e.g. /context/clubs/: what it covers, a world map coloured
+// by its status (or by how far the research has got), the figures, and every profile's summary
+// with a link to its section. The data is in src/lib/contexts.js.
+import Base from "../../layouts/Base.astro";
+import { Icon } from "astro-icon/components";
+import { contextTopics, mapFill } from "../../lib/contexts.js";
+import { worldMap, MAP_RATIO } from "../../lib/world-map.js";
+import { tierColor } from "../../lib/tiers.js";
+
+export function getStaticPaths() {
+  return contextTopics.map((topic) => ({ params: { topic: topic.slug }, props: { topic } }));
+}
+
+const { topic } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const W = 1000, H = Math.round(W / MAP_RATIO);
+const map = worldMap(W, H, 0);
+const rowOf = new Map(topic.rows.map((r) => [r.c.id, r]));
+const pct = (n) => Math.round((n / topic.total) * 100);
+const isStatus = topic.scheme.kind === "status";
+const isDeath = topic.key === "death_penalty";
+// what a country's entry on the map says on hover
+const tipOf = (row) => !row ? "" : `${row.c.name}: ${isStatus ? row.status?.label ?? "not researched" : row.state === "filled" ? "written" : row.state === "not_applicable" ? "not applicable" : "not researched yet"}`;
+// the hover box's line: the section's first sentence, or its opening if that runs long
+const firstLine = (t) => { if (!t) return ""; const m = t.match(/^.{20,170}?[.;](?=\s|$)/); return m ? m[0] : t.length > 150 ? `${t.slice(0, 150).replace(/\s+\S*$/, "")}…` : t; };
+const sectionHref = (c) => `${base}country/${c.slug}/#seg-ctx-${topic.key}`;
+// the map's key
+const legend = isDeath
+  ? [["var(--t0)", "Provided for by law"], ["var(--map-na)", "Explained as not applicable"], ["var(--map-none)", "Not researched yet"]]
+  : isStatus
+    ? [...topic.scheme.values.filter((v) => v.tier != null).map((v) => [`var(--t${v.tier})`, v.label]), ["var(--map-none)", "Not researched yet"]]
+    : [["var(--u3)", "Written"], ["var(--map-na)", "Explained as not applicable"], ["var(--map-none)", "Not researched yet"]];
+const figures = [
+  [topic.filled, "profiles with this section"],
+  [topic.notApplicable, "explained as not applicable"],
+  [topic.missing, "not researched yet"],
+  [`${pct(topic.filled + topic.notApplicable)}%`, `of ${topic.total} places covered`],
+];
+const others = contextTopics.filter((t) => t.key !== topic.key);
+const description = `${topic.name} in every country: ${topic.help.charAt(0).toLowerCase()}${topic.help.slice(1)}. A world map, the figures and each country's summary.`;
+---
+<Base title={`${topic.name}: country by country`} description={description}>
+  <main class="changes-page ctxp">
+    <section class="hero hero-sm">
+      <p class="eyebrow"><a href={`${base}context/`}>Context</a> <span>not part of the score</span></p>
+      <h1><Icon name={topic.icon} class="ctxp-icon" aria-hidden="true" /> {topic.name}</h1>
+      <p class="lede">{topic.help}.</p>
+    </section>
+
+    <dl class="hist-stats ctxp-stats">
+      {figures.map(([n, label]) => <div><dt>{label}</dt><dd>{n}</dd></div>)}
+    </dl>
+
+    <section class="section ctxp-map-sec" aria-labelledby="ctxpMap">
+      <div class="section-head"><div>
+        <h2 id="ctxpMap">{isStatus ? topic.scheme.title : isDeath ? "Where the law provides for it" : "Where it is written"}, on the map</h2>
+        <p class="sub">Select a country for its section.{topic.mapView && <> <a href={`${base}map/${topic.mapView.slug}/`}>The interactive map&nbsp;→</a></>}</p>
+      </div></div>
+      {/* the same hover box and zoom buttons as the home page's map (src/lib/unit-map-tip.js, d3-zoom) */}
+      <figure class="unit-map ctxp-map" data-ctxp-map>
+        <svg viewBox={`0 0 ${W} ${H}`} role="group" aria-label={`World map: ${topic.name}`}>
+          <g class="ctxp-zoom">
+            {map.shapes.map((s) => {
+              const row = s.c ? rowOf.get(s.c.id) : null;
+              return row
+                ? <a href={sectionHref(row.c)} data-tip={row.c.id} aria-label={tipOf(row)}><path d={s.d} style={`fill:${mapFill(topic, row)}`} /></a>
+                : <path d={s.d} class="ctxp-blank" />;
+            })}
+            {map.dots.map((d) => {
+              const row = rowOf.get(d.c.id);
+              return row && <a href={sectionHref(row.c)} data-tip={row.c.id} aria-hidden="true" tabindex="-1"><circle cx={d.x} cy={d.y} r={map.r * 1.6} style={`fill:${mapFill(topic, row)}`} /></a>;
+            })}
+          </g>
+        </svg>
+        <div class="map-zoom">
+          <button type="button" class="icon-btn" data-zoom="in" aria-label="Zoom in">+</button>
+          <button type="button" class="icon-btn" data-zoom="out" aria-label="Zoom out">−</button>
+          <button type="button" class="icon-btn" data-zoom="reset" aria-label="Reset zoom">⟲</button>
+        </div>
+        <div class="tooltip" hidden></div>
+        {topic.rows.map((r) => (
+          <template data-tip-for={r.c.id}>
+            <div class="tip-head"><strong><span class="tip-flag">{r.c.flag}</span>{r.c.name}</strong><span class="tip-chip" style={`--chip:${tierColor(r.c.raw)}`}>{r.c.score}</span></div>
+            <div class="tip-meta">{isStatus ? (r.status ? <span class={`chip s-${r.status.tone}`}>{r.status.label}</span> : "No status yet") : r.state === "filled" ? "Section written" : r.state === "not_applicable" ? "Explained as not applicable" : "Not researched yet"}</div>
+            {r.state !== "missing" && <p class="tip-note">{firstLine(r.state === "filled" ? r.summary : r.reason)}</p>}
+            <div class="tip-foot">Click for the section</div>
+          </template>
+        ))}
+        <figcaption class="ctxp-legend">
+          {legend.map(([color, label]) => <span><i style={`background:${color}`}></i>{label}</span>)}
+        </figcaption>
+      </figure>
+    </section>
+
+    {isStatus && (
+      <section class="section" aria-labelledby="ctxpStatuses">
+        <div class="section-head"><div>
+          <h2 id="ctxpStatuses">{topic.scheme.title}: how the {topic.total - topic.unknown} researched places divide</h2>
+          {topic.unknown > 0 && <p class="sub">{topic.unknown} places have no status yet.</p>}
+        </div></div>
+        <table class="src-table ctxp-status">
+          <thead><tr><th>Status</th><th>What it means</th><th class="num">Places</th><th class="ctxp-bar-col" aria-hidden="true"></th></tr></thead>
+          <tbody>
+            {topic.statuses.map((v) => (
+              <tr>
+                <td><span class={`chip s-${v.tone}`}>{v.label}</span></td>
+                <td>{v.help}</td>
+                <td class="num">{v.rows.length}</td>
+                <td class="ctxp-bar-col"><i class="ctxp-bar" style={`--w:${(v.rows.length / Math.max(...topic.statuses.map((x) => x.rows.length))) * 100}%;--c:${v.tier != null ? `var(--t${v.tier})` : "var(--muted)"}`}></i></td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </section>
+    )}
+
+    <section class="section" aria-labelledby="ctxpTable" data-ctxp-table>
+      <div class="section-head"><div>
+        <h2 id="ctxpTable">Country by country</h2>
+        <p class="sub">The opening of each profile's section; the country's page has it in full, with sources.</p>
+      </div></div>
+      <div class="ctxp-filter">
+        <input type="search" placeholder="Filter by country or word" aria-label="Filter the table" data-ctxp-q />
+        <label><input type="checkbox" data-ctxp-written checked /> Only places with a section</label>
+        <span class="ctxp-count" aria-live="polite" data-ctxp-count></span>
+      </div>
+      <table class="src-table ctxp-table">
+        <thead><tr><th>Country</th>{isStatus && <th>{topic.scheme.title}</th>}<th>What the profile says</th></tr></thead>
+        <tbody>
+          {topic.rows.map((r) => (
+            <tr data-state={r.state} data-region={r.c.region} hidden={r.state !== "filled"}>
+              <td class="ctxp-country"><a href={sectionHref(r.c)}><span aria-hidden="true">{r.c.flag}</span> {r.c.name}</a><small>{r.c.region}</small></td>
+              {isStatus && <td>{r.status ? <span class={`chip s-${r.status.tone}`}>{r.status.label}</span> : <span class="ctxp-none">—</span>}</td>}
+              <td class="ctxp-summary">{r.state === "filled" ? r.summary : r.state === "not_applicable" ? <span class="ctxp-na">Not applicable: {r.reason}</span> : <span class="ctxp-none">Not researched yet</span>}</td>
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </section>
+
+    <nav class="section ctxp-others" aria-label="Other context topics">
+      <div class="section-head"><div><h2>Other topics</h2></div></div>
+      <ul>
+        {others.map((t) => <li><a href={`${base}context/${t.slug}/`}><Icon name={t.icon} class="ctxp-icon" aria-hidden="true" /> {t.name}</a></li>)}
+      </ul>
+    </nav>
+  </main>
+</Base>
+
+<script>
+  import "../../lib/unit-map-tip.js";
+  import { select } from "d3-selection";
+  import { zoom, zoomIdentity } from "d3-zoom";
+  import "d3-transition";
+
+  // zoom and pan, by wheel, drag or the buttons, as on the home page's map
+  for (const figure of document.querySelectorAll("[data-ctxp-map]")) {
+    const svg = select(figure.querySelector("svg"));
… 35 more lines
src/pages/context/index.astro +45 −0
@@ -0,0 +1,45 @@
+---
+// Context: the topics every profile covers beyond its score, each with its own page across all
+// countries (/context/<topic>/). For each: what it covers, how far the research has got, and for a
+// topic with a status, how the places divide. The data is in src/lib/contexts.js.
+import Base from "../../layouts/Base.astro";
+import { Icon } from "astro-icon/components";
+import { contextTopics } from "../../lib/contexts.js";
+
+const base = import.meta.env.BASE_URL;
+const total = contextTopics[0].total;
+const written = contextTopics.reduce((n, t) => n + t.filled, 0);
+---
+<Base title="Context" description="Beyond the score: medical cannabis, clubs, seeds, hemp, the black market, driving, visitors, arrests and more, each compared across every country with a map and each profile's summary.">
+  <main class="changes-page ctxp">
+    <section class="hero hero-sm">
+      <p class="eyebrow">Context <span>{contextTopics.length} topics · {total} places · {written.toLocaleString("en-GB")} sections written</span></p>
+      <h1>Beyond the score</h1>
+      <p class="lede">The index scores six things the law does to people who use cannabis. Each profile also explains what the score does not: how patients get medicine, whether clubs and seeds are allowed, what the black market looks like, what happens to a driver or a visitor. Here each topic is compared across every country, with a map.</p>
+    </section>
+
+    <section class="section">
+      <ol class="ctxp-topics">
+        {contextTopics.map((t) => (
+          <li>
+            <a href={`${base}context/${t.slug}/`}>
+              <span class="ctxp-topic-name"><Icon name={t.icon} class="ctxp-icon" aria-hidden="true" /> {t.name}</span>
+              <span class="ctxp-topic-help">{t.help}</span>
+              <span class="ctxp-cover" role="img" aria-label={`${t.filled} written, ${t.notApplicable} not applicable, ${t.missing} not researched, of ${t.total}`}>
+                <i style={`width:${(t.filled / t.total) * 100}%;background:var(--u3)`}></i>
+                <i style={`width:${(t.notApplicable / t.total) * 100}%;background:var(--map-na)`}></i>
+              </span>
+              <span class="ctxp-topic-n">{t.filled} written{t.notApplicable > 0 && ` · ${t.notApplicable} n/a`}</span>
+              {t.statuses.length > 0 && (
+                <span class="ctxp-split" role="img" aria-label={t.statuses.map((v) => `${v.label}: ${v.rows.length}`).join(", ")}>
+                  {t.statuses.map((v) => <i style={`flex:${v.rows.length};background:${v.tier != null ? `var(--t${v.tier})` : "var(--muted)"}`} title={`${v.label}: ${v.rows.length}`}></i>)}
+                </span>
+              )}
+            </a>
+          </li>
+        ))}
+      </ol>
+      <p class="ctxp-key"><span><i style="background:var(--u3)"></i>Written</span><span><i style="background:var(--map-na)"></i>Explained as not applicable</span><span><i style="background:var(--map-none)"></i>Not researched yet</span><span><i class="ctxp-key-split"></i>Status, where the topic has one</span></p>
+    </section>
+  </main>
+</Base>

src/pages/search.json.js +4 −1
@@ -6,4 +6,5 @@
 //   n: what is shown and matched   s: the smaller second line, matched too   u: address under the site root
 //   g: words matched but not shown (a history entry's tags)
+import { contextTopics } from "../lib/contexts.js";
 import { getCollection } from "astro:content";
 import { termAnchor } from "../lib/terms.js";
@@ -25,6 +26,8 @@ const rows = [
     ["History", "Laws, rulings and treaties", "history/"], ["Can I?", "Plain answers per country", "can-i/"], ["Compare", "Countries side by side", "compare/"],
     ["Download the data", "Scores, history and yearly panel", "data/"], ["Score changes", "Every score change", "score-changes/"], ["Changelog", "Every change to the site, data and text", "changelog/"],
-    ["Articles", "The longer reads on single events", "articles/"], ["About", "What the project is, its aims and commitments", "about/"], ["Did you know?", "Curious cases from the index", "did-you-know/"], ["State of Cannabis", "The yearly survey and its results", "survey/"], ["Take the survey", "Anonymous, about ten minutes", "survey/take/"], ["Culture", "A cultural atlas of cannabis", "culture/"], ["Culture: People", "Officials, preachers, scientists and presidents", "culture/people/"], ["Culture: Stories", "The people behind the laws", "culture/stories/"], ["Sources", "Every source the index cites", "sources/"], ["Research progress", "How much of each country is researched, and what is missing", "research/"], ["Glossary", "The words of cannabis law, explained", "glossary/"], ["Charts", "The history and the scores in charts", "charts/"], ["World map", "Every country coloured by its score, full screen", "map/"], ["Map poster", "The world map as a printable poster", "map/poster/"],
+    ["Articles", "The longer reads on single events", "articles/"], ["About", "What the project is, its aims and commitments", "about/"], ["Did you know?", "Curious cases from the index", "did-you-know/"], ["State of Cannabis", "The yearly survey and its results", "survey/"], ["Take the survey", "Anonymous, about ten minutes", "survey/take/"], ["Culture", "A cultural atlas of cannabis", "culture/"], ["Culture: People", "Officials, preachers, scientists and presidents", "culture/people/"], ["Culture: Stories", "The people behind the laws", "culture/stories/"], ["Sources", "Every source the index cites", "sources/"], ["Research progress", "How much of each country is researched, and what is missing", "research/"], ["Glossary", "The words of cannabis law, explained", "glossary/"], ["Charts", "The history and the scores in charts", "charts/"], ["World map", "Every country coloured by its score, full screen", "map/"], ["Map poster", "The world map as a printable poster", "map/poster/"], ["Context", "Beyond the score: each topic compared across countries", "context/"],
   ].map(([n, s, u]) => ({ t: "p", n, s, u, ic: PAGE_ICONS[u] })),
+  // the context topics, each compared across every country
+  ...contextTopics.map((t) => ({ t: "p", n: `Context: ${t.name}`, s: t.help, u: `context/${t.slug}/`, ic: PAGE_ICONS["context/"] })),
   // the exhibitions: one row each, its rooms' names matched but not listed
   { t: "p", n: "Exhibitions", s: "One episode at a time, in rooms of its own", u: "exhibitions/", ic: EXHIBITION_ICON },

src/pages/sitemap.xml.js +3 −0
@@ -15,4 +15,5 @@ import { SITE } from "../lib/exports.js";
 import { MAP_VIEWS } from "../lib/map-views.js";
 import { CULTURE_SECTIONS, cultureEntries } from "../lib/culture.js";
+import { contextTopics } from "../lib/contexts.js";
 import { commits, pageCount, pageOf } from "../lib/gitlog.js";
 import { lastChanged } from "../lib/lastmod.js";
@@ -34,4 +35,6 @@ export function GET({ site }) {
     ["map/poster/", latest],
     ...MAP_VIEWS.map((v) => [v.slug ? `map/${v.slug}/` : "map/", latest]),
+    ["context/", latest],
+    ...contextTopics.map((t) => [`context/${t.slug}/`, latest]),
     // profiles
     ...places.map((c) => [`country/${c.slug}/`, c.details.reviewed]),

src/styles/global.css +73 −0
@@ -3437,2 +3437,75 @@ a.ch-db-track:hover { background: color-mix(in srgb, var(--text) 6%, transparent
   #work-for-a-gram .ch-bars li a { grid-template-columns: 100px minmax(0, 1fr) 115px; }
 }
+
+/* ══ Context topics (/context/, /context/<topic>/, src/lib/contexts.js) ═══════════════════════
+   Every topic a profile explains beyond its score, compared across countries: a ruled list of
+   topics with their coverage, and per topic the figures, a world map, a status table and a
+   filterable table of every profile's summary. */
+.ctxp { --map-none: color-mix(in srgb, var(--text) 9%, var(--bg)); --map-na: color-mix(in srgb, var(--muted) 55%, var(--bg)); }
+.ctxp .eyebrow a { color: inherit; text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; }
+.ctxp-icon { width: .8em; height: .8em; vertical-align: -.05em; color: var(--muted); }
+.ctxp h1 .ctxp-icon { width: .7em; height: .7em; margin-right: 4px; }
+.ctxp-stats { margin-top: 22px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
+@media (max-width: 700px) { .ctxp-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+/* the list of topics */
+.ctxp-topics { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--text); }
+.ctxp-topics a { display: grid; grid-template-columns: minmax(0, 15rem) minmax(0, 1fr) minmax(8rem, 14rem); gap: 6px 28px; align-items: baseline; padding: 16px 0; border-bottom: 1px solid var(--border); color: inherit; }
+.ctxp-topic-name { grid-row: span 2; font: 400 22px/1.15 var(--display); }
+.ctxp-topic-help { grid-row: span 2; color: var(--text-2); font-size: 14px; line-height: 1.45; }
+.ctxp-topics a:hover .ctxp-topic-name { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; }
+.ctxp-cover, .ctxp-split { display: flex; height: 6px; overflow: hidden; border-radius: 3px; background: var(--map-none); align-self: center; }
+.ctxp-cover i, .ctxp-split i { display: block; height: 100%; }
+.ctxp-split i + i { box-shadow: inset 1px 0 0 var(--bg); }
+.ctxp-topic-n { grid-column: 3; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
+.ctxp-topics a > .ctxp-split { grid-column: 3; margin-top: 4px; }
+@media (max-width: 760px) {
+  .ctxp-topics a { grid-template-columns: minmax(0, 1fr); }
+  .ctxp-topic-name, .ctxp-topic-help { grid-row: auto; }
+  .ctxp-topic-n, .ctxp-topics a > .ctxp-split { grid-column: 1; }
+}
+.ctxp-key, .ctxp-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 14px 0 0; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
+.ctxp-key span, .ctxp-legend span { display: inline-flex; align-items: center; gap: 6px; }
+.ctxp-key i, .ctxp-legend i { display: inline-block; width: 12px; height: 12px; border-radius: 2px; }
+.ctxp-key .ctxp-key-split { width: 24px; height: 6px; background: linear-gradient(90deg, var(--t5) 0 33%, var(--t3) 33% 66%, var(--t0) 66%); }
+/* the map */
+.ctxp-map { margin: 0; }
+.ctxp-map { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r); }
+.ctxp-map svg { display: block; width: 100%; height: auto; max-height: none; cursor: grab; }
+.ctxp-map svg:active { cursor: grabbing; }
+.ctxp-map .ctxp-legend { padding: 0 14px 12px; }
+.ctxp-map path, .ctxp-map circle { stroke: var(--bg); stroke-width: .6; stroke-linejoin: round; vector-effect: non-scaling-stroke; transition: opacity .15s; }
+.ctxp-map a:hover path, .ctxp-map a:hover circle { opacity: .7; }
+.ctxp-map .ctxp-blank { fill: var(--map-none); }
+.ctxp-map-sec .sub a, .ctxp .sub a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; }
+/* the tables */
+.ctxp-status td { vertical-align: top; }
+.ctxp-bar-col { width: 22%; }
+.src-table td.ctxp-bar-col, .src-table th.ctxp-bar-col { padding-left: 18px; }
+.ctxp-bar { display: block; height: 6px; width: var(--w); min-width: 2px; margin-top: 8px; border-radius: 3px; background: var(--c); }
+.ctxp-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; margin: 0 0 14px; }
+.ctxp-filter input[type="search"] { flex: 1 1 260px; max-width: 420px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); color: var(--text); font: inherit; font-size: 14px; }
+.ctxp-filter label { display: inline-flex; align-items: center; gap: 6px; font-size: 13.5px; color: var(--text-2); }
+.ctxp-count { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
+.ctxp-table td { vertical-align: top; }
+.ctxp-country { white-space: nowrap; }
+.ctxp-country a { color: var(--text); }
+.ctxp-country a:hover { text-decoration: underline; text-underline-offset: 3px; }
+.ctxp-country small { display: block; margin-top: 2px; font: 400 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
+.ctxp-summary { color: var(--text-2); font-size: 14px; line-height: 1.45; }
+.ctxp-na, .ctxp-none { color: var(--muted); font-size: 13px; }
+/* on a phone each row stacks: the country and its status on one line, the summary under them */
+@media (max-width: 640px) {
+  .ctxp-table thead { display: none; }
+  .ctxp-table tr:not([hidden]) { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 12px 0; border-bottom: 1px solid var(--border); }
+  .ctxp-table td { padding: 0; border: 0; }
+  .ctxp-table .ctxp-country { white-space: normal; }
+  .ctxp-table .ctxp-summary { grid-column: 1 / -1; }
+  .ctxp-status th:nth-child(2), .ctxp-status td:nth-child(2) { display: none; }
+}
+/* other topics */
+.ctxp-others ul { display: flex; flex-wrap: wrap; gap: 8px 22px; margin: 0; padding: 0; list-style: none; }
+.ctxp-others a { color: var(--text-2); font-size: 14px; }
+.ctxp-others a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
+/* a context section's title on a profile leads to the topic across every country */
+a.ctx-topic { color: inherit; }
+a.ctx-topic:hover { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; }

    ↑↓ to move, ↵ to open.