Cannabis Freedom Index

Changelog 8c1bb27tree

Count every context category in research completion

12 files of code, design or docs +231 −22 lines

Code, design and documents 12 files

.github/workflows/deploy.yml +1 −0
@@ -38,4 +38,5 @@ jobs:
       # the facts earlier issues corrected, which a later rewrite must not lose
       - run: npm run history:test
+      - run: npm run research:test
       # the survey: relay text never as HTML, deletions confirmed, one respondent per key
       - run: npm run survey:test

docs/COUNTRY-CONTEXTS.md +7 −3
@@ -1,5 +1,8 @@
 # Context sections: fix and extend
 
-Status: **proposal**, 27 October 2026. Nothing here is built yet. It comes out of issue #224.
+Status: **implemented structure; country migration in progress**, 27 October 2026.
+It comes out of issue #224. Research progress now targets every current category:
+cited content or a sourced explanation that it does not apply. See
+[the coverage rules](DATA-MAINTENANCE.md#context-coverage-on-research-progress).
 
 Everything stays in the country profile, `countries/<cc>.yaml`, under `context`. Every section
@@ -96,6 +99,7 @@ and what bans, eradication or imported hybrids did to them.
 - Seed-shop strain names are not evidence ("Malawi Gold" does not prove a living landrace).
 - No exact grow locations and no farmers' names.
-- "Not documented" never means "does not exist", so leave the section out rather than writing
-  "none".
+- "Not documented" never means "does not exist". Leave the section out while researching;
+  Research progress records it as a gap. An explained exception needs evidence that the
+  category does not apply, not simply that no account has been found.
 
 ### `research`: Research

docs/DATA-MAINTENANCE.md +39 −0
@@ -358,4 +358,43 @@ and Germany are written this way and can serve as models.
 move its content and delete the section. The schema accepts both during the move.
 
+### Context coverage on Research progress
+
+The completion target on `/research/` is all sixteen current context categories for
+every country. Each category needs cited content, or a sourced explanation of why
+it does not apply. This is a research target: partially researched profiles remain
+publishable. It does not change the cannabis freedom score.
+
+The page shows coverage by category and by country, and lists every gap when a
+country's row is expanded. Filled sections and explained exceptions are counted
+separately. Empty text, bare `None` / `N/A` placeholders, missing sources and unknown
+source keys do not count. Every spotlight in a section needs its own cited text.
+The retired `products` section earns no replacement-category credit.
+
+For a reviewed exception, use country-level `context_not_applicable`, with the
+category's existing key and a cited explanation. For example, **only after checking
+the law for that country**:
+
+```yaml
+context_not_applicable:
+  death_penalty:
+    text: >-
+      Cannabis offences carry fines or imprisonment under the criminal code;
+      none of the applicable offences provides a capital sentence.
+    sources: [criminal_code]
+```
+
+Source keys must exist in the country's `sources`. The research page displays the
+explanation and references. A category cannot have both regular context content
+and a not-applicable explanation. For a category that has relevant negative facts,
+such as a law prohibiting commercial cultivation, explain those facts in its usual
+context section instead. Missing statistics, an undocumented landrace or a study
+not yet found are research gaps, not proof that a category is inapplicable. Do not
+invent spotlights or traditions to make the percentage rise.
+
+Context now contributes its own sixteen points to the research percentage, beside
+the profile and history checks. The existing manual sign-off is still required
+for overall 100%; full context coverage alone is insufficient. Run
+`npm run research:test` when changing coverage rules.
+
 ### Statuses for hemp, industry and research
 

package.json +1 −0
@@ -13,4 +13,5 @@
     "books:test": "node --test scripts/books-status.test.cjs",
     "history:test": "node --test scripts/history-scope.test.cjs",
+    "research:test": "node --test scripts/context-coverage.test.mjs",
     "seo:audit": "node scripts/seo-audit.mjs",
     "answers:test": "node --test scripts/answers.test.mjs scripts/profile-claims.test.mjs scripts/use-freedom-ratio.test.mjs",

scripts/context-coverage.test.mjs +71 −0
@@ -0,0 +1,71 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { contextCoverage } from "../src/lib/context-coverage.js";
+import { CONTEXT_RESEARCH_IDS, CONTEXT_IDS, OPTIONAL_CONTEXT_IDS, STATUS_SECTIONS } from "../src/lib/segment-ids.js";
+
+const source = { title: "Reviewed official source", url: "https://example.org/law" };
+const block = (text = "A licensed system governs cannabis production and sale.") => ({ text, sources: ["law"] });
+const profile = (context = {}, context_not_applicable) => ({ context, sources: { law: source }, context_not_applicable });
+const only = key => [[key, key]];
+
+test("the target includes every current category, excluding the retired Products section", () => {
+  const schemaKeys = [...new Set(["death_penalty", "planting", "spotlights", ...CONTEXT_IDS, ...OPTIONAL_CONTEXT_IDS, ...Object.keys(STATUS_SECTIONS)])].filter(key => key !== "products");
+  assert.deepEqual([...CONTEXT_RESEARCH_IDS].sort(), schemaKeys.sort());
+  assert.equal(CONTEXT_RESEARCH_IDS.length, 16);
+});
+
+test("missing sections remain gaps even where cannabis is banned", () => {
+  const result = contextCoverage({ ...profile(), recreational: "illegal", medical: "none" });
+  assert.equal(result.covered, 0);
+  assert.equal(result.missing, 16);
+  assert.equal(result.pct, 0);
+});
+
+test("all sixteen cited categories reach the coverage target", () => {
+  const context = Object.fromEntries(CONTEXT_RESEARCH_IDS.map(key => [key, key === "spotlights" ? [{ title: "A local feature", ...block() }] : block()]));
+  const result = contextCoverage(profile(context));
+  assert.equal(result.filled, 16);
+  assert.equal(result.notApplicable, 0);
+  assert.equal(result.pct, 100);
+});
+
+test("legacy Products cannot fill recreational, medical or hemp coverage", () => {
+  assert.equal(contextCoverage(profile({ products: block() })).covered, 0);
+});
+
+test("bare placeholders, empty text and missing or unknown references stay incomplete", () => {
+  for (const text of ["", "  ", "None.", "None found.[@law]", "N/A", "Not applicable", "No data available."]) {
+    assert.equal(contextCoverage(profile({ research: block(text) }), only("research")).covered, 0, text);
+  }
+  for (const sources of [[], ["unknown"], ["law", "unknown"]]) {
+    assert.equal(contextCoverage(profile({ research: { ...block(), sources } }), only("research")).covered, 0);
+  }
+});
+
+test("a sourced absence is content, while not-applicable explanations are counted separately", () => {
+  const result = contextCoverage(profile({ production: block("Commercial cannabis cultivation is prohibited under the drug law.") }, {
+    death_penalty: block("Cannabis offences are punishable by fines or imprisonment; the law provides no capital sentence."),
+  }), [["production", "Industry"], ["death_penalty", "Death penalty"]]);
+  assert.equal(result.filled, 1);
+  assert.equal(result.notApplicable, 1);
+  assert.equal(result.covered, 2);
+  assert.equal(result.pct, 100);
+  assert.match(result.sections[1].reason, /no capital sentence/);
+});
+
+test("an unexplained or uncited exception cannot inflate coverage", () => {
+  for (const exception of [block("N/A"), { text: "This category does not apply because the law excludes it.", sources: [] }]) {
+    assert.equal(contextCoverage(profile({}, { genetics: exception }), only("genetics")).covered, 0);
+  }
+});
+
+test("content and a conflicting not-applicable claim cannot both count", () => {
+  const result = contextCoverage(profile({ research: block() }, { research: block() }), only("research"));
+  assert.equal(result.covered, 0);
+  assert.equal(result.sections[0].state, "incomplete");
+});
+
+test("every spotlight needs its own explanation and sources", () => {
+  const result = contextCoverage(profile({ spotlights: [{ title: "Complete", ...block() }, { title: "Unfinished", text: "", sources: [] }] }), only("spotlights"));
+  assert.equal(result.covered, 0);
+});

scripts/history-scope.test.cjs +1 −0
@@ -1000,4 +1000,5 @@ test("country sections and history entries have complete, valid inline citation
     visit(profile.segments, `countries/${file}: segments`);
     visit(profile.context, `countries/${file}: context`);
+    visit(profile.context_not_applicable, `countries/${file}: context_not_applicable`);
   }
   assert.deepEqual(bad, []);

src/content.config.ts +12 −1
@@ -6,5 +6,6 @@ import { z } from "astro/zod";
 import { isMixedCannabisPrice } from "./lib/price-selection.js";
 import { TREATMENT, parseAmount, parseDuration } from "./lib/penalties.js";
-import { SEGMENT_IDS, CONTEXT_IDS, OPTIONAL_CONTEXT_IDS, sectionSources, CHANGE_CATEGORIES, CHANGE_TAGS, PLANTING_STATUS, PLANTING_TRADE, STATUS_SECTIONS } from "./lib/segment-ids.js";
+import { SEGMENT_IDS, CONTEXT_IDS, OPTIONAL_CONTEXT_IDS, CONTEXT_RESEARCH_IDS, sectionSources, CHANGE_CATEGORIES, CHANGE_TAGS, PLANTING_STATUS, PLANTING_TRADE, STATUS_SECTIONS } from "./lib/segment-ids.js";
+import { hasContextExplanation } from "./lib/context-coverage.js";
 
 const score = z.number().int().min(0).max(10);
@@ -118,4 +119,8 @@ const countries = defineCollection({
       // list of `spotlights`
       context: z.object({ death_penalty: cited.optional(), planting: planting.optional(), ...Object.fromEntries(OPTIONAL_CONTEXT_IDS.map((id) => [id, cited.optional()])), spotlights: z.array(spotlight).min(1).optional(), ...Object.fromEntries(CONTEXT_IDS.map((id) => [id, cited])), ...statusSections }).strict(),
+      // Research-only exceptions: absence of evidence is a gap, not an exception.
+      context_not_applicable: z.object(Object.fromEntries(CONTEXT_RESEARCH_IDS.map(id => [id,
+        z.object({ text: z.string().refine(hasContextExplanation, { message: "explain why this category does not apply" }), sources: z.array(z.string()).min(1) }).strict().optional(),
+      ]))).strict().optional(),
       sources: z.record(z.string(), source),
       subunits: z
@@ -136,4 +141,10 @@ const countries = defineCollection({
       }
       const blocks = { ...data.segments, ...data.context } as Record<string, unknown>;
+      for (const [name, block] of Object.entries(data.context_not_applicable ?? {})) {
+        if (data.context[name]) ctx.addIssue({ code: "custom", message: `"${name}" has context content and a not-applicable explanation; choose one` });
+        for (const key of block.sources) {
+          if (!(key in data.sources)) ctx.addIssue({ code: "custom", message: `"context_not_applicable.${name}" cites unknown source "${key}"` });
+        }
+      }
       for (const [name, block] of Object.entries(blocks)) {
         for (const key of sectionSources(block)) {

src/lib/context-coverage.js +34 −0
@@ -0,0 +1,34 @@
+// Research coverage is separate from a country's legal freedom score. A category is
+// addressed by cited content or an explicit, cited explanation that it does not apply.
+import { CONTEXT_RESEARCH_IDS } from "./segment-ids.js";
+import { stripCites } from "./cite.js";
+
+const PLACEHOLDER = /^(?:none(?: found)?|n\/?a|not applicable|unknown|no data|not (?:found|documented)|no (?:information|data)(?: (?:found|available))?)[.!]?$/i;
+export const hasContextExplanation = (text) => {
+  if (typeof text !== "string") return false;
+  const plain = stripCites(text).trim();
+  return Boolean(plain) && !PLACEHOLDER.test(plain);
+};
+
+export function contextCoverage(profile, categories = CONTEXT_RESEARCH_IDS.map(key => [key, key])) {
+  const valid = (block) => hasContextExplanation(block?.text) && Array.isArray(block.sources)
+    && block.sources.length > 0 && block.sources.every(key => Boolean(profile.sources?.[key]));
+  const sections = categories.map(([key, label]) => {
+    const content = profile.context?.[key];
+    const exception = profile.context_not_applicable?.[key];
+    if (content && exception) return { key, label, state: "incomplete", reason: "Choose context content or a not-applicable explanation", sources: [] };
+    if (content) {
+      const blocks = Array.isArray(content) ? content : [content];
+      const filled = blocks.length > 0 && blocks.every(valid);
+      return { key, label, state: filled ? "filled" : "incomplete", reason: filled ? "" : "Needs an explanation and valid sources", sources: [] };
+    }
+    if (exception && valid(exception)) {
+      return { key, label, state: "not_applicable", reason: stripCites(exception.text), sources: exception.sources };
+    }
+    return { key, label, state: exception ? "incomplete" : "missing", reason: exception ? "The not-applicable explanation needs valid sources" : "Not yet researched", sources: [] };
+  });
+  const filled = sections.filter(s => s.state === "filled").length;
+  const notApplicable = sections.filter(s => s.state === "not_applicable").length;
+  const covered = filled + notApplicable;
+  return { sections, filled, notApplicable, covered, total: sections.length, missing: sections.length - covered, pct: sections.length ? Math.floor(100 * covered / sections.length) : 0 };
+}

src/lib/research.js +23 −8
@@ -5,6 +5,7 @@
 // through the country and found nothing left to add.
 import { getCollection } from "astro:content";
-import { SEGMENTS, dateTime, articles } from "./countries.js";
-import { CONTEXT_IDS, STATUS_SECTIONS } from "./segment-ids.js";
+import { SEGMENTS, CONTEXT, dateTime, articles } from "./countries.js";
+import { CONTEXT_RESEARCH_IDS } from "./segment-ids.js";
+import { contextCoverage } from "./context-coverage.js";
 import { archivedUrl } from "./archive.js";
 import { datasetsOf, latestOf } from "./datasets.js";
@@ -14,7 +15,5 @@ const WEAK = /cannabisregulations\.ai|tripbase\.com|sensiseeds\.com/;
 const WIKI = /wikipedia\.org/;
 const solid = (src) => src && (!src.url || !(WEAK.test(src.url) || WIKI.test(src.url))); // a book has no url
-const SECTIONS = [...new Set([...CONTEXT_IDS, "products", "planting", ...Object.keys(STATUS_SECTIONS).filter((k) => !STATUS_SECTIONS[k].optional), "black_market", "arrests"])];
-// a profile that has folded `products` into `recreational` and `hemp` has not lost a section
-const researched = (context, k) => k === "products" && !context.products ? Boolean(context.recreational?.sources.length && context.hemp?.sources.length) : Boolean(context[k]?.sources.length);
+const CONTEXT_SECTIONS = CONTEXT.filter(([key]) => CONTEXT_RESEARCH_IDS.includes(key));
 const NOW = new Date();
 const THIS_YEAR = NOW.getFullYear();
@@ -35,6 +34,7 @@ export const research = (await getCollection("countries")).map(({ id: slug, data
   const soft = SEGMENTS.filter((seg) => data.segments[seg.id].sources.length && !src(data.segments[seg.id].sources).some(solid));
   add("profile", "Every score rests on more than Wikipedia", SEGMENTS.length - uncited.length - soft.length, SEGMENTS.length, soft.map((s) => `${s.name}: only Wikipedia or a weak site`));
-  const empty = SECTIONS.filter((k) => !researched(data.context, k));
-  add("profile", "Context sections researched", SECTIONS.length - empty.length, SECTIONS.length, empty.map((k) => `${k.replace("_", " ")}: ${data.context[k] ? "no source" : "missing"}`));
+  const coverage = contextCoverage(data, CONTEXT_SECTIONS);
+  add("context", "Every context category filled or explained", coverage.covered, coverage.total,
+    coverage.sections.filter(s => s.state === "missing" || s.state === "incomplete").map(s => `${s.label}: ${s.reason}`));
   const use = latestOf(slug).cannabis_use ?? {};
   add("profile", "Use is a measured figure", use.year && !use.estimate ? 1 : 0, 1, use.year && !use.estimate ? [] : ["prevalence of use is an estimate"]);
@@ -84,5 +84,6 @@ export const research = (await getCollection("countries")).map(({ id: slug, data
     slug, name: data.name, flag: data.flag ?? flagOf(slug), region: data.region, disputed: Boolean(data.disputed || data.dependency),
     population: datasetsOf(slug).population?.at(-1).population ?? 0,
-    pct, profile: Math.floor(share("profile") * 100), history: Math.floor(share("history") * 100), signed,
+    pct, profile: Math.floor(share("profile") * 100), context: coverage.pct, history: Math.floor(share("history") * 100), signed,
+    contextCoverage: { ...coverage, sections: coverage.sections.map(s => ({ ...s, references: src(s.sources) })) },
     entries: own.length, scored: scored.length, articles: own.filter((e) => articles.has(e.id)).length,
     first: first ? (first.estimate ? "est." : first.date.slice(0, 4)) : "—",
@@ -103,3 +104,17 @@ export const researchTotals = {
   entries: entries.length,
   estimates: research.filter((r) => r.first === "est.").length,
+  contexts: (() => {
+    const filled = research.reduce((n, r) => n + r.contextCoverage.filled, 0);
+    const notApplicable = research.reduce((n, r) => n + r.contextCoverage.notApplicable, 0);
+    const total = research.reduce((n, r) => n + r.contextCoverage.total, 0);
+    return { filled, notApplicable, total, covered: filled + notApplicable, missing: total - filled - notApplicable,
+      pct: total ? Math.floor(100 * (filled + notApplicable) / total) : 0,
+      sections: CONTEXT_SECTIONS.map(([key, label]) => {
+        const sections = research.map(r => r.contextCoverage.sections.find(s => s.key === key));
+        const filled = sections.filter(s => s.state === "filled").length;
+        const notApplicable = sections.filter(s => s.state === "not_applicable").length;
+        return { key, label, filled, notApplicable, missing: research.length - filled - notApplicable, pct: Math.floor(100 * (filled + notApplicable) / research.length) };
+      }),
+    };
+  })(),
 };

src/lib/segment-ids.js +3 −0
@@ -6,4 +6,7 @@ export const CONTEXT_IDS = ["production", "medical", "consequences"];
 // `recreational`, `medical` and `hemp`; it stays valid until every country has moved.
 export const OPTIONAL_CONTEXT_IDS = ["recreational", "products", "hemp", "black_market", "arrests", "culture", "genetics", "research"];
+// Completion targets include every current context category. The retired Products section
+// cannot substitute for its recreational, medical and hemp replacements.
+export const CONTEXT_RESEARCH_IDS = ["death_penalty", "recreational", "medical", "clubs", "planting", "hemp", "production", "black_market", "visitors", "driving", "arrests", "consequences", "culture", "genetics", "research", "spotlights"];
 // `spotlights` is the one section that is a list: [{ title, text, sources }], one item per spotlight.
 // The source keys a section cites, whichever shape it has.

src/pages/research.astro +33 −8
@@ -11,4 +11,5 @@ const num = (n) => n.toLocaleString("en-US");
 const example = research[0].checks;
 const points = (part) => example.filter((c) => c.part === part).reduce((a, c) => a + c.of, 0);
+const parts = [["profile", "Profile"], ["context", "Context"], ["history", "History"]];
 const stats = [
   [`${T.average}%`, "Average"],
@@ -18,4 +19,6 @@ const stats = [
   [num(T.poor), "Below 40%"],
   [num(T.estimates), "First ban only estimated"],
+  [`${T.contexts.pct}%`, "Context categories covered"],
+  [num(T.contexts.missing), "Context gaps"],
 ];
 ---
@@ -25,5 +28,6 @@ const stats = [
       <p class="eyebrow">Internal</p>
       <h1>Research progress</h1>
-      <p class="lede">For each of the {num(T.places)} countries and territories: how much of the profile is filled in and sourced, how many history entries there are, and how complete that history looks. The figure is the share of points a country has out of {points("profile")} for its profile and {points("history")} for its history.</p>
+      <p class="lede">For each of the {num(T.places)} countries and territories: how much of the profile and context is filled in and sourced, and how complete the history looks. The figure is the share of points a country has out of {points("profile")} for its profile, {points("context")} for context and {points("history")} for history.</p>
+      <p class="lede">The context target is every category in every country: cited content, or a cited explanation of why it does not apply. Missing information and bare “None” placeholders remain gaps. The old Products section does not replace recreational, medical or hemp coverage.</p>
       <p class="lede">The checks only see what is in the files. They cannot know that nothing is missing, so they stop at 99%. A country shows 100% only when every check passes and someone has signed it off by hand, with <code>complete: YYYY-MM-DD</code> in its file.</p>
     </section>
@@ -41,7 +45,7 @@ const stats = [
       </div></div>
       <div class="rs-checks">
-        {["profile", "history"].map((part) => (
+        {parts.map(([part, label]) => (
           <table class="src-table">
-            <thead><tr><th>{part === "profile" ? "Profile" : "History"}</th><th class="num">Points</th></tr></thead>
+            <thead><tr><th>{label}</th><th class="num">Points</th></tr></thead>
             <tbody>{example.filter((c) => c.part === part).map((c) => <tr><td>{c.label}</td><td class="num">{c.of}</td></tr>)}</tbody>
           </table>
@@ -50,4 +54,15 @@ const stats = [
     </section>
 
+    <section class="section" aria-labelledby="contextCoverageTitle">
+      <div class="section-head"><div>
+        <h2 id="contextCoverageTitle">Context coverage</h2>
+        <p class="sub">{num(T.contexts.filled)} filled sections and {num(T.contexts.notApplicable)} explained as not applicable, out of {num(T.contexts.total)} country-category pairs. The target is 100% coverage; unavailable evidence still needs research.</p>
+      </div></div>
+      <div class="rs-scroll"><table class="src-table rs-context-table">
+        <thead><tr><th>Category</th><th class="num">Filled</th><th class="num">Not applicable, explained</th><th class="num">Gaps</th><th class="num">Covered</th></tr></thead>
+        <tbody>{T.contexts.sections.map(s => <tr><th scope="row">{s.label}</th><td class="num">{s.filled}</td><td class="num">{s.notApplicable}</td><td class="num">{s.missing}</td><td class="num">{s.pct}%</td></tr>)}</tbody>
+      </table></div>
+    </section>
+
     <section class="section">
       <div class="section-head"><div>
@@ -62,4 +77,5 @@ const stats = [
           <th class="num" data-sort="pct" aria-sort="descending">Research</th>
           <th class="num" data-sort="profile">Profile</th>
+          <th class="num" data-sort="context" title="Cited content or a sourced not-applicable explanation for every context category">Context</th>
           <th class="num" data-sort="history">History</th>
           <th class="num" data-sort="entries">Entries</th>
@@ -72,9 +88,12 @@ const stats = [
         </tr></thead>
         {research.map((r) => (
-          <tbody data-name={r.name.toLowerCase()} data-region={r.region.toLowerCase()} data-pct={r.pct} data-profile={r.profile} data-history={r.history} data-entries={r.entries} data-scored={r.scored} data-articles={r.articles} data-first={r.first === "est." ? 9998 : r.first === "—" ? 9999 : r.first} data-silent={r.silent} data-gap={r.gap ?? -1} data-reviewed={r.reviewed}>
+          <tbody data-name={r.name.toLowerCase()} data-region={r.region.toLowerCase()} data-pct={r.pct} data-profile={r.profile} data-context={r.context} data-history={r.history} data-entries={r.entries} data-scored={r.scored} data-articles={r.articles} data-first={r.first === "est." ? 9998 : r.first === "—" ? 9999 : r.first} data-silent={r.silent} data-gap={r.gap ?? -1} data-reviewed={r.reviewed}>
             <tr class="rs-row" tabindex="0" aria-expanded="false">
               <th scope="row"><a href={`${base}country/${r.slug}/`}>{r.flag} {r.name}</a>{r.disputed && <small> territory</small>}</th>
               <td class="num"><span class="rs-bar" style={`--p:${r.pct}%`}><b>{r.pct}%</b></span></td>
               <td class="num">{r.profile}%</td>
+              <td class="num rs-context-count" title={`${r.contextCoverage.filled} filled; ${r.contextCoverage.notApplicable} explained as not applicable; ${r.contextCoverage.missing} gaps`}>
+                {r.context}% <small>{r.contextCoverage.covered}/{r.contextCoverage.total}</small>
+              </td>
               <td class="num">{r.history}%</td>
               <td class="num">{r.entries}</td>
@@ -86,11 +105,17 @@ const stats = [
               <td class="num mono">{r.reviewed}</td>
             </tr>
-            <tr class="rs-more" hidden><td colspan="11">
+            <tr class="rs-more" hidden><td colspan="12">
               <div class="rs-missing">
-                {["profile", "history"].map((part) => (
+                {parts.map(([part, label]) => (
                   <div>
-                    <h3>{part === "profile" ? "Profile" : "History"}</h3>
+                    <h3>{label}</h3>
                     <ul>
-                      {r.checks.filter((c) => c.part === part).map((c) => (
+                      {part === "context" ? r.contextCoverage.sections.map(s => (
+                        <li class={s.state === "filled" || s.state === "not_applicable" ? "ok" : ""}>
+                          <span>{s.state === "filled" || s.state === "not_applicable" ? "✓" : "·"} {s.label} <small>{s.state === "not_applicable" ? "Not applicable, explained" : s.state === "filled" ? "Filled" : "Gap"}</small></span>
+                          {s.reason && <em>{s.reason}</em>}
+                          {s.references.length > 0 && <span class="rs-context-sources">{s.references.map((source, i) => source.url ? <a href={source.url} target="_blank" rel="noopener noreferrer">Source {i + 1}: {source.title}</a> : <span>{source.title}</span>)}</span>}
+                        </li>
+                      )) : r.checks.filter((c) => c.part === part).map((c) => (
                         <li class={c.got >= c.of ? "ok" : ""}>
                           <span>{c.got >= c.of ? "✓" : "·"} {c.label} <small>{Math.round(c.got * 10) / 10} of {c.of}</small></span>

src/styles/global.css +6 −2
@@ -2237,5 +2237,5 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase
 .rg-table summary { cursor: pointer; font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-2); }
 @media (max-width: 760px) { .rg-grid { grid-template-columns: minmax(0, 1fr); } .rg-plot { height: 120px; } }
-.rs-checks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 40px; }
+.rs-checks { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 28px; }
 #rsFind { align-self: end; min-width: 240px; }
 .rs-scroll { overflow-x: auto; }
@@ -2251,5 +2251,9 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase
 .rs-bar b { font-weight: 500; }
 .rs-more td { padding: 4px 0 22px; }
-.rs-missing { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 40px; width: min(100%, calc(100vw - 80px)); }
+.rs-missing { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 28px; width: min(100%, calc(100vw - 80px)); }
+.rs-context-count small { display: block; font: 400 11px var(--mono); color: var(--muted); }
+.rs-context-sources { display: grid; gap: 4px; margin-top: 4px; font-size: 12px; overflow-wrap: anywhere; }
+.rs-context-sources a { text-decoration: underline; }
+.rs-context-table { min-width: 580px; }
 .rs-missing h3 { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 12px 0 6px; }
 .rs-missing ul { list-style: none; margin: 0; padding: 0; }

    ↑↓ to move, ↵ to open.