Cannabis Freedom Index

Changelog f05a413

better map tooltips

1 atlas entry edited6 files of code, design or docs +234 −60 lines

Articles and atlas entries 1

Hasch in the GDR culture/traditions/hasch-in-the-gdr.md

text

In divided Germany, hashish smoking meant opposite things on either side of the border. I … used and the limited availability of cannabis. ## Cannabis in the socialist state's law ![Original 1973 East German government gazette page containing the Suchtmittelgesetz, including Section 1 explicitly naming Cannabis, Haschisch and Marihuana.](../../src/assets/culture/gdr-suchtmittelgesetz-1973-section-1.png "The GDR's Suchtmittelgesetz of December 1973 explicitly prohibited cannabis, hashish and marijuana, reflecting the state's restrictive drug policy. | Gesetzblatt der Deutschen Demokratischen Republik, 1973 / GVOON historical archive. | https://www.gvoon.de/gesetzblatt-gbl-ddr-teil-1-1973/seite-572-423475.html") The [1973 Suchtmittelgesetz](/history/germany-gdr-narcotic-drugs-act-1973/), reproduced above, was dated **19 December 1973** and published in *Gesetzblatt*, Part I, no. 58, on **21 De … strictive policy, rather than proving widespread cannabis use among East German citizens.

image

src ../../src/assets/culture/gdr-suchtmittelgesetzcustoms-1973dog-sectionberlin-11989-uhlemann.pngjpg · fit contain · alt Original 1973Black-and-white photograph of an East German government gazette page containing the Suchtmittelgesetz, including Section 1 explicitly naming Cannabis, Haschisch and Marihuanacustoms officer with a drug-detection dog inspecting a car at Berlin's Heinrich-Heine-Straße border crossing in November 1989. · caption The GDR's Suchtmittelgesetz of December 1973 explicitly prohibited cannabis, hashishAn East German customs officer and marijuana, reflecting the statedrug-detection dog inspect a car at Berlin's restrictive drug policyHeinrich-Heine-Straße border crossing, 28 November 1989. The original archive caption reports that no drugs were found in this spot check. · credit Gesetzblatt der Deutschen Demokratischen RepublikBundesarchiv, 1973Bild 183-1989-1128-012 / GVOON historical archiveThomas Uhlemann / CC BY-SA 3.0 DE. · credit url https://wwwcommons.gvoonwikimedia.deorg/gesetzblattwiki/File:Bundesarchiv_Bild_183-gbl1989-ddr1128-teil-1-1973012,_Berlin,_Rauschgiftspürhund_im_Einsatz.jpg · license url https:/seite/creativecommons.org/licenses/by-572-423475sa/3.html0/de/deed.en

sources

Bundesarchiv — Berlin, Rauschgiftspürhund im Einsatz, Thomas Uhlemann (28 November 1989) 6 unchanged

Code, design and documents 6 files

docs/research/hasch-gdr-illustration-2026-10-16.md +24 −5
@@ -1,5 +1,25 @@
-# Hasch in the GDR: original 1973 law
+# Hasch in the GDR: customs photograph and 1973 law
 
-Primary illustration is an actual rendered crop of Gesetzblatt der Deutschen
+## Current primary illustration
+
+At the user's request, replaced the law as the primary illustration with the
+attached 1989 photograph, copied unchanged from Downloads to:
+src/assets/culture/gdr-customs-dog-berlin-1989-uhlemann.jpg
+The supplied version is 800 × 574 and retains its archival credit strip.
+The full image uses contain with no forced aspect ratio or further cropping.
+
+Verified Wikimedia Commons' reproduction of the Bundesarchiv record:
+https://commons.wikimedia.org/wiki/File:Bundesarchiv_Bild_183-1989-1128-012,_Berlin,_Rauschgiftspürhund_im_Einsatz.jpg
+Thomas Uhlemann photographed an East German customs officer and detection dog
+at the Heinrich-Heine-Straße crossing on 28 November 1989. The original caption
+explicitly describes a spot check and says it was negative: no drugs were found.
+The entry's caption states that outcome and does not imply a cannabis seizure.
+Credit includes Bundesarchiv identifier, photographer and CC BY-SA 3.0 DE,
+with source and licence links. No colour or other visual alteration was applied.
+Removed the body reference to the law being pictured above.
+
+## Secondary illustration inside the article
+
+Retained as a research asset, an actual rendered crop of Gesetzblatt der Deutschen
 Demokratischen Republik, Teil I, Nr. 58, p. 572. The law is dated 19 December
 1973; the issue date in the page header is 21 December 1973. Both are retained.
@@ -17,6 +37,5 @@ Adjacent right-column text is retained without rearrangement. No redrawing,
 colourisation or reconstruction was applied. Asset:
 src/assets/culture/gdr-suchtmittelgesetz-1973-section-1.png
-The entry uses contain without a forced aspect ratio. Caption, alt text and
-credit match the request. No open image licence is asserted.
+At the user’s follow-up request, this law image is now embedded inside the article under “Cannabis in the socialist state's law”, with its original caption, alt text and archive credit/link. The customs photograph remains the lead image. No open image licence is asserted for the law asset.
 
 § 1(3) prohibits circulation of specified substances; § 3(1) permits ministerial
@@ -38,3 +57,3 @@ numerical comparison of sedative and cannabis prevalence. Western communal
 smoking and socialist suspicion are presented as political interpretations,
 not universal claims about youth or evidence of widespread GDR cannabis use.
-The 1989 customs photograph was unnecessary and is not used.
+The 1989 photograph now serves as the primary illustration, as documented above.

source-archive.json +36 −0

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

src/assets/culture/gdr-customs-dog-berlin-1989-uhlemann.jpg +0 −0

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

src/components/HomePage.astro +93 −43
@@ -105,5 +105,5 @@ const PLANTING_FIELDS = { seeds: "Seeds", seeds_trade: "Seed sales", clones: "Cl
 // Each status gets its own step of the tier colours, so that no two legend entries look alike.
 const PLANTING_COLOR = { legal: 5, licensed: 4, grey: 3, illegal: 0, open: 5, regulated: 4, gift: 3, unclear: 2, banned: 0 };
-const plantingScale = (statuses) => Object.fromEntries(Object.entries(statuses).map(([k, v]) => [k, { label: v.label, color: `var(--t${PLANTING_COLOR[k]})` }]));
+const plantingScale = (statuses) => Object.fromEntries(Object.entries(statuses).map(([k, v]) => [k, { label: v.label, help: v.help, color: `var(--t${PLANTING_COLOR[k]})` }]));
 const PLANTING_SCALES = { status: plantingScale(PLANTING_STATUS), trade: plantingScale(PLANTING_TRADE) };
 // Sections with one status (clubs, driving, visitors) on the map: one option each.
@@ -482,5 +482,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
   <script type="application/json" id="cfi-price-scale" set:html={JSON.stringify(PRICE_SCALE)} />
   <script type="application/json" id="cfi-planting" set:html={JSON.stringify({ fields: PLANTING_FIELDS, scales: PLANTING_SCALES, sections: STATUS_SCALES, subs: Object.fromEntries(Object.entries(MAP_SECTIONS).map(([key, section]) => [key, section.complete ? section.sub : `${section.sub} Hatched countries have not been researched yet. Not part of the score.`])) })} />
-  <script type="application/json" id="cfi-segments" set:html={JSON.stringify(SEGMENTS.map((s) => s.name))} />
+  <script type="application/json" id="cfi-segments" set:html={JSON.stringify(SEGMENTS.map((s) => ({ name: s.name, desc: s.desc, levels: s.levels })))} />
 
   <script>
@@ -498,5 +498,6 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
     const $ = (sel, root = document) => root.querySelector(sel);
     const COUNTRIES = JSON.parse($("#cfi-data").textContent);
-    const SEGMENT_NAMES = JSON.parse($("#cfi-segments").textContent);
+    const SEGMENT_INFO = JSON.parse($("#cfi-segments").textContent); // [{ name, desc, levels: [[score, text], …] }]
+    const SEGMENT_NAMES = SEGMENT_INFO.map((s) => s.name);
     const byId = new Map(COUNTRIES.map((c) => [c.id, c]));
     const byNum = new Map(COUNTRIES.filter((c) => c.num != null).map((c) => [c.num, c]));
@@ -628,59 +629,108 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
       const tip = $("#tooltip");
       const wrap = $("#mapWrap");
+      // The words in the hover box (summaries and segment explanations) come from map-notes.json,
+      // fetched the first time anything is hovered; until it arrives the box shows numbers only.
+      let notes = null, notesLoading = null, lastTip = null;
+      const loadNotes = () => (notesLoading ??= fetch(`${base}map-notes.json`).then((r) => r.json()).then((n) => {
+        notes = n;
+        if (lastTip && !tip.hidden) showTip(...lastTip);
+      }).catch(() => {}));
+      const esc = (t) => String(t).replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch]);
+      const noteOf = (key) => notes?.[key];
+      const para = (text, cls = "tip-note") => (text ? `<p class="${cls}">${esc(text)}</p>` : "");
+      const row = (label, value, sw) => `<div class="row"><span>${label}</span><b>${sw ? `<i class="sw" style="background:${sw}"></i>` : ""}${value}</b></div>`;
+      const head = (flag, name, chip, color) =>
+        `<div class="tip-head"><strong>${flag ? `<span class="tip-flag">${flag}</span>` : ""}${esc(name)}</strong>${chip != null ? `<span class="tip-chip"${color ? ` style="--chip:${color}"` : ""}>${chip}</span>` : ""}</div>`;
+      const bar = (value, color) => `<div class="tip-bar"><i style="width:${Math.max(2, Math.min(100, value))}%;background:${color}"></i></div>`;
+      const foot = (c) => (c ? `<div class="tip-foot">Click for the full profile</div>` : "");
+      // what a segment score means, from segments.yaml; a score between two levels sits between them
+      const levelText = (seg, v) => {
+        const levels = SEGMENT_INFO[seg].levels;
+        let i = 0;
+        while (i < levels.length - 1 && v >= levels[i + 1][0]) i++;
+        return levels[i][0] === v || i === levels.length - 1 ? levels[i][1] : `Between “${levels[i][1]}” and “${levels[i + 1][1]}”`;
+      };
+      const rankText = (c) => (c.rank ? `#${c.rank}` : "not ranked");
+      // a scored place: the whole index, or one segment of it (`n` holds its notes)
+      const describeScore = (place, n, sub) => {
+        const v = metricValue(place), color = tierColor(v), tier = tierOf(v).name;
+        if (metric === "index") {
+          return `<div class="tip-meta">${tier} · ${sub}</div>${bar(v, color)}${para(n?.n)}`;
+        }
+        const seg = +metric, raw = place.s[seg];
+        return `<div class="tip-meta">${SEGMENT_NAMES[seg]} · ${tier}</div>${bar(v, color)}
+          ${para(levelText(seg, raw), "tip-level")}${para(n?.s?.[seg])}
+          ${row("Index", `${place.score} / 100 · ${sub}`)}`;
+      };
       // what the hover box says about a country, or about one of its states (`u`)
       const describe = (d) => {
         const c = d.c;
-        if (d.u && !isUse() && !isPrice() && !isPlant() && !isClubs()) {
+        if (d.u && isScore()) {
           const u = d.u, home = byId.get(u.country);
-          return `<strong>${home.flag} ${u.name}</strong>
-               <div class="row"><span>${metricLabel()}</span><b>${metricText(u)}</b></div>
-               <div class="row"><span>Tier</span><b>${tierOf(metricValue(u)).name}</b></div>
-               <div class="row"><span>Rank in ${home.name}</span><b>#${u.rank} of ${u.of}</b></div>`;
+          return head(home.flag, u.name, metric === "index" ? u.score : `${u.s[+metric]}<small>/10</small>`, tierColor(metricValue(u)))
+            + describeScore(u, noteOf(d.id), `#${u.rank} of ${u.of} in ${esc(home.name)}`)
+            + foot(home);
         }
-        if (d.u) return describe({ c: byId.get(d.u.country), properties: d.properties }) + `<div class="row"><span>${d.u.name}</span><b>national figure</b></div>`;
-        if (c && isClubs()) return `<strong>${c.flag} ${c.name}</strong><div class="row"><span>${PLANTING.sections[statusKey()].name}</span><b>${clubsOf(c)?.label ?? "Not researched yet"}</b></div>`;
-        if (c && isPlant()) {
-          const rows = Object.entries(PLANTING.fields).map(([field, label]) =>
-            `<div class="row"><span>${label}</span><b>${plantOf(c, field)?.label ?? "—"}</b></div>`).join("");
-          return `<strong>${c.flag} ${c.name}</strong>${c.planting ? rows : `<div class="row"><span>Seeds and clones</span><b>Not researched yet</b></div>`}`;
+        if (d.u) return describe({ c: byId.get(d.u.country), properties: d.properties }).replace(`<div class="tip-foot">`, `<div class="tip-meta">National figure, shown for ${esc(d.u.name)}</div><div class="tip-foot">`);
+        if (!c) return head(null, d.properties.name) + `<div class="tip-meta">Not scored</div>`;
+        const n = noteOf(c.id);
+        if (isClubs()) {
+          const key = statusKey(), st = clubsOf(c);
+          const text = key === "rec" ? n?.n : key === "med" ? n?.x?.medical : n?.x?.[key];
+          return head(c.flag, c.name)
+            + row(PLANTING.sections[key].name, st?.label ?? "Not researched yet", st?.color)
+            + para(st?.help, "tip-level") + para(st && text) + foot(c);
         }
-        if (c && isPrice()) {
+        if (isPlant()) {
+          if (!c.planting) return head(c.flag, c.name) + row("Seeds and clones", "Not researched yet") + foot(c);
+          const field = plantField(), st = plantOf(c);
+          const rows = Object.entries(PLANTING.fields).map(([f, label]) => {
+            const s = plantOf(c, f);
+            return `<div class="row${f === field ? " on" : ""}"><span>${label}</span><b>${s ? `<i class="sw" style="background:${s.color}"></i>${s.label}` : "—"}</b></div>`;
+          }).join("");
+          const help = st?.help?.[field.startsWith("clones") ? 1 : 0];
+          return head(c.flag, c.name) + rows + para(help, "tip-level") + para(n?.x?.planting) + foot(c);
+        }
+        if (isPrice()) {
           const p = priceOf(c);
-          return `<strong>${c.flag} ${c.name}</strong>
-            <div class="row"><span>${metricLabel()}</span><b>${p ? `$${p.usd.toFixed(2)}/g` : "No data"}</b></div>
-            ${p ? `<div class="row"><span>Local price</span><b>${p.price} ${p.currency}/g</b></div>
-                    <div class="row"><span>Observation</span><b>${p.date}</b></div>` : ""}`;
+          return head(c.flag, c.name, p ? `$${p.usd.toFixed(2)}<small>/g</small>` : null, p ? priceColor(c) : null)
+            + row(metricLabel(), p ? `$${p.usd.toFixed(2)} / g` : "No data")
+            + (p ? row("Local price", `${p.price} ${p.currency} / g`) + row("Observed", p.date) : "") + foot(c);
+        }
+        if (metric === "culture") {
+          const k = c.use.culture;
+          return head(c.flag, c.name, k || "0", useColor(c))
+            + `<div class="tip-meta">${k ? `${k} atlas ${k === 1 ? "entry" : "entries"}` : "No atlas entries yet"}</div>`
+            + (c.atlas.length ? `<ul class="tip-list">${c.atlas.map((t) => `<li>${esc(t)}</li>`).join("")}${k > c.atlas.length ? `<li class="more">and ${k - c.atlas.length} more</li>` : ""}</ul>` : "") + foot(c);
+        }
+        const est = c.use.estimate ? "~" : "";
+        if (metric === "ratio") {
+          return head(c.flag, c.name, `${est}${c.use.ratio}`, useColor(c))
+            + row("Share aged 15–64", `${est}${c.use.prevalence}%`) + row("Index", `${c.score} / 100`)
+            + para(USE_SCALES.ratio.note) + foot(c);
         }
-        if (c && metric === "culture") {
-          const n = c.use.culture;
-          return `<strong>${c.flag} ${c.name}</strong>
-            <div class="row"><span>Atlas entries</span><b>${n || "None yet"}</b></div>
-            ${c.atlas.map((t) => `<div class="row"><span>${t}</span></div>`).join("")}
-            ${n > c.atlas.length ? `<div class="row"><span>and ${n - c.atlas.length} more</span></div>` : ""}`;
+        if (isUse()) {
+          return head(c.flag, c.name, metric === "users" ? `${est}${compact(c.use.users)}` : `${est}${c.use.prevalence}%`, useColor(c))
+            + row("Users", `${est}${compact(c.use.users)}`) + row("Share aged 15–64", `${est}${c.use.prevalence}%`)
+            + row(c.use.estimate ? "Source" : "Survey year", c.use.estimate ? "regional estimate" : c.use.year) + foot(c);
         }
-        return c && metric === "ratio"
-            ? `<strong>${c.flag} ${c.name}</strong>
-               <div class="row"><span>Share ÷ index</span><b>${c.use.estimate ? "~" : ""}${c.use.ratio}</b></div>
-               <div class="row"><span>Share aged 15–64</span><b>${c.use.estimate ? "~" : ""}${c.use.prevalence}%</b></div>
-               <div class="row"><span>Index</span><b>${c.score} / 100</b></div>`
-            : c && isUse()
-            ? `<strong>${c.flag} ${c.name}</strong>
-               <div class="row"><span>Users</span><b>${c.use.estimate ? "~" : ""}${compact(c.use.users)}</b></div>
-               <div class="row"><span>Share aged 15–64</span><b>${c.use.estimate ? "~" : ""}${c.use.prevalence}%</b></div>
-               <div class="row"><span>${c.use.estimate ? "Regional estimate" : "Survey year"}</span><b>${c.use.estimate ? "no survey" : c.use.year}</b></div>`
-            : c
-            ? `<strong>${c.flag} ${c.name}</strong>
-               <div class="row"><span>${metricLabel()}</span><b>${metricText(c)}</b></div>
-               <div class="row"><span>Tier</span><b>${tierOf(metricValue(c)).name}</b></div>
-               <div class="row"><span>World rank</span><b>${c.rank ? "#" + c.rank : "not ranked"}</b></div>`
-            : `<strong>${d.properties.name}</strong><div class="row"><span>No data</span></div>`;
+        const chip = metric === "index" ? c.score : `${c.s[+metric]}<small>/10</small>`;
+        const extra = metric === "index"
+          ? row("Recreational", PLANTING.sections.rec.scale[c.rec]?.label ?? "—", PLANTING.sections.rec.scale[c.rec]?.color)
+            + row("Medical", PLANTING.sections.med.scale[c.med]?.label ?? "—", PLANTING.sections.med.scale[c.med]?.color)
+          : "";
+        return head(c.flag, c.name, chip, tierColor(metricValue(c))) + describeScore(c, n, c.rank ? `World rank ${rankText(c)}` : "Not ranked") + extra + foot(c);
       };
+      // above the pointer where there is room, below it otherwise; never past the map's edges
       const showTip = (e, d) => {
+        lastTip = [e, d];
+        if (!notes) loadNotes();
… 12 more lines
src/pages/map-notes.json.js +40 −0
@@ -0,0 +1,40 @@
+// What the map's hover box says in words: for every place, a short form of its summary, of each
+// segment's explanation and of the context sections the map can be coloured by. Fetched once, the
+// first time a country is hovered, so the home page itself carries only scores.
+//   n: summary   s: the six segment explanations, in SEGMENTS order
+//   x: context sections by key (planting, medical, clubs, driving, visitors)
+// States and provinces are keyed like their map shapes ("us/ca") and carry n and s only.
+import { places, countries, subunitsOf } from "../lib/countries.js";
+
+const MAP_CONTEXT = ["planting", "medical", "clubs", "driving", "visitors"];
+const LIMIT = 220;
+
+// The first sentences of a text, as many as fit in about LIMIT characters; a first sentence that
+// is longer than that is cut at a word and ends in an ellipsis.
+function short(text) {
+  if (!text) return undefined;
+  const flat = text.replace(/\s+/g, " ").trim();
+  // a sentence ends at . ! or ? followed by a space and a capital, digit or quote, so "7.5 g" stays whole
+  const sentences = flat.split(/(?<=[.!?])\s+(?=[A-Z0-9“"'(])/);
+  let out = "";
+  for (const s of sentences) {
+    if (out && out.length + 1 + s.length > LIMIT) break;
+    out = out ? `${out} ${s}` : s;
+  }
+  if (out.length <= LIMIT + 40) return out;
+  return out.slice(0, LIMIT).replace(/\s+\S*$/, "") + "…";
+}
+
+const notes = {};
+for (const c of places) {
+  const x = {};
+  for (const ctx of c.details.context) if (ctx && MAP_CONTEXT.includes(ctx.key)) x[ctx.key] = short(ctx.t);
+  notes[c.id] = { n: short(c.note), s: c.details.segments.map((seg) => short(seg.t)), x };
+}
+for (const c of countries) {
+  for (const u of subunitsOf(c.id)?.units ?? []) {
+    notes[`${c.slug}/${u.id}`] = { n: short(u.note), s: u.seg.map((seg) => short(seg?.text ?? seg)) };
+  }
+}
+
+export const GET = () => new Response(JSON.stringify(notes), { headers: { "Content-Type": "application/json" } });

src/styles/global.css +29 −6
@@ -148,11 +148,34 @@ select:focus-visible, input:focus-visible, button:focus-visible, tr:focus-visibl
 .tooltip {
   position: absolute; pointer-events: none; z-index: 5;
-  min-width: 170px; padding: 10px 12px; border-radius: var(--r);
-  background: var(--surface-2); border: 1px solid var(--border); box-shadow: var(--shadow);
-  font-size: 13px;
+  width: max-content; min-width: 210px; max-width: min(310px, calc(100% - 16px));
+  padding: 12px 14px 10px; border-radius: var(--r);
+  background: var(--surface); border: 1px solid var(--border); box-shadow: var(--shadow);
+  font-size: 13px; line-height: 1.4;
 }
-.tooltip strong { display: block; font-size: 14px; }
-.tooltip .row { display: flex; justify-content: space-between; gap: 14px; color: var(--text-2); }
-.tooltip .row b { color: var(--text); }
+.tooltip .tip-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 2px; }
+.tooltip strong { display: flex; align-items: center; gap: 7px; font-size: 15px; font-weight: 650; line-height: 1.25; }
+.tooltip .tip-flag { font-size: 17px; line-height: 1; }
+.tooltip .tip-chip {
+  --chip: var(--text-2);
+  flex: none; padding: 2px 8px; border-radius: var(--r);
+  background: color-mix(in srgb, var(--chip) 22%, transparent); border: 1px solid color-mix(in srgb, var(--chip) 60%, transparent);
+  color: var(--text); font-weight: 700; font-size: 14px; font-variant-numeric: tabular-nums;
+}
+.tooltip .tip-chip small { font-weight: 500; font-size: 11px; color: var(--text-2); margin-left: 1px; }
+.tooltip .tip-meta { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
+.tooltip .tip-bar { height: 4px; background: var(--surface-2); border-radius: var(--r); overflow: hidden; margin: 2px 0 8px; }
+.tooltip .tip-bar i { display: block; height: 100%; }
+.tooltip .tip-level { margin: 0 0 6px; color: var(--text); font-weight: 550; font-size: 12.5px; }
+.tooltip .tip-note { margin: 0 0 8px; color: var(--text-2); font-size: 12.5px; }
+.tooltip .row { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 2px 0; color: var(--text-2); }
+.tooltip .row b { display: inline-flex; align-items: center; gap: 6px; color: var(--text); font-weight: 600; text-align: right; }
+.tooltip .row .sw { width: 10px; height: 10px; }
+.tooltip .row.on { margin: 0 -6px; padding: 2px 6px; background: var(--surface-2); border-radius: var(--r); }
+.tooltip .tip-head + .row { margin-top: 6px; }
+.tooltip .row + .tip-level, .tooltip .row + .tip-note { margin-top: 8px; }
+.tooltip .tip-list { margin: 0 0 6px; padding-left: 16px; color: var(--text-2); }
+.tooltip .tip-list .more { list-style: none; margin-left: -16px; color: var(--muted); }
+.tooltip .tip-foot { margin-top: 8px; padding-top: 7px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11.5px; }
+@media (hover: none) { .tooltip .tip-foot { display: none; } }
 .legend {
   display: flex; flex-wrap: wrap; gap: 8px 18px;

    ↑↓ to move, ↵ to open.