diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..849963a
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,54 @@
+name: Docs
+
+on:
+ push:
+ branches:
+ - 'main'
+ pull_request:
+ branches:
+ - 'main'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ # Full history so make.jl can date the data from the last commit to version_map.jl.
+ fetch-depth: 0
+ - uses: julia-actions/setup-julia@v2
+ with:
+ version: '1'
+ - name: Build the site
+ run: julia --color=yes docs/make.jl
+ - uses: actions/configure-pages@v6
+ if: github.event_name != 'pull_request'
+ with:
+ # Turn Pages on (source: GitHub Actions) the first time this runs.
+ enablement: true
+ - uses: actions/upload-pages-artifact@v5
+ with:
+ path: docs/build
+
+ deploy:
+ # Only publish from main; PRs just check that the build succeeds.
+ if: github.event_name != 'pull_request'
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/README.md b/README.md
index e4fb302..788f161 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,9 @@
Loads historical stdlib version information into Pkg to allow Pkg to resolve stdlib versions for prior Julia versions.
+An interactive chart of which stdlib versions shipped with each Julia release is at
+ .
+
Usage:
```julia
julia> import HistoricalStdlibVersions
diff --git a/docs/make.jl b/docs/make.jl
new file mode 100644
index 0000000..be2052a
--- /dev/null
+++ b/docs/make.jl
@@ -0,0 +1,129 @@
+#!/usr/bin/env julia
+#
+# Builds the GitHub Pages site into `docs/build/`.
+#
+# This is deliberately not a Documenter site: the only content is an interactive
+# visualization of stdlib versions across Julia releases. The static page lives in
+# `docs/src/`; this script copies it and generates `data.js` from `src/version_map.jl`.
+#
+# Usage: julia docs/make.jl
+
+const repo_root = dirname(@__DIR__)
+include(joinpath(repo_root, "src", "StdlibInfo.jl"))
+include(joinpath(repo_root, "src", "version_map.jl"))
+
+# Minimal JSON writer; the data is plain strings, numbers, bools, arrays and dicts,
+# so we avoid pulling in a dependency just for this.
+json(io::IO, x::Nothing) = print(io, "null")
+json(io::IO, x::Bool) = print(io, x ? "true" : "false")
+json(io::IO, x::Integer) = print(io, x)
+json(io::IO, x::VersionNumber) = json(io, string(x))
+function json(io::IO, s::AbstractString)
+ print(io, '"')
+ for c in s
+ if c == '"'
+ print(io, "\\\"")
+ elseif c == '\\'
+ print(io, "\\\\")
+ elseif c == '\n'
+ print(io, "\\n")
+ elseif c < ' '
+ print(io, "\\u", lpad(string(UInt16(c), base=16), 4, '0'))
+ else
+ print(io, c)
+ end
+ end
+ print(io, '"')
+end
+function json(io::IO, xs::Union{AbstractVector,Tuple})
+ print(io, '[')
+ for (i, x) in enumerate(xs)
+ i > 1 && print(io, ',')
+ json(io, x)
+ end
+ print(io, ']')
+end
+function json(io::IO, d::AbstractDict)
+ print(io, '{')
+ for (i, (k, v)) in enumerate(d)
+ i > 1 && print(io, ',')
+ json(io, string(k))
+ print(io, ':')
+ json(io, v)
+ end
+ print(io, '}')
+end
+json(io::IO, nt::NamedTuple) = json(io, Dict(pairs(nt)))
+
+function git(args...)
+ try
+ return strip(read(setenv(Cmd(["git", args...]); dir=repo_root), String))
+ catch
+ return ""
+ end
+end
+
+julia_versions = first.(STDLIBS_BY_VERSION)
+by_version = Dict(STDLIBS_BY_VERSION)
+
+# Stable identity is the UUID; names never change in the data but the UUID is what Pkg uses.
+names = Dict{UUID,String}()
+for (_, stdlibs) in STDLIBS_BY_VERSION, (uuid, info) in stdlibs
+ names[uuid] = info.name
+end
+sorted_uuids = sort(collect(keys(names)); by = u -> lowercase(names[u]))
+
+stdlibs = map(sorted_uuids) do uuid
+ # One entry per Julia column: `nothing` if the stdlib is absent in that release,
+ # otherwise the version (or `nothing` for unversioned) plus dependency names.
+ entries = map(julia_versions) do jv
+ info = get(by_version[jv], uuid, nothing)
+ info === nothing && return nothing
+ return (
+ v = info.version,
+ d = sort([names[d] for d in info.deps]),
+ w = sort([names[d] for d in info.weakdeps]),
+ )
+ end
+ return (
+ name = names[uuid],
+ uuid = string(uuid),
+ jll = endswith(names[uuid], "_jll"),
+ registered = !haskey(UNREGISTERED_STDLIBS, uuid),
+ entries = entries,
+ )
+end
+
+pkg_version = let m = nothing
+ for line in eachline(joinpath(repo_root, "Project.toml"))
+ m = match(r"^version\s*=\s*\"([^\"]+)\"", line)
+ m !== nothing && break
+ end
+ m === nothing ? "" : m.captures[1]
+end
+
+data = (
+ package_version = pkg_version,
+ commit = git("rev-parse", "--short", "HEAD"),
+ data_updated = git("log", "-1", "--format=%cs", "--", "src/version_map.jl"),
+ julia_versions = julia_versions,
+ stdlibs = stdlibs,
+)
+
+build_dir = joinpath(@__DIR__, "build")
+src_dir = joinpath(@__DIR__, "src")
+rm(build_dir; force=true, recursive=true)
+mkpath(build_dir)
+for f in readdir(src_dir)
+ cp(joinpath(src_dir, f), joinpath(build_dir, f))
+end
+# Written as a script rather than fetched JSON so the page also works from a `file://` URL.
+open(joinpath(build_dir, "data.js"), "w") do io
+ print(io, "window.HSV_DATA = ")
+ json(io, data)
+ println(io, ";")
+end
+# Tell GitHub Pages not to run Jekyll over the output.
+touch(joinpath(build_dir, ".nojekyll"))
+
+@info "Built site" build_dir julia_versions=length(julia_versions) stdlibs=length(stdlibs)
diff --git a/docs/src/app.js b/docs/src/app.js
new file mode 100644
index 0000000..593f147
--- /dev/null
+++ b/docs/src/app.js
@@ -0,0 +1,620 @@
+/* Stdlib versions across Julia releases: a run-length "swimlane" matrix.
+ *
+ * Rows are stdlibs, columns are the Julia releases tracked in version_map.jl.
+ * Consecutive columns with the same stdlib version are merged into one bar.
+ * Everything is rendered into four SVGs (corner / header / labels / chart) so the
+ * header and row labels can stay sticky while the grid scrolls.
+ */
+(function () {
+ "use strict";
+
+ const DATA = window.HSV_DATA;
+ const SVG_NS = "http://www.w3.org/2000/svg";
+
+ // Column width depends on the density setting; "fit" sizes it to the viewport.
+ const DENSITY_W = { compact: 40, wide: 64 };
+ const MIN_COL_W = 22;
+ let COL_W = DENSITY_W.compact;
+ const ROW_H = 24;
+ const LABEL_W = 168;
+ const GROUP_H = 20;
+ const BAR_PAD = 4;
+ const RAMP_STEPS = 10;
+
+ const $ = (id) => document.getElementById(id);
+ const el = (tag, attrs, parent) => {
+ const n = document.createElementNS(SVG_NS, tag);
+ for (const k in attrs) n.setAttribute(k, attrs[k]);
+ if (parent) parent.appendChild(n);
+ return n;
+ };
+ const h = (tag, cls, text) => {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text != null) n.textContent = text;
+ return n;
+ };
+
+ // ---------- data prep ----------
+ const versions = DATA.julia_versions;
+ const NCOL = versions.length;
+ const minorOf = (v) => v.split(".").slice(0, 2).join(".");
+ const byName = new Map();
+
+ const stdlibs = DATA.stdlibs.map((s) => {
+ const runs = [];
+ let cur = null;
+ s.entries.forEach((e, i) => {
+ if (!e) { cur = null; return; }
+ if (cur && cur.v === e.v) { cur.end = i; return; }
+ cur = { start: i, end: i, v: e.v };
+ runs.push(cur);
+ });
+ const versioned = runs.filter((r) => r.v !== null);
+ versioned.forEach((r, k) => {
+ r.ramp = versioned.length === 1 ? 4 : Math.round((k * (RAMP_STEPS - 1)) / (versioned.length - 1));
+ });
+ let bumps = 0;
+ for (let i = 1; i < NCOL; i++) {
+ const a = s.entries[i - 1], b = s.entries[i];
+ if (a && b && a.v !== null && b.v !== null && a.v !== b.v) bumps++;
+ }
+ const present = s.entries.map((e, i) => (e ? i : -1)).filter((i) => i >= 0);
+ const out = {
+ ...s, runs, bumps,
+ firstCol: present[0],
+ lastCol: present[present.length - 1],
+ removed: present[present.length - 1] < NCOL - 1,
+ };
+ byName.set(s.name, out);
+ return out;
+ });
+
+ // Per-column diff against the previous tracked release.
+ const diffs = versions.map((v, i) => {
+ const d = { added: [], removed: [], bumped: [] };
+ if (i === 0) return d;
+ for (const s of stdlibs) {
+ const a = s.entries[i - 1], b = s.entries[i];
+ if (!a && b) d.added.push(s);
+ else if (a && !b) d.removed.push(s);
+ else if (a && b && a.v !== b.v) d.bumped.push({ s, from: a.v, to: b.v });
+ }
+ return d;
+ });
+
+ const dependents = (name, col) => stdlibs.filter((s) => {
+ const e = s.entries[col];
+ return e && (e.d.includes(name) || e.w.includes(name));
+ });
+
+ // Minor-series groups for the header bands.
+ const groups = [];
+ versions.forEach((v, i) => {
+ const m = minorOf(v);
+ const g = groups[groups.length - 1];
+ if (g && g.minor === m) g.end = i; else groups.push({ minor: m, start: i, end: i });
+ });
+
+ // ---------- state ----------
+ const state = {
+ query: "",
+ kind: "all",
+ sort: "name",
+ onlyChanged: false,
+ view: "chart",
+ density: "fit", // fit | compact | wide
+ release: null, // column index
+ stdlib: null, // stdlib name
+ };
+
+ function visibleRows() {
+ const q = state.query.trim().toLowerCase();
+ let rows = stdlibs.filter((s) => {
+ if (state.kind === "lib" && s.jll) return false;
+ if (state.kind === "jll" && !s.jll) return false;
+ if (q && !s.name.toLowerCase().includes(q)) return false;
+ if (state.onlyChanged && state.release !== null) {
+ const d = diffs[state.release];
+ const hit = d.added.includes(s) || d.removed.includes(s) || d.bumped.some((b) => b.s === s);
+ if (!hit) return false;
+ }
+ return true;
+ });
+ const byNameCmp = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
+ const cmp = {
+ name: byNameCmp,
+ added: (a, b) => a.firstCol - b.firstCol || byNameCmp(a, b),
+ bumps: (a, b) => b.bumps - a.bumps || byNameCmp(a, b),
+ removed: (a, b) => (b.removed - a.removed) || (a.lastCol - b.lastCol) || byNameCmp(a, b),
+ }[state.sort];
+ rows.sort(cmp);
+ // Keep libraries and JLLs as two blocks when both are shown and sorted by name.
+ if (state.kind === "all" && state.sort === "name") {
+ rows = rows.filter((s) => !s.jll).concat(rows.filter((s) => s.jll));
+ }
+ return rows;
+ }
+
+ // ---------- rendering ----------
+ const corner = $("corner"), header = $("header"), labels = $("labels"), chart = $("chart");
+ const textW = (s, px) => s.length * px * 0.58;
+
+ function updateColumnWidth() {
+ if (state.density === "fit") {
+ const avail = $("matrix-scroll").clientWidth - LABEL_W;
+ COL_W = Math.max(MIN_COL_W, Math.floor(avail / NCOL));
+ } else {
+ COL_W = DENSITY_W[state.density];
+ }
+ }
+
+ function renderHeader() {
+ header.innerHTML = "";
+ corner.innerHTML = "";
+ const W = NCOL * COL_W;
+
+ // Minor-series labels ("1.x"; the corner cell says these are Julia versions). Labels that
+ // would overlap a neighbour drop to a second tier; if that is taken too, they
+ // are left out (the column tooltip still names the release).
+ const short = COL_W < 56;
+ const font = short ? 11 : 12;
+ const tiers = [[], []];
+ const placed = groups.map((g) => {
+ const x = g.start * COL_W, w = (g.end - g.start + 1) * COL_W;
+ const text = g.minor;
+ const lw = textW(text, font) + 6;
+ const cx = x + w / 2;
+ const span = [cx - lw / 2, cx + lw / 2];
+ let tier = -1;
+ for (let t = 0; t < tiers.length && tier < 0; t++) {
+ const last = tiers[t][tiers[t].length - 1];
+ if (!last || last[1] <= span[0]) { tiers[t].push(span); tier = t; }
+ }
+ return { g, x, w, text, cx, tier };
+ });
+ const twoTier = placed.some((p) => p.tier === 1);
+ const groupH = twoTier ? GROUP_H + 13 : GROUP_H;
+ const headerH = groupH + 24;
+ header.setAttribute("width", W);
+ header.setAttribute("height", headerH);
+ corner.setAttribute("width", LABEL_W);
+ corner.setAttribute("height", headerH);
+ // Axis names in the corner: the column axis on the band row, the row axis under it.
+ el("text", { x: LABEL_W - 10, y: 14, "text-anchor": "end", class: "group-label" }, corner).textContent = "julia version";
+ el("text", { x: 16, y: headerH - 8, class: "group-label" }, corner).textContent = "stdlib";
+
+ placed.forEach((p, gi) => {
+ el("rect", { x: p.x, y: 0, width: p.w, height: groupH, class: "hdr-band" + (gi % 2 ? " alt" : "") }, header);
+ if (p.tier >= 0) {
+ const t = el("text", { x: p.cx, y: 14 + p.tier * 13, "text-anchor": "middle", class: "hdr-group", "font-size": font }, header);
+ t.textContent = p.text;
+ }
+ el("line", { x1: p.x + 0.5, y1: 0, x2: p.x + 0.5, y2: headerH, class: "col-sep" }, header);
+ });
+ el("line", { x1: 0, y1: headerH - 0.5, x2: W, y2: headerH - 0.5, class: "hdr-underline" }, header);
+
+ versions.forEach((v, i) => {
+ const x = i * COL_W;
+ if (state.release === i) el("rect", { x, y: groupH, width: COL_W, height: headerH - groupH, class: "hdr-sel" }, header);
+ const t = el("text", { x: x + COL_W / 2, y: headerH - 8, "text-anchor": "middle", class: "hdr-col" + (state.release === i ? " selected" : "") }, header);
+ // Narrow columns only have room for the patch number; the band above names the minor series.
+ t.textContent = short ? "." + v.split(".")[2] : v;
+ const hit = el("rect", { x, y: 0, width: COL_W, height: headerH, class: "hdr-hit" }, header);
+ hit.addEventListener("click", () => selectRelease(state.release === i ? null : i));
+ hit.addEventListener("mouseenter", (ev) => showTip(ev, releaseTip(i)));
+ hit.addEventListener("mousemove", moveTip);
+ hit.addEventListener("mouseleave", hideTip);
+ });
+ }
+
+ function renderRows(rows) {
+ labels.innerHTML = "";
+ chart.innerHTML = "";
+ const H = Math.max(rows.length * ROW_H, ROW_H);
+ const W = NCOL * COL_W;
+ labels.setAttribute("width", LABEL_W);
+ labels.setAttribute("height", H);
+ chart.setAttribute("width", W);
+ chart.setAttribute("height", H);
+
+ const selected = state.stdlib ? byName.get(state.stdlib) : null;
+ const selCol = state.release !== null ? state.release : (selected ? selected.lastCol : null);
+ const depSet = new Set();
+ if (selected && selCol !== null && selected.entries[selCol]) {
+ selected.entries[selCol].d.forEach((n) => depSet.add(n));
+ selected.entries[selCol].w.forEach((n) => depSet.add(n));
+ }
+
+ // Background: row bands, group separators, selected column wash.
+ const bg = el("g", {}, chart);
+ rows.forEach((s, r) => {
+ if (r % 2 === 1) el("rect", { x: 0, y: r * ROW_H, width: W, height: ROW_H, class: "row-band" }, bg);
+ });
+ groups.forEach((g) => el("line", { x1: g.start * COL_W + 0.5, y1: 0, x2: g.start * COL_W + 0.5, y2: H, class: "col-sep" }, bg));
+ if (state.release !== null) {
+ el("rect", { x: state.release * COL_W, y: 0, width: COL_W, height: H, class: "col-sel" }, bg);
+ }
+
+ const fg = el("g", {}, chart);
+ let prevJll = null;
+ rows.forEach((s, r) => {
+ const y = r * ROW_H;
+
+ // Row label
+ const isSel = s.name === state.stdlib;
+ const hit = el("rect", { x: 0, y, width: LABEL_W, height: ROW_H, class: "row-hit" + (isSel ? " selected" : "") }, labels);
+ hit.addEventListener("click", () => selectStdlib(isSel ? null : s.name));
+ hit.addEventListener("mouseenter", (ev) => showTip(ev, stdlibTip(s)));
+ hit.addEventListener("mousemove", moveTip);
+ hit.addEventListener("mouseleave", hideTip);
+ const t = el("text", { x: 16, y: y + ROW_H / 2 + 4.5, class: "row-label" + (s.jll ? " jll" : "") + (isSel ? " selected" : ""), "pointer-events": "none" }, labels);
+ t.textContent = s.name;
+ if (!s.registered) {
+ el("line", { x1: 16, y1: y + ROW_H / 2 + 8.5, x2: 16 + Math.min(textW(s.name, 12.5), LABEL_W - 40), y2: y + ROW_H / 2 + 8.5, stroke: "var(--muted)", "stroke-width": 1, "stroke-dasharray": "1.5 2.5", "pointer-events": "none" }, labels);
+ }
+ if (depSet.has(s.name)) {
+ el("text", { x: LABEL_W - 12, y: y + ROW_H / 2 + 4, "text-anchor": "end", class: "dep-badge", "pointer-events": "none" }, labels).textContent = "dep";
+ }
+ if (state.kind === "all" && state.sort === "name" && prevJll === false && s.jll) {
+ el("line", { x1: 0, y1: y + 0.5, x2: LABEL_W, y2: y + 0.5, class: "hdr-underline" }, labels);
+ el("line", { x1: 0, y1: y + 0.5, x2: W, y2: y + 0.5, class: "hdr-underline" }, bg);
+ }
+ prevJll = s.jll;
+
+ // Runs
+ for (const run of s.runs) {
+ const x = run.start * COL_W + 1;
+ const w = (run.end - run.start + 1) * COL_W - 2;
+ const cls = run.v === null ? "run unversioned" : "run r" + run.ramp;
+ el("rect", { x, y: y + BAR_PAD, width: w, height: ROW_H - BAR_PAD * 2, class: cls }, fg);
+ const label = run.v === null ? "–" : run.v;
+ const fontPx = COL_W < 48 ? 10 : 11;
+ if (textW(label, fontPx) + 8 <= w) {
+ const flip = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--ramp-flip"), 10) || 4;
+ const ink = run.v === null ? "unversioned" : (run.ramp >= flip ? "dark-ink" : "light-ink");
+ const tl = el("text", { x: x + w / 2, y: y + ROW_H / 2 + 4, "text-anchor": "middle", class: "run-label " + ink, "font-size": fontPx }, fg);
+ tl.textContent = label;
+ }
+ const outline = el("rect", { x: x + 0.5, y: y + BAR_PAD + 0.5, width: w - 1, height: ROW_H - BAR_PAD * 2 - 1, class: "run-outline" }, fg);
+ const rh = el("rect", { x, y, width: w, height: ROW_H, class: "run-hit" }, fg);
+ fg.insertBefore(rh, outline);
+ rh.addEventListener("mouseenter", (ev) => { outline.classList.add("hover"); showTip(ev, runTip(s, run, colAt(ev))); });
+ rh.addEventListener("mousemove", (ev) => { moveTip(ev); setTipHTML(runTip(s, run, colAt(ev))); });
+ rh.addEventListener("mouseleave", () => { outline.classList.remove("hover"); hideTip(); });
+ rh.addEventListener("click", (ev) => selectRelease(colAt(ev)));
+ }
+ });
+
+ if (state.release !== null) {
+ el("rect", { x: state.release * COL_W + 0.75, y: 0.75, width: COL_W - 1.5, height: H - 1.5, class: "col-sel-outline" }, chart);
+ }
+
+ if (rows.length === 0) {
+ el("text", { x: 16, y: 18, class: "empty-svg", fill: "var(--muted)", "font-size": 13 }, labels).textContent = "No stdlibs match.";
+ }
+ }
+
+ function colAt(ev) {
+ const rect = chart.getBoundingClientRect();
+ return Math.max(0, Math.min(NCOL - 1, Math.floor((ev.clientX - rect.left) / COL_W)));
+ }
+
+ // ---------- tooltip ----------
+ const tip = $("tooltip");
+ let tipHTML = "";
+ function setTipHTML(html) { if (html !== tipHTML) { tipHTML = html; tip.innerHTML = html; } }
+ function showTip(ev, html) { setTipHTML(html); tip.hidden = false; moveTip(ev); }
+ function hideTip() { tip.hidden = true; tipHTML = ""; }
+ function moveTip(ev) {
+ const pad = 14;
+ const w = tip.offsetWidth, hgt = tip.offsetHeight;
+ let x = ev.clientX + pad, y = ev.clientY + pad;
+ if (x + w > window.innerWidth - 8) x = ev.clientX - w - pad;
+ if (y + hgt > window.innerHeight - 8) y = ev.clientY - hgt - pad;
+ tip.style.left = x + "px";
+ tip.style.top = y + "px";
+ }
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
+ const releaseRange = (start, end) => {
+ const a = versions[start], b = versions[end];
+ if (start === end) return "Julia " + a;
+ return "Julia " + a + " – " + b;
+ };
+ const untilNext = (end) => (end + 1 < NCOL ? " (through releases before " + versions[end + 1] + ")" : " (latest tracked)");
+
+ function runTip(s, run, col) {
+ const e = s.entries[col];
+ const n = run.end - run.start + 1;
+ let html = "" + esc(s.name) + " in Julia " + esc(versions[col]) + " ";
+ html += '' + (run.v === null ? "unversioned" : esc(run.v)) + " ";
+ html += '' + esc(releaseRange(run.start, run.end)) + (n > 1 ? " · " + n + " tracked releases" : "") + esc(untilNext(run.end)) + " ";
+ if (e) {
+ if (e.d.length) html += '
deps: ' + esc(e.d.join(", ")) + "
";
+ if (e.w.length) html += 'weakdeps: ' + esc(e.w.join(", ")) + "
";
+ }
+ if (!s.registered) html += 'never registered in General
';
+ return html;
+ }
+ function releaseTip(i) {
+ const d = diffs[i];
+ const n = stdlibs.filter((s) => s.entries[i]).length;
+ let html = "Julia " + esc(versions[i]) + " · " + n + " stdlibs ";
+ if (i === 0) html += 'first tracked release ';
+ else html += 'vs ' + esc(versions[i - 1]) + ": +" + d.added.length + " / −" + d.removed.length + " / " + d.bumped.length + " bumped ";
+ html += 'click to see what changed
';
+ return html;
+ }
+ function stdlibTip(s) {
+ let html = "" + esc(s.name) + " ";
+ html += '' + esc(releaseRange(s.firstCol, s.lastCol)) + (s.removed ? " · removed after " + esc(versions[s.lastCol]) : "") + " ";
+ html += '' + s.bumps + " version bump" + (s.bumps === 1 ? "" : "s") + (s.registered ? "" : " · never registered") + " ";
+ html += 'click for full history
';
+ return html;
+ }
+
+ // ---------- details panel ----------
+ const panel = $("panel"), panelTitle = $("panel-title"), panelBody = $("panel-body");
+
+ function chip(s, extra, cls, onClick) {
+ const c = h("button", "chip" + (cls ? " " + cls : ""));
+ c.type = "button";
+ if (cls) c.appendChild(h("span", "dot"));
+ c.appendChild(h("span", null, s.name));
+ if (extra) c.appendChild(extra);
+ c.addEventListener("click", onClick || (() => selectStdlib(s.name)));
+ return c;
+ }
+ const verSpan = (from, to) => {
+ const sp = h("span", "v");
+ sp.append((from === null ? "–" : from), " ");
+ sp.appendChild(h("span", "arrow", "→"));
+ sp.append(" ", (to === null ? "–" : to));
+ return sp;
+ };
+
+ function renderPanel() {
+ panelBody.innerHTML = "";
+ if (state.stdlib) {
+ const s = byName.get(state.stdlib);
+ panelTitle.textContent = s.name;
+ const meta = h("p", "meta-line");
+ meta.append(s.jll ? "JLL binary wrapper" : "Standard library", " · ");
+ meta.append(releaseRange(s.firstCol, s.lastCol), s.removed ? " (removed after " + versions[s.lastCol] + ")" : "");
+ meta.append(" · ", s.registered ? "registered in General" : "never registered (always treated as a stdlib)");
+ meta.append(" · uuid ");
+ meta.appendChild(h("code", null, s.uuid));
+ panelBody.appendChild(meta);
+
+ const cols = h("div", "cols-2");
+ const left = h("div"), right = h("div");
+ cols.append(left, right);
+ panelBody.appendChild(cols);
+
+ left.appendChild(h("h3", null, "Version history"));
+ const hist = h("div", "hist");
+ s.runs.forEach((run) => {
+ const hv = h("div", "hv");
+ const sw = h("span", "swatch");
+ sw.style.background = run.v === null ? "var(--unversioned)" : "var(--r" + run.ramp + ")";
+ hv.appendChild(sw);
+ hv.append(run.v === null ? "unversioned" : run.v);
+ const hr = h("div", "hr", releaseRange(run.start, run.end) + untilNext(run.end));
+ hist.append(hv, hr);
+ });
+ left.appendChild(hist);
+
+ const col = state.release !== null && s.entries[state.release] ? state.release : s.lastCol;
+ const e = s.entries[col];
+ right.appendChild(h("h3", null, "Depends on (Julia " + versions[col] + ")"));
+ const dl = h("div", "chips");
+ e.d.forEach((n) => dl.appendChild(chip(byName.get(n))));
+ e.w.forEach((n) => { const c = chip(byName.get(n)); c.appendChild(h("span", "v", "weak")); dl.appendChild(c); });
+ if (!e.d.length && !e.w.length) dl.appendChild(h("span", "empty", "No stdlib dependencies."));
+ right.appendChild(dl);
+
+ right.appendChild(h("h3", null, "Depended on by (Julia " + versions[col] + ")"));
+ const rl = h("div", "chips");
+ const deps = dependents(s.name, col);
+ deps.forEach((d) => {
+ const c = chip(d);
+ if (d.entries[col].w.includes(s.name)) c.appendChild(h("span", "v", "weak"));
+ rl.appendChild(c);
+ });
+ if (!deps.length) rl.appendChild(h("span", "empty", "Nothing depends on it."));
+ right.appendChild(rl);
+ } else if (state.release !== null) {
+ const i = state.release, d = diffs[i];
+ panelTitle.textContent = "Julia " + versions[i];
+ const n = stdlibs.filter((s) => s.entries[i]).length;
+ const meta = h("p", "meta-line");
+ meta.append(n + " stdlibs. ");
+ if (i === 0) meta.append("First tracked release.");
+ else meta.append("Changes relative to Julia " + versions[i - 1] + ", the previous tracked release.");
+ if (i + 1 < NCOL) meta.append(" Releases up to but not including " + versions[i + 1] + " share this stdlib set.");
+ panelBody.appendChild(meta);
+
+ const section = (title, items, build) => {
+ panelBody.appendChild(h("h3", null, title + " (" + items.length + ")"));
+ const wrap = h("div", "chips");
+ if (!items.length) wrap.appendChild(h("span", "empty", "None."));
+ items.forEach((it) => wrap.appendChild(build(it)));
+ panelBody.appendChild(wrap);
+ };
+ section("Version bumps", d.bumped, (b) => chip(b.s, verSpan(b.from, b.to), "bumped"));
+ section("Added", d.added, (s) => chip(s, h("span", "v", s.entries[i].v === null ? "" : s.entries[i].v), "added"));
+ section("Removed", d.removed, (s) => chip(s, null, "removed"));
+ } else {
+ panel.hidden = true;
+ return;
+ }
+ panel.hidden = false;
+ }
+
+ // ---------- table view ----------
+ function renderTable(rows) {
+ const tv = $("table-view");
+ tv.innerHTML = "";
+ const table = h("table");
+ const thead = h("thead"), tr = h("tr");
+ tr.appendChild(h("th", null, "stdlib"));
+ versions.forEach((v) => tr.appendChild(h("th", null, v)));
+ thead.appendChild(tr);
+ table.appendChild(thead);
+ const tbody = h("tbody");
+ rows.forEach((s) => {
+ const r = h("tr");
+ r.appendChild(h("th", null, s.name));
+ s.entries.forEach((e, i) => {
+ const prev = s.entries[i - 1];
+ let td;
+ if (!e) td = h("td", "absent", "·");
+ else if (e.v === null) td = h("td", "unversioned", "–");
+ else td = h("td", prev && prev.v !== e.v ? "changed" : null, e.v);
+ r.appendChild(td);
+ });
+ tbody.appendChild(r);
+ });
+ table.appendChild(tbody);
+ tv.appendChild(table);
+ }
+
+ // ---------- top-level ----------
+ function render() {
+ const rows = visibleRows();
+ if (state.view === "chart") {
+ $("matrix-scroll").hidden = false;
+ $("table-view").hidden = true;
+ updateColumnWidth();
+ renderHeader();
+ renderRows(rows);
+ } else {
+ $("matrix-scroll").hidden = true;
+ $("table-view").hidden = false;
+ renderTable(rows);
+ }
+ renderPanel();
+ writeHash();
+ }
+
+ function selectRelease(i) {
+ state.release = i;
+ if (i !== null) state.stdlib = null;
+ render();
+ if (i !== null) scrollColumnIntoView(i);
+ }
+ function selectStdlib(name) {
+ state.stdlib = name;
+ if (name) { state.release = null; state.onlyChanged = false; $("only-changed").checked = false; }
+ render();
+ if (name) panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+ function scrollColumnIntoView(i) {
+ const sc = $("matrix-scroll");
+ const x = LABEL_W + i * COL_W;
+ if (x < sc.scrollLeft + LABEL_W || x + COL_W > sc.scrollLeft + sc.clientWidth) {
+ sc.scrollTo({ left: Math.max(0, x - sc.clientWidth / 2), behavior: "smooth" });
+ }
+ }
+
+ // Shareable state in the URL hash: #release=1.6.0 or #stdlib=Pkg
+ function writeHash() {
+ let hsh = "";
+ if (state.stdlib) hsh = "#stdlib=" + encodeURIComponent(state.stdlib);
+ else if (state.release !== null) hsh = "#release=" + versions[state.release];
+ if (hsh !== location.hash) history.replaceState(null, "", hsh || location.pathname + location.search);
+ }
+ function readHash() {
+ const m = /^#(stdlib|release)=(.+)$/.exec(location.hash);
+ if (!m) return;
+ const val = decodeURIComponent(m[2]);
+ if (m[1] === "stdlib" && byName.has(val)) state.stdlib = val;
+ if (m[1] === "release" && versions.includes(val)) state.release = versions.indexOf(val);
+ }
+
+ // ---------- KPIs and footer ----------
+ function renderKpis() {
+ $("kpi-releases").textContent = NCOL;
+ $("kpi-range").textContent = "Julia " + versions[0] + " → " + versions[NCOL - 1];
+ const libs = stdlibs.filter((s) => !s.jll);
+ const current = stdlibs.filter((s) => s.entries[NCOL - 1]);
+ $("kpi-stdlibs").textContent = stdlibs.length;
+ $("kpi-stdlibs-sub").textContent = current.length + " in the latest release · " + stdlibs.filter((s) => !s.registered).length + " never registered";
+ $("kpi-jlls").textContent = stdlibs.length - libs.length;
+ const total = stdlibs.reduce((a, s) => a + s.bumps, 0);
+ const top = stdlibs.slice().sort((a, b) => b.bumps - a.bumps)[0];
+ $("kpi-bumps").textContent = total;
+ $("kpi-bumps-sub").textContent = "most: " + top.name + " (" + top.bumps + ")";
+ const meta = [];
+ if (DATA.package_version) meta.push("HistoricalStdlibVersions v" + DATA.package_version);
+ if (DATA.data_updated) meta.push("data updated " + DATA.data_updated);
+ if (DATA.commit) meta.push("commit " + DATA.commit);
+ $("foot-meta").textContent = meta.join(" · ");
+ }
+
+ // ---------- controls ----------
+ $("search").addEventListener("input", (ev) => { state.query = ev.target.value; render(); });
+ document.querySelectorAll(".seg [data-kind]").forEach((b) => b.addEventListener("click", () => {
+ document.querySelectorAll(".seg [data-kind]").forEach((x) => x.classList.toggle("on", x === b));
+ state.kind = b.dataset.kind;
+ render();
+ }));
+ document.querySelectorAll(".seg [data-view]").forEach((b) => b.addEventListener("click", () => {
+ document.querySelectorAll(".seg [data-view]").forEach((x) => x.classList.toggle("on", x === b));
+ state.view = b.dataset.view;
+ render();
+ }));
+ document.querySelectorAll(".seg [data-density]").forEach((b) => b.addEventListener("click", () => {
+ document.querySelectorAll(".seg [data-density]").forEach((x) => x.classList.toggle("on", x === b));
+ state.density = b.dataset.density;
+ try { localStorage.setItem("hsv-density", state.density); } catch (_) { /* ignore */ }
+ render();
+ }));
+ try {
+ const d = localStorage.getItem("hsv-density");
+ if (d && (d === "fit" || DENSITY_W[d])) {
+ state.density = d;
+ document.querySelectorAll(".seg [data-density]").forEach((x) => x.classList.toggle("on", x.dataset.density === d));
+ }
+ } catch (_) { /* storage unavailable */ }
+ let resizeTimer = null;
+ window.addEventListener("resize", () => {
+ if (state.density !== "fit" || state.view !== "chart") return;
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(render, 120);
+ });
+ $("sort").addEventListener("change", (ev) => { state.sort = ev.target.value; render(); });
+ $("only-changed").addEventListener("change", (ev) => {
+ state.onlyChanged = ev.target.checked;
+ if (state.onlyChanged && state.release === null) {
+ state.release = NCOL - 1;
+ state.stdlib = null;
+ }
+ render();
+ });
+ $("panel-close").addEventListener("click", () => { state.stdlib = null; state.release = null; state.onlyChanged = false; $("only-changed").checked = false; render(); });
+ document.addEventListener("keydown", (ev) => {
+ if (ev.key === "Escape" && !panel.hidden) $("panel-close").click();
+ });
+ window.addEventListener("hashchange", () => { state.stdlib = null; state.release = null; readHash(); render(); });
+
+ // Theme toggle: explicit choice wins over the OS setting and is remembered per browser.
+ const root = document.documentElement;
+ function applyTheme(t) {
+ if (t) root.setAttribute("data-theme", t); else root.removeAttribute("data-theme");
+ }
+ try { applyTheme(localStorage.getItem("hsv-theme")); } catch (_) { /* storage unavailable */ }
+ $("theme-toggle").addEventListener("click", () => {
+ const dark = root.getAttribute("data-theme") === "dark" ||
+ (!root.getAttribute("data-theme") && window.matchMedia("(prefers-color-scheme: dark)").matches);
+ const next = dark ? "light" : "dark";
+ applyTheme(next);
+ try { localStorage.setItem("hsv-theme", next); } catch (_) { /* ignore */ }
+ render();
+ });
+
+ readHash();
+ renderKpis();
+ render();
+ if (state.release !== null) requestAnimationFrame(() => scrollColumnIntoView(state.release));
+})();
diff --git a/docs/src/index.html b/docs/src/index.html
new file mode 100644
index 0000000..d4dbdf3
--- /dev/null
+++ b/docs/src/index.html
@@ -0,0 +1,123 @@
+
+
+
+
+
+Julia Stdlib Versions
+
+
+
+
+
+Skip to the chart
+
+
+
+
+
+
+
+ –
JLL binary wrappers
bundled since Julia 1.6
+
+
+
+
+
+
+
+ Colour deepens with each version bump of that stdlib
+ No version in Project.toml (tracks Julia)
+ Never registered (dotted name)
+ Selected release
+
+
+
+ Hover a bar for details. Click a column to see what changed in that release, or a row name for its full history.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/src/style.css b/docs/src/style.css
new file mode 100644
index 0000000..097cd24
--- /dev/null
+++ b/docs/src/style.css
@@ -0,0 +1,298 @@
+:root {
+ color-scheme: light;
+ --page: #f9f9f7;
+ --surface: #fcfcfb;
+ --surface-2: #f3f2ee;
+ --ink: #0b0b0b;
+ --ink-2: #52514e;
+ --muted: #898781;
+ --grid: #e1e0d9;
+ --axis: #c3c2b7;
+ --border: rgba(11, 11, 11, 0.10);
+ --accent: #2a78d6;
+ --accent-wash: rgba(42, 120, 214, 0.10);
+ --accent-wash-strong: rgba(42, 120, 214, 0.22);
+ --unversioned: #dedcd5;
+ --unversioned-ink: #52514e;
+ --added: #006300;
+ --removed: #d03b3b;
+ --shadow: 0 1px 2px rgba(11, 11, 11, 0.05), 0 8px 24px rgba(11, 11, 11, 0.06);
+ /* Blue sequential ramp, steps 250..700 (ordinal: nearest-to-surface step clears 2:1). */
+ --r0: #86b6ef; --r1: #6da7ec; --r2: #5598e7; --r3: #3987e5; --r4: #2a78d6;
+ --r5: #256abf; --r6: #1c5cab; --r7: #184f95; --r8: #104281; --r9: #0d366b;
+ --ramp-ink-dark: #ffffff;
+ --ramp-ink-light: #0b0b0b;
+ --ramp-flip: 4; /* first ramp index whose bar text is white */
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:where(:not([data-theme="light"])) {
+ color-scheme: dark;
+ --page: #0d0d0d;
+ --surface: #1a1a19;
+ --surface-2: #222220;
+ --ink: #ffffff;
+ --ink-2: #c3c2b7;
+ --muted: #898781;
+ --grid: #2c2c2a;
+ --axis: #383835;
+ --border: rgba(255, 255, 255, 0.10);
+ --accent: #3987e5;
+ --accent-wash: rgba(57, 135, 229, 0.16);
+ --accent-wash-strong: rgba(57, 135, 229, 0.30);
+ --unversioned: #3a3a37;
+ --unversioned-ink: #c3c2b7;
+ --added: #0ca30c;
+ --removed: #e66767;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
+ /* Same ramp, dimmest step nearest the dark surface, brightening with each bump. */
+ --r0: #184f95; --r1: #1c5cab; --r2: #256abf; --r3: #2a78d6; --r4: #3987e5;
+ --r5: #5598e7; --r6: #6da7ec; --r7: #86b6ef; --r8: #9ec5f4; --r9: #b7d3f6;
+ --ramp-flip: 6;
+ }
+}
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --page: #0d0d0d;
+ --surface: #1a1a19;
+ --surface-2: #222220;
+ --ink: #ffffff;
+ --ink-2: #c3c2b7;
+ --muted: #898781;
+ --grid: #2c2c2a;
+ --axis: #383835;
+ --border: rgba(255, 255, 255, 0.10);
+ --accent: #3987e5;
+ --accent-wash: rgba(57, 135, 229, 0.16);
+ --accent-wash-strong: rgba(57, 135, 229, 0.30);
+ --unversioned: #3a3a37;
+ --unversioned-ink: #c3c2b7;
+ --added: #0ca30c;
+ --removed: #e66767;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
+ --r0: #184f95; --r1: #1c5cab; --r2: #256abf; --r3: #2a78d6; --r4: #3987e5;
+ --r5: #5598e7; --r6: #6da7ec; --r7: #86b6ef; --r8: #9ec5f4; --r9: #b7d3f6;
+ --ramp-flip: 6;
+}
+
+* { box-sizing: border-box; }
+html { -webkit-text-size-adjust: 100%; }
+body {
+ margin: 0;
+ background: var(--page);
+ color: var(--ink);
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, monospace; }
+button, input, select { font: inherit; color: inherit; }
+
+.skip {
+ position: absolute; left: -999px; top: 8px; background: var(--surface); padding: 8px 12px; border-radius: 6px;
+}
+.skip:focus { left: 16px; z-index: 50; }
+
+.masthead { border-bottom: 1px solid var(--border); background: var(--surface); }
+.masthead-inner {
+ max-width: 1440px; margin: 0 auto; padding: 28px 16px 22px;
+ display: flex; gap: 24px; align-items: flex-start; justify-content: space-between;
+}
+.masthead h1 { margin: 0 0 6px; font-size: 26px; font-weight: 650; letter-spacing: -0.01em; line-height: 1.2; }
+.lede { margin: 0; max-width: 62ch; color: var(--ink-2); font-size: 14.5px; }
+.masthead-actions { display: flex; gap: 8px; flex-shrink: 0; }
+
+.icon-btn {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border);
+ background: var(--surface); color: var(--ink-2); cursor: pointer;
+}
+.icon-btn:hover { color: var(--ink); background: var(--surface-2); text-decoration: none; }
+.icon-btn .moon { display: none; }
+:root[data-theme="dark"] .icon-btn .sun { display: none; }
+:root[data-theme="dark"] .icon-btn .moon { display: block; }
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .icon-btn .sun { display: none; }
+ :root:not([data-theme="light"]) .icon-btn .moon { display: block; }
+}
+
+.page { max-width: 1440px; margin: 0 auto; padding: 20px 16px 40px; }
+
+.kpis {
+ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px;
+}
+.tile {
+ background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px 12px;
+}
+.tile-value { font-size: 30px; font-weight: 600; line-height: 1.1; letter-spacing: -0.02em; }
+.tile-label { color: var(--ink-2); font-size: 13px; margin-top: 4px; }
+.tile-sub { color: var(--muted); font-size: 12px; margin-top: 2px; min-height: 1.4em; }
+
+.controls {
+ display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin-bottom: 12px;
+}
+.search {
+ display: flex; align-items: center; gap: 8px; padding: 0 10px; height: 34px; min-width: 220px;
+ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; color: var(--muted);
+}
+.search input { border: 0; background: transparent; outline: none; width: 100%; color: var(--ink); }
+.search:focus-within { border-color: var(--accent); }
+.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); }
+.seg button {
+ border: 0; background: transparent; padding: 0 12px; height: 32px; cursor: pointer; color: var(--ink-2);
+}
+.seg button + button { border-left: 1px solid var(--border); }
+.seg button.on { background: var(--accent-wash); color: var(--ink); font-weight: 600; }
+.select-wrap { display: inline-flex; align-items: center; gap: 8px; color: var(--ink-2); font-size: 14px; }
+.select-wrap select {
+ height: 34px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); padding: 0 8px;
+}
+.check { display: inline-flex; align-items: center; gap: 6px; color: var(--ink-2); font-size: 14px; }
+.check input { accent-color: var(--accent); }
+.view-seg { margin-left: auto; }
+
+.matrix-card {
+ background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow);
+ padding: 12px 0 8px; overflow: hidden;
+}
+.legend {
+ display: flex; flex-wrap: wrap; gap: 6px 18px; padding: 0 16px 10px; color: var(--ink-2); font-size: 12.5px;
+}
+.legend-item { display: inline-flex; align-items: center; gap: 7px; }
+.sw { display: inline-block; width: 26px; height: 12px; border-radius: 3px; }
+.sw-ramp { background: linear-gradient(90deg, var(--r0), var(--r9)); }
+.sw-unversioned { background: var(--unversioned); }
+.sw-unreg { width: 26px; height: 0; border-radius: 0; border-bottom: 2px dotted var(--muted); }
+.sw-selected { background: var(--accent-wash-strong); border: 1px solid var(--accent); box-sizing: border-box; }
+
+.matrix-scroll {
+ position: relative; overflow: auto; max-height: calc(100vh - 140px); min-height: 320px;
+ border-top: 1px solid var(--grid); border-bottom: 1px solid var(--grid);
+ overscroll-behavior: contain;
+}
+/* Sticky header row and sticky label column: flex rows so each sticky item is
+ constrained by the full-width row, not by a grid cell. */
+.matrix-inner { width: max-content; }
+.matrix-inner svg { display: block; flex: none; }
+.matrix-head { display: flex; position: sticky; top: 0; z-index: 3; background: var(--surface); }
+.matrix-body { display: flex; }
+#corner { position: sticky; left: 0; z-index: 4; background: var(--surface); }
+#labels { position: sticky; left: 0; z-index: 2; background: var(--surface); }
+
+.hint { margin: 10px 16px 4px; color: var(--muted); font-size: 12.5px; }
+
+/* SVG parts */
+svg text { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
+.hdr-group { fill: var(--ink); font-weight: 650; }
+.hdr-col { fill: var(--ink-2); font-size: 10.5px; font-variant-numeric: tabular-nums; }
+.hdr-band { fill: var(--surface-2); }
+.hdr-band.alt { fill: transparent; }
+.hdr-hit { fill: transparent; cursor: pointer; }
+.hdr-hit:hover { fill: var(--accent-wash); }
+.hdr-col.selected, .hdr-group.selected { fill: var(--accent); }
+.hdr-sel { fill: var(--accent-wash-strong); }
+.hdr-underline { stroke: var(--axis); stroke-width: 1; }
+
+.row-label { fill: var(--ink); font-size: 12px; cursor: pointer; }
+.row-label.jll { fill: var(--ink-2); }
+.row-label.unregistered { text-decoration: underline dotted; text-underline-offset: 3px; text-decoration-color: var(--muted); }
+.row-label.selected { fill: var(--accent); font-weight: 650; }
+.row-hit { fill: transparent; cursor: pointer; }
+.row-hit:hover { fill: var(--accent-wash); }
+.row-hit.selected { fill: var(--accent-wash); }
+.group-label { fill: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
+.dep-badge { fill: var(--accent); font-size: 10px; font-weight: 600; }
+
+.row-band { fill: var(--surface-2); opacity: 0.5; }
+.col-sel-outline { fill: none; stroke: var(--accent); stroke-width: 1.5; pointer-events: none; }
+.col-sep { stroke: var(--grid); stroke-width: 1; }
+.col-sel { fill: var(--accent-wash); }
+.run { rx: 4; ry: 4; stroke: var(--surface); stroke-width: 1; transition: opacity 120ms; }
+.run.unversioned { fill: var(--unversioned); }
+.run.r0 { fill: var(--r0); } .run.r1 { fill: var(--r1); } .run.r2 { fill: var(--r2); } .run.r3 { fill: var(--r3); }
+.run.r4 { fill: var(--r4); } .run.r5 { fill: var(--r5); } .run.r6 { fill: var(--r6); } .run.r7 { fill: var(--r7); }
+.run.r8 { fill: var(--r8); } .run.r9 { fill: var(--r9); }
+.run-label { pointer-events: none; font-variant-numeric: tabular-nums; }
+.run-label.light-ink { fill: var(--ramp-ink-light); }
+.run-label.dark-ink { fill: var(--ramp-ink-dark); }
+.run-label.unversioned { fill: var(--unversioned-ink); }
+.run-hit { fill: transparent; cursor: default; }
+.run-hit:hover + .run-outline, .run-outline.hover { stroke: var(--ink); }
+.run-outline { fill: none; stroke: transparent; stroke-width: 1.5; rx: 4; ry: 4; pointer-events: none; }
+.dimmed .run { opacity: 0.25; }
+.dimmed .row-focus .run { opacity: 1; }
+.dep-mark { fill: none; stroke: var(--accent); stroke-width: 1.5; rx: 5; ry: 5; pointer-events: none; }
+
+/* Tooltip */
+.tooltip {
+ position: fixed; z-index: 20; pointer-events: none; max-width: 320px;
+ background: var(--surface); color: var(--ink); border: 1px solid var(--border); border-radius: 8px;
+ box-shadow: var(--shadow); padding: 8px 10px; font-size: 12.5px; line-height: 1.4;
+}
+.tooltip b { font-weight: 650; }
+.tooltip .tt-ver { font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums; }
+.tooltip .tt-sub { color: var(--ink-2); }
+.tooltip .tt-deps { color: var(--ink-2); margin-top: 4px; }
+
+/* Details panel */
+.panel {
+ margin-top: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
+ box-shadow: var(--shadow); padding: 14px 18px 18px;
+}
+.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.panel h2 { margin: 0; font-size: 18px; font-weight: 650; }
+.panel-body { margin-top: 8px; }
+.panel-body h3 { margin: 14px 0 6px; font-size: 13px; color: var(--ink-2); text-transform: uppercase; letter-spacing: 0.05em; }
+.chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.chip {
+ display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px;
+ border: 1px solid var(--border); background: var(--surface-2); font-size: 12.5px; cursor: pointer; color: var(--ink);
+}
+.chip:hover { border-color: var(--accent); }
+.chip .v { color: var(--ink-2); font-variant-numeric: tabular-nums; }
+.chip .arrow { color: var(--muted); }
+.chip.added { border-color: color-mix(in srgb, var(--added) 50%, transparent); }
+.chip.removed { border-color: color-mix(in srgb, var(--removed) 50%, transparent); }
+.chip .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.chip.added .dot { background: var(--added); }
+.chip.removed .dot { background: var(--removed); }
+.chip.bumped .dot { background: var(--accent); }
+.empty { color: var(--muted); font-size: 13px; }
+.hist { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; font-size: 13.5px; align-items: baseline; }
+.hist .hv { font-variant-numeric: tabular-nums; font-weight: 600; }
+.hist .hr { color: var(--ink-2); }
+.hist .hv .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; }
+.meta-line { color: var(--ink-2); font-size: 13.5px; margin: 4px 0 0; }
+.meta-line code { background: var(--surface-2); padding: 1px 5px; border-radius: 4px; }
+.cols-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 28px; }
+
+/* Table view */
+.table-view { overflow: auto; max-height: calc(100vh - 140px); }
+.table-view table { border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
+.table-view th, .table-view td {
+ padding: 4px 8px; white-space: nowrap; border-bottom: 1px solid var(--grid); text-align: left;
+ font-variant-numeric: tabular-nums;
+}
+.table-view thead th { position: sticky; top: 0; background: var(--surface); z-index: 2; color: var(--ink-2); font-weight: 600; }
+.table-view tbody th { position: sticky; left: 0; background: var(--surface); z-index: 1; font-weight: 600; }
+.table-view thead th:first-child { left: 0; z-index: 3; }
+.table-view td.changed { color: var(--accent); font-weight: 600; }
+.table-view td.absent { color: var(--grid); }
+.table-view td.unversioned { color: var(--muted); }
+
+.foot {
+ max-width: 1440px; margin: 0 auto; padding: 0 16px 40px; color: var(--muted); font-size: 12.5px;
+ display: flex; flex-wrap: wrap; gap: 4px 24px;
+}
+
+@media (max-width: 900px) {
+ .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .masthead-inner { flex-direction: column; gap: 12px; }
+ .view-seg { margin-left: 0; }
+}
+@media (max-width: 520px) {
+ .kpis { grid-template-columns: 1fr 1fr; gap: 8px; }
+ .tile-value { font-size: 24px; }
+ .search { min-width: 0; flex: 1 1 100%; }
+}
+@media (prefers-reduced-motion: reduce) { .run { transition: none; } }