Cannabis Freedom Index

Changelog 56a682d

culture intro

1 article edited45 atlas entries written73 files of code, design or docs +6,120 −311 lines

Articles and atlas entries 46

russia-griner-sentence-2022 articles/russia-griner-sentence-2022.md

image

src https://upload.wikimedia.org/wikipediasrc/commonsassets/thumbarticles/a/a6/Brittney_Griner_3.jpg/1280pxbrittney-Brittney_Griner_3griner-3.jpg · alt Brittney Griner in a Phoenix Mercury jersey during a game in 2019 · caption Bri … kimedia Commons · credit url https://commons.wikimedia.org/wiki/File:Brittney_Griner_3.jpg

Code, design and documents 73 files

astro.config.mjs +44 −0
@@ -9,8 +9,52 @@ import { defineConfig } from "astro/config";
 const base = (process.env.BASE_PATH || "").replace(/\/+$/, "") + "/";
 
+// Links written in Markdown (articles, culture entries): one that leaves the site opens in a new
+// tab and is marked `ext`, so the stylesheet can give it the arrow-in-a-square sign; one that
+// starts with "/" is a page of this site and gets the base path in front.
+function markdownLinks() {
+  const walk = (node) => {
+    if (node.tagName === "a" && typeof node.properties?.href === "string") {
+      const href = node.properties.href;
+      if (/^https?:\/\//.test(href)) {
+        node.properties.target = "_blank";
+        node.properties.rel = "noopener noreferrer";
+        node.properties.className = [...(node.properties.className ?? []), "ext"];
+      } else if (href.startsWith("/") && !href.startsWith("//")) {
+        node.properties.href = base + href.slice(1);
+      }
+    }
+    node.children?.forEach(walk);
+  };
+  return walk;
+}
+
+// A picture in Markdown whose title has the form "Caption | Credit | https://link-to-file" becomes
+// a figure with that caption and credit underneath. The image itself stays a Markdown image, so
+// Astro still finds the local file and optimises it like an <Image />.
+function markdownFigures() {
+  const esc = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
+  return (tree) => {
+    tree.children = tree.children.flatMap((node) => {
+      const img = node.type === "paragraph" && node.children.length === 1 && node.children[0].type === "image" ? node.children[0] : null;
+      const parts = img?.title?.split(" | ");
+      if (!parts || parts.length !== 3) return [node];
+      const [caption, credit, link] = parts;
+      img.title = null;
+      return [
+        { type: "html", value: "<figure>" },
+        node,
+        { type: "html", value: `<figcaption>${esc(caption)} <span><a href="${esc(link)}" target="_blank" rel="noopener noreferrer">${esc(credit)}</a></span></figcaption></figure>` },
+      ];
+    });
+  };
+}
+
 export default defineConfig({
   // also the address used in canonical links, the sitemap and structured data
   site: process.env.SITE || "https://cannabisfreedom.fyi",
   base,
+  markdown: { remarkPlugins: [markdownFigures], rehypePlugins: [markdownLinks] },
+  // pictures in Markdown get a set of widths, so a phone does not download the 1280-pixel file
+  image: { layout: "constrained" },
   // A build keeps its own Vite cache. Sharing the dev server's would wipe the dependencies the
   // running dev server has prepared (Fuse, nostr-tools), and its lazy imports would then fail

docs/CULTURE.md +217 −0
@@ -0,0 +1,217 @@
+# Writing for Culture: the cultural atlas
+
+Culture (`/culture/`) is the way in to what the site knows about cannabis in culture. The index
+says what the law allows; Culture says what the law was written over: the words people have for
+the plant, the customs it belongs to, the people who kept using it. This file says what belongs
+in each part, what an entry should focus on, and how to write one.
+
+Read it together with `docs/DATA-MAINTENANCE.md`. The rules there about sources, pictures and
+history entries apply here too.
+
+## The five parts
+
+| Part | Address | What it holds | Made of |
+|---|---|---|---|
+| Language | `/culture/language/` | Words for the plant, its products, its tools and the acts around it | `culture/language/*.md` |
+| Traditions | `/culture/traditions/` | Things a community does with cannabis, again and again | `culture/traditions/*.md` |
+| History | `/culture/history/` | Attitudes, propaganda, counterculture, as they show in the record of laws | A hand-picked list of history entries, in `src/pages/culture/history.astro` |
+| People | not open yet | Cultural icons, activists, historical figures | — |
+| Stories | `/culture/stories/` | Personal experiences and ethnographic accounts, gathered from the public | The participatory project; nothing is collected yet |
+
+## Where does it go?
+
+Ask what the subject is.
+
+| The subject is… | It goes in | Example |
+|---|---|---|
+| A word, name, spelling or piece of slang | Language | ganja, dagga, 420, sinsemilla |
+| The name of a thing whose interest is the thing itself | Traditions | the sebsi, sambe, the coffeeshop |
+| A practice repeated by a group: a ritual, festival, craft, dish, remedy, yearly gathering, social institution | Traditions | bhang at Holi, Hash Bash, cannabis social clubs |
+| One dated event: a law, ruling, report, arrest, a single demonstration | `changes.yaml` (the history), with an article in `articles/` if it deserves one | Jamaica's 2015 act, the first Global Marijuana March |
+| What people thought of cannabis and its users at some time and place | A history entry, then a line in `/culture/history/` | the 1894 Hemp Drugs Commission |
+| One person | People, when it opens. Until then, mention them in the entries they belong to | Leonard Howell, John Sinclair |
+| A first-hand account | Stories, when it opens | — |
+| A strain, brand, shop or product review | Nowhere | — |
+
+When a subject is both a word and a custom, write two entries only if each has enough of its own
+to say: *kif* is about the word and how its meaning drifted; *kif and the sebsi* is about the pipe
+and the way of smoking. Otherwise write one and list the word under `also`.
+
+Anything found during research that is a dated event goes into `changes.yaml` as well, and a
+finding that changes what a country allows today goes into its country file and score. Culture
+entries link to history; they do not replace it.
+
+## What Language is about
+
+An entry answers, in this order of importance:
+
+1. **What exactly it names.** Which part of the plant, which preparation, which act. Many words
+   mean something narrower where they come from than where they ended up (bhang is the leaf,
+   ganja the flower, charas the resin).
+2. **Where it comes from.** The language, the older form, the first time it is written down and
+   by whom. Where the origin is disputed, give the competing theories and say which are doubted
+   and by whom. A folk etymology is worth telling as long as it is called one.
+3. **How it travelled.** Words follow people: traders, indentured labourers, enslaved people,
+   soldiers, sailors, tourists, record labels. Say who carried it and when.
+4. **What the law did with it.** Statutes choose words. Note where a word is a legal term, where
+   a law was named after it, and where officials picked one word over another on purpose
+   ("marihuana" in the United States of the 1930s).
+5. **What it says about the speaker.** Stigma, class, race, generation, pride. Whether the word
+   is an insult, a neutral term or one that has been taken back.
+6. **Its relatives.** The same word in other languages, the words it replaced, the slang that
+   grew from it. A table is the right form when there are more than four.
+
+Give the `spelling`: the word in its own script, the language and, where known, how it is said.
+
+Not for Language: strain names, product names, and slang that has no history beyond "people say
+it".
+
+## What Traditions is about
+
+An entry answers:
+
+1. **Who does it, and where.** The community, named as it names itself, and the places. Cover
+   the whole area where the custom is kept, not one town from one source.
+2. **What is done.** The occasion, the season, the preparation, the tools, the order of events.
+   Concrete detail: what is ground, in what, mixed with what, by whom.
+3. **What it means to those who do it.** In their terms where a source gives them. Do not
+   explain a living religion from the outside as superstition or as an excuse.
+4. **How old it is, and how we know.** Separate what is documented from what is claimed. Say
+   when the only account is an outsider's (a colonial official, an explorer, a missionary).
+5. **What the law made of it.** This is the link to the index, and every entry needs it: an
+   exemption written into the law, tolerance in practice, a licence, prosecution, or a ban that
+   ended the custom. Name the laws and link the history entries.
+6. **Where it stands now.** Still kept, fading, revived, or commercialised for visitors.
+
+Traditions includes modern institutions and yearly gatherings (the coffeeshop, social clubs,
+4/20 rallies) when they have lasted long enough to have customs of their own. It includes uses
+that have nothing to do with intoxication (hemp cloth, hemp-seed dishes, ritual rope) because
+the law so often failed to tell the two apart.
+
+Not for Traditions: a single event, a company, a piece of legislation as such.
+
+## The shape of an entry
+
+```markdown
+---
+title: Bhang
+summary: One or two sentences. Shown on cards, under the title and in search results.
+origin: Hindi                      # language entries: where the word is from, in a few words
+spelling:                          # language entries: the word in its own script
+  - { text: "भांग", lang: Hindi, say: "bhāṅg" }
+also: [bangue, bang]               # other spellings, related words, names to be found by
+countries: [in, ir]                # file names in /countries, the most closely tied first
+history: [india-bhang-exemption-1961, india-ndps-act-1985]   # ids in changes.yaml
+image:                             # the lead picture, optional
+  src: ../../src/assets/culture/example.jpg   # a local file, relative to this Markdown file
+  alt: What the picture shows
+  caption: One line under the picture.
+  credit: Author, licence, via Wikimedia Commons
+  credit_url: https://commons.wikimedia.org/wiki/File:Example.jpg
+model: opus-5.5
+written: 2026-10-14
+sources:
+  - title: Publisher — headline (date)
+    url: https://…
+---
+
+The body in Markdown.
+```
+
+**Length.** 500 to 900 words of body. Shorter only when the sources are thin, and then say less
+instead of padding. (History articles in `articles/` stay compact at 350 to 500 words; Culture
+entries are reference pieces and are meant to be read at leisure.)
+
+**Structure.**
+
+- Open with one or two paragraphs that would stand alone: what it is, where, and why it matters.
+  No heading above them.
+- Then three to five sections with `## ` headings that say what is in them ("From Bengal to the
+  Caribbean", not "History").
+- End with the law or the present day, whichever is the later news. No summary paragraph.
+
+**Tables.** Use one where the content is a set of like things: a word in several languages, the
+rules of an institution, the laws of several countries on one custom, the stages of a
+preparation. One or two per entry. Never a table of prose.
+
+**Quotations.** A short quotation from a primary source (a law, a traveller, a commission) is
+worth more than a paraphrase. Quote only what the source that was read actually prints, and say
+who is speaking.
+
+**Pictures.** Every picture is a file in the repository, under `src/assets/culture/` (or
+`src/assets/articles/` for history articles), never a link to another site. Astro resizes and
+converts them at build time: the lead picture is drawn with `<Picture>`, the cards and the
+country pages with `<Image>`, and pictures in the text are optimised the same way.
+
+1. Download the file from its Wikimedia Commons page (the 1280-pixel version is enough) into
+   `src/assets/culture/`, with a short lower-case name: `nepal-charas.jpg`.
+2. Lead picture: put its relative path in the front matter, as above.
+3. Further pictures go in the text where they belong, as a Markdown image whose title holds the
+   caption, the credit and the link to the file's page, separated by ` | `:
+
+```markdown
+![What it shows](../../src/assets/culture/nepal-charas.jpg "Charas from Nepal. | CFynn, CC0, via Wikimedia Commons | https://commons.wikimedia.org/wiki/File:Nepal_Charas.jpg")
+```
+
+The site turns that into a figure with the caption and credit underneath. Rules:
+
+- A picture must show the subject itself: the thing, the act, the place or the people. No
+  buildings of institutions, no generic leaves, no portraits of officials.
… 59 more lines
docs/DATA-MAINTENANCE.md +6 −0
@@ -567,4 +567,10 @@ changed and why, which score timelines were re-based, which claims rest on sourc
 could not be confirmed. Nothing is committed or deployed unless the user asks.
 
+## Culture entries
+
+The cultural atlas (`/culture/`, files in `culture/language/` and `culture/traditions/`) has its own
+guide: `docs/CULTURE.md`. It says what belongs in each part, what an entry focuses on and how long
+it is.
+
 ## Long reads for history entries
 

package-lock.json +2091 −81

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

package.json +1 −0
@@ -10,4 +10,5 @@
   },
   "dependencies": {
+    "@astrojs/markdown-remark": "^7.3.2",
     "@fontsource-variable/inter": "^5.3.0",
     "@fontsource-variable/jetbrains-mono": "^5.3.0",

source-archive.json +301 −129

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

src/assets/articles/brittney-griner-3.jpg +0 −0

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

src/assets/culture/anti-cannabis-prohibition-banner.jpg +0 −0

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

src/assets/culture/apr-7-07-hashbash.jpg +0 −0

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

src/assets/culture/batik-baby-carrier-hmong-vietnam-museum-of-ethnology-hanoi-vietnam-dsc.jpg +0 −0

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

src/assets/culture/bhang-4381292529.jpg +0 −0

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

src/assets/culture/bhang-ki-patti-ka-peda.jpg +0 −0

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

src/assets/culture/bhang-shop-in-jaisalmer-rajasthan-india-on-november-15-2008.jpg +0 −0

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

src/assets/culture/calgary-alberta-canada-may-5-2007-global-marijuana-march.jpg +0 −0

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

src/assets/culture/cannabis-ganja-farm-18272580718.jpg +0 −0

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

src/assets/culture/cannabis-sativa-koehler-drawing.jpg +0 −0

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

src/assets/culture/cape-town-cannabis-march-2015-may-9.jpg +0 −0

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

src/assets/culture/coffeeshop-green-place-fa-ade-covered-with-stickers-in-amsterdam.jpg +0 −0

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

src/assets/culture/coffeeshop-reefer.jpg +0 −0

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

src/assets/culture/cph-pusherstreet.jpg +0 −0

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

src/assets/culture/egyptian-type-e-scenes-hashishe-smokers-n-d-front-timea.jpg +0 −0

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

src/assets/culture/ganja-smoking-gangasagar-fair-transit-camp-kolkata-2013-01-12-2646.jpg +0 −0

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

src/assets/culture/gouache-by-an-amritsar-artist-depicting-the-preparation-and-consumptio.jpg +0 −0

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

src/assets/culture/gouache-by-an-amritsar-artist-depicting-the-smoking-of-charras-a-type.jpg +0 −0

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

src/assets/culture/hadzabe-1.jpg +0 −0

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

src/assets/culture/haschisch-rif-maroc.jpg +0 −0

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

src/assets/culture/hindu-kush-mountains.jpg +0 −0

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

src/assets/culture/joint.jpg +0 −0

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

src/assets/culture/legal-hashish-shop-in-kathmandu-nepal-in-1973.jpg +0 −0

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

src/assets/culture/local-villagers-make-charas-in-tosh-village-parvati-valley-himachal-pr.jpg +0 −0

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

src/assets/culture/louis-pasteur-statue-san-rafael-high-school-crop.jpg +0 −0

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

src/assets/culture/marcha-da-maconha-brasilia-2013.jpg +0 −0

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

src/assets/culture/mardigrass-2010-peace-park.jpg +0 −0

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

src/assets/culture/mardigrass-parade.jpg +0 −0

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

src/assets/culture/marijuana-march-rio-de-janeiro-2014-may-10-brazil-marcha-maconha.jpg +0 −0

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

src/assets/culture/mature-cannabis-tree-in-india.jpg +0 −0

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

src/assets/culture/mezz-mezzrow-1946.jpg +0 −0

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

src/assets/culture/mural-in-christiania-against-hard-drugs.jpg +0 −0

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

src/assets/culture/mural-of-two-nihang-sikhs-making-bhang-or-sukh-nidhan.jpg +0 −0

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

src/assets/culture/naga-sadhu-smoking-ganja-gangasagar-fair-transit-camp-kolkata-2013-01.jpg +0 −0

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

src/assets/culture/nepal-charas.jpg +0 −0

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

src/assets/culture/nihangs-from-annandpur-sahib-002.jpg +0 −0

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

src/assets/culture/offerings-in-a-kathmandu-pot-shop-1973.jpg +0 −0

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

src/assets/culture/pazyryk-barrows.jpg +0 −0

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

src/assets/culture/reefer-madness-1936.jpg +0 −0

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

src/assets/culture/rembetes-karaiskaki-1933.jpg +0 −0

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

src/assets/culture/row-purple-kush.jpg +0 −0

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

src/assets/culture/sambe.jpg +0 −0

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

src/assets/culture/sebse.jpg +0 −0

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

src/assets/culture/shinto-oonusa-whole-hemp.jpg +0 −0

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

src/assets/culture/skirt-hmong-hoa-tuyen-quang-view-2-1976-three-sewn-panels-in-hemp-majo.jpg +0 −0

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

src/assets/culture/spliff-2.jpg +0 −0

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

src/assets/culture/unrolled-joint.jpg +0 −0

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

src/assets/culture/vancouver-canada-2012-april-20-crowd.jpg +0 −0

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

src/assets/culture/wien-hanfwandertag-2014.jpg +0 −0

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

src/components/CountryArticle.astro +50 −5
@@ -11,4 +11,6 @@ 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 { Image } from "astro:assets";
+import { cultureOf, cultureSection } from "../lib/culture.js";
 
 // `sub` is optional: one state, province or territory of `c`. Without it the national view is shown.
@@ -42,4 +44,14 @@ const travel = sub ? null : [
 ].filter(Boolean);
 const answers = answersFor(v.s);
+// 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.
+const tabs = [
+  ["score", "Score breakdown"],
+  d && ["context", "More context"],
+  history.length > 0 && ["history", "History", history.length],
+  d && ["sources", "Sources", d.sources.length],
+].filter(Boolean);
+// words and traditions of the cultural atlas that belong to this country
+const culture = sub ? [] : cultureOf(c);
 ---
 <article class="country" data-id={c.id}>
@@ -135,7 +147,30 @@ const answers = answersFor(v.s);
     {SHOW_PRICES && !sub && <PricePanel id={c.id} />}
     {scored.length > 0 && <CountryChart history={scored} context={history.filter((ch) => !ch.score)} />}
+    {culture.length > 0 && (
+      <section class="c-culture">
+        <h2>Culture <a href={`${base}culture/`}>The atlas →</a></h2>
+        <ul>
+          {culture.map((e) => (
+            <li>
+              <a href={`${base}${e.url}`} title={e.summary}>
+                {e.image ? <Image src={e.image.src} alt="" width={104} height={104} fit="cover" /> : <span class="c-culture-none" aria-hidden="true">{e.title.slice(0, 1)}</span>}
+                <span><small>{cultureSection(e.section).name}</small><b>{e.title}{e.spelling && e.spelling[0].text.toLowerCase() !== e.title.toLowerCase() && <i> {e.spelling[0].text}</i>}</b></span>
+              </a>
+            </li>
+          ))}
+        </ul>
+      </section>
+    )}
   </div>
 
   <div class="c-main">
+    {tabs.length > 1 && (
+      <nav class="c-tabs" role="tablist" aria-label={`${c.name}: parts of the profile`}>
+        {tabs.map(([id, label, count], i) => (
+          <button type="button" role="tab" id={`tab-${id}`} data-tab={id} aria-controls={`panel-${id}`} aria-selected={i === 0 ? "true" : "false"} tabindex={i === 0 ? 0 : -1}>{label}{count != null && <small> {count}</small>}</button>
+        ))}
+      </nav>
+    )}
+    <div class="c-panel" id="panel-score" data-panel="score" role={tabs.length > 1 ? "tabpanel" : undefined} aria-labelledby={tabs.length > 1 ? "tab-score" : undefined}>
     <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
@@ -216,6 +251,8 @@ const answers = answersFor(v.s);
     )}
 
+    </div>
+
     {d && (
-      <>
+      <div class="c-panel" id="panel-context" data-panel="context" role="tabpanel" aria-labelledby="tab-context" hidden>
         <h2 class="d-h">More context <span>not part of the score</span></h2>
         {d.context.filter(Boolean).map((x) => (
@@ -251,14 +288,22 @@ const answers = answersFor(v.s);
           </div>
         )}
-
+      </div>
+    )}
+    {history.length > 0 && (
+      <div class="c-panel" id="panel-history" data-panel="history" role="tabpanel" aria-labelledby="tab-history" hidden>
+        <CountryChanges history={history} />
+      </div>
+    )}
+    {d && (
+      <div class="c-panel" id="panel-sources" data-panel="sources" role="tabpanel" aria-labelledby="tab-sources" hidden>
         <section class="c-sources">
-          <h2 class="d-h">All sources</h2>
+          <h2 class="d-h">All sources <span>{d.sources.length} cited in this profile</span></h2>
           <ol>
             {d.sources.map((source) => <SourceCitation source={source} />)}
           </ol>
         </section>
-      </>
+      </div>
     )}
-    {history.length > 0 && <CountryChanges history={history} />}
+    {tabs.length > 1 && <noscript><style>{`.c-panel[hidden]{display:block}.c-tabs{display:none}`}</style></noscript>}
     {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/CultureCards.astro +24 −0
@@ -0,0 +1,24 @@
+---
+// 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.
+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 base = import.meta.env.BASE_URL;
+---
+<ol class="cu-cards">
+  {entries.map((e) => (
+    <li>
+      <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" />
+          : <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>
+        <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>
+        <p class="cu-flags" title={e.countries.map((c) => c.name).join(", ")}>{e.countries.slice(0, 8).map((c) => c.flag).join(" ")}{e.countries.length > 8 && ` +${e.countries.length - 8}`}</p>
+      </a>
+    </li>
+  ))}
+</ol>

src/content.config.ts +31 −3
@@ -193,4 +193,8 @@ const prices = defineCollection({
 });
 
+// A picture with who made it and under which licence: the lead image of an article or culture entry.
+// `src` is a file in src/assets, given relative to the Markdown file, so that Astro can optimise it.
+const picture = (image: () => z.ZodTypeAny) => z.object({ src: image(), alt: z.string().min(1), caption: z.string().optional(), credit: z.string().min(1), credit_url: z.string().url().optional() }).strict();
+
 // articles/<change id>.md: an optional long read for a history entry. The entry in changes.yaml
 // keeps its title and short description; the article adds the full story in Markdown, an
@@ -198,7 +202,7 @@ const prices = defineCollection({
 const articles = defineCollection({
   loader: glob({ base: "./articles", pattern: "*.md" }),
-  schema: z
+  schema: ({ image }) => z
     .object({
-      image: z.object({ src: z.string().min(1), alt: z.string().min(1), caption: z.string().optional(), credit: z.string().min(1), credit_url: z.string().url().optional() }).strict().optional(),
+      image: picture(image).optional(),
       model: z.string().min(1).optional(), // which model wrote it, with its effort where known: "opus-5.5", "gpt-5.6-sol-medium"
       written: z.coerce.date().optional(), // the day it was written
@@ -208,3 +212,27 @@ const articles = defineCollection({
 });
 
-export const collections = { countries, units, segments, changes, prices, articles };
+// culture/<section>/<slug>.md: one entry of the cultural atlas, a word for cannabis (language) or
+// a custom around it (traditions). The Markdown is the explanation; the front matter says where
+// it is said or practised and which history entries it touches, so that the pages can link both ways.
+const culture = defineCollection({
+  loader: glob({ base: "./culture", pattern: "*/*.md" }),
+  schema: ({ image }) => z
+    .object({
+      title: z.string().min(1),
+      summary: z.string().min(1), // one or two sentences, shown on cards and under the title
+      origin: z.string().min(1).optional(), // language: where the word comes from, in a few words
+      also: z.array(z.string().min(1)).optional(), // other spellings and related words
+      // how the word is written where it is used: the spelling in its own script, the language, and
+      // optionally how it is said (IPA or a romanisation)
+      spelling: z.array(z.object({ text: z.string().min(1), lang: z.string().min(1), say: z.string().min(1).optional() }).strict()).optional(),
+      countries: z.array(z.string().regex(/^[a-z]{2}$/)).min(1), // file names in /countries, the most closely tied first
+      history: z.array(z.string()).default([]), // ids in changes.yaml
+      image: picture(image).optional(),
+      model: z.string().min(1).optional(),
+      written: z.coerce.date().optional(),
+      sources: z.array(source).min(1),
+    })
+    .strict(),
+});
+
+export const collections = { countries, units, segments, changes, prices, articles, culture };

src/layouts/Base.astro +35 −0
@@ -81,4 +81,5 @@ const schemas = jsonld ? [jsonld].flat() : [];
       <a href={`${base}#methodology`}>Methodology</a>
       <a href={`${base}history/`}>History</a>
+      <a href={`${base}culture/`}>Culture</a>
       <a href={`${base}survey/`}>Survey</a>
     </nav>
@@ -206,4 +207,6 @@ const schemas = jsonld ? [jsonld].flat() : [];
       var row = article && article.querySelector('.segrow[data-seg="' + id + '"]');
       if (!row) return;
+      var panel = row.closest(".c-panel");
+      if (panel) showTab(article, panel.dataset.panel);
       row.open = true;
       row.classList.remove("flash");
@@ -212,4 +215,34 @@ const schemas = jsonld ? [jsonld].flat() : [];
       row.scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" });
     }
+    // The tabs of a country article: score breakdown, more context, history, sources. One panel
+    // shows at a time. On the country's own page the tab is kept in the address (#history), so it
+    // can be linked and survives a reload; in the drawer the address belongs to the drawer.
+    function showTab(article, id, focus) {
+      var tab = article && article.querySelector('.c-tabs [data-tab="' + id + '"]');
+      if (!tab) return false;
+      article.querySelectorAll(".c-tabs [data-tab]").forEach(function (t) {
+        var on = t === tab;
+        t.setAttribute("aria-selected", String(on)); t.tabIndex = on ? 0 : -1;
+      });
+      article.querySelectorAll(".c-panel").forEach(function (p) { p.hidden = p.dataset.panel !== id; });
+      if (focus) tab.focus();
+      return true;
+    }
+    document.addEventListener("click", function (e) {
+      var tab = e.target.closest(".c-tabs [data-tab]");
+      if (!tab) return;
+      var article = tab.closest("article.country");
+      showTab(article, tab.dataset.tab);
+      if (!article.closest("dialog")) history.replaceState(null, "", tab.dataset.tab === "score" ? location.pathname + location.search : "#" + tab.dataset.tab);
+      // a tab chosen from far down a long panel: bring the top of the new one into view
+      var main = article.querySelector(".c-main");
+      if (main.getBoundingClientRect().top < 0) main.scrollIntoView({ block: "start" });
+    });
+    document.addEventListener("keydown", function (e) {
+      var tab = e.target.closest && e.target.closest(".c-tabs [data-tab]");
+      if (!tab || (e.key !== "ArrowRight" && e.key !== "ArrowLeft")) return;
+      var all = Array.prototype.slice.call(tab.parentElement.children), next = all[(all.indexOf(tab) + (e.key === "ArrowRight" ? 1 : all.length - 1)) % all.length];
+      e.preventDefault(); showTab(tab.closest("article.country"), next.dataset.tab, true);
+    });
     document.addEventListener("click", function (e) {
       var link = e.target.closest("a[data-seg-link]");
@@ -296,4 +329,6 @@ const schemas = jsonld ? [jsonld].flat() : [];
 
     // A link straight to a segment, e.g. /country/de/#seg-possession
+    // or to a tab: /country/de/#history
+    if (/^#(context|history|sources)$/.test(location.hash) && showTab(document.querySelector("article.country"), location.hash.slice(1))) document.querySelector("article.country .c-main").scrollIntoView({ block: "start" });
     if (/^#seg-[a-z_-]+$/.test(location.hash)) showSegment(document.querySelector("article.country"), location.hash.slice(5));
   </script>

src/lib/culture.js +50 −0
@@ -0,0 +1,50 @@
+// The cultural atlas: culture/<section>/<slug>.md, loaded and tied to the rest of the site.
+// Every entry names the countries where the word is said or the custom kept, and the history
+// entries it touches; both must exist, so a typing mistake stops the build.
+import { getCollection } from "astro:content";
+import { places, changes } from "./countries.js";
+import { sourceTuple } from "./sources.js";
+
+// The parts of the atlas, in menu order. `entries: true` means the section is made of files in
+// /culture/<id>/; the others are single pages. `soon` marks a part with nothing to read yet.
+export const CULTURE_SECTIONS = [
+  { id: "language", name: "Language", entries: true, one: "word", lede: "Bhang, charas, dagga, ganja, kif, 420: the words people have for the plant, where each comes from and where it is said.", intro: "A name for cannabis is rarely only a name. It says which part of the plant is meant, who brought it, and often what the people in charge thought of those who used it." },
+  { id: "traditions", name: "Traditions", entries: true, one: "tradition", lede: "Rituals, religion, ceremonies and traditional uses, and what the law made of each.", intro: "Where cannabis belongs to a festival, a faith or a daily habit, the law has had to decide what to do about it: write an exception, look away, or prosecute." },
+  { id: "history", name: "History", lede: "Social attitudes, propaganda and counterculture: the places where the record of laws shows what people thought." },
+  { id: "people", name: "People", soon: true, lede: "Cultural icons, activists and historical figures." },
+  { id: "stories", name: "Stories", lede: "Personal experiences and ethnographic accounts, gathered from the people who live with these laws." },
+];
+export const cultureSection = (id) => CULTURE_SECTIONS.find((s) => s.id === id);
+
+export const cultureEntries = (await getCollection("culture"))
+  .map((entry) => {
+    const [section, slug] = entry.id.split("/");
+    const file = `culture/${entry.id}.md`;
+    if (!cultureSection(section)?.entries) throw new Error(`${file}: "${section}" is not a section that holds entries`);
+    const d = entry.data;
+    const countries = d.countries.map((code) => {
+      const c = places.find((x) => x.slug === code);
+      if (!c) throw new Error(`${file}: there is no country "${code}"`);
+      return c;
+    });
+    const history = d.history.map((id) => {
+      const ch = changes.find((x) => x.id === id);
+      if (!ch) throw new Error(`${file}: there is no history entry "${id}"`);
+      return ch;
+    }).sort((a, b) => a.date.localeCompare(b.date));
+    // links in the text to pages of this site must lead somewhere
+    for (const [, kind, id] of (entry.body ?? "").matchAll(/\]\(\/(history|country)\/([^/)#]+)\/?[^)]*\)/g)) {
+      const found = kind === "history" ? changes.some((x) => x.id === id) : places.some((x) => x.slug === id);
+      if (!found) throw new Error(`${file}: the text links to /${kind}/${id}/, which does not exist`);
+    }
+    return { entry, section, slug, url: `culture/${section}/${slug}/`, ...d, countries, history, sources: d.sources.map(sourceTuple) };
+  })
+  .sort((a, b) => 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)) {
+  if (!cultureEntries.some((x) => x.url === path + "/")) throw new Error(`culture/${e.entry.id}.md: the text links to /${path}/, which does not exist`);
+}
+
+export const cultureIn = (section) => cultureEntries.filter((e) => e.section === section);
+// what the atlas has on one country, for its page
+export const cultureOf = (c) => cultureEntries.filter((e) => e.countries.includes(c));

src/pages/culture.astro +0 −86
@@ -1,86 +0,0 @@
----
-// Culture: the announcement page for the participatory research that is to sit beside the index.
-// The index maps laws; this is about the people and beliefs behind them. Nothing is collected
-// here yet: the page says what the project asks and shows where the history already touches it.
-import Base from "../layouts/Base.astro";
-import { changes } from "../lib/countries.js";
-
-const base = import.meta.env.BASE_URL;
-// What the project wants to understand, in the owner's own terms.
-const themes = [
-  ["Attitudes", "What people in a country actually think of cannabis and of those who use it, and how far that is from what the law says."],
-  ["Stigma", "Who is judged for using it, by whom, and what it costs them at work, in the family and in public."],
-  ["Traditions", "Where cannabis belongs to religion, medicine, farming or celebration, and what happened to those customs under prohibition."],
-  ["Obedience and authority", "Why people follow a law they do not believe in, and who they think has the right to decide."],
-  ["Resistance", "How people get around a ban, organise against it or simply ignore it, and what they risk."],
-  ["Generations", "What changed between grandparents, parents and children, and what did not."],
-];
-// Places where the history on this site already records culture meeting law. Each must exist.
-const threads = [
-  ["india-hemp-drugs-commission-1894", "An inquiry of 1,200 witnesses found the habit widespread, and advised against banning it partly because of “the social and religious feeling on the subject”."],
-  ["india-bhang-exemption-1961", "India kept bhang out of the world treaty so that it could go on being drunk at Holi."],
-  ["nepal-pashupati-distribution-ended-1995", "Until 1995 a temple trust in Kathmandu handed cannabis to holy men at Shivaratri, from the Home Ministry's confiscated stock."],
-  ["jamaica-ganja-reform-2015", "Jamaica wrote Rastafari sacramental use into its law."],
-  ["transvaal-mine-dagga-survey-1908", "Asked in 1908 whether dagga should be banned, the managers of the Transvaal gold mines mostly said no: use was moderate, and restrictions would keep men from coming to work."],
-  ["south-africa-enforcement-compromise-1922", "A month after South Africa banned dagga, police were told to go easy in remote African areas, where an older man's morning pipe was “a custom so deep-rooted as almost to amount to a religious rite”."],
-  ["mexico-salazar-marijuana-myth-1938", "A Mexican health official told the Academy of Medicine that the madness of marijuana was a myth, and ended by handing his colleagues cigarettes of it."],
-  ["afghanistan-hashish-fertiliser-edicts-1969", "With Western travellers buying, Afghanistan's king issued edicts urging farmers to use fertiliser and grow more hashish."],
-  ["south-africa-natal-1870", "One of the first cannabis laws anywhere applied to one group only: Indian labourers in Natal."],
-  ["brazil-rio-pango-ban-1830", "Rio de Janeiro's ban of 1830 named the slaves who smoked it."],
-  ["egypt-first-ban-1868", "Egypt's first ban has been read as a centralising state's wish to civilise its lower classes."],
-].map(([id, text]) => {
-  const ch = changes.find((x) => x.id === id);
-  if (!ch) throw new Error(`culture.astro: there is no history entry "${id}"`);
-  return { ch, text };
-}).sort((x, y) => x.ch.date.localeCompare(y.ch.date));
----
-<Base title="Culture" description="Culture is a participatory anthropology project beside the Cannabis Freedom Index: how cannabis shapes, and is shaped by, society, through anonymous surveys, personal stories and community research.">
-  <main class="changes-page culture">
-    <section class="hero hero-sm">
-      <p class="eyebrow">Culture <span>in preparation</span></p>
-      <h1>The people behind the laws</h1>
-      <p class="lede">Culture is a participatory anthropology project exploring how cannabis shapes, and is shaped by, society. Through anonymous surveys, personal stories and community research, it examines cultural attitudes, stigma, traditions, obedience, authority and resistance across different countries and generations.</p>
-      <p class="lede">While the Freedom Index maps laws, Culture explores the people, beliefs and social forces behind them.</p>
-    </section>
-
-    <section class="section">
-      <div class="section-head"><div>
-        <h2>What it asks</h2>
-        <p class="sub">A score says what a person is allowed to do. It does not say what their neighbours think, what their grandmother grew, or why a law nobody respects is still obeyed.</p>
-      </div></div>
-      <dl class="culture-themes">
-        {themes.map(([name, text]) => <div><dt>{name}</dt><dd>{text}</dd></div>)}
-      </dl>
-    </section>
-
-    <section class="section">
-      <div class="section-head"><div>
-        <h2>How it will work</h2>
-        <p class="sub">Three kinds of material, gathered from the people who live with these laws. None of it is open yet; this page will say when it is.</p>
-      </div></div>
-      <dl class="culture-themes three">
-        <div><dt>Anonymous surveys</dt><dd>Short sets of questions, the same in every country, so that answers can be compared with each other and with the index. The first, <a href={`${base}survey/`}>State of Cannabis</a>, can already be previewed.</dd></div>
-        <div><dt>Personal stories</dt><dd>Accounts in a person's own words: an arrest, a family argument, a harvest, a ritual.</dd></div>
-        <div><dt>Community research</dt><dd>Local knowledge that no statute records, collected and checked by people who know the place.</dd></div>
-      </dl>
-    </section>
-
-    <section class="section">
-      <div class="section-head"><div>
-        <h2>Where the history already touches it</h2>
-        <p class="sub">The index's <a href={`${base}history/`}>history</a> is a record of laws, but culture shows through it. A few of the places where it does:</p>
-      </div></div>
-      <ol class="culture-threads">
-        {threads.map(({ ch, text }) => (
-          <li>
-            <a href={`${base}history/${ch.id}/`}>
-              <time datetime={ch.date}>{ch.date.slice(0, 4)}</time>
-              <span>{ch.place.flag} {ch.place.name}</span>
-              <p>{text}</p>
-            </a>
-          </li>
-        ))}
-      </ol>
-    </section>
-  </main>
-</Base>

src/pages/culture/[section]/[slug].astro +89 −0
@@ -0,0 +1,89 @@
+---
+// One entry of the cultural atlas: a word or a custom, explained, with its picture; beside it the
+// countries where it is said or kept, each with its score today, and the history entries it touches.
+import Base from "../../../layouts/Base.astro";
… 86 more lines
src/pages/culture/[section]/index.astro +47 −0

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

src/pages/culture/history.astro +50 −0

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

src/pages/culture/index.astro +46 −0

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

src/pages/culture/stories.astro +51 −0

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

src/pages/history/[id].astro +2 −1

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

src/pages/og/[...path].png.js +6 −1

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

src/pages/search.json.js +5 −1

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

src/pages/sitemap.xml.js +4 −0

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

src/pages/survey/index.astro +1 −1

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

src/pages/survey/take.astro +1 −1

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

src/styles/global.css +90 −1

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

    ↑↓ to move, ↵ to open.