Cannabis Freedom Index

Changelog 879dad1tree, with Claude Opus 5.5

States and provinces: their own context sections, penalty tables and page tabs

- A state or province file may give its segments penalty tables and add a `context` block with any country section and status, citing its numbered sources ([@2], sources: [2]). - State pages gain Context, Datasets and Sources tabs. Context shows the state's own sections and, where it writes none, its country's, marked as national; datasets are the country's, marked so. The penalty table is now a shared component (PenaltyTable.astro). - The audit and the gap script handle state files; the gap script lists a state's own sections and those it borrows. DATA-MAINTENANCE.md gains "State and province files" and EXPAND-COUNTRY.md a step for federations. - Pilot: California's possession and cultivation tables and its own recreational section.

1 country profile edited8 files of code, design or docs +194 −39 lines

Countries and states 1 file

California countries/us/ca.yaml

segments › possession

Adults 21+ may hold 28.5 g of flower and 8 g of concentrate.

segments › cultivation

Six plants per household. More than six is a misdemeanour with up to six months.

segments › possession › text

Adults 21+ may hold 28.5 g of flower and 8 g of concentrate. More is a misdemeanour with up to six months in jail and a $500 fine.[@1]

segments › possession › currency

USD

segments › possession › penalties

upto 28.5 g · treatment legal · note Adults 21 and over; up to 8 g of concentrate besides, over 28.5 g · treatment criminal · term Misdemeanor · prison 6 months · fine 500

segments › cultivation › text

Six plants per household. More than six is a misdemeanour with up to six months and a $500 fine.[@1]

segments › cultivation › currency

USD

segments › cultivation › penalties

upto 6 plants · treatment legal · note Per household, adults 21 and over, over 6 plants · treatment criminal · term Misdemeanor · prison 6 months · fine 500

context › recreational › text

Since Proposition 64 in 2016, adults 21 and over may grow six plants and keep up to an ounce of flower and 8 g of concentrate; the same law licenses commercial growing and retail sales.[@1] Under-18s caught with any amount get an infraction with drug counselling and community service, not a criminal record.[@1]

context › recreational › sources

1

Code, design and documents 8 files

docs/DATA-MAINTENANCE.md +42 −0
@@ -573,4 +573,46 @@ one-line texts in the four original sections (`products`, `production`, `medical
   malformed penalty steps.
 
+## State and province files
+
+The United States, Canada and Australia have a file per state, province or territory
+(`countries/us/ca.yaml`) besides the national profile (`countries/us/index.yaml`). A state file
+can hold everything a country profile holds:
+
+```yaml
+segments:
+  possession: Adults 21+ may hold 28.5 g of flower.     # plain text, as before, or:
+  cultivation:
+    text: >-
+      Six plants per household. More is a misdemeanour with up to six months and a $500 fine.[@1]
+    currency: USD
+    penalties: [...]                                       # exactly as in a country profile
+context:
+  recreational:
+    text: >-
+      Since Proposition 64 in 2016, adults 21 and over may grow six plants…[@1]
+    sources: [1]                                           # numbers into the file's `sources` list
+  clubs:
+    status: tolerated                                      # statuses as in a country profile
+    text: >-
+      …
+    sources: [2, 3]
+sources:
+  - title: NORML — California laws and penalties
+    url: https://norml.org/laws/california-penalties/
+```
+
+- **Sources are numbered**: a state file keeps its `sources` as a list, and both its segments and
+  its context sections cite them by number (`[@2]`, `sources: [2]`).
+- **Write only what differs.** A section a state does not write is shown from the national
+  profile, marked "For all of the United States; nothing written for California yet". Write the
+  state's own section where its law or practice differs: recreational use, medical cannabis,
+  clubs, driving, visitors, arrests are the usual ones; hemp and industry often too.
+- **Penalty tables** go on the state's segments as in a country (possession, cultivation, sharing,
+  consumption), with the state's own figures.
+- A state page shows its country's datasets, marked as national, until a dataset has rows for
+  states.
+- `node scripts/country-gaps.cjs us/ca` lists a state's own sections and those it borrows;
+  `node scripts/profile-audit.cjs us/ca` checks it. California is the model.
+
 ## Penalty steps
 

docs/EXPAND-COUNTRY.md +7 −0
@@ -96,4 +96,11 @@ How every section reads:
   jargon to `glossary.yaml`, or write a Culture/Language entry for a local word for cannabis.
 
+## 5b. States and provinces (United States, Canada, Australia)
+
+For a federation, expand the national profile first, then its states or provinces one by one:
+penalty tables from each state's law, and context sections where the state differs from the
+country (DATA-MAINTENANCE.md, "State and province files"). A state section the country already
+covers well need not be repeated. `node scripts/country-gaps.cjs us/ca` is a state's work list.
+
 ## 6. Culture and the atlas
 

scripts/country-gaps.cjs +10 −3
@@ -61,8 +61,11 @@ function gapsOf(id) {
     missingResearched: unit ? [] : RESEARCHED.filter((k) => !ctx[k]),
     missingOptional: unit ? [] : OPTIONAL.filter((k) => !ctx[k]),
+    // a state's own sections; the rest are shown from its country
+    unitOwn: unit ? Object.keys(ctx) : [],
+    unitBorrowed: unit ? [...REQUIRED, ...RESEARCHED].filter((k) => !ctx[k]) : [],
     deathWithoutSection: p.recreational === "death" && !ctx.death_penalty,
     products: !unit && Boolean(ctx.products),
     statuses: ["hemp", "production", "research"].filter((k) => ctx[k] && !ctx[k].status).map((k) => `${k}.status`),
-    penalties: unit ? [] : ["possession", "cultivation"].filter((k) => p.segments?.[k] && !p.segments[k].penalties),
+    penalties: ["possession", "cultivation"].filter((k) => p.segments?.[k] && !(typeof p.segments[k] === "object" && p.segments[k].penalties)),
     uncited: blocks.filter(([, b]) => b?.text && !/\[@/.test(b.text)).map(([k]) => k),
     unsourced: blocks.filter(([, b]) => b?.text && !(b.sources ?? []).length).map(([k]) => k),
@@ -99,10 +102,14 @@ function printOne(g) {
   out();
   out("PROFILE");
-  if (g.unit) out(`  a state or province file: context sections live in countries/${g.id.split("/")[0]}/index.yaml`);
+  if (g.unit) {
+    out(`  a state or province file: sections it does not write are shown from countries/${g.id.split("/")[0]}/index.yaml`);
+    list("its own context sections", g.unitOwn);
+    list("shown from the country (write where the state differs)", g.unitBorrowed);
+  }
   if (!g.unit) { list("missing standard sections", g.missingRequired); list("missing researched sections", g.missingResearched); list("optional sections not written", g.missingOptional); }
   if (g.deathWithoutSection) out("  ! recreational is \"death\" but there is no death_penalty section");
   if (g.products) out("  products still present: fold it into recreational, medical and hemp, then delete it");
   list("statuses not set", g.statuses);
-  if (!g.unit) list("penalty tables missing", g.penalties);
+  list("penalty tables missing", g.penalties);
   list("texts without inline citations", g.uncited);
   list("texts with no source at all", g.unsourced);

scripts/profile-audit.cjs +4 −4
@@ -34,5 +34,5 @@ for (const id of ids) {
   // ── penalty tables ──
   for (const [seg, block] of Object.entries(p.segments ?? {})) {
-    const rows = block?.penalties;
+    const rows = typeof block === "object" ? block?.penalties : null; // a state's segment may be plain text
     if (!rows) continue;
     const where = `segments.${seg}.penalties`;
@@ -78,6 +78,6 @@ for (const id of ids) {
   // ── context sections ──
   const ctx = p.context ?? {};
-  if (!unit) {
-    if (ctx.products && ctx.recreational && ctx.hemp) warn(id, "context.products", "recreational and hemp exist: fold products into them and delete it");
+  {
+    if (!unit && ctx.products && ctx.recreational && ctx.hemp) warn(id, "context.products", "recreational and hemp exist: fold products into them and delete it");
     for (const [key, b] of Object.entries(ctx)) {
       const items = Array.isArray(b) ? b.map((x, i) => [`context.${key}[${i}]`, x]) : [[`context.${key}`, b]];
@@ -97,5 +97,5 @@ for (const id of ids) {
         }
         if (AVOID.test(text)) warn(id, where, `uses a word WRITING.md asks to avoid ("${text.match(AVOID)[0]}")`);
-        if (/gov\.uk\/foreign-travel-advice/.test(JSON.stringify(it.sources?.map((k) => p.sources?.[k])))) warn(id, where, "rests on GOV.UK travel advice, meant only for the UK and closed countries");
+        if (!unit && /gov\.uk\/foreign-travel-advice/.test(JSON.stringify(it.sources?.map((k) => p.sources?.[k])))) warn(id, where, "rests on GOV.UK travel advice, meant only for the UK and closed countries");
       }
     }

src/components/CountryArticle.astro +25 −26
@@ -23,4 +23,5 @@ import { TREATMENT, punishmentText, punishmentExtra } from "../lib/penalties.js"
 import { viewByMetric } from "../lib/map-views.js";
 import { Icon } from "astro-icon/components";
+import PenaltyTable from "./PenaltyTable.astro";
 import { answersFor } from "../lib/answers.js";
 import CultureList from "./CultureList.astro";
@@ -59,5 +60,9 @@ const unitParts = sub ? sub.segCite.map((text, i) => citeParts(text, unitRefs,
   citedKeys(text) ?? sub.sources.map((_, i) => String(i + 1)),
   `countries/${c.slug}/${sub.id}.yaml: ${SEGMENTS[i].id}`)) : [];
-const ctxParts = d ? Object.fromEntries(d.context.filter(Boolean).map((x) => [x.key, x.items ? x.items.map((item, i) => partsOf(item, ctxRefs, `${x.key}[${i}]`)) : partsOf(x, ctxRefs, x.key)])) : {};
+// The context shown: a country's sections, or for a state its own, with its country's sections in
+// place of those it does not write (marked `national`). Its sources: the state's numbered list.
+const ctxList = sub ? c.details.context.map((nat, i) => sub.context[i] ?? (nat && { ...nat, national: true })) : d?.context ?? null;
+const srcList = sub ? sub.sources : d?.sources ?? [];
+const ctxParts = ctxList ? Object.fromEntries(ctxList.filter(Boolean).map((x) => [x.key, x.items ? x.items.map((item, i) => partsOf(item, ctxRefs, `${x.key}[${i}]`)) : partsOf(x, ctxRefs, x.key)])) : {};
 // a maximum fine in the profile's currency: "€5,500", "$300"
 const money = (n, currency) => new Intl.NumberFormat("en-GB", { style: "currency", currency, maximumFractionDigits: 0 }).format(n);
@@ -66,5 +71,5 @@ const mapOf = (metric) => (viewByMetric[metric] ? `${base}map/${viewByMetric[met
 const onMap = (help) => `${help} · See the world map`;
 // the recreational and medical status chips lead to their own context sections, where those exist
-const ctxHas = (key) => Boolean(d?.context.some((x) => x?.key === key));
+const ctxHas = (key) => Boolean(ctxList?.some((x) => x?.key === key));
 const INCB = ["International Narcotics Control Board — Travellers carrying medicines containing controlled substances", "https://www.incb.org/incb/en/travellers/index.html", null, "https://www.incb.org/incb/en/travellers/index.html"];
 // 48,000,000 → "48 million", 770,000 → "770,000"
@@ -91,5 +96,6 @@ const method = (segId) => `${base}#${segId ? `method-${segId}` : "methodology"}`
 
 // outside datasets with figures for the country, listed in their own tab
-const datasetCount = sub ? 0 : Object.keys(datasetsOf(c.slug)).length;
+// a state shows its country's datasets, marked as national
+const datasetCount = Object.keys(datasetsOf(c.slug)).length;
 // 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]. Labels are kept short so that the row fits; the
@@ -100,5 +106,8 @@ const tabs = (sub
   ? [
       ["score", "Score breakdown"],
+      ["context", "Context"],
       history.length > 0 && ["history", "History", history.length],
+      datasetCount > 0 && ["datasets", "Datasets", datasetCount],
+      ["sources", "Sources", srcList.length],
       ["units", `Other ${group.plural.split(" ")[0]}`, group.units.length - 1],
     ]
@@ -158,7 +167,7 @@ const culture = cultureOf(c);
         : <span class={`chip s-${v.med}`} title={MED_HELP[v.med]}>Medical: {MED_LABEL[v.med]}</span>}
     </div>
-    {d && d.context.some((x) => x && (x.status || x.seeds)) && (
+    {ctxList && ctxList.some((x) => x && (x.status || x.seeds)) && (
       <div class="d-chips d-chips-ctx">
-        {d.context.filter((x) => x && (x.status || x.seeds)).map((x) => x.seeds ? (
+        {ctxList.filter((x) => x && (x.status || x.seeds)).map((x) => x.seeds ? (
           <>
             <a class={`chip s-${PLANTING_STATUS[x.seeds].tone}`} href={`#seg-ctx-${x.key}`} data-seg-link={`ctx-${x.key}`} title={PLANTING_STATUS[x.seeds].help[0]}>Seeds: {PLANTING_STATUS[x.seeds].label}</a>
@@ -322,4 +331,5 @@ const culture = cultureOf(c);
         <div class="why">
           {sub.seg && <p><Cited parts={unitParts[i]} /></p>}
+          {sub.segPenalties[i] && <PenaltyTable penalties={sub.segPenalties[i]} />}
         </div>
       </details>
@@ -348,19 +358,5 @@ const culture = cultureOf(c);
             <div class="why">
               <p><Cited parts={segParts[i]} /></p>
-              {seg.penalties && (
-                <table class="pen">
-                  <thead><tr><td>Amount</td><td>What happens</td><td>Punishment</td><td>Fine</td></tr></thead>
-                  <tbody>
-                    {seg.penalties.rows.map((r) => (
-                      <tr>
-                        <td data-label="Amount">{r.amount}{r.note && <span class="pen-note">{r.note}</span>}</td>
-                        <td data-label="What happens"><span class={`pen-t s-${TREATMENT[r.treatment].tone}`} title={TREATMENT[r.treatment].help}>{TREATMENT[r.treatment].label}</span>{r.term && <span class="pen-note">{r.term}</span>}</td>
-                        <td data-label="Punishment" class:list={{ "pen-death": r.death }}>{punishmentText(r) ?? "—"}{punishmentExtra(r) && <span class="pen-note">{punishmentExtra(r)}</span>}</td>
-                        <td data-label="Fine">{r.fine && r.fineMin ? `${money(r.fineMin, seg.penalties.currency)} – ${money(r.fine, seg.penalties.currency)}` : r.fine ? `Up to ${money(r.fine, seg.penalties.currency)}` : r.fineMin ? `At least ${money(r.fineMin, seg.penalties.currency)}` : "—"}</td>
-                      </tr>
-                    ))}
-                  </tbody>
-                </table>
-              )}
+              {seg.penalties && <PenaltyTable penalties={seg.penalties} />}
             </div>
           )}
@@ -372,10 +368,12 @@ const culture = cultureOf(c);
     </div>
 
-    {d && (
+    {ctxList && (
       <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) => (
+        {sub && <p class="d-hint">Sections written for {sub.name} come first in their place; the others hold for all of {c.name} and are marked so.</p>}
+        {ctxList.filter(Boolean).map((x) => (
           <div class="segrow" data-seg={`ctx-${x.key}`} id={`seg-ctx-${x.key}`}>
             <div class="segrow-top"><span>{CONTEXT_ICONS[x.key] && <Icon name={CONTEXT_ICONS[x.key]} class="ctx-icon" aria-hidden="true" />}{x.name}<SourceTip label="What this covers" inline>{x.help}</SourceTip></span></div>
+            {x.national && <p class="ctx ctx-general">For all of {c.name}; nothing written for {sub.name} yet.</p>}
             {x.key === "recreational" && (
               <div class="d-chips"><a class={`chip s-${v.rec}`} href={mapOf("status:rec")} title={onMap(REC_HELP[v.rec])}>Recreational: {REC_LABEL[v.rec]}</a></div>
@@ -423,15 +421,16 @@ const culture = cultureOf(c);
       </div>
     )}
-    {d && datasetCount > 0 && (
+    {datasetCount > 0 && (
       <div class="c-panel" id="panel-datasets" data-panel="datasets" role="tabpanel" aria-labelledby="tab-datasets" hidden>
+        {sub && <p class="d-hint">These figures are for all of {c.name}; no dataset here is broken down by {group.label.toLowerCase()} yet.</p>}
         <CountryDatasetTab c={c} />
       </div>
     )}
-    {d && (
+    {(d || sub) && (
       <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 <span>{d.sources.length} cited in this profile</span></h2>
+          <h2 class="d-h">All sources <span>{srcList.length} cited in this profile</span></h2>
           <ol>
-            {d.sources.map((source) => <SourceCitation source={source} />)}
+            {srcList.map((source) => <SourceCitation source={source} />)}
           </ol>
         </section>

src/components/PenaltyTable.astro +22 −0
@@ -0,0 +1,22 @@
+---
+// A segment's penalty steps as a plain table (src/lib/penalties.js), under its text on a country's or
+// a state's page. `penalties` is { rows, currency, summary }.
+import { TREATMENT, punishmentText, punishmentExtra } from "../lib/penalties.js";
+const { penalties } = Astro.props;
+// a fine in the profile's currency: "€5,500", "$300"
+const money = (n) => new Intl.NumberFormat("en-GB", { style: "currency", currency: penalties.currency, maximumFractionDigits: 0 }).format(n);
+const fine = (r) => (r.fine && r.fineMin ? `${money(r.fineMin)} – ${money(r.fine)}` : r.fine ? `Up to ${money(r.fine)}` : r.fineMin ? `At least ${money(r.fineMin)}` : "—");
+---
+<table class="pen">
+  <thead><tr><td>Amount</td><td>What happens</td><td>Punishment</td><td>Fine</td></tr></thead>
+  <tbody>
+    {penalties.rows.map((r) => (
+      <tr>
+        <td data-label="Amount">{r.amount}{r.note && <span class="pen-note">{r.note}</span>}</td>
+        <td data-label="What happens"><span class={`pen-t s-${TREATMENT[r.treatment].tone}`} title={TREATMENT[r.treatment].help}>{TREATMENT[r.treatment].label}</span>{r.term && <span class="pen-note">{r.term}</span>}</td>
+        <td data-label="Punishment" class:list={{ "pen-death": r.death }}>{punishmentText(r) ?? "—"}{punishmentExtra(r) && <span class="pen-note">{punishmentExtra(r)}</span>}</td>
+        <td data-label="Fine">{fine(r)}</td>
+      </tr>
+    ))}
+  </tbody>
+</table>

src/content.config.ts +25 −1
@@ -156,4 +156,27 @@ const countries = defineCollection({
 });
 
+// A state's or province's own context: the same sections as a country's, each optional. Its sources
+// are numbers into the file's numbered `sources` list (as its segments cite [@2]); a section it does
+// not write is shown from its country, marked as national. Statuses use the country vocabularies.
+const unitBlock = z.object({
+  text: z.string().min(1),
+  sources: z.array(z.number().int().positive()).optional(),
+  status: z.string().optional(),
+  seeds: plantingStatus.optional(), seeds_trade: plantingTrade.optional(), clones: plantingStatus.optional(), clones_trade: plantingTrade.optional(),
+}).strict();
+const UNIT_SECTIONS = ["death_penalty", "planting", ...Object.keys(STATUS_SECTIONS), ...OPTIONAL_CONTEXT_IDS, ...CONTEXT_IDS];
+const unitContext = z.object({
+  ...Object.fromEntries([...new Set(UNIT_SECTIONS)].map((k) => [k, unitBlock.optional()])),
+  spotlights: z.array(z.object({ title: z.string().min(1), text: z.string().min(1), sources: z.array(z.number().int().positive()).optional() }).strict()).min(1).optional(),
+}).strict()
+  .superRefine((ctx, issue) => {
+    for (const [key, section] of Object.entries(STATUS_SECTIONS)) {
+      const st = (ctx as Record<string, { status?: string } | undefined>)[key]?.status;
+      if (st && !(st in section.statuses)) issue.addIssue({ code: "custom", message: `context.${key}.status "${st}" is not one of ${Object.keys(section.statuses).join(", ")}` });
+    }
+  });
+// a state's segment: its plain text, or the text with a penalty table
+const unitSegment = z.union([z.string().min(1), z.object({ text: z.string().min(1), currency: z.string().regex(/^[A-Z]{3}$/).optional(), penalties: z.array(penalty).min(1).optional() }).strict()]);
+
 // countries/us/ca.yaml → id "us/ca"
 const units = defineCollection({
@@ -172,6 +195,7 @@ const units = defineCollection({
       scores: perSegment(score),
       answers,
-      segments: perSegment(z.string().min(1)),
+      segments: perSegment(unitSegment),
       sources: z.array(source),
+      context: unitContext.optional(),
       // optional: how many people used cannabis in the past year, from the country's own survey.
       // The age band is the survey's (NSDUH 12+, Canadian Cannabis Survey 16+, NDSHS 14+), so it is

src/lib/countries.js +23 −3
@@ -198,4 +198,21 @@ export function subunitsOf(id) {
       const s = scoreList(data.scores);
       const raw = scoreOf(s);
+      const file = `countries/${path}.yaml`;
+      const sources = [...data.sources.map(pair), ...groupSources]; // the unit's own sources first
+      // a segment is its text, or { text, currency, penalties }
+      const segText = (seg) => (typeof seg === "string" ? seg : seg.text);
+      // a context section cited by number into the unit's sources; with no `sources`, all of them
+      const numbered = (block, where) => {
+        const keys = (block.sources ?? []).map(String);
+        for (const k of keys) if (!sources[+k - 1]) throw new Error(`${file}: ${where} cites source ${k}, but the file lists ${data.sources.length}`);
+        return { t: stripCites(block.text), src: keys.map((k) => sources[+k - 1]), cite: { text: block.text, keys, of: (k) => /^[1-9]\d*$/.test(k) && sources[+k - 1] } };
+      };
+      const ctx = data.context ?? {};
+      const context = CONTEXT.map(([key, name, help]) => {
+        const b = ctx[key];
+        if (!b) return null;
+        if (key === "spotlights") return { key, name, help, ...numbered({ text: b.map((x) => `${x.title}. ${x.text}`).join(" "), sources: [...new Set(b.flatMap((x) => x.sources ?? []))] }, key), items: b.map((x, i) => ({ title: x.title, ...numbered(x, `${key}[${i}]`) })) };
+        return { key, name, help, ...numbered(b, `context.${key}`), ...(key === "planting" && b.seeds && plantingOf(b)), ...(STATUS_SECTIONS[key] && b.status && { kind: key, status: b.status }) };
+      });
       return {
         id: path.split("/")[1], raw, s,
@@ -209,7 +226,10 @@ export function subunitsOf(id) {
         reviewed: isoDate(data.updated),
         worldRank: 1 + countries.filter((c) => c.raw > raw).length,
-        seg: SEGMENTS.map((seg) => stripCites(data.segments[seg.id])),
-        segCite: SEGMENTS.map((seg) => data.segments[seg.id]),          // six explanations, in SEGMENTS order
-        sources: [...data.sources.map(pair), ...groupSources],      // the unit's own source first
+        seg: SEGMENTS.map((seg) => stripCites(segText(data.segments[seg.id]))),
+        segCite: SEGMENTS.map((seg) => segText(data.segments[seg.id])),          // six explanations, in SEGMENTS order
+        // penalty steps of a segment, as for a country (penalties.js); null where it has none
+        segPenalties: SEGMENTS.map((seg) => { const b = data.segments[seg.id], rows = typeof b === "object" ? b.penalties?.map(penaltyRow) : null; return rows ? { rows, currency: b.currency ?? null, summary: penaltySummary(rows) } : null; }),
+        context, // the unit's own context sections, in CONTEXT order; null where it writes none
+        sources,
         use: data.use ? { ...data.use, source: pair(data.use.source) } : null, // past-year use from its country's survey
       };

    ↑↓ to move, ↵ to open.