Code, design and documents 11 files
README.md +1 −0
@@ -39,4 +39,5 @@ Built with [Astro](https://astro.build). Pushing to `main` deploys to GitHub Pag
| `countries/us/` | A federation: `index.yaml` for the country, one file per state |
| `segments.yaml` | The six segments, their weights and level descriptions |
+| `changes.yaml` | Laws and events that moved a country's score, with the scores before; the home page shows the latest three |
| `src/` | The website: pages, components, styles |
src/components/ChangeCard.astro +17 −0
@@ -0,0 +1,17 @@
+---
+// A change in short: date, country, title and the overall score before → after.
+// The whole card links to the change's own page.
+import { formatDate } from "../lib/countries.js";
+import ScoreChange from "./ScoreChange.astro";
+
+const { ch } = Astro.props;
+const base = import.meta.env.BASE_URL;
+---
+<a class="card change" href={`${base}changes/${ch.id}/`}>
+ <p class="change-meta">
+ <time datetime={ch.date}>{formatDate(ch.date)}</time>
+ <span>{ch.country.flag} {ch.country.name}</span>
+ </p>
+ <h3>{ch.title}</h3>
+ <ScoreChange score={ch.score} />
+</a>
src/components/Changes.astro +16 −0
@@ -0,0 +1,16 @@
+---
+// The three most recent changes that moved a country's score (see /changes.yaml).
+import { changes } from "../lib/countries.js";
+import ChangeCard from "./ChangeCard.astro";
+
+const base = import.meta.env.BASE_URL;
+---
+<section class="changes" aria-labelledby="changesTitle">
+ <div class="changes-head">
+ <h2 class="changes-title" id="changesTitle">Latest changes</h2>
+ <a href={`${base}changes/`}>All changes →</a>
+ </div>
+ <div class="changes-grid">
+ {changes.slice(0, 3).map((ch) => <ChangeCard ch={ch} />)}
+ </div>
+</section>
src/components/ScoreChange.astro +13 −0
@@ -0,0 +1,13 @@
+---
+// Overall index score before → after, with the difference in points.
+import { tierColor } from "../lib/tiers.js";
+
+const { score } = Astro.props;
+const delta = score.after - score.before;
+---
+<p class="change-score" aria-label={`Score changed from ${score.before} to ${score.after}`}>
+ <span class="pill" style={`--c:${tierColor(score.before)}`}>{score.before}</span>
+ <span class="arrow" aria-hidden="true">→</span>
+ <span class="pill" style={`--c:${tierColor(score.after)}`}>{score.after}</span>
+ <span class={`delta ${delta >= 0 ? "up" : "down"}`}>{delta >= 0 ? "+" : "−"}{Math.abs(delta)} points</span>
+</p>
src/content.config.ts +17 −1
@@ -91,3 +91,19 @@ const segments = defineCollection({
});
-export const collections = { countries, units, segments };
+// changes.yaml — developments that moved a country's score
+const changes = defineCollection({
+ loader: file("changes.yaml"),
+ schema: z
+ .object({
+ id: z.string(),
+ date: z.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, "use YYYY-MM-DD or YYYY-MM"),
+ country: z.string().regex(/^[a-z]{2}$/), // file name in /countries
+ title: z.string().min(1),
+ description: z.string().min(1),
+ before: perSegment(score), // segment scores before the change
+ source,
+ })
+ .strict(),
+});
+
+export const collections = { countries, units, segments, changes };
src/layouts/Base.astro +1 −0
@@ -41,4 +41,5 @@ const fullTitle = title ? `${title} — Cannabis Freedom Index` : "Cannabis Free
<a href={`${base}#methodology`}>Methodology</a>
<a href={`${base}#story`}>Story</a>
+ <a href={`${base}changes/`}>Changes</a>
<a href={`${base}compare/`}>Compare</a>
</nav>
src/lib/countries.js +24 −1
@@ -105,7 +105,30 @@ export function subunitsOf(id) {
}
+// ── Changes: developments that moved a score, newest first ──
+export const changes = (await getCollection("changes"))
+ .map(({ data }) => {
+ const country = countries.find((c) => c.slug === data.country);
+ if (!country) throw new Error(`changes.yaml: "${data.id}" refers to unknown country "${data.country}"`);
+ const s = scoreList(data.before);
+ const before = Math.round(scoreOf(s));
+ return {
+ ...data,
+ country,
+ source: pair(data.source),
+ score: { before, after: country.score },
+ // every segment, with its score before and after
+ segments: SEGMENTS.map((seg, i) => ({ name: seg.name, weight: seg.weight, before: s[i], after: country.s[i] })),
+ };
+ })
+ .sort((a, b) => b.date.localeCompare(a.date));
+for (const ch of changes) {
+ if (changes.filter((x) => x.country === ch.country).length > 1) throw new Error(`changes.yaml: more than one change for "${ch.country.slug}" — keep only the latest`);
+ if (ch.segments.every((seg) => seg.before === seg.after)) throw new Error(`changes.yaml: "${ch.id}" has the same scores before and after`);
+}
+
// Review dates are stored as YYYY-MM-DD; this is how they are shown everywhere.
+// A date without a day ("2026-02") is shown as month and year.
export const formatDate = (iso) =>
- new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
+ new Date(`${iso.length === 7 ? iso + "-01" : iso}T00:00:00Z`).toLocaleDateString("en-GB", { day: iso.length === 7 ? undefined : "numeric", month: "long", year: "numeric", timeZone: "UTC" });
// The most recent review date across every country and state file.
src/pages/changes/[id].astro +66 −0
@@ -0,0 +1,66 @@
+---
+import Base from "../../layouts/Base.astro";
+import ScoreChange from "../../components/ScoreChange.astro";
+import { changes, formatDate } from "../../lib/countries.js";
+import { tierColor } from "../../lib/tiers.js";
+
+export function getStaticPaths() {
+ return changes.map((ch, i) => ({ params: { id: ch.id }, props: { ch, newer: changes[i - 1], older: changes[i + 1] } }));
+}
+
+const { ch, newer, older } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const c = ch.country;
+const diff = (seg) => seg.after - seg.before;
+---
+<Base title={ch.title} description={`${ch.title}: ${c.name} moved from ${ch.score.before} to ${ch.score.after} on the Cannabis Freedom Index. ${ch.description}`}>
+ <main class="change-page">
+ <div class="page-bar">
+ <a class="back" href={`${base}changes/`}>← All changes</a>
+ <a class="btn" href={`${base}country/${c.slug}/`}>{c.flag} {c.name} in full</a>
+ </div>
+
+ <article class="change-detail">
+ <p class="eyebrow"><time datetime={ch.date}>{formatDate(ch.date)}</time> · {c.name}</p>
+ <h1>{ch.title}</h1>
+ <p class="lede">{ch.description}</p>
+
+ <h2 class="d-h">Index score</h2>
+ <ScoreChange score={ch.score} />
+
+ <h2 class="d-h">Score by segment</h2>
+ <table class="change-table">
+ <thead>
+ <tr><th scope="col">Segment</th><th scope="col">Before</th><th scope="col">After</th><th scope="col">Change</th></tr>
+ </thead>
+ <tbody>
+ {ch.segments.map((seg) => (
+ <tr class={diff(seg) === 0 ? "same" : ""}>
+ <th scope="row">{seg.name}<small>{seg.weight}% of the index</small></th>
+ <td><span class="dot" style={`background:${tierColor(seg.before * 10)}`}></span>{seg.before}</td>
+ <td><span class="dot" style={`background:${tierColor(seg.after * 10)}`}></span>{seg.after}</td>
+ <td>{diff(seg) === 0
+ ? "—"
+ : <span class={`delta ${diff(seg) > 0 ? "up" : "down"}`}>{diff(seg) > 0 ? "+" : "−"}{Math.abs(diff(seg))}</span>}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ <p class="change-note">Segments are scored from 0 to 10. The scores after the change are {c.name}'s current scores.</p>
+
+ <h2 class="d-h">Source</h2>
+ <ul class="srcs">
+ <li><a href={ch.source[1]} target="_blank" rel="noopener noreferrer">{ch.source[0]}</a></li>
+ </ul>
+ </article>
+
+ <nav class="pager" aria-label="Other changes">
+ {newer
+ ? <a href={`${base}changes/${newer.id}/`}><small>Newer · {formatDate(newer.date)}</small><span>{newer.country.flag} {newer.title}</span></a>
+ : <span></span>}
+ {older
+ ? <a class="next" href={`${base}changes/${older.id}/`}><small>Older · {formatDate(older.date)}</small><span>{older.country.flag} {older.title}</span></a>
+ : <span></span>}
+ </nav>
+ </main>
+</Base>
src/pages/changes/index.astro +43 −0
@@ -0,0 +1,43 @@
+---
+import Base from "../../layouts/Base.astro";
+import ScoreChange from "../../components/ScoreChange.astro";
+import { changes, formatDate } from "../../lib/countries.js";
+import { tierColor } from "../../lib/tiers.js";
+
+const base = import.meta.env.BASE_URL;
+// Newest first, grouped by year.
+const years = [...new Set(changes.map((ch) => ch.date.slice(0, 4)))]
+ .map((year) => ({ year, items: changes.filter((ch) => ch.date.startsWith(year)) }));
+---
+<Base title="Changes" description="New laws and other developments that moved a country's score on the Cannabis Freedom Index, newest first.">
+ <main class="changes-page">
+ <section class="hero hero-sm">
+ <p class="eyebrow">Changes</p>
+ <h1>What moved the scores</h1>
+ <p class="lede">New laws and other important developments that changed a country's score, newest first. Open one to see which segments moved and the source.</p>
+ </section>
+
+ <div class="chlog">
+ {years.map(({ year, items }) => (
+ <section aria-labelledby={`y${year}`}>
+ <h2 class="chl-year" id={`y${year}`}>{year}</h2>
+ <ol class="chl-list">
+ {items.map((ch) => (
+ <li class="chl-item" style={`--c:${tierColor(ch.score.after)}`}>
+ <a class="card chl-card" href={`${base}changes/${ch.id}/`}>
+ <p class="change-meta">
+ <time datetime={ch.date}>{formatDate(ch.date)}</time>
+ <span>{ch.country.flag} {ch.country.name}</span>
+ </p>
+ <h3>{ch.title}</h3>
+ <p class="chl-text">{ch.description}</p>
+ <ScoreChange score={ch.score} />
+ </a>
+ </li>
+ ))}
+ </ol>
+ </section>
+ ))}
+ </div>
+ </main>
+</Base>
src/pages/index.astro +2 −0
@@ -3,4 +3,5 @@ import Base from "../layouts/Base.astro";
import DidYouKnow from "../components/DidYouKnow.astro";
import Lore from "../components/Lore.astro";
+import Changes from "../components/Changes.astro";
import { SEGMENTS, DATA_SNAPSHOT, REC_LABEL, MED_LABEL, countries, regions, worldAvg, lastUpdated, formatDate } from "../lib/countries.js";
import { TIERS, tierColor } from "../lib/tiers.js";
@@ -33,4 +34,5 @@ const mapData = countries.map(({ id, num, name, flag, raw, score, rank, s }) =>
</div>
<DidYouKnow />
+ <Changes />
</section>
src/styles/global.css +53 −1
@@ -69,5 +69,7 @@ code { background: var(--surface-2); padding: 1px 6px; border-radius: 5px; font-
border-radius: 10px; background: var(--accent); color: var(--accent-ink);
}
-.nav { display: flex; gap: 22px; margin-left: auto; }
+.nav { display: flex; gap: clamp(12px, 2vw, 22px); margin-left: auto; }
+@media (min-width: 900px) { .brand { white-space: nowrap; } }
+@media (max-width: 899px) { .nav { gap: 14px; font-size: 14.5px; } }
.nav a { color: var(--text-2); font-weight: 500; }
.nav a:hover { color: var(--text); }
@@ -430,2 +432,52 @@ td.num, th.num { width: 52px; color: var(--muted); }
.c-updated { margin: 28px 0 0; padding-top: 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.table-updated { margin: 12px 4px 0; color: var(--muted); font-size: 13px; text-align: right; }
+
+/* ── Changes ────────────────────────────────────────── */
+.changes { margin-top: 28px; }
+.changes-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.changes-title { font-size: 20px; margin: 0; }
+.changes-head a { color: var(--text-2); font-size: 14px; font-weight: 500; white-space: nowrap; }
+.changes-head a:hover { color: var(--text); }
+.changes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
+.change { padding: 18px 20px; display: flex; flex-direction: column; color: inherit; transition: border-color .15s, transform .15s; }
+.change:hover { border-color: var(--muted); transform: translateY(-1px); }
+.change-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; margin: 0 0 8px; color: var(--muted); font-size: 12.5px; }
+.change-meta span { color: var(--text-2); font-weight: 500; }
+.change h3 { font-size: 17px; line-height: 1.25; margin: 0 0 14px; flex: 1; }
+.change-score { display: flex; align-items: center; gap: 8px; margin: 0; font-variant-numeric: tabular-nums; }
+.pill { min-width: 38px; padding: 3px 9px; border-radius: 999px; text-align: center; font-weight: 700; font-size: 15px; color: var(--text); border: 2px solid var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
+.change-score .arrow { color: var(--muted); }
+.delta { font-size: 13px; font-weight: 600; }
+.change-score .delta { margin-left: 4px; }
+.delta.up { color: var(--t5); }
+.delta.down { color: var(--t1); }
+
+.changes-page { padding-bottom: 20px; }
+.chlog { max-width: 760px; margin-top: 28px; }
+.chl-year { font-size: 22px; margin: 0 0 14px; }
+.chl-list { list-style: none; margin: 0 0 32px; padding: 0 0 0 28px; position: relative; display: grid; gap: 14px; }
+.chl-list::before { content: ""; position: absolute; left: 6px; top: 6px; bottom: 6px; width: 2px; border-radius: 1px; background: var(--border); }
+.chl-item { position: relative; }
+.chl-item::before { content: ""; position: absolute; left: -28px; top: 20px; width: 10px; height: 10px; border-radius: 50%; background: var(--c); border: 2px solid var(--bg); box-shadow: 0 0 0 2px var(--border); }
+.chl-card { display: block; padding: 16px 20px; color: inherit; transition: border-color .15s; }
+.chl-card:hover { border-color: var(--muted); }
+.chl-card h3 { font-size: 18px; line-height: 1.25; margin: 0 0 6px; }
+.chl-text { color: var(--text-2); font-size: 14.5px; margin: 0 0 12px; }
+.change-page { padding-top: 26px; padding-bottom: 20px; }
+.change-detail { max-width: 760px; }
+.change-detail h1 { font-size: clamp(30px, 4.4vw, 46px); line-height: 1.1; margin: 8px 0 16px; }
+.change-detail .change-score .pill { font-size: 20px; min-width: 52px; padding: 5px 12px; }
+.change-detail .change-score .delta { font-size: 15px; }
+.change-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
+.change-table th, .change-table td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--border); text-align: left; vertical-align: middle; }
+.change-table thead th { color: var(--muted); font-size: 12.5px; font-weight: 500; }
+.change-table th { cursor: default; white-space: normal; }
+.change-table tbody th { font-weight: 600; font-size: 15px; color: var(--text); text-transform: none; letter-spacing: 0; }
+.change-table tbody th small { text-transform: none; letter-spacing: 0; }
+.change-table tbody tr:last-child :is(th, td) { border-bottom: 0; }
+.change-table tbody th small { display: block; color: var(--muted); font-weight: 400; font-size: 12.5px; }
+.change-table td { font-weight: 600; white-space: nowrap; }
+.change-table tr.same :is(th, td) { color: var(--muted); font-weight: 400; }
+.change-table .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 7px; }
+.change-table .delta { font-size: inherit; }
+.change-note { color: var(--muted); font-size: 13px; margin: 10px 0 0; }