Cannabis Freedom Index

Changelog 00ba68e

update

11 history entries added9 history entries edited1 history entry removed1 score changed1 country profile edited12 files of code, design or docs +689 −129 lines

Score changes 1 place

  1. Bosnia and Herzegovina1112+1access 0 → 1

History changes.yaml

Added 11

Edited 9

Kuwait's tougher anti-drug law comes into force

description

AnThe cabinet approved the law on 29 October 2025 and it was issued as an Amiri decree-law merged, merging and replacedreplacing the 1983 and 1987 laws. It allows death or life imprisonment for importingtraffickers, smugglingdealers and intermediaries, producing or growing drugs for trafficking, with fines of up to two million dinars, and introduces random drug testingtests for public employees and for people applying for a driving licence or to marry.

checked

true

Mauritius allows medical cannabis and diverts users to treatment

date

2022-11

description

An amendmentAmendments to the Dangerous Drugs Act lets committeespassed in November 2022 let a Medicinal Cannabis Therapeutic Committee in each regional hospitalshospital approve medicinal cannabis patient by patient. ItThey also created a Drug Users Administrative Panel: on the prosecutor's recommendation, to which people caught with drugs for personal use are sent tocan be referred for rehabilitation andin place of prosecution. The changes had not prosecutedyet been proclaimed in January 2023.

Kenya lowers the penalty for cannabis for personal use

date

2022-03-07

description

An amendment to the narcotics Act sethalved the penaltymaximum for cannabis held solely for one's own use at up, from ten years to five years in prison or, and added the option of a fine of up to 100,000 shillings, while raising. For other possession it set very heavy tiered penalties, from at least five years for other drugs and for traffickingunder a gram to life above 100 grams.

sources

Kenya Law — Narcotic Drugs and Psychotropic Substances (Control) Act, as amended 2022 The Star — Police, narcotics consumers on target as new law is gazetted

Iraq replaces its 1960s drug law

description

Law No. 50 of 2017 sets one to three years in prison for possession and offersreplaced an old law that had not kept up with international practice. It grants amnesty to drug users who come forward for treatment and reduces penalties for offenders who help the authorities catch dealers. Under Saddam Hussein's government drug offences could bring a death sentence.

Vietnam makes drug use a crime

sources

Research Square — Government and law enforcement perspectives on the drug decriminalisation policy in Vietnam Law and World — The changes in drug laws to apply the death penalty for drug-related offences in Vietnam

Iran declares cannabis illegal

description

The government outlaweddeclared cannabis an illegal substance in 1959, four years after banning opium poppy cultivation, ahead of signing the 1961 Single Convention. The same source says a 1926 penal law had already set prison terms and fines for cannabis use, so the first restriction may be older.

checked

true

Norway's Opium Act reaches beyond opium

title

Norway's Opium Act covers cannabisreaches beyond opium

description

The Storting replaced the 1913 law, which was limited to opium, opiates and cocaine and carried no penalties, with one that reachedcovering other narcotics named by the governmentroyal decree, and added fines or up to six months in prison. Cannabis has been prohibited in Norway since thenhad taken on control of cannabis through the 1925 Geneva convention, although itthough the drug was almost unknown there until the 1960s.

Removed 1

Countries and states 2 files

Bosnia and Herzegovina countries/ba.yaml

access 0 → 1

medical

nonelimited

summary

Illegal; personal possession is generally fined as a misdemeanour while growing and dealing are crimes. A 2016 medical-Since December 2025 doctors can prescribe cannabis task force produced no law-based pharmaceutical preparations.

segments › access › text

No legal source for recreational use. Since December 2025 a doctor can prescribe cannabis-based pharmaceutical preparations, which are imported.

segments › access › sources

gi, wiki

context › medical › text

None. AMedical use was approved in December 2025, after a decade of campaigning that began with a state task force was set up in 2016 to explore legalising medical cannabis,. Only pharmaceutical preparations prescribed by a doctor are allowed; the Agency for Medicines and patients have campaigned since, without resultMedical Devices oversees import and distribution. Growing cannabis for medicine remains prohibited.

context › medical › sources

wikigi, cannwiki

sources

Global Initiative — How cannabis legalization reshaped illicit markets in the Western Balkans (April 2026) 3 unchanged

France countries/fr.yaml

context › medical › text

A national experiment began in 2021 for a few thousand patients with severe conditions. It formally ended on 31 December 2024 with no permanent regime in place; existing patients were kept on treatment through extensions, the latest to 31 MarchDecember 2026. A decree setting out how cannabis medicines will be assessed and reimbursed went to the Conseil d'État on 2 July 2026; first prescriptions under a permanent regime are expected in 2027 at the earliest.

context › medical › sources

hl, sp, chn

sources

Cannabis Health News — France submits decree for reimbursement of medical cannabis (July 2026) 4 unchanged

Code, design and documents 12 files

README.md +2 −0
@@ -41,4 +41,6 @@ Built with [Astro](https://astro.build). Pushing to `main` deploys to GitHub Pag
 | `changes.yaml` | The History page: laws and events that moved a country's score, with the scores before, plus treaties; the home page shows the latest four |
 | `src/` | The website: pages, components, styles |
+| `docs/DATA-MAINTENANCE.md` | How the country profiles and the history are checked and extended, and what is still open |
+| `scripts/` | Helpers for that work: apply a batch of history edits, list what still needs a source |
 
 The build checks every data file against a schema (`src/content.config.ts`) and fails with a clear message if, say, a score is out of range or a segment cites a source that isn't defined.

docs/DATA-MAINTENANCE.md +173 −0
@@ -0,0 +1,173 @@
+# Maintaining the data: country profiles and history
+
+How to check, correct and extend the two data sets behind the site. Written as a handover: it
+records how the work has been done so far, what was learned, and what is still open.
+
+## The task in one paragraph
+
+The site scores cannabis freedom per country (`countries/*.yaml`) and keeps a history of every
+law, ruling and treaty that shaped it (`changes.yaml`). The job is to make both **complete and
+trustworthy**: every country should have its whole cannabis history, from the first restriction
+to today, each entry backed by a source that was actually read; and every country profile should
+say what the law is *now*. When research turns up something that changes a country's current
+situation, the profile and its scores are updated too, not only the history.
+
+## Where things are
+
+| Path | What |
+|---|---|
+| `countries/<id>.yaml`, `countries/{us,ca,au}/index.yaml` | One profile per country: status, six scores, a text and sources per segment, context, sources |
+| `changes.yaml` | The history, newest first. The header comment documents every field |
+| `segments.yaml` | The six segments, their weights and what each score level means |
+| `src/content.config.ts` | The schema. The build fails with a clear message when data breaks it |
+| `scripts/history-batch.cjs` | Applies a batch of history edits from a JSON file (see below) |
+| `scripts/data-status.cjs` | Counts, and lists of what is unchecked, estimated, thin or weakly sourced |
+
+Run everything from the project root. `npm run build` validates all data; run it after every
+batch. The dev server (`npm run dev`) does **not** pick up edits to `changes.yaml` or the schema
+until it is restarted.
+
+## How a history entry works
+
+```yaml
+- id: tunisia-law-52-amended-2017     # also the page address; never reuse or rename lightly
+  date: "2017-04-25"                  # YYYY-MM-DD, YYYY-MM or YYYY, as precise as the source
+  category: easing                    # ban tightening death easing legalisation medical debate international other
+  country: tn                         # omit for international entries
+  title: Tunisia gives judges discretion in drug cases
+  description: >-
+    One to three sentences. Only what the source states.
+  before: {possession: 0, cultivation: 0, enforcement: 0, sharing: 0, consumption: 1, access: 0}
+  checked: true                       # only when the source was opened and read
+  sources:
+    - title: TalkingDrugs — Tunisia parliament approves …
+      url: https://…
+```
+
+- **`before`** is the six scores *before* the event. The scores after it are the `before` of the
+  same country's next scored entry, or the country's current scores. So the chain must be
+  consistent: the newest scored entry's "after" is the profile's current scores.
+- Leave `before` out for a **context entry**: a debate, a failed bill, a law that changed
+  nothing a user would feel. Most entries are context entries.
+- If the law or the reality changed, the entry **should move the score**, by an honest small
+  amount (usually one point in one or two segments).
+- **`estimate: true`** (with an optional `window: [from, to]`) marks a first restriction whose
+  date is unknown. Replace these with a dated law whenever one is found.
+- **`checked: true`** means: the date and the event were compared with a source that was read.
+  A search-result summary is not a read source (see "What went wrong").
+
+## Workflow that has worked
+
+1. **Pick countries.** `node scripts/data-status.cjs thin` lists places with two or fewer
+   entries; `unchecked` and `estimates` list what still needs a source. Go by population, or by
+   region in batches of ten or so.
+2. **Read sources.** In rough order of usefulness:
+   - Wikipedia "Cannabis in <country>", "Drug policy of <country>", "Legal history of cannabis in
+     <country>" (also the local-language edition). Ask for *every dated legal event the page
+     states*, and for exact quotes when checking one claim.
+   - The statute itself on a national law site (legislation.gov.*, *lii.org, PacLII, kenyalaw,
+     lawphil, BOE, sejm, slov-lex, legifrance …) or UNODC SHERLOC.
+   - Library of Congress Global Legal Monitor, Transnational Institute country guides, IDPC,
+     Human Rights Watch, Amnesty, Harm Reduction International, EMCDDA/EUDA country reports.
+   - National or regional news for recent events.
+3. **Write a batch file** and apply it:
+
+   ```bash
+   node scripts/history-batch.cjs batch.json
+   npm run build
+   ```
+
+   ```json
+   {
+     "checks": [["existing-entry-id", ["Source title", "https://…"], "1998-04-17"]],
+     "entries": [{
+       "id": "norway-21-years-1984", "date": "1984", "c": "no", "cat": "tightening",
+       "title": "Norway raises the drug maximum to 21 years",
+       "desc": "Parliament lifted the upper limit …",
+       "before": null,
+       "page": ["FHI — Historisk oversikt …", "https://…"]
+     }]
+   }
+   ```
+
+   - `checks`: mark an existing entry checked, add the source, optionally correct the date.
+   - `before`: `null` for a context entry; `"FREE"` for the unregulated state before a first
+     ban; an array of six scores; or a delta object such as `{"enforcement": 1}`, meaning "one
+     point higher before this event than after it". Deltas are resolved against the chain.
+   - `page`: a Wikipedia page name, or `[title, url]`.
+   - `replaces`: id of an estimated entry this dated one replaces.
+   - `unchecked: true`: add the entry without the checked mark (source not actually read).
+   - Omit `c` for an international entry.
+   - The script re-sorts the file and refuses duplicate ids.
+4. **Compare with the profile.** If a recent event changes what is true today, edit the country
+   file: `recreational` / `medical`, the six `scores`, the `summary`, the segment or context
+   texts, add the source under `sources`, and set `updated` to today. Keep the history chain
+   consistent with the new scores.
+5. **Build, restart the preview, spot-check** one country page and `/history/`.
+
+## Rules learned the hard way
+
+- **Never trust a search summary for a fact you will mark as checked.** One summary attributed
+  Bosnia's December 2025 medical approval to Kosovo; others invented sentence lengths or dates.
+  Open the page and read it. When the page will not load (403, PDF), add the entry unchecked.
+- **Write only what the source says.** Do not add colour from memory to a description, even if
+  you are sure. Several such sentences had to be removed afterwards.
+- **Distrust `cannabisregulations.ai` and `tripbase.com`.** The first one described a 2024
+  legalisation in Palau that never happened. Do not use either as the only source for anything.
+  Sensi Seeds country blogs are weak too.
+- **Wikipedia is fine for dates and events, weak for first bans.** Colonial first restrictions
+  usually need a statute database, a UN document or an academic paper.
+- **A statute's date is not always the first ban.** A country's "first narcotics law" may come
+  long after cannabis was already restricted (Oman 1999, Zimbabwe 1956, Iran 1959). Then add it
+  as a context entry and leave the estimate in place.
+- **Federations:** state-level changes (US, Australia, Canada, India) are context entries on the
+  national timeline unless the national picture really shifts.
+- **Search tool limits:** more than about ten web searches in one go start failing with
+  rate-limit errors. Batches of five work.
+- **Blocked sources:** the UN Digital Library (403), UNODC Bulletin on Narcotics pages (404),
+  Library of Congress (403), TNI Spanish pages (403) and most PDFs cannot be read by the fetch
+  tool. If the user saves such a PDF into the project it can be read from disk.
+
+## What is still open
+
+Run `node scripts/data-status.cjs` for current numbers. As of 5 October 2026: 784 entries,
+673 checked, 38 unchecked, 73 estimated first bans.
+
+1. **Profiles resting on unreliable sites — the most important item.**
+   `node scripts/data-status.cjs weak` lists 61 profiles where at least one scored segment
+   cites only `cannabisregulations.ai` or `tripbase.com`. Worst: Azerbaijan, Cameroon, Cabo
+   Verde, Chad (all six segments); Angola, Burkina Faso, Central African Republic, Congo,
+   Eritrea, Micronesia, Comoros, Libya, Montenegro, Mongolia, Niger, Sudan, Yemen. For several
+   the real statute is already cited in the history (Cameroon Law 97/019, Burkina Faso Law
+   017-99/AN, Niger Ordinance 99-42, Angola Law 3/99, Libya Law 7/1990, Yemen Law 3/1993) and
+   can be reused. Re-source, and correct scores if the statute says something different.
+2. **Entries that rest on summaries only.** Many entries added in October 2026 are marked
+   checked on the strength of a search summary. Open their sources; downgrade or correct.
+   Known to be unconfirmed: Senegal 1997/2007, Sierra Leone 2008, The Gambia 2003/2011/2014,
+   Liberia 2014/2023, El Salvador 2003, Kazakhstan 2011, Eswatini 1922, Botswana 1922,
+   Mozambique 1914, Zambia 1926, Ghana 1935, Hungary 1930, Costa Rica 1928, Kiribati/Tuvalu 1948,
+   St Kitts 1937, Saint Lucia 1938, Iraq 1933.
+3. **Unchecked entries** (`data-status.cjs unchecked`): Singapore and Papua New Guinea (blocked
+   sources), the Baltic states 1940, French Equatorial Africa 1926, Laos 1996, Canada 1954,
+   Jamaica 1941, UK 1967, Germany 1872, Bahamas 2024 (passage unconfirmed), Antigua's December
+   2018 Act, Saudi flogging (February vs April 2020), Taiwan 1955, Iran 1959, Kuwait's in-force
+   date, Norway's June 2025 passage.
+4. **Estimated first bans** (`data-status.cjs estimates`): mostly African colonies, Gulf states,
+   Central America and small islands. Leads not yet usable: Tanzania (Noxious Plants
+   (Prohibition) Ordinance, 1926?), Lesotho (1922 proclamation), Belgian Congo (1903 decree),
+   Uruguay (Law 9.692 of 1937).
+5. **Thin countries** (`data-status.cjs thin`): about twenty places still have one entry.
… 15 more lines
scripts/data-status.cjs +30 −0
@@ -0,0 +1,30 @@
+// Where the data stands: node scripts/data-status.cjs [unchecked|estimates|thin|weak]
+// Run from the project root. Needs js-yaml (already in node_modules).
+const fs = require("fs"), y = require("js-yaml");
+const changes = y.load(fs.readFileSync("changes.yaml", "utf8"));
+const file = (id) => (fs.existsSync(`countries/${id}.yaml`) ? `countries/${id}.yaml` : `countries/${id}/index.yaml`);
+const ids = fs.readdirSync("countries").map((f) => f.replace(".yaml", ""));
+const country = Object.fromEntries(ids.map((id) => [id, y.load(fs.readFileSync(file(id), "utf8"))]));
+const what = process.argv[2];
+
+const unchecked = changes.filter((e) => !e.checked && !e.estimate);
+const estimates = changes.filter((e) => e.estimate);
+console.log(`history: ${changes.length} entries | checked ${changes.filter((e) => e.checked).length} | unchecked ${unchecked.length} | estimated first bans ${estimates.length}`);
+
+if (what === "unchecked") for (const e of unchecked) console.log([e.country || "--", e.date, e.id, e.sources[0].url].join(" | "));
+if (what === "estimates") console.log(estimates.map((e) => `${e.country}${e.window ? `[${e.window}]` : ""}`).join(" "));
+if (what === "thin") {
+  const n = {};
+  for (const e of changes) if (e.country) n[e.country] = (n[e.country] || 0) + 1;
+  for (const k of [0, 1, 2]) console.log(`${k} entries:`, ids.filter((id) => (n[id] || 0) === k).map((id) => `${id}:${country[id].name}`).join(", "));
+}
+if (what === "weak") {
+  // Profiles whose scored segments rest only on sites that proved unreliable.
+  const weak = /cannabisregulations\.ai|tripbase\.com/;
+  for (const id of ids) {
+    const c = country[id];
+    const bad = Object.entries(c.sources || {}).filter(([, s]) => weak.test(s.url)).map(([k]) => k);
+    const only = Object.entries(c.segments || {}).filter(([, s]) => s.sources?.length && s.sources.every((k) => bad.includes(k))).map(([k]) => k);
+    if (only.length) console.log(`${id} ${c.name}: ${only.join(", ")}`);
+  }
+}

scripts/history-batch.cjs +43 −0
@@ -0,0 +1,43 @@
+// Generic runner: node fill.cjs data.json
+// data = { checks: [[id, wikiPage, newDate?]], entries: [{id,date,c,cat,title,desc,before,page,replaces}] }
+// `before`: "FREE", "REG", an array, a delta object applied to the scores that follow, or null (context).
+const fs=require("fs"),y=require("js-yaml");
+const data=JSON.parse(fs.readFileSync(process.argv[2],"utf8"));
+let text=fs.readFileSync("changes.yaml","utf8");
+const K=["possession","cultivation","enforcement","sharing","consumption","access"];
+const cur={};for(const f of fs.readdirSync("countries")){const id=f.replace(".yaml","");const p="countries/"+f+(f.endsWith(".yaml")?"":"/index.yaml");cur[id]=y.load(fs.readFileSync(p,"utf8"))}
+const T=d=>Date.parse(d.length===4?d+"-07-01":d.length===7?d+"-15":d);
+const block=id=>{const i=text.indexOf(`- id: ${id}\n`);if(i<0)throw new Error("missing "+id);let j=text.indexOf("\n- id:",i);j=j<0?text.length:j+1;return [i,j]};
+const drop=id=>{const [i,j]=block(id);text=text.slice(0,i)+text.slice(j)};
+const edit=(id,fn)=>{const [i,j]=block(id);text=text.slice(0,i)+fn(text.slice(i,j))+text.slice(j)};
+const wrap=(t,w=96)=>{const out=[];let line="";for(const word of t.split(" ")){if((line+" "+word).trim().length>w){out.push(line);line=word}else line=(line+" "+word).trim()}out.push(line);return out.map(l=>"    "+l).join("\n")};
+const q=t=>(/: |^["'\[{>|*&!%@`]| #/.test(t)?JSON.stringify(t):t);
+const src=(page)=>/^https?:/.test(page[1]||"")?page:["Wikipedia — "+decodeURIComponent(page).replace(/_/g," "),"https://en.wikipedia.org/wiki/"+page];
+const PRESET={FREE:[10,10,10,10,10,8],REG:[6,3,6,3,5,6]};
+const fmt=b=>"{"+K.map((k,i)=>`${k}: ${b[i]}`).join(", ")+"}";
+for(const [id,page,date] of data.checks||[]) edit(id,b=>{
+  if(date) b=b.replace(/  date: "[^"]+"/,`  date: "${date}"`);
+  if(!b.includes("\n  checked: true\n")) b=b.replace("\n  sources:\n","\n  checked: true\n  sources:\n");
+  const [t,u]=src(page); if(!b.includes(u)) b=b.replace(/\n*$/,"\n")+`    - title: ${q(t)}\n      url: ${u}\n\n`;
+  return b});
+const N=(data.entries||[]).map(n=>({...n,before:typeof n.before==="string"?PRESET[n.before]:n.before,src:src(n.page)}));
+for(const n of N){ if(n.c&&!cur[n.c]) throw new Error("no country "+n.c); if(n.replaces) drop(n.replaces); }
+const existing=y.load(text);
+const scored=existing.filter(e=>e.before).map(e=>({c:e.country,t:T(e.date),b:K.map(k=>e.before[k])}));
+const afterOf=(c,t)=>{const next=scored.filter(s=>s.c===c&&s.t>t).sort((a,b)=>a.t-b.t)[0];return next?next.b:K.map(k=>cur[c].scores[k])};
+N.sort((a,b)=>T(b.date)-T(a.date));
+let flat=0;
+for(const n of N){
+  if(n.before&&!Array.isArray(n.before)){const after=afterOf(n.c,T(n.date));const b=after.map((v,i)=>Math.max(0,Math.min(10,v+(n.before[K[i]]||0))));
+    if(b.every((v,i)=>v===after[i])){n.before=null;flat++}else n.before=b}
+  if(n.before){const after=afterOf(n.c,T(n.date));if(n.before.every((v,i)=>v===after[i]))throw new Error("no change: "+n.id);scored.push({c:n.c,t:T(n.date),b:n.before})}
+}
+for(const n of N){ if(text.includes(`- id: ${n.id}\n`)) throw new Error("dup "+n.id);
+  text=text.replace(/\n*$/,"\n")+`\n- id: ${n.id}\n  date: "${n.date}"\n  category: ${n.cat}\n`+(n.c?`  country: ${n.c}\n`:"")+`  title: ${q(n.title)}\n  description: >-\n${wrap(n.desc)}\n`+(n.before?`  before: ${fmt(n.before)}\n`:"")+(n.unchecked?"":"  checked: true\n")+`  sources:\n    - title: ${q(n.src[0])}\n      url: ${n.src[1]}\n`}
+const head=text.slice(0,text.indexOf("- id:"));
+const blocks=text.slice(text.indexOf("- id:")).split(/\n(?=- id: )/).map(b=>b.replace(/\n+$/,""));
+const d=b=>b.match(/  date: "([^"]+)"/)[1];
+blocks.sort((a,b)=>T(d(b))-T(d(a)));
+fs.writeFileSync("changes.yaml",head+blocks.join("\n\n")+"\n");
+const all=y.load(fs.readFileSync("changes.yaml","utf8"));
+console.log("added",N.length,"(flat deltas kept as context:",flat+")","| total",all.length,"| checked",all.filter(e=>e.checked).length,"| unchecked",all.filter(e=>!e.checked&&!e.estimate).length,"| estimates",all.filter(e=>e.estimate).length);

src/components/CountryArticle.astro +13 −11
@@ -75,13 +75,16 @@ const circ = 2 * Math.PI * r;
     )}
     <h2 class="d-h">In short <span><a href={`${base}can-i/`}>all countries →</a></span></h2>
-    <dl class="answers">
+    <ul class="answers">
       {answers.map((x) => (
-        <div class={`answer a-${x.answer}`}>
-          <dt>{x.q}</dt>
-          <dd><b>{x.label}</b><span>{x.text}</span></dd>
-        </div>
+        <li>
+          <a class={`answer a-${x.answer}`} href={`#seg-${x.seg}`} data-seg-link={x.seg} title="Show the rules and sources">
+            <span class="q">{x.q}</span>
+            <b>{x.label}</b>
+            <span class="t">{x.text}</span>
+          </a>
+        </li>
       ))}
-    </dl>
-    <p class="d-hint">A rough guide read from the scores below, not legal advice. The details and the exceptions are in each segment.</p>
+    </ul>
+    <p class="d-hint">A rough guide read from the scores below, not legal advice. The details and the exceptions are in each segment: select an answer to jump to it.</p>
     {use && (
       <dl class="c-use">
@@ -101,5 +104,4 @@ const circ = 2 * Math.PI * r;
     )}
     {scored.length > 0 && <CountryChart history={scored} />}
-    {history.length > 0 && <CountryChanges history={history} place="side" />}
     {group && (
       <label class="field sub-select">
@@ -119,5 +121,5 @@ const circ = 2 * Math.PI * r;
       : <p class="d-hint">Select a segment to show or hide the exact rules and sources.</p>}
     {sub && SEGMENTS.map((s, i) => (
-      <details class="segrow" open>
+      <details class="segrow" data-seg={s.id} open>
         <summary>
           <div class="segrow-top">
@@ -145,5 +147,5 @@ const circ = 2 * Math.PI * r;
       const seg = d?.segments[i];
       return (
-        <details class="segrow" open>
+        <details class="segrow" data-seg={s.id} open>
           <summary>
             <div class="segrow-top">
@@ -170,5 +172,4 @@ const circ = 2 * Math.PI * r;
       );
     })}
-    {history.length > 0 && <CountryChanges history={history} place="main" />}
 
     {group && (
@@ -231,4 +232,5 @@ const circ = 2 * Math.PI * r;
       </>
     )}
+    {history.length > 0 && <CountryChanges history={history} />}
     {updated && <p class="c-updated">Last updated: <time datetime={updated}>{updatedText}</time>{d && ` · ${d.sources.length} sources`}{scored.length > 0 && ` · history: ${history.filter((ch) => ch.confidence.key === "checked").length} of ${history.length} entries checked`}</p>}
   </div>

src/components/CountryChanges.astro +18 −16
@@ -1,26 +1,28 @@
 ---
-// A country's recorded changes, newest first. Placed twice in the country article: in the side
-// pane on wide screens, and under the score breakdown where the layout is a single column.
+// A country's recorded changes, newest first, at the end of the country article.
+// Drawn as the same timeline as the History page.
+import ScoreChange from "./ScoreChange.astro";
 import { changeDate } from "../lib/countries.js";
+import { tierColor } from "../lib/tiers.js";
 
-const { history, place } = Astro.props;
+const { history } = Astro.props;
 const base = import.meta.env.BASE_URL;
 ---
-<div class={`c-changes c-changes-${place}`}>
-  <h2>History</h2>
-  <ul>
+<div class="c-changes">
+  <h2 class="d-h">History <span>{history.filter((ch) => ch.confidence.key === "checked").length} of {history.length} checked against a source</span></h2>
+  <ol class="chl-list">
     {history.map((ch) => (
-      <li>
-        <a href={`${base}history/${ch.id}/`}>
-          <time datetime={ch.date}>{changeDate(ch)} <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span></time>
-          <b><span class="kind" title={ch.kind.label} aria-hidden="true">{ch.kind.emoji}</span><span class="visually-hidden">{ch.kind.label}: </span>{ch.title}</b>
-          {ch.score && <span class="c-change-score">
-            {ch.score.before} <span aria-hidden="true">→</span><span class="visually-hidden">to</span> {ch.score.after}
-            <span class={`delta ${ch.score.after >= ch.score.before ? "up" : "down"}`}>{ch.score.after >= ch.score.before ? "+" : "−"}{Math.abs(ch.score.after - ch.score.before)}</span>
-          </span>}
+      <li class="chl-item" style={`--c:${ch.score ? tierColor(ch.score.after) : "var(--muted)"}`}>
+        <a class="card chl-card" href={`${base}history/${ch.id}/`}>
+          <p class="change-meta">
+            <time datetime={ch.date}>{changeDate(ch)}</time>
+            <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span>
+          </p>
+          <h3><span class="kind" title={ch.kind.label} aria-hidden="true">{ch.kind.emoji}</span><span class="visually-hidden">{ch.kind.label}: </span>{ch.title}</h3>
+          <p class="chl-text">{ch.description}</p>
+          {ch.score && <ScoreChange score={ch.score} />}
         </a>
       </li>
     ))}
-  </ul>
-  <p class="c-changes-note">{history.filter((ch) => ch.confidence.key === "checked").length} of {history.length} checked against a source</p>
+  </ol>
 </div>

src/layouts/Base.astro +21 −0
@@ -67,4 +67,25 @@ const fullTitle = title ? `${title} — Cannabis Freedom Index` : "Cannabis Free
       try { localStorage.setItem("cfi-theme", root.dataset.theme); } catch (e) {}
     });
+
+    // "In short" answers on a country article: go to the segment they were read from,
+    // open it and light it up. Works on the country page and inside the home page drawer,
+    // so the address is left alone (the drawer uses it to know which country is open).
+    function showSegment(article, id) {
+      var row = article && article.querySelector('.segrow[data-seg="' + id + '"]');
+      if (!row) return;
+      row.open = true;
+      row.classList.remove("flash");
+      void row.offsetWidth; // restart the animation when the same answer is chosen twice
+      row.classList.add("flash");
+      row.scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" });
+    }
+    document.addEventListener("click", function (e) {
+      var link = e.target.closest("a[data-seg-link]");
+      if (!link || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
+      e.preventDefault();
+      showSegment(link.closest("article.country"), link.dataset.segLink);
+    });
+    // A link straight to a segment, e.g. /country/de/#seg-possession
+    if (/^#seg-[a-z]+$/.test(location.hash)) showSegment(document.querySelector("article.country"), location.hash.slice(5));
   </script>
 </body>

src/lib/answers.js +4 −1
@@ -3,4 +3,6 @@
 // sharing, consumption, access. See segments.yaml for what each level means.
 
+import { SEGMENT_IDS } from "./segment-ids.js";
+
 export const ANSWER_LABEL = { yes: "Yes", limited: "Limited", no: "No" };
 
@@ -42,8 +44,9 @@ export const QUESTIONS = [
 
 // `s` is the array of six segment scores of a country, state or province.
+// `seg` in the result is the id of the segment the answer was read from.
 export const answersFor = (s) =>
   QUESTIONS.map(({ id, q, seg, steps }) => {
     const [answer, text, label = ANSWER_LABEL[answer]] = pick(s[seg], steps);
-    return { id, q, answer, text, label };
+    return { id, q, answer, text, label, seg: SEGMENT_IDS[seg] };
   });
 

src/pages/can-i.astro +41 −4
@@ -4,9 +4,18 @@ import Base from "../layouts/Base.astro";
 import { places, regions } from "../lib/countries.js";
 import { answersFor, QUESTIONS, ANSWER_LABEL } from "../lib/answers.js";
+import { SEGMENTS } from "../lib/countries.js";
+import { SEGMENT_IDS } from "../lib/segment-ids.js";
 
 const base = import.meta.env.BASE_URL;
 const rows = [...places]
   .sort((a, b) => a.name.localeCompare(b.name))
-  .map((c) => ({ c, answers: answersFor(c.s) }));
+  .map((c) => ({
+    c,
+    // Each answer also carries the explanation of the segment it was read from, for the tooltip.
+    answers: answersFor(c.s).map((x) => {
+      const i = SEGMENT_IDS.indexOf(x.seg);
+      return { ...x, name: SEGMENTS[i].name, score: c.s[i], why: c.details?.segments[i]?.t ?? "" };
+    }),
+  }));
 ---
 <Base title="Can I?" description="Can I carry, grow, buy or use cannabis in public, and what happens if I'm caught? Five plain answers for every country.">
@@ -43,7 +52,9 @@ const rows = [...places]
                 <th scope="row"><a href={`${base}country/${c.slug}/`}><span class="flag">{c.flag}</span> {c.name}</a></th>
                 {answers.map((x) => (
-                  <td class={`a-${x.answer}`} title={x.text}>
-                    <b>{x.label}</b>
-                    <small>{x.text}</small>
+                  <td class={`a-${x.answer}`}>
+                    <a href={`${base}country/${c.slug}/#seg-${x.seg}`} data-tip={x.why} data-tip-head={`${c.name} · ${x.name} ${x.score}/10`}>
+                      <b>{x.label}</b>
+                      <small>{x.text}</small>
+                    </a>
                   </td>
                 ))}
@@ -54,4 +65,5 @@ const rows = [...places]
       </div>
     </div>
+    <div class="cani-tip" id="caniTip" role="tooltip" hidden><b></b><p></p><small>Select to open the details</small></div>
     <p class="table-updated" id="caniCount">{rows.length} countries and territories</p>
   </main>
@@ -72,4 +84,29 @@ const rows = [...places]
       count.textContent = `${shown} of ${rows.length} countries and territories`;
     };
+    // Hovering or focusing an answer shows the explanation from the country's score breakdown.
+    const tip = document.getElementById("caniTip");
+    const table = document.getElementById("caniTable");
+    const showTip = (a) => {
+      tip.querySelector("b").textContent = a.dataset.tipHead;
+      tip.querySelector("p").textContent = a.dataset.tip;
+      tip.hidden = false;
+      const cell = a.getBoundingClientRect();
+      const box = tip.getBoundingClientRect();
+      const left = Math.min(Math.max(8, cell.left), innerWidth - box.width - 8);
+      // Below the cell, or above it when there is no room underneath.
+      const top = cell.bottom + box.height + 8 > innerHeight ? cell.top - box.height - 6 : cell.bottom + 6;
+      tip.style.left = `${left}px`;
+      tip.style.top = `${Math.max(8, top)}px`;
+    };
+    const hideTip = () => { tip.hidden = true; };
+    table.addEventListener("mouseover", (e) => {
+      const a = e.target.closest("a[data-tip]");
+      if (a) showTip(a); else hideTip();
+    });
+    table.addEventListener("mouseleave", hideTip);
+    table.addEventListener("focusin", (e) => { const a = e.target.closest("a[data-tip]"); if (a) showTip(a); });
+    table.addEventListener("focusout", hideTip);
+    addEventListener("scroll", hideTip, { passive: true });
+
     // The filter bar and the table's header row stay in view while scrolling;
     // they need to know how tall the bars above them are.

src/pages/history/[id].astro +55 −3
@@ -2,6 +2,6 @@
 import Base from "../../layouts/Base.astro";
 import ScoreChange from "../../components/ScoreChange.astro";
-import { changes, formatDate, changeDate } from "../../lib/countries.js";
-import { tierColor } from "../../lib/tiers.js";
+import { changes, countries, changeDate, REC_LABEL, REC_HELP, MED_LABEL } from "../../lib/countries.js";
+import { tierOf, tierColor } from "../../lib/tiers.js";
 
 export function getStaticPaths() {
@@ -13,4 +13,10 @@ const base = import.meta.env.BASE_URL;
 const c = ch.country;
 const diff = (seg) => seg.after - seg.before;
+// The country's other entries closest in time to this one, for the side panel.
+const own = c ? changes.filter((x) => x.country === c && !x.estimate) : [];
+const at = own.indexOf(ch);
+const nearby = own.slice(Math.max(0, at - 3), at + 4);
+const r = 30;
+const circ = 2 * Math.PI * r;
 ---
 <Base title={ch.title} description={ch.score ? `${ch.title}: ${c.name} moved from ${ch.score.before} to ${ch.score.after} on the Cannabis Freedom Index. ${ch.description}` : `${ch.title}. ${ch.description}`}>
@@ -18,7 +24,7 @@ const diff = (seg) => seg.after - seg.before;
     <div class="page-bar">
       <a class="back" href={`${base}history/`}>← History</a>
-      {c && <a class="btn" href={`${base}country/${c.slug}/`}>{c.flag} {c.name} in full</a>}
     </div>
 
+    <div class={`change-layout${c ? "" : " no-aside"}`}>
     <article class="change-detail">
       <p class="eyebrow"><span title={ch.kind.help}>{ch.kind.emoji} {ch.kind.label}</span> · <time datetime={ch.date}>{changeDate(ch)}</time> · {ch.place.name}</p>
@@ -62,4 +68,50 @@ const diff = (seg) => seg.after - seg.before;
     </article>
 
+    {c && (
+      <aside class="change-aside" aria-label={`About ${c.name}`}>
+        <div class="card ca-card">
+          <a class="ca-head" href={`${base}country/${c.slug}/`}>
+            <span class="ca-flag">{c.flag}</span>
+            <span><b>{c.name}</b><small>{c.region}</small></span>
+          </a>
+          <div class="ca-score">
+            <svg class="ring" width="76" height="76" viewBox="0 0 76 76" aria-hidden="true">
+              <circle cx="38" cy="38" r={r} fill="none" stroke="var(--border)" stroke-width="7" />
+              <circle cx="38" cy="38" r={r} fill="none" stroke={tierColor(c.raw)} stroke-width="7" stroke-linecap="round"
+                stroke-dasharray={`${(c.raw / 100) * circ} ${circ}`} transform="rotate(-90 38 38)" />
+              <text x="38" y="46" text-anchor="middle">{c.score}</text>
+            </svg>
+            <div>
+              <div class="tier">{tierOf(c.raw).name}</div>
+              <p class="sub">{c.disputed ? "Not ranked: disputed territory" : `#${c.rank} of ${countries.length} today`}</p>
+            </div>
+          </div>
+          <div class="d-chips">
+            <span class={`chip s-${c.rec}`} title={REC_HELP[c.rec]}>Recreational: {REC_LABEL[c.rec]}</span>
+            <span class={`chip s-${c.med}`}>Medical: {MED_LABEL[c.med]}</span>
+          </div>
+          <p class="ca-note">{c.note}</p>
+          <a class="btn btn-primary ca-open" href={`${base}country/${c.slug}/`}>Open {c.name} <span aria-hidden="true">→</span></a>
+        </div>
+
+        {nearby.length > 1 && (
+          <div class="ca-more">
+            <h2>More from {c.name}</h2>
+            <ol>
+              {nearby.map((x) => (
+                <li class={x === ch ? "current" : undefined}>
+                  {x === ch
+                    ? <span><time datetime={x.date}>{changeDate(x)}</time><b>{x.kind.emoji} {x.title}</b></span>
+                    : <a href={`${base}history/${x.id}/`}><time datetime={x.date}>{changeDate(x)}</time><b>{x.kind.emoji} {x.title}</b></a>}
+                </li>
+              ))}
+            </ol>
+            {own.length > nearby.length && <p class="ca-all"><a href={`${base}country/${c.slug}/`}>All {own.length} entries for {c.name} →</a></p>}
+          </div>
+        )}
+      </aside>
+    )}
+    </div>
+
     <nav class="pager" aria-label="Other entries">
       {newer

src/pages/index.astro +9 −7
@@ -202,7 +202,4 @@ const USE_SCALES = {
         <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 20V9M12 20V4M19 20v-7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
       </a>
-      <a class="icon-btn" id="dExpand" href={base} aria-label="Open full page" title="Open full page">
-        <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M14 4h6v6M10 20H4v-6M20 4l-7 7M4 20l7-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
-      </a>
       <button class="icon-btn" id="dClose" aria-label="Close">✕</button>
     </div>
@@ -459,9 +456,15 @@ const USE_SCALES = {
       return cache.get(url).cloneNode(true);
     }
+    // The drawer is a preview: a big button under the summary leads to the full page.
+    function withOpenButton(article, url) {
+      const open = Object.assign(document.createElement("a"), { className: "btn btn-primary drawer-open", href: url });
+      open.innerHTML = 'Open full page <span aria-hidden="true">→</span>';
+      (article.querySelector(".c-disputed") ?? article.querySelector(".d-note")).after(open);
+      return article;
+    }
     // Swap the drawer to another view of the same country (a state, or back to national).
     async function showInDrawer(url) {
       try {
-        body.replaceChildren(await loadArticle(url));
-        $("#dExpand").href = url;
+        body.replaceChildren(withOpenButton(await loadArticle(url), url));
         dialog.scrollTop = 0;
       } catch (err) {
@@ -502,10 +505,9 @@ const USE_SCALES = {
       markActive();
       if (!id) return dialog.open && dialog.close();
-      $("#dExpand").href = pageUrl(id);
       $("#dCompare").href = `${base}compare/?c=${id.toLowerCase()}`;
       try {
         const article = await loadArticle(pageUrl(id));
         if (currentId() !== id) return;
-        body.replaceChildren(article);
+        body.replaceChildren(withOpenButton(article, pageUrl(id)));
       } catch (err) {
         location.href = pageUrl(id);

src/styles/global.css +64 −21
@@ -257,5 +257,6 @@ th.num-col { text-align: right; }
 .drawer-bar .icon-btn { background: var(--surface-2); box-shadow: 0 2px 10px rgba(0, 0, 0, .25); }
 .drawer-inner { padding: 22px 24px 40px; }
-.drawer-inner .d-head { padding-right: 134px; }
+.drawer-inner .d-head { padding-right: 92px; }
+.drawer-inner .drawer-open { display: flex; justify-content: center; padding: 12px 16px; font-size: 15px; margin: 16px 0 4px; }
 .drawer-inner .c-sources { display: none; }
 .d-head { display: flex; align-items: flex-start; gap: 14px; }
@@ -538,18 +539,8 @@ th.num-col { text-align: right; }
 
 /* ── Changes on a country page ──────────────────────── */
-.c-changes { margin-top: 18px; }
-/* single column (phones, the drawer): under the score breakdown; two columns: in the side pane */
-.c-changes-side { display: none; }
-.c-changes-main { margin-top: 26px; }
-@media (min-width: 861px) {
… 91 more lines

    ↑↓ to move, ↵ to open.