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 + + + + + + + +
+
+
+

Julia stdlib versions

+

+ Which version of each standard library shipped with each Julia release, + as recorded by + HistoricalStdlibVersions.jl. + Only releases where the stdlib set changed are listed, so a column stands + for every release from that version up to the next column. The newest + column may come from a nightly build. +

+
+
+ + + + +
+
+
+ +
+
+
–
Julia releases tracked
+
–
Standard libraries
+
–
JLL binary wrappers
bundled since Julia 1.6
+
–
Version bumps recorded
+
+ +
+ +
+ + + +
+ + +
+ + + +
+
+ + +
+
+ +
+
+ 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; } }