Cannabis Freedom Index

Changelog 8969566

add research progress; croatia and bulgaria laws

76 history entries added4 history entries edited2 country profiles edited3 articles written8 files of code, design or docs +1,792 −54 lines

History changes.yaml

Added 76

Edited 4

Croatia legalises medical cannabis

description

Doctors mayA regulation in force from 15 October let doctors prescribe cannabis-based medicines containing THC for a short list of conditions, including multiple sclerosis, cancer and, epilepsy and AIDS: a specialist recommends, the family doctor writes the prescription, for at most 30 days and 0.75 g of THC. The health minister, Siniša Varga, promised products within weeks. A patients' association called it a hollow regulation made for the elections, and a wholesaler expected a month's therapy to cost up to 800 euros.

sources

tportal — Terapija travom i do 800 eura mjesečno (15 October 2015) 2 unchanged

Bulgaria sets one to six years for possession

date

2006-10-13

description

After two years in which any possession was treated as a serious crime, the penal code was amended: one to six years in prison, or a fine in minor cases that still counts as a conviction. The amendment, which rewrote every drug offence in the code to match a European Union framework decision, was published in State Gazette 75 of 2006 and took effect on 13 October. The courts had been filling up: 525 people were convicted of drug crimes in 2003, 921 in 2005 and 1,587 in 2006.

sources

Reitox National Focal Point — 2007 National Report (2006 data) to the EMCDDA, Bulgaria (PDF) 1 unchanged

Bulgaria abolishes the personal dose

description

The lawParagraph 3 of Article 354a of the penal code had exempted a loosely defineddependent person holding a single dose for personal use from punishment. The exemptionAn amendment known as the single dose law repealed it, so that possessing any drug in any quantity became a crime, whether or not the person was removeddependent; wholesalers, makingsmall dealers and users were treated alike. A study a year later found the ban and its effects to be part of a wider failure of law and institutions. It made Bulgarian law among the harshest in Europe.

sources

Tihomir Bezlov — Heroin users in Bulgaria one year after outlawing the dose for personal use, Center for the Study of Democracy (2005) Novinite — Preparation for "Freedom for Weed" procession 2018 (16 May 2018) 1 unchanged

Bulgaria prohibits growing cannabis for resin

description

After Greece banned cultivation in 1920, hashish traffickers brought cannabis growing for resin to Bulgaria in 1919 and 1920, and Bulgarian farmers, like Romanian and Yugoslav ones, worked to meet the demand of Egypt, which Greece had supplied until then. Bulgaria prohibited itthe crop in 1925, according to the UN's 1951 survey of cannabis, which gives the year but not the text of the law; a historian of the hashish trade dates the Balkan bans to the late 1920s.

sources

Liat Kozma — The criminalization of hashish consumption in the Middle East, Levantine Journal (1 March 2017) 1 unchanged

Countries and states 2 files

Bulgaria countries/bg.yaml

segments › possession › sources

wiki, dr, nr2007

segments › cultivation › text

Cultivation is a crime with prison terms; the penal code has a separate offence for growing hemp-type plants. In 2014 a multiple sclerosis patient with two plants was fined 1,000 leva on appeal after a first acquittal.

segments › cultivation › sources

wiki, nr2007, dr

segments › sharing › sources

wiki, dr14

segments › access › text

None. Production, import and sale are prohibited, and nothing containing THC is legally sold.

segments › access › sources

wiki, cmsg

context › production › text

IndustrialRegistered farmers with no drug or customs convictions may grow hemp with less than 0.2% THC under a free three-year licence onlyfrom the agriculture minister, after declaring that no part of the plant will be separated or processed. Only the stems and seeds may be processed, for fibre, feed or sowing seed; leaves and flowers are still treated as a source of marijuana. Parliament rejected a bill to allow hemp processing in April 2024. No other production.

context › production › sources

wikimondaq, bta24

context › products › text

Reliable national information about legally available cannabis products wasCBD is not established byregulated as a drug: it cannot be produced in Bulgaria but may be imported and sold, and in May 2019 the retained sourcesfood authorities certified one firm's CBD supplements for open sale. The risk lies in THC, for which the law sets no permitted trace amount, so a product containing any counts as a controlled preparation. Nothing with THC is legally sold.

context › products › sources

mondaq, nutra

context › medical › text

No medical cannabis programme — unlike neighbouring Greece and Romania, there is not even a law on paper. A doctor who prescribes cannabis faces up to five years in prison and a fine of up to 3,000 leva. In October 2015 the national drugs council declared that Bulgaria had no need for medical marijuana, and since June 2016 licences for cannabis may be issued only for medical research.

context › medical › sources

wiki, cmsg, dr16

sources

Drugreporter — Dangerous turn in Bulgarian drug policy as proposed in a new penal code bill (23 January 2014) Drugreporter — Hemp and cannabis in Bulgaria after the June 2016 amendment China-CEE Institute — Bulgaria social briefing, drivers under the influence of intoxicating substances (July 2022) The Sofia Globe — Bulgaria's Parliament approves amendments to Penal Code BNR — Confiscation of cars driven by drunk drivers does not contradict the constitution, rules Constitutional Court (17 July 2025) Reitox National Focal Point — 2007 National Report (2006 data) to the EMCDDA, Bulgaria (PDF) Schoenherr / Mondaq — Growing cannabis in Bulgaria, legal but still stigmatised (30 July 2019) BTA — Bill allowing industrial hemp processing rejected (10 April 2024) NutraIngredients — Bulgaria in contention to be first EU country to allow open sale of CBD (11 June 2019) 5 unchanged

context › driving › status

zero

context › driving › text

Driving after using drugs is an offence in itself, with no level set: police use a roadside test for seven drugs, marijuana among them, and a positive result opens criminal proceedings. Since July 2023 the fine is 1,000 to 5,000 leva, 2,000 to 10,000 for a repeat, and refusing a test after an accident brings up to a year in prison. The car is confiscated if the driver owns it, a rule the Constitutional Court upheld in July 2025.

context › driving › sources

ccee22, sg23, bnr25

context › arrests › text

The only yearly series found ends in 2006. Convictions for drug crimes of all kinds rose from 232 people in 2000 to 525 in 2003, 921 in 2005 and 1,587 in 2006, the year the penal code was amended. In 2006 prosecutors monitored 4,223 proceedings for acquiring or keeping drugs against 204 for dealing, and the sentence most often imposed by the courts was prison of up to three years. Figures by drug were not published, but cannabis was the most widely used. No recent national figures were found.

context › arrests › sources

nr2007

Croatia countries/hr.yaml

segments › possession › text

Since 1 January 2013 possession of a small amount for personal use is a misdemeanour, not … 'decriminalised' regimes in Europe. No fixed gram threshold; courts assess personal use. Possession had been a crime from April 1996 until then, with a fine or up to a year in prison.

segments › possession › sources

wiki, tripalo

segments › cultivation › text

Growing even a few plants for yourself remains a criminal offence, with prison of up to three years; cultivation for sale carries a mandatory minimum. A multiple sclerosis patient who grew eleven plants for oil was sentenced to two years in 2017 and escaped prison only by a presidential pardon in 2019.

segments › cultivation › sources

wiki, index19, nl19

segments › access › text

No adult-use source. Legalisation bills, including one proposing nine plants per adult, have been tabled without success; the government rejected the latest after a debate in November 2022.

segments › access › sources

wiki, pp, nl22

context › medical › text

Legal since October 2015 for conditions such as multiple sclerosis, cancer, epilepsy and AIDS, on prescription, capped at 0.75 g of THC per 30 days, with no home growing. In practice there is little to prescribe: pharmacies ran out in March 2018 and the importer did not reorder, the health fund does not reimburse, and a 2025 market survey found no cannabis medicine on sale and no company fully authorised to produce or distribute one.

context › medical › sources

mjb, wiki, n118, pp25

sources

Dražen Tripalo — Kaznenopravni aspekti zlouporabe droga, Vrhovni sud Republike Hrvatske (2003, PDF) Index.hr — Huanito mora u zatvor (25 January 2019) Novi list — Potpuni oprost za Huanita Luksetića (2 March 2019) Novi list — Delikatan zakonski prijedlog (4 November 2022) N1 — Croatian pharmacies out of cannabis-based drug (May 2018) Prohibition Partners — Croatia medical cannabis market overview 2025 (30 September 2025) 7 unchanged

Articles and atlas entries 3

Code, design and documents 8 files

docs/DATA-MAINTENANCE.md +19 −0
@@ -662,2 +662,21 @@ from. A relay may ignore deletion requests, which is why a deleted part is also
 an empty one. Someone who clears their browser without a backup of the key can no longer change
 their answers.
+
+## Research progress (/research/)
+
+A hidden page, not linked, not in the search or the sitemap and not indexed. It shows for each
+country how far the research has come: a percentage, the same split into profile and history,
+the number of entries, scored entries and articles, the year of the first ban, the decades with
+no entry and the longest gap between two entries. Clicking a row lists what is missing.
+
+The checks and their points are in `src/lib/research.js`. For the profile: every score cited,
+every score resting on more than Wikipedia or a weak site, the ten context sections, a measured
+figure for use, a review in the last year, sources archived. For the history: a dated first
+restriction, entries checked, a score timeline with no flag, no decade without an entry, an
+entry in the last three years, fifteen entries or more, entries resting on more than Wikipedia,
+an article for every score change.
+
+The checks see only what is in the files, so they stop at 99%. A country reaches 100% only when
+every check passes and its file carries `complete: YYYY-MM-DD`, set by hand by whoever went
+through the country and found nothing left to add. An empty decade is a prompt to look, not
+proof of a gap: some decades had no event.

source-archive.json +61 −33

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

src/content.config.ts +3 −0
@@ -75,4 +75,7 @@ const countries = defineCollection({
       ...status,
       updated: z.coerce.date(),
+      // set by hand, once someone has gone through the country and found nothing left to add:
+      // only then can the research page show 100% (see src/lib/research.js)
+      complete: z.coerce.date().optional(),
       summary: z.string().min(1),
       scores: perSegment(score),

src/lib/research.js +102 −0
@@ -0,0 +1,102 @@
+// How far the research on each country has come: a list of checks on its profile and on its
+// history, each worth some points, and the share of the points it has. The page /research/
+// shows the result. A check can only see what is in the files, so on its own it never reaches
+// 100%: that needs `complete: <date>` in the country file, set by hand once someone has gone
+// through the country and found nothing left to add.
+import { getCollection } from "astro:content";
+import { SEGMENTS, dateTime, articles } from "./countries.js";
+import { CONTEXT_IDS, STATUS_SECTIONS } from "./segment-ids.js";
+import { archivedUrl } from "./archive.js";
+
+// sites that proved unreliable, and the encyclopedia: fine as a lead, not enough on their own
+const WEAK = /cannabisregulations\.ai|tripbase\.com|sensiseeds\.com/;
+const WIKI = /wikipedia\.org/;
+const solid = (src) => src && (!src.url || !(WEAK.test(src.url) || WIKI.test(src.url))); // a book has no url
+const SECTIONS = [...CONTEXT_IDS, "planting", ...Object.keys(STATUS_SECTIONS), "black_market", "arrests"];
+const NOW = new Date();
+const THIS_YEAR = NOW.getFullYear();
+const total = (scores) => SEGMENTS.reduce((sum, seg) => sum + (scores[seg.id] * seg.weight) / 10, 0);
+const UP = new Set(["easing", "legalisation", "medical"]), DOWN = new Set(["ban", "tightening", "death"]);
+
+const entries = (await getCollection("changes")).map(({ data }) => data);
+const flagOf = (id) => String.fromCodePoint(...[...id.toUpperCase()].map((ch) => 0x1f1a5 + ch.charCodeAt(0)));
+
+export const research = (await getCollection("countries")).map(({ id: slug, data }) => {
+  const checks = []; // { part, label, got, of, missing: [..] }
+  const add = (part, label, got, of, missing = []) => checks.push({ part, label, got, of, missing });
+  const src = (keys) => keys.map((k) => data.sources[k]);
+
+  // ── the profile ──
+  const uncited = SEGMENTS.filter((seg) => !data.segments[seg.id].sources.length);
+  add("profile", "Every score is cited", SEGMENTS.length - uncited.length, SEGMENTS.length, uncited.map((s) => `${s.name}: no source`));
+  const soft = SEGMENTS.filter((seg) => data.segments[seg.id].sources.length && !src(data.segments[seg.id].sources).some(solid));
+  add("profile", "Every score rests on more than Wikipedia", SEGMENTS.length - uncited.length - soft.length, SEGMENTS.length, soft.map((s) => `${s.name}: only Wikipedia or a weak site`));
+  const empty = SECTIONS.filter((k) => !data.context[k]?.sources.length);
+  add("profile", "Context sections researched", SECTIONS.length - empty.length, SECTIONS.length, empty.map((k) => `${k.replace("_", " ")}: ${data.context[k] ? "no source" : "missing"}`));
+  const use = data.cannabis_use;
+  add("profile", "Use is a measured figure", use.year && !use.estimate ? 1 : 0, 1, use.year && !use.estimate ? [] : ["prevalence of use is an estimate"]);
+  const age = (NOW - data.updated) / 864e5;
+  add("profile", "Reviewed in the last year", age <= 365 ? 1 : 0, 1, age <= 365 ? [] : [`last reviewed ${data.updated.toISOString().slice(0, 10)}`]);
+  const urls = Object.values(data.sources).map((s) => s.url).filter(Boolean);
+  const saved = urls.filter((u) => archivedUrl(u)).length;
+  add("profile", "Sources archived", urls.length ? (2 * saved) / urls.length : 0, 2, saved < urls.length ? [`${urls.length - saved} of ${urls.length} sources have no archived copy`] : []);
+
+  // ── the history ──
+  const own = entries.filter((e) => e.country === slug).sort((a, b) => dateTime(a.date) - dateTime(b.date));
+  const dated = own.filter((e) => !e.estimate);
+  const scored = own.filter((e) => e.before);
+  const first = scored[0];
+  add("history", "The first restriction is dated", first && !first.estimate ? 3 : 0, 3, first && !first.estimate ? [] : [first ? "the first ban is an estimate" : "no first ban recorded"]);
+  const unchecked = dated.filter((e) => !e.checked);
+  add("history", "Entries checked against a source", dated.length ? (2 * (dated.length - unchecked.length)) / dated.length : 0, 2, unchecked.map((e) => `not checked: ${e.id}`));
+  const flags = [];
+  scored.forEach((e, i) => {
+    const b = total(e.before), a = total(scored[i + 1] ? scored[i + 1].before : data.scores);
+    if (UP.has(e.category) && a < b) flags.push(`${e.id}: ${e.category}, but the score falls`);
+    if (DOWN.has(e.category) && a > b) flags.push(`${e.id}: ${e.category}, but the score rises`);
+    if (i === 0 && b < 90 && !e.estimate) flags.push(`${e.id}: the timeline starts below the unregulated state`);
+  });
+  add("history", "The score timeline makes sense", scored.length && !flags.length ? 2 : 0, 2, scored.length ? flags : ["no scored entry"]);
+  // a decade with no entry at all, between the first dated entry (1900 at the earliest) and now
+  const years = dated.map((e) => +e.date.slice(0, 4));
+  const from = years.length ? Math.max(1900, Math.floor(Math.min(...years) / 10) * 10) : 1900;
+  const decades = [];
+  for (let d = from; d <= THIS_YEAR; d += 10) decades.push(d);
+  const silent = decades.filter((d) => !years.some((y) => y >= d && y < d + 10));
+  add("history", "No decade without an entry", years.length ? (6 * (decades.length - silent.length)) / decades.length : 0, 6, years.length ? silent.map((d) => `${d}s: nothing recorded`) : ["no dated entry"]);
+  const recent = years.some((y) => y >= THIS_YEAR - 3);
+  add("history", "Followed up to the present", recent ? 1 : 0, 1, recent ? [] : [`nothing since ${years.length ? Math.max(...years) : "ever"}`]);
+  add("history", "Depth (15 entries or more)", (4 * Math.min(dated.length, 15)) / 15, 4, dated.length < 15 ? [`${dated.length} dated entries`] : []);
+  const thin = dated.filter((e) => !e.sources.some(solid));
+  add("history", "Entries rest on more than Wikipedia", dated.length ? (3 * (dated.length - thin.length)) / dated.length : 0, 3, thin.length ? [`${thin.length} entries cite only Wikipedia or a weak site`] : []);
+  const told = scored.filter((e) => articles.has(e.id));
+  add("history", "An article for every score change", scored.length ? (3 * told.length) / scored.length : 0, 3, scored.filter((e) => !articles.has(e.id)).map((e) => `no article: ${e.id}`));
+
+  const share = (part) => { const cs = checks.filter((c) => !part || c.part === part); return cs.reduce((a, c) => a + c.got, 0) / cs.reduce((a, c) => a + c.of, 0); };
+  const signed = data.complete ? data.complete.toISOString().slice(0, 10) : null;
+  // every check passed is 99%; the last point is the signature
+  const pct = signed && share() === 1 ? 100 : Math.min(99, Math.floor(share() * 100));
+  const gaps = years.slice(1).map((y, i) => y - years[i]);
+  return {
+    slug, name: data.name, flag: data.flag ?? flagOf(slug), region: data.region, disputed: Boolean(data.disputed),
+    population: data.population[Object.keys(data.population).sort().at(-1)],
+    pct, profile: Math.floor(share("profile") * 100), history: Math.floor(share("history") * 100), signed,
+    entries: own.length, scored: scored.length, articles: own.filter((e) => articles.has(e.id)).length,
+    first: first ? (first.estimate ? "est." : first.date.slice(0, 4)) : "—",
+    gap: gaps.length ? Math.max(...gaps) : null, silent: silent.length,
+    reviewed: data.updated.toISOString().slice(0, 10),
+    checks,
+  };
+}).sort((a, b) => b.pct - a.pct || b.entries - a.entries || a.name.localeCompare(b.name));
+
+export const researchTotals = {
+  places: research.length,
+  average: Math.round(research.reduce((a, r) => a + r.pct, 0) / research.length),
+  // the same, counted by people: a well-researched large country weighs more
+  byPopulation: Math.round(research.reduce((a, r) => a + r.pct * r.population, 0) / research.reduce((a, r) => a + r.population, 0)),
+  complete: research.filter((r) => r.pct === 100).length,
+  good: research.filter((r) => r.pct >= 75).length,
+  poor: research.filter((r) => r.pct < 40).length,
+  entries: entries.length,
+  estimates: research.filter((r) => r.first === "est.").length,
+};

src/pages/history/index.astro +9 −1
@@ -4,8 +4,16 @@ import Base from "../../layouts/Base.astro";
 import HistoryList from "../../components/HistoryList.astro";
 import { historyPeriods } from "../../lib/countries.js";
+import { researchTotals } from "../../lib/research.js";
+
+const base = import.meta.env.BASE_URL;
 ---
 <Base title="History" description="Laws, court rulings and treaties that shaped cannabis freedom, from the first bans to today, and how each one moved a country's score on the Cannabis Freedom Index.">
   <main class="changes-page">
-    <section class="hero hero-sm">
+    <section class="hero hero-sm hero-progress">
+      <a class="rs-pill" href={`${base}research/`} title={`Average research progress over ${researchTotals.places} countries and territories. Open the details.`}>
+        <span>Research progress</span>
+        <i style={`--p:${researchTotals.average}%`}></i>
+        <b>{researchTotals.average}%</b>
+      </a>
       <p class="eyebrow">History</p>
       <h1>A history of cannabis law</h1>

src/pages/og/[...path].png.js +1 −0
@@ -59,4 +59,5 @@ const cards = {
   "survey/take": page("Take the survey", "State of Cannabis: about ten minutes, no account, answers encrypted.", [], `STATE OF CANNABIS · ${survey.edition}`),
   "survey/me": page("Your submissions", "State of Cannabis: read, change or delete what you sent.", [], `STATE OF CANNABIS · ${survey.edition}`),
+  research: page("Research progress", "How much of each country's profile and history has been researched.", [], `INTERNAL · ${BRAND}`),
   "survey/scan": page("Submission scanner", "What the survey's public relays hold, as anyone can see it.", [], `STATE OF CANNABIS · ${survey.edition}`),
 };

src/pages/research.astro +140 −0
@@ -0,0 +1,140 @@
+---
+// Research progress: how much of each country's profile and history has been filled in, and what
+// is still missing. An internal working page: not linked from the menu, the search or the
+// sitemap, and not indexed. The checks and their weights are in src/lib/research.js.
+import Base from "../layouts/Base.astro";
+import { research, researchTotals as T } from "../lib/research.js";
+
+const base = import.meta.env.BASE_URL;
+const num = (n) => n.toLocaleString("en-US");
+const example = research[0].checks;
+const points = (part) => example.filter((c) => c.part === part).reduce((a, c) => a + c.of, 0);
+const stats = [
+  [`${T.average}%`, "Average"],
+  [`${T.byPopulation}%`, "Weighted by population"],
+  [num(T.complete), "Complete (100%)"],
+  [num(T.good), "At 75% or more"],
+  [num(T.poor), "Below 40%"],
+  [num(T.estimates), "First ban only estimated"],
+];
+---
+<Base title="Research progress" description="How much of each country's profile and history has been researched, and what is still missing." noindex>
+  <main class="changes-page research-page">
+    <section class="hero hero-sm">
+      <p class="eyebrow">Internal</p>
+      <h1>Research progress</h1>
+      <p class="lede">For each of the {num(T.places)} countries and territories: how much of the profile is filled in and sourced, how many history entries there are, and how complete that history looks. The figure is the share of points a country has out of {points("profile")} for its profile and {points("history")} for its history.</p>
+      <p class="lede">The checks only see what is in the files. They cannot know that nothing is missing, so they stop at 99%. A country shows 100% only when every check passes and someone has signed it off by hand, with <code>complete: YYYY-MM-DD</code> in its file.</p>
+    </section>
+
+    <dl class="hist-stats">
+      {stats.map(([n, label]) => <div><dt>{label}</dt><dd>{n}</dd></div>)}
+    </dl>
+
+    <section class="section">
+      <div class="section-head"><div>
+        <h2>What is counted</h2>
+        <p class="sub">Each check gives points; a partly met check gives part of them.</p>
+      </div></div>
+      <div class="rs-checks">
+        {["profile", "history"].map((part) => (
+          <table class="src-table">
+            <thead><tr><th>{part === "profile" ? "Profile" : "History"}</th><th class="num">Points</th></tr></thead>
+            <tbody>{example.filter((c) => c.part === part).map((c) => <tr><td>{c.label}</td><td class="num">{c.of}</td></tr>)}</tbody>
+          </table>
+        ))}
+      </div>
+    </section>
+
+    <section class="section">
+      <div class="section-head"><div>
+        <h2>By country</h2>
+        <p class="sub">Click a column to sort by it, and a row to see what is missing.</p>
+      </div>
+        <input type="search" id="rsFind" placeholder="Filter by name or region" aria-label="Filter countries" />
+      </div>
+      <div class="rs-scroll"><table class="src-table rs-table" id="rsTable">
+        <thead><tr>
+          <th data-sort="name">Country</th>
+          <th class="num" data-sort="pct" aria-sort="descending">Research</th>
+          <th class="num" data-sort="profile">Profile</th>
+          <th class="num" data-sort="history">History</th>
+          <th class="num" data-sort="entries">Entries</th>
+          <th class="num" data-sort="scored" title="Entries that moved a score">Scored</th>
+          <th class="num" data-sort="articles">Articles</th>
+          <th class="num" data-sort="first" title="Year of the first recorded restriction">First ban</th>
+          <th class="num" data-sort="silent" title="Decades since the first entry (1900 at the earliest) with no entry">Empty decades</th>
+          <th class="num" data-sort="gap" title="Longest stretch of years between two entries">Longest gap</th>
+          <th class="num" data-sort="reviewed">Reviewed</th>
+        </tr></thead>
+        {research.map((r) => (
+          <tbody data-name={r.name.toLowerCase()} data-region={r.region.toLowerCase()} data-pct={r.pct} data-profile={r.profile} data-history={r.history} data-entries={r.entries} data-scored={r.scored} data-articles={r.articles} data-first={r.first === "est." ? 9998 : r.first === "—" ? 9999 : r.first} data-silent={r.silent} data-gap={r.gap ?? -1} data-reviewed={r.reviewed}>
+            <tr class="rs-row" tabindex="0" aria-expanded="false">
+              <th scope="row"><a href={`${base}country/${r.slug}/`}>{r.flag} {r.name}</a>{r.disputed && <small> territory</small>}</th>
+              <td class="num"><span class="rs-bar" style={`--p:${r.pct}%`}><b>{r.pct}%</b></span></td>
+              <td class="num">{r.profile}%</td>
+              <td class="num">{r.history}%</td>
+              <td class="num">{r.entries}</td>
+              <td class="num">{r.scored}</td>
+              <td class="num">{r.articles}</td>
+              <td class="num">{r.first}</td>
+              <td class="num">{r.silent}</td>
+              <td class="num">{r.gap ?? "—"}</td>
+              <td class="num mono">{r.reviewed}</td>
+            </tr>
+            <tr class="rs-more" hidden><td colspan="11">
+              <div class="rs-missing">
+                {["profile", "history"].map((part) => (
+                  <div>
+                    <h3>{part === "profile" ? "Profile" : "History"}</h3>
+                    <ul>
+                      {r.checks.filter((c) => c.part === part).map((c) => (
+                        <li class={c.got >= c.of ? "ok" : ""}>
+                          <span>{c.got >= c.of ? "✓" : "·"} {c.label} <small>{Math.round(c.got * 10) / 10} of {c.of}</small></span>
+                          {c.missing.length > 0 && <em>{c.missing.slice(0, 12).join("; ")}{c.missing.length > 12 && `; and ${c.missing.length - 12} more`}</em>}
+                        </li>
+                      ))}
+                    </ul>
+                  </div>
+                ))}
+              </div>
+              <p class="rs-sign">{r.signed ? `Signed off as complete on ${r.signed}.` : "Not signed off as complete."} <a href={`${base}country/${r.slug}/`}>Open the country page</a></p>
+            </td></tr>
+          </tbody>
+        ))}
+      </table></div>
+    </section>
+  </main>
+
+  <script is:inline>
+    (() => {
+      const table = document.getElementById("rsTable");
+      const bodies = () => [...table.tBodies];
+      const open = (row) => {
+        const more = row.nextElementSibling;
+        more.hidden = !more.hidden;
+        row.setAttribute("aria-expanded", String(!more.hidden));
+      };
+      table.addEventListener("click", (e) => {
+        const th = e.target.closest("th[data-sort]");
+        if (th) {
+          const key = th.dataset.sort, text = key === "name" || key === "reviewed";
+          const now = th.getAttribute("aria-sort");
+          const down = now ? now === "ascending" : !text; // numbers start from the top, names from A
+          table.querySelectorAll("th[aria-sort]").forEach((x) => x.removeAttribute("aria-sort"));
+          th.setAttribute("aria-sort", down ? "descending" : "ascending");
+          const val = (b) => (text ? b.dataset[key] : +b.dataset[key]);
+          bodies().sort((a, b) => (val(a) < val(b) ? -1 : val(a) > val(b) ? 1 : 0) * (down ? -1 : 1)).forEach((b) => table.appendChild(b));
+          return;
+        }
+        const row = e.target.closest(".rs-row");
+        if (row && !e.target.closest("a")) open(row);
+      });
+      table.addEventListener("keydown", (e) => { if (e.key === "Enter" && e.target.classList.contains("rs-row")) open(e.target); });
+      document.getElementById("rsFind").addEventListener("input", (e) => {
+        const q = e.target.value.trim().toLowerCase();
+        for (const b of bodies()) b.hidden = Boolean(q) && !b.dataset.name.includes(q) && !b.dataset.region.includes(q);
+      });
+    })();
+  </script>
+</Base>

src/styles/global.css +39 −0
@@ -1619,2 +1619,41 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase
 .about .numbers-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
 @media (max-width: 760px) { .about .numbers-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+
+/* ── Research progress (/research/, internal) ──────────────────────────────── */
+.research-page code { font: 400 .9em var(--mono); }
+.rs-checks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 40px; }
+#rsFind { align-self: end; min-width: 240px; }
+.rs-scroll { overflow-x: auto; }
+.rs-table th, .rs-table td { padding-right: 10px; }
+.rs-table .num { padding-left: 12px; }
+.rs-table thead th[data-sort] { cursor: pointer; white-space: nowrap; }
+.rs-table thead th[aria-sort="descending"]::after { content: " ↓"; }
+.rs-table thead th[aria-sort="ascending"]::after { content: " ↑"; }
+.rs-table tbody th small { color: var(--muted); font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; }
+.rs-row { cursor: pointer; }
+.rs-row:hover td, .rs-row:hover th, .rs-row[aria-expanded="true"] td, .rs-row[aria-expanded="true"] th { background: var(--surface); }
+.rs-bar { position: relative; display: inline-block; width: 110px; padding: 1px 6px; text-align: right; background: linear-gradient(to right, var(--border) var(--p), transparent var(--p)); border: 1px solid var(--border); }
+.rs-bar b { font-weight: 500; }
+.rs-more td { padding: 4px 0 22px; }
+.rs-missing { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 40px; width: min(100%, calc(100vw - 80px)); }
+.rs-missing h3 { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 12px 0 6px; }
+.rs-missing ul { list-style: none; margin: 0; padding: 0; }
+.rs-missing li { padding: 6px 0; border-top: 1px solid var(--border); font-size: 13.5px; }
+.rs-missing li.ok { color: var(--muted); }
+.rs-missing li small { font: 400 11px var(--mono); color: var(--muted); margin-left: 6px; }
+.rs-missing li em { display: block; font-style: normal; font-size: 12.5px; color: var(--text-2); margin-top: 2px; }
+.rs-sign { margin: 12px 0 0; font-size: 12.5px; color: var(--muted); }
+.rs-sign a { text-decoration: underline; text-underline-offset: 3px; }
+@media (max-width: 900px) {
+  .rs-checks, .rs-missing { grid-template-columns: 1fr; }
+  .rs-table th:nth-child(n+5), .rs-table td:nth-child(n+5) { display: none; }
+  .rs-bar { width: 80px; }
+  #rsFind { min-width: 0; width: 100%; }
+}
+/* the same figure, small, at the top right of the History page */
+.hero-progress { position: relative; }
+.rs-pill { position: absolute; top: clamp(28px, 5vw, 52px); right: 0; display: flex; align-items: center; gap: 10px; padding: 6px 10px; border: 1px solid var(--border); font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
+.rs-pill:hover { border-color: var(--muted); color: var(--text); }
+.rs-pill i { width: 64px; height: 4px; background: linear-gradient(to right, var(--text) var(--p), var(--border) var(--p)); }
+.rs-pill b { font-weight: 500; font-size: 12px; color: var(--text); }
+@media (max-width: 700px) { .rs-pill { position: static; width: fit-content; margin-bottom: 16px; } }

    ↑↓ to move, ↵ to open.