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; }