Cannabis Freedom Index

Changelog d662ed6

more culture

6 atlas entries written20 atlas entries edited15 files of code, design or docs +715 −40 lines

Articles and atlas entries 26

420 culture/language/420.md

featured

5

Bhang culture/language/bhang.md

featured

3

Cannabis culture/language/cannabis.md

featured

6

Charas culture/language/charas.md

featured

7

Dagga culture/language/dagga.md

featured

9

Ganja culture/language/ganja.md

featured

1

Hashish culture/language/hashish.md

featured

2

Kif culture/language/kif.md

featured

8

Maconha culture/language/maconha.md

featured

10

Marijuana culture/language/marijuana.md

featured

4

Bhang at Holi culture/traditions/bhang-at-holi.md

text

At Holi, the spring festival of colours, much of north India drinks cannabis. The leaf is … n_Amritsar_artist_depicting_the_preparation_and_consumption_of_Indian_hemp_(bhang).jpg") ![Balls of green bhang paste in a tray beside a bag of dried leaves](../../src/assets/culture/bhang-4381292529.jpg "Bhang paste rolled into balls, ready to be mixed into lassi. | Reuben Strayer, CC BY-SA 2.0, via Wikimedia Commons | https://commons.wikimedia.org/wiki/File:Bhang_(4381292529).jpg") ## Not only Hindus, not only Holi The same drink belongs to other calendars. The Nihang S … has received complaints of misuse of prohibition act against those found drinking bhang."

image

src ../../src/assets/culture/bhang-4381292529and-thandai.jpg · alt Balls of green bhang paste besideA man pours thandai from a bagmetal pot into a row of dried leavesglasses while customers hold out banknotes · caption Bhang paste rolled into ballsThandai poured at a stall during Holi, ready to be mixed into lassiwith customers crowding the counter. Bhang is added for those who ask. · credit Reuben StrayerKaushalspeed, CC BY-SA 24.0, via Wikimedia Commons · credit url https://commons.wikimedia.org/wiki/File:Bhang_(4381292529)Bhang-and-Thandai.jpg

featured

1

Cannabis social clubs culture/traditions/cannabis-social-clubs.md

featured

4

image

src ../../src/assets/culture/dr-dou-social-club.webp · alt The lounge of a cannabis social club, with leather armchairs, exposed brick, coloured lanterns and a bar · caption The lounge of Dr. Dou, a cannabis social club in Barcelona. · credit Photo published by Cannabis BCN · credit url https://cannabisbcn.com/

Code, design and documents 15 files

docs/CULTURE.md +21 −0
@@ -91,4 +91,23 @@ the law so often failed to tell the two apart.
 Not for Traditions: a single event, a company, a piece of legislation as such.
 
+## Which entries are featured
+
+Each part has a short ranked list of its most important entries, set with `featured: 1`, `2`, …
+in the front matter. They come first on the part's page in that order, and the first five are
+shown on the Culture page, the first of them as a large card. Everything else follows
+alphabetically.
+
+An entry deserves a place by importance to a reader who knows nothing yet, in roughly this order:
+
+1. **Reach.** Words and customs known across many countries (*ganja*, *hashish*, *marijuana*),
+   or practised by large numbers of people (bhang at Holi, the Rastafari sacrament).
+2. **Weight in law.** Entries that a law was written around or because of: an exemption, a
+   named statute, a court ruling (bhang and the 1961 treaty, the coffeeshop).
+3. **Explanatory power.** Entries that make other entries make sense (*cannabis*, the Scythian
+   vapour bath as the oldest account).
+
+Keep each list to about ten. Number 1 must have a picture. Change the ranks when a better entry
+is written; do not add ties.
+
 ## The shape of an entry
 
@@ -109,4 +128,5 @@ image:                             # the lead picture, optional
   credit: Author, licence, via Wikimedia Commons
   credit_url: https://commons.wikimedia.org/wiki/File:Example.jpg
+featured: 3                        # optional: rank among the part's most important entries
 model: opus-5.5
 written: 2026-10-14
@@ -173,4 +193,5 @@ The site turns that into a figure with the caption and credit underneath. Rules:
   `[ganja](/culture/language/ganja/)`, `[Jamaica](/country/jm/)`. Culture entries should link
   to one another wherever one explains a word the other uses.
+- Links to countries, history entries and culture entries get a hover preview on the site (data in `/previews/*.json`), so a reader can check what is behind a link without leaving the page. Link the precise history entry, not the history index.
 - A link is not a source. What the text states must still rest on the `sources` list.
 - Only link a page that was opened and exists.

source-archive.json +30 −18

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

src/assets/culture/bhang-and-thandai.jpg +0 −0

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

src/assets/culture/dr-dou-social-club.webp +0 −0

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

src/components/CountryArticle.astro +11 −3
@@ -11,4 +11,5 @@ import { SHOW_PRICES } from "../lib/config.js";
 import { PLANTING_STATUS, STATUS_SECTIONS, PLANTING_TRADE } from "../lib/segment-ids.js";
 import { answersFor } from "../lib/answers.js";
+import SegmentScale from "./SegmentScale.astro";
 import { Image } from "astro:assets";
 import { cultureOf, cultureSection } from "../lib/culture.js";
@@ -44,4 +45,7 @@ const travel = sub ? null : [
 ].filter(Boolean);
 const answers = answersFor(v.s);
+// The methodology lives on the home page: its section, or the description of one segment there.
+const method = (segId) => `${base}#${segId ? `method-${segId}` : "methodology"}`;
+
 // The right-hand side is split into tabs so that the history is one click away instead of at
 // the bottom of a long page: [id, label, count]. A single state has only its score breakdown.
@@ -175,6 +179,6 @@ const culture = sub ? [] : cultureOf(c);
     <h2 class="d-h">Score breakdown <span>▏world average <button type="button" class="seg-toggle" data-seg-toggle aria-expanded="false">Open all</button></span></h2>
     {sub
-      ? <p class="d-hint">{group.intro} Select a segment to show or hide the rules.</p>
-      : <p class="d-hint">Select a segment to show or hide the exact rules and sources.</p>}
+      ? <p class="d-hint">{group.intro} Select a segment to show or hide the rules. <a href={method()}>How the scores are made →</a></p>
+      : <p class="d-hint">Select a segment to show or hide the exact rules and sources. <a href={method()}>How the scores are made →</a></p>}
     {sub && SEGMENTS.map((s, i) => (
       <details class="segrow" data-seg={s.id}>
@@ -190,5 +194,8 @@ const culture = sub ? [] : cultureOf(c);
           <div class="pts">adds {segmentPoints(i, sub.s[i]).toFixed(1)} of {segmentMax(i).toFixed(0)} points</div>
         </summary>
-        {sub.seg && <div class="why"><p>{sub.seg[i]}</p></div>}
+        <div class="why">
+          {sub.seg && <p>{sub.seg[i]}</p>}
+          <SegmentScale segment={s} score={sub.s[i]} />
+        </div>
       </details>
     ))}
@@ -222,4 +229,5 @@ const culture = sub ? [] : cultureOf(c);
                 {seg.src.map((source) => <SourceCitation source={source} />)}
               </ul>
+              <SegmentScale segment={s} score={c.s[i]} />
             </div>
           )}

src/components/CultureCards.astro +10 −7
@@ -1,19 +1,22 @@
 ---
 // Entries of the cultural atlas as cards: the picture when there is one, the title, a line of
-// explanation and the countries it belongs to.
+// explanation and the countries it belongs to. With `lead`, the first card is drawn large: the
+// entries come most important first (see `featured` in docs/CULTURE.md).
 import { Image } from "astro:assets";
 import { cultureSection } from "../lib/culture.js";
 
-const { entries, label = false } = Astro.props; // label: say which section each card is from
+const { entries, label = false, lead = false, id } = Astro.props; // label: say which section each card is from
 const base = import.meta.env.BASE_URL;
 ---
-<ol class="cu-cards">
-  {entries.map((e) => (
-    <li>
+<ol class:list={["cu-cards", { "cu-lead": lead }]} {...(id && { id })}>
+  {entries.map((e, i) => (
+    <li class={lead && i === 0 ? "cu-first-card" : undefined} data-rank={i} data-name={e.title.toLowerCase()}>
       <a href={`${base}${e.url}`}>
         {e.image
-          ? <Image src={e.image.src} alt={e.image.alt} width={600} height={400} fit="cover" widths={[300, 600]} sizes="(max-width: 600px) 100vw, 300px" />
+          ? (lead && i === 0
+            ? <Image src={e.image.src} alt={e.image.alt} width={1200} height={760} fit="cover" widths={[600, 900, 1200]} sizes="(max-width: 900px) 100vw, 600px" loading="eager" />
+            : <Image src={e.image.src} alt={e.image.alt} width={600} height={400} fit="cover" widths={[300, 600]} sizes="(max-width: 600px) 100vw, 300px" />)
           : <span class="cu-word" aria-hidden="true">{e.title}</span>}
-        <p class="ev-label">{label ? cultureSection(e.section).name : (e.origin ?? e.countries[0].name)}</p>
+        <p class="ev-label">{lead && i === 0 && e.featured && <span class="cu-pick">Start here · </span>}{label ? cultureSection(e.section).name : (e.origin ?? e.countries[0].name)}</p>
         <h3>{e.title}{e.spelling && e.spelling[0].text.toLowerCase() !== e.title.toLowerCase() && <span class="cu-native"> {e.spelling[0].text}</span>}</h3>
         <p class="cu-sum">{e.summary}</p>

src/components/HomePage.astro +11 −1
@@ -370,4 +370,14 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
     </section>
 
+    {!view && (
+      <script is:inline>
+        // Arriving from a country's score breakdown at #method-<segment> (or #methodology): the map
+        // and charts above change the page's height as they load, so scroll again once they have.
+        if (/^#method/.test(location.hash)) window.addEventListener("load", function () {
+          var t = document.getElementById(location.hash.slice(1));
+          if (t) requestAnimationFrame(function () { t.scrollIntoView({ block: "start" }); });
+        });
+      </script>
+    )}
     {!view && <section class="section" id="methodology">
       <div class="section-head">
@@ -388,5 +398,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
       <div class="seg-grid">
         {SEGMENTS.map((s) => (
-          <div class="seg">
+          <div class="seg" id={`method-${s.id}`}>
             <div class="seg-top"><h3>{s.name}</h3><span class="seg-w">{s.weight}%</span></div>
             <p>{s.desc}</p>

src/components/SegmentScale.astro +16 −0
@@ -0,0 +1,16 @@
+---
+// Where a score sits on its segment's fixed scale: the level it reaches and the next one up, with
+// a link to the whole scale in the methodology on the home page. Shown at the foot of each opened
+// segment of a country's score breakdown.
+const { segment, score } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const reached = [...segment.levels].reverse().find(([n]) => n <= score) ?? segment.levels[0];
+const next = segment.levels.find(([n]) => n > score);
+---
+<div class="seg-scale">
+  <p class="ev-label">On the scale <a href={`${base}#method-${segment.id}`}>All levels of {segment.name.toLowerCase()} →</a></p>
+  <dl>
+    <div class="here"><dt>{reached[0]}</dt><dd>{reached[1]}</dd></div>
+    {next && <div><dt>{next[0]}</dt><dd>{next[1]}</dd></div>}
+  </dl>
+</div>

src/content.config.ts +3 −0
@@ -229,4 +229,7 @@ const culture = defineCollection({
       history: z.array(z.string()).default([]), // ids in changes.yaml
       image: picture(image).optional(),
+      // the entry's rank among the most important of its part (1 = first); see docs/CULTURE.md.
+      // Featured entries lead the Culture page and their section; the rest follow alphabetically.
+      featured: z.number().int().positive().optional(),
       model: z.string().min(1).optional(),
       written: z.coerce.date().optional(),

src/layouts/Base.astro +91 −1
@@ -79,5 +79,4 @@ const schemas = jsonld ? [jsonld].flat() : [];
       <a href={`${base}#map`}>Map</a>
       <a href={`${base}#rankings`}>Rankings</a>
-      <a href={`${base}#methodology`}>Methodology</a>
       <a href={`${base}history/`}>History</a>
       <a href={`${base}culture/`}>Culture</a>
@@ -333,4 +332,95 @@ const schemas = jsonld ? [jsonld].flat() : [];
     if (/^#seg-[a-z_-]+$/.test(location.hash)) showSegment(document.querySelector("article.country"), location.hash.slice(5));
   </script>
+  <script is:inline>
+    // Hover previews. A link in the page to a country, a history entry or a culture entry shows a
+    // small card with what is behind it. The data is /previews/<kind>.json, fetched the first time a
+    // link of that kind is hovered or focused. Not on touch, not in the menus, and not on cards or list rows that already show the same.
+    (function () {
+      var base = (document.getElementById("siteSearch") || {}).dataset ? document.getElementById("siteSearch").dataset.base : "/";
+      var cache = {}, tip = null, timer = 0, current = null;
+      var esc = function (t) { return String(t == null ? "" : t).replace(/[&<>"]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]; }); };
+      function kindOf(a) {
+        if (!a || a.closest("header, footer, nav, svg, .cu-cards, .reads, .search, .hc, .avg-chart, .c-chart, [data-no-preview]")) return null;
+        // A row of a list (history, changes, the side lists) already shows what the card would show:
+        // only links in running text and short labels get a preview.
+        if (a.querySelector("h1, h2, h3, h4, p, div, time, ul, ol, figure, img")) return null;
+        var u; try { u = new URL(a.href, location.href); } catch (e) { return null; }
+        if (u.origin !== location.origin || u.pathname.indexOf(base) !== 0 || u.pathname === location.pathname) return null;
+        var p = u.pathname.slice(base.length);
+        if (/^country\/[a-z]{2}\/$/.test(p)) return ["country", p];
+        if (/^history\/[^/]+\/$/.test(p)) return ["history", p];
+        if (/^culture\/(language|traditions)\/[^/]+\/$/.test(p)) return ["culture", p];
+        return null;
+      }
+      function load(kind) {
+        return cache[kind] || (cache[kind] = fetch(base + "previews/" + kind + ".json").then(function (r) { return r.json(); }).catch(function () { delete cache[kind]; return {}; }));
+      }
+      function card(kind, d) {
+        if (kind === "country") return '<p class="hc-eyebrow">' + esc(d.r) + ' · ' + (d.k ? "#" + d.k + " of " + d.of : "not ranked") + '</p>' +
+          '<div class="hc-head"><span class="hc-flag">' + d.f + '</span><b class="hc-title">' + esc(d.n) + '</b><span class="hc-score" style="--c:' + d.c + '">' + d.s + '<small>/100</small></span></div>' +
+          '<div class="hc-meter" style="--c:' + d.c + '"><i style="width:' + d.s + '%"></i></div>' +
+          '<p class="hc-chips"><span class="chip s-' + d.rec[1] + '">Recreational: ' + esc(d.rec[0]) + '</span><span class="chip s-' + d.med[1] + '">Medical: ' + esc(d.med[0]) + '</span></p>' +
+          '<p class="hc-text">' + esc(d.x) + '</p><p class="hc-foot">' + esc(d.t) + (d.h ? " · " + d.h + " history entries" : "") + '</p>';
+        if (kind === "history") return '<p class="hc-eyebrow">' + esc(d.k) + ' · ' + esc(d.d) + '</p>' +
+          '<p class="hc-place">' + esc(d.p) + '</p><b class="hc-title">' + esc(d.t) + '</b>' +
+          (d.b ? '<p class="hc-move"><b style="--c:' + d.b[1] + '">' + d.b[0] + '</b><span>→</span><b style="--c:' + d.a[1] + '">' + d.a[0] + '</b><small>index score</small></p>' : '') +
+          '<p class="hc-text">' + esc(d.x) + '</p><p class="hc-foot"><span class="conf-mark c-' + d.cf[2] + '">' + esc(d.cf[0]) + '</span> ' + esc(d.cf[1]) + (d.ar ? ' · <span class="he-article">Article</span>' : '') + '</p>';
+        return (d.i ? '<img class="hc-img" src="' + esc(d.i) + '" alt="" width="360" height="180">' : '') +
+          '<p class="hc-eyebrow">Culture · ' + esc(d.sec) + (d.o ? ' · ' + esc(d.o) : '') + '</p>' +
+          '<b class="hc-title">' + esc(d.t) + (d.sp ? ' <span class="hc-native">' + esc(d.sp) + '</span>' : '') + '</b>' +
+          '<p class="hc-text">' + esc(d.x) + '</p><p class="hc-foot hc-flags">' + d.fl + (d.n > 10 ? " +" + (d.n - 10) : "") + '</p>';
+      }
+      // Place the card under the line of the link that the pointer is on, starting where the pointer
+      // is, so that a link spanning a whole row (as in the lists of changes) does not throw the card
+      // to the row's far edge. A tall block link uses the pointer's height instead of its bottom.
+      // Keyboard focus has no pointer, so then the card goes under the start of the link.
+      var pt = null;
+      document.addEventListener("pointermove", function (e) { pt = { x: e.clientX, y: e.clientY }; }, { passive: true });
+      function place(a, byPointer) {
+        var rects = a.getClientRects(), r = null, i, w = tip.offsetWidth, h = tip.offsetHeight, m = 10;
+        if (byPointer && pt) for (i = 0; i < rects.length; i++) if (pt.y >= rects[i].top - 2 && pt.y <= rects[i].bottom + 2) r = rects[i];
+        r = r || rects[0] || a.getBoundingClientRect();
+        var top = r.top, bottom = r.bottom, x = r.left;
+        if (byPointer && pt) {
+          x = pt.x - 24;
+          if (r.height > 48) { top = pt.y - 14; bottom = pt.y + 18; }
+        }
+        x = Math.min(Math.max(m, x), window.innerWidth - w - m);
+        var below = bottom + 8 + h < window.innerHeight || top - 8 - h < 0;
+        tip.style.left = x + "px";
+        tip.style.top = (below ? bottom + 8 : top - 8 - h) + "px";
+      }
+      function show(a, byPointer) {
+        var k = kindOf(a); if (!k) return;
+        current = a;
+        load(k[0]).then(function (data) {
+          var d = data[k[1]]; if (!d || current !== a) return;
+          if (!tip) { tip = document.createElement("div"); tip.className = "hc"; tip.setAttribute("role", "tooltip"); }
+          (a.closest("dialog[open]") || document.body).appendChild(tip);
+          tip.className = "hc hc-" + k[0];
+          tip.innerHTML = card(k[0], d);
+          tip.hidden = false;
+          place(a, byPointer);
+          var img = tip.querySelector("img"); if (img) img.onload = function () { if (current === a) place(a, byPointer); };
+        });
+      }
+      function hide() { clearTimeout(timer); current = null; if (tip) tip.hidden = true; }
+      document.addEventListener("pointerover", function (e) {
+        if (e.pointerType === "touch") return;
+        var a = e.target.closest && e.target.closest("a[href]");
+        if (!a || a === current) return;
+        clearTimeout(timer); hide();
+        if (kindOf(a)) timer = setTimeout(function () { show(a, true); }, 220);
+      });
+      document.addEventListener("pointerout", function (e) {
+        var a = e.target.closest && e.target.closest("a[href]");
+        if (a && !a.contains(e.relatedTarget)) hide();
+      });
+      document.addEventListener("focusin", function (e) { var a = e.target.closest && e.target.closest("a[href]"); if (a && kindOf(a) && !a.matches(":hover")) { hide(); show(a, false); } });
+      document.addEventListener("focusout", hide);
+      document.addEventListener("keydown", function (e) { if (e.key === "Escape") hide(); });
+      window.addEventListener("scroll", hide, { passive: true, capture: true });
+    })();
+  </script>
 </body>
 </html>

src/lib/culture.js +2 −1
@@ -40,5 +40,6 @@ export const cultureEntries = (await getCollection("culture"))
     return { entry, section, slug, url: `culture/${section}/${slug}/`, ...d, countries, history, sources: d.sources.map(sourceTuple) };
   })
-  .sort((a, b) => a.title.localeCompare(b.title));
+  // featured entries first, by rank; then the rest alphabetically
+  .sort((a, b) => (a.featured ?? Infinity) - (b.featured ?? Infinity) || a.title.localeCompare(b.title));
 // and so must links from one entry to another
 for (const e of cultureEntries) for (const [, path] of (e.entry.body ?? "").matchAll(/\]\(\/(culture\/(?:language|traditions)\/[^/)#]+)\/?[^)]*\)/g)) {

src/pages/culture/[section]/index.astro +41 −1
@@ -27,6 +27,46 @@ const byCountry = [...new Set(entries.flatMap((e) => e.countries))]
 
     <section class="section cu-first">
-      <CultureCards entries={entries} />
+      {/* Order and view: by importance (the `featured` ranks, then the rest A–Z) or by name, as
+          cards or as a compact list. The choice is kept in the browser for the next visit. */}
+      <div class="cu-controls" id="cuControls">
+        <div class="hist-order" role="group" aria-label="Order of the entries">
+          <span class="cu-ctl-label">Order</span>
+          <button type="button" data-order="rank" aria-pressed="true">Importance</button>
+          <button type="button" data-order="name" aria-pressed="false">Name A–Z</button>
+        </div>
+        <div class="hist-order" role="group" aria-label="How to show the entries">
+          <span class="cu-ctl-label">Show</span>
+          <button type="button" data-view="cards" aria-pressed="true">Cards</button>
+          <button type="button" data-view="list" aria-pressed="false">List</button>
+        </div>
+      </div>
+      <CultureCards entries={entries} lead id="cuEntries" />
     </section>
+    <script is:inline>
+      (function () {
+        var list = document.getElementById("cuEntries"), box = document.getElementById("cuControls");
+        var state = { order: "rank", view: "cards" };
+        try { Object.assign(state, JSON.parse(localStorage.getItem("cfi-culture-view") || "{}")); } catch (e) {}
+        function apply() {
+          var items = Array.prototype.slice.call(list.children);
+          items.sort(function (a, b) {
+            return state.order === "name" ? a.dataset.name.localeCompare(b.dataset.name) : a.dataset.rank - b.dataset.rank;
+          }).forEach(function (li) { list.appendChild(li); });
+          // the large first card only makes sense for the most important entry, shown as cards
+          list.classList.toggle("cu-lead", state.order === "rank" && state.view === "cards");
+          list.classList.toggle("cu-as-list", state.view === "list");
+          box.querySelectorAll("[data-order]").forEach(function (b) { b.setAttribute("aria-pressed", String(b.dataset.order === state.order)); });
+          box.querySelectorAll("[data-view]").forEach(function (b) { b.setAttribute("aria-pressed", String(b.dataset.view === state.view)); });
+        }
+        box.addEventListener("click", function (e) {
+          var b = e.target.closest("button"); if (!b) return;
+          if (b.dataset.order) state.order = b.dataset.order;
+          if (b.dataset.view) state.view = b.dataset.view;
+          try { localStorage.setItem("cfi-culture-view", JSON.stringify(state)); } catch (e) {}
+          apply();
+        });
+        apply();
+      })();
+    </script>
 
     <section class="section">

src/pages/culture/index.astro +4 −3
@@ -9,6 +9,7 @@ import { CULTURE_SECTIONS, cultureEntries, cultureIn } from "../../lib/culture.j
 const base = import.meta.env.BASE_URL;
 const countryCount = new Set(cultureEntries.flatMap((e) => e.countries)).size;
-// The hub shows four entries of each part, those with a picture first; the rest are a click away.
-const preview = (id) => [...cultureIn(id)].sort((a, b) => Boolean(b.image) - Boolean(a.image)).slice(0, 4);
+// The hub shows the five most important entries of each part (their `featured` rank), the first
+// as a large lead card; the rest are a click away.
+const preview = (id) => cultureIn(id).slice(0, 5);
 const count = (s) => (s.entries ? `${cultureIn(s.id).length} ${cultureIn(s.id).length === 1 ? "entry" : "entries"}` : s.soon ? "In preparation" : "");
 ---
@@ -39,5 +40,5 @@ const count = (s) => (s.entries ? `${cultureIn(s.id).length} ${cultureIn(s.id).l
           <p class="sub">{s.intro}</p>
         </div></div>
-        <CultureCards entries={preview(s.id)} />
+        <CultureCards entries={preview(s.id)} lead />
         <p class="cu-all front-go"><a class="go" href={`${base}culture/${s.id}/`}>All {cultureIn(s.id).length} {s.id === "language" ? "words" : "traditions"} <span aria-hidden="true">→</span></a></p>
       </section>

src/pages/previews/[kind].json.js +45 −0
@@ -0,0 +1,45 @@
+// What the hover previews show (see the script in Base.astro): one file per kind of page, fetched
+// the first time a link of that kind is hovered. Keys are the part of the address after the base.
+//   country:  country/de/             history: history/<id>/        culture: culture/language/bhang/
+import { getImage } from "astro:assets";
+import { places, changes, articles, changeDate, REC_LABEL, MED_LABEL } from "../../lib/countries.js";
+import { cultureEntries, cultureSection } from "../../lib/culture.js";
+import { tierOf, tierColor } from "../../lib/tiers.js";
+import { file } from "../../lib/exports.js";
+
+export const getStaticPaths = () => ["country", "history", "culture"].map((kind) => ({ params: { kind } }));
+
+// cut a text at a word boundary
+const cut = (t, n) => (t.length <= n ? t : t.slice(0, t.lastIndexOf(" ", n)).replace(/[,;:.]$/, "") + "…");
+
+async function build(kind) {
+  if (kind === "country") {
+    const entries = new Map();
+    for (const ch of changes) if (ch.country && !ch.estimate) entries.set(ch.country.slug, (entries.get(ch.country.slug) ?? 0) + 1);
+    return Object.fromEntries(places.map((c) => [`country/${c.slug}/`, {
+      f: c.flag, n: c.name, r: c.region, s: c.score, c: tierColor(c.raw), t: tierOf(c.raw).name,
+      k: c.disputed ? null : c.rank, of: places.filter((x) => !x.disputed).length,
+      rec: [REC_LABEL[c.rec], c.rec], med: [MED_LABEL[c.med], c.med], x: cut(c.note, 230), h: entries.get(c.slug) ?? 0,
+    }]));
+  }
+  if (kind === "history") {
+    return Object.fromEntries(changes.map((ch) => [`history/${ch.id}/`, {
+      d: changeDate(ch), k: `${ch.kind.emoji} ${ch.kind.label}`, p: `${ch.place.flag} ${ch.place.name}`, t: ch.title, x: cut(ch.description, 240),
+      ...(ch.score && { b: [ch.score.before, tierColor(ch.score.before)], a: [ch.score.after, tierColor(ch.score.after)] }),
+      ...(articles.has(ch.id) && { ar: 1 }), cf: [ch.confidence.mark, ch.confidence.label, ch.confidence.key],
+    }]));
+  }
+  const out = {};
+  for (const e of cultureEntries) {
+    const img = e.image && (await getImage({ src: e.image.src, width: 360, height: 180, fit: "cover", format: "webp" }));
+    out[e.url] = {
+      sec: cultureSection(e.section).name, t: e.title, o: e.origin ?? null, sp: e.spelling?.[0]?.text.toLowerCase() !== e.title.toLowerCase() ? e.spelling?.[0]?.text ?? null : null,
+      x: cut(e.summary, 220), i: img?.src ?? null, fl: e.countries.slice(0, 10).map((c) => c.flag).join(" "), n: e.countries.length,
+    };
+  }
+  return out;
+}
+
+export async function GET({ params }) {
+  return file(JSON.stringify(await build(params.kind)), "application/json");
+}

src/styles/global.css +77 −0
@@ -1319,4 +1319,13 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon
 .cu-cards a:hover h3 { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; }
 .cu-all { margin: 22px 0 0; }
+/* the first card large, beside the others: four columns, the lead spanning two by two */
+@media (min-width: 900px) {
+  .cu-cards.cu-lead { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+  .cu-lead .cu-first-card { grid-column: span 2; grid-row: span 2; border-right: 0; }
+  .cu-lead .cu-first-card img, .cu-lead .cu-first-card .cu-word { aspect-ratio: 30 / 19; }
+  .cu-lead .cu-first-card h3 { font-size: clamp(40px, 4vw, 56px); line-height: .98; margin-top: 8px; }
+  .cu-lead .cu-first-card .cu-sum { font-size: 17px; max-width: 46ch; }
+}
+.cu-pick { color: var(--text); }
 .cu-sum { margin: 0; font-size: 14px; line-height: 1.5; color: var(--text-2); }
 .cu-flags { margin: 12px 0 0; font-size: 16px; letter-spacing: .1em; color: var(--muted); }
@@ -1755,2 +1764,70 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase
 .year-note a { color: var(--text-2); text-decoration: underline; text-underline-offset: 3px; white-space: nowrap; }
 #table.past-year [data-col], #table.past-year .trend { display: none; }
+
+/* hover previews of links to countries, history entries and culture entries (script in Base.astro) */
+.hc { position: fixed; z-index: 60; width: min(340px, calc(100vw - 20px)); padding: 14px 16px 12px; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-top: 2px solid var(--text); box-shadow: 0 12px 32px rgb(0 0 0 / .28); pointer-events: none; font-size: 13.5px; line-height: 1.45; animation: hc-in .12s ease-out; }
+.hc[hidden] { display: none; }
+@keyframes hc-in { from { opacity: 0; transform: translateY(3px); } }
+@media (prefers-reduced-motion: reduce) { .hc { animation: none; } }
+.hc p { margin: 0; }
+.hc-eyebrow { font: 400 10px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
+.hc-title { display: block; margin-top: 6px; font: 400 22px/1.1 var(--display); letter-spacing: -.01em; text-wrap: balance; }
+.hc-head { display: flex; align-items: baseline; gap: 8px; margin-top: 6px; }
+.hc-head .hc-title { margin: 0; flex: 1; }
+.hc-flag { font-size: 20px; line-height: 1; }
+.hc-score { font: 400 30px/1 var(--mono); letter-spacing: -.04em; color: var(--text); }
+.hc-score small { font-size: 11px; color: var(--muted); letter-spacing: 0; margin-left: 2px; }
+.hc-meter { height: 3px; margin: 8px 0 10px; background: var(--border); }
+.hc-meter i { display: block; height: 100%; background: var(--c); }
+.hc-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px !important; }
+.hc-chips .chip { font-size: 11px; }
+.hc-place { margin-top: 4px !important; font-size: 12.5px; color: var(--text-2); }
+.hc-move { display: flex; align-items: baseline; gap: 8px; margin-top: 8px !important; font: 400 20px var(--mono); }
+.hc-move b { font-weight: 400; color: var(--c); }
+.hc-move span { color: var(--muted); font-size: 14px; }
+.hc-move small { font: 400 10px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-left: 4px; }
+.hc-text { margin-top: 8px !important; color: var(--text-2); }
+.hc-foot { margin-top: 10px !important; padding-top: 8px; border-top: 1px solid var(--border); font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
+.hc-foot .he-article { margin-left: 2px; }
+.hc-flags { font-family: inherit; font-size: 15px; letter-spacing: .1em; text-transform: none; }
+.hc-img { display: block; width: calc(100% + 32px); height: auto; aspect-ratio: 2 / 1; object-fit: cover; margin: -14px -16px 12px; background: var(--surface-2); }
+.hc-native { font: 400 14px Inter, sans-serif; color: var(--muted); margin-left: 4px; }
+
+/* inside an opened segment of a country's score breakdown: the level the score reaches on the
+   segment's fixed scale and the next one up, linked to the methodology */
+.seg-scale { margin-top: 14px; padding-top: 10px; border-top: 1px dashed var(--border); }
+.why .seg-scale .ev-label { display: flex; justify-content: space-between; gap: 12px; margin: 0; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
+.seg-scale .ev-label a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; }
+.seg-scale .ev-label a:hover { color: var(--text); }
+.seg-scale dl { margin: 8px 0 0; display: grid; gap: 6px; }
+.seg-scale dl > div { display: grid; grid-template-columns: 2.4ch 1fr; gap: 10px; align-items: baseline; color: var(--muted); font-size: 13.5px; line-height: 1.45; }
+.seg-scale dl > div.here { color: var(--text-2); }
+.seg-scale dt { font: 400 13px var(--mono); text-align: right; }
+.seg-scale .here dt { color: var(--text); font-weight: 600; }
+.seg-scale dd { margin: 0; }
+.d-hint a { white-space: nowrap; }
+/* a segment of the methodology reached from a country's breakdown: clear of the sticky header, and marked */
+.seg[id] { scroll-margin-top: 84px; }
+.seg:target { outline: 2px solid var(--text); outline-offset: 6px; }
+#methodology { scroll-margin-top: 60px; }
+
+/* a part of the cultural atlas: order and view switches, and the entries as a compact list */
+.cu-controls { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 32px; margin-bottom: 18px; }
+.cu-controls .hist-order { margin: 0; align-items: baseline; }
+.cu-ctl-label { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-right: -6px; }
+.cu-cards.cu-as-list { display: block; }
+.cu-as-list li { border-bottom: 1px solid var(--border); }
+.cu-as-list a { display: grid; grid-template-columns: 64px minmax(0, 15rem) minmax(0, 1fr) auto; gap: 4px 20px; align-items: center; padding: 10px 0; }
+.cu-as-list img, .cu-as-list .cu-word { grid-row: span 2; width: 64px; height: 44px; aspect-ratio: auto; margin: 0; object-fit: cover; }
+.cu-as-list .cu-word { font-size: 0; }
+.cu-as-list .ev-label { grid-column: 2; grid-row: 1; align-self: end; }
+.cu-as-list h3 { grid-column: 2; grid-row: 2; align-self: start; margin: 0; font-size: 21px; }
+.cu-as-list .cu-sum { grid-column: 3; grid-row: 1 / span 2; font-size: 13.5px; }
+.cu-as-list .cu-flags { grid-column: 4; grid-row: 1 / span 2; margin: 0; font-size: 14px; white-space: nowrap; }
+.cu-as-list .cu-pick { display: none; }
+@media (max-width: 720px) {
+  .cu-as-list a { grid-template-columns: 56px minmax(0, 1fr); }
+  .cu-as-list img, .cu-as-list .cu-word { width: 56px; height: 40px; }
+  .cu-as-list .cu-sum, .cu-as-list .cu-flags { display: none; }
+}
+.cu-cards:not(.cu-lead) .cu-pick { display: none; }

    ↑↓ to move, ↵ to open.