···15341534 Buffer.contents buf
15351535 in
15361536 Oi.Cache.Logs.write ~fs ~cache_root log_path body;
15371537+ let missing_names =
15381538+ OpamSysPkg.Set.elements missing |> List.map OpamSysPkg.to_string
15391539+ in
15401540+ let n = List.length missing_names in
15411541+ (* Cap the inline list at 8 names so a 30-package depext
15421542+ set doesn't blow out the warning line; the full list is
15431543+ always in [log_path] for follow-up. *)
15441544+ let max_inline = 8 in
15451545+ let shown, suffix =
15461546+ if n <= max_inline then (missing_names, "")
15471547+ else
15481548+ ( List.filteri (fun i _ -> i < max_inline) missing_names,
15491549+ Fmt.str ", … +%d more" (n - max_inline) )
15501550+ in
15371551 Log.warn (fun m ->
15381552 m
15391539- "%s: opam reports %d missing system package(s); proceeding \
15401540- with the build anyway. See %s"
15411541- group_targets
15421542- (OpamSysPkg.Set.cardinal missing)
15431543- log_path)
15531553+ "%s: opam reports %d missing system package(s) [%s%s]; \
15541554+ proceeding with the build anyway. See %s"
15551555+ group_targets n (String.concat ", " shown) suffix log_path)
15441556 | None -> ());
15451557 (* After the build, any package in this group's plan whose
15461558 layer hash is in [failed_layers] with a non-empty path
+177-11
registry/index.html
···427427 letter-spacing: 0.04em;
428428}
429429.group-header .group-count { color: var(--fg-dim); margin-left: 0.4rem; font-weight: normal; }
430430-.overlay-tag { color: var(--accent); font-size: 0.72rem; }
431431-.overlay-tag.none { color: var(--fg-faint); }
430430+/* Default colouring is overridden per-handle via inline [style] from
431431+ [handleStyle] — the [color]/[background]/[border-color] declared
432432+ here are the no-handle fallback (used by the [.none] variant). */
433433+.overlay-tag {
434434+ color: var(--accent);
435435+ font-size: 0.72rem;
436436+ display: inline-block;
437437+ padding: 0.05rem 0.4rem;
438438+ border-radius: 3px;
439439+ border: 1px solid transparent;
440440+ line-height: 1.4;
441441+ white-space: nowrap;
442442+}
443443+.overlay-tag.none {
444444+ color: var(--fg-faint);
445445+ background: transparent;
446446+ border-color: transparent;
447447+ padding: 0;
448448+}
432449/* Dropdown for the multi-handle case: when an entry's [callers[]]
433450 names more than one overlay, the row would otherwise overflow the
434451 ~120px overlay column. Show the first chip plus a "+N" counter that
···487504 .handle-dd-list { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); }
488505}
489506.pkg-row:hover { background: var(--bg-sunken); }
490490-.pkg-row.expanded { background: var(--bg-sunken); }
507507+/* The handle-marker stripe lives in [box-shadow]; clear it when
508508+ [.expanded] / [.upstream] take over the same slot via ::before. */
509509+.pkg-row.expanded { background: var(--bg-sunken); box-shadow: none !important; }
491510.pkg-row.expanded::before { content: ""; width: 3px; background: var(--accent); position: absolute; left: 0; top: 0; bottom: 0; }
492492-.pkg-row.upstream { background: rgba(198, 40, 40, 0.12); }
511511+.pkg-row.upstream { background: rgba(198, 40, 40, 0.12); box-shadow: none !important; }
493512.pkg-row.upstream::before { content: ""; width: 3px; background: var(--fail); position: absolute; left: 0; top: 0; bottom: 0; }
494513@media (prefers-color-scheme: dark) {
495514 .pkg-row.upstream { background: rgba(239, 83, 80, 0.18); }
···909928 return [...set].sort();
910929}
911930931931+// Palette of (hue, saturation, lightness) triples. The hue ladder is
932932+// hand-laid so consecutive entries sit ~30° apart on the colour wheel
933933+// — distinct enough that adjacent slots never look like the same
934934+// colour family. Sat/lit rotate through two tiers so even doubling
935935+// back through hues produces a recognisably different chip.
936936+const HANDLE_PALETTE = [
937937+ [ 0, 70, 58 ], /* red */
938938+ [ 200, 70, 55 ], /* sky blue */
939939+ [ 35, 80, 55 ], /* tangerine */
940940+ [ 270, 60, 60 ], /* purple */
941941+ [ 130, 55, 45 ], /* forest green */
942942+ [ 320, 60, 58 ], /* magenta */
943943+ [ 60, 65, 50 ], /* mustard */
944944+ [ 230, 65, 60 ], /* royal blue */
945945+ [ 165, 65, 42 ], /* teal */
946946+ [ 295, 50, 55 ], /* orchid */
947947+ [ 18, 75, 55 ], /* coral */
948948+ [ 100, 50, 45 ], /* leaf green */
949949+ [ 250, 55, 65 ], /* periwinkle */
950950+ [ 345, 65, 60 ], /* rose */
951951+ [ 180, 60, 45 ], /* aqua */
952952+ [ 85, 55, 50 ], /* lime */
953953+ [ 215, 55, 50 ], /* azure */
954954+ [ 50, 70, 55 ], /* amber */
955955+];
956956+957957+// Per-render handle → palette-slot map. Built lazily by [handleHsl]
958958+// when the cache is empty: collects every handle present in the
959959+// active manifest, sorts alphabetically, then walks the palette so
960960+// the assignment is stable for a given (set-of-handles, palette).
961961+//
962962+// The deterministic-by-set approach guarantees ZERO collisions among
963963+// handles that appear together — switching from a hash-based scheme
964964+// where two handles could randomly land on the same slot.
965965+let _handleColorCache = null;
966966+967967+function invalidateHandleColors() { _handleColorCache = null; }
968968+969969+function buildHandleColors() {
970970+ const set = new Set();
971971+ for (const m of state.manifests.values()) {
972972+ for (const r of m.results || []) {
973973+ for (const c of r.callers || []) {
974974+ if (c.overlay && c.overlay.handle) set.add(c.overlay.handle);
975975+ }
976976+ }
977977+ }
978978+ const handles = [...set].sort();
979979+ const map = new Map();
980980+ handles.forEach((h, i) => {
981981+ map.set(h, HANDLE_PALETTE[i % HANDLE_PALETTE.length]);
982982+ });
983983+ return map;
984984+}
985985+986986+// "(none)" stays neutral; everything else picks up its deterministic
987987+// slot from the per-set assignment table.
988988+function handleHsl(name) {
989989+ if (!name || name === "(none)") return null;
990990+ if (!_handleColorCache) _handleColorCache = buildHandleColors();
991991+ return _handleColorCache.get(name) || null;
992992+}
993993+994994+// Inline style for a handle pill: muted pastel background + matched
995995+// accent text. Uses CSS [color-mix] so the same hue reads correctly in
996996+// both light and dark themes (mixing against [--bg] auto-adapts).
997997+// "(none)" is rendered transparent so unaffiliated rows blend in.
998998+function handleStyle(name) {
999999+ const hsl = handleHsl(name);
10001000+ if (!hsl) return "";
10011001+ const [h, s, l] = hsl;
10021002+ return `--handle-hue:${h}; ` +
10031003+ `background: color-mix(in srgb, hsl(${h} ${s}% ${l}%) 22%, transparent); ` +
10041004+ `color: color-mix(in oklab, hsl(${h} ${s}% ${Math.max(l - 18, 28)}%) 80%, var(--fg)); ` +
10051005+ `border-color: color-mix(in srgb, hsl(${h} ${s}% ${l}%) 45%, transparent);`;
10061006+}
10071007+10081008+// Per-row handle marker: a thin coloured stripe along the leading
10091009+// edge. Skipped when the row is [.expanded] or [.upstream] (those
10101010+// classes already paint the same 3px slot via ::before with a more
10111011+// important meaning). We use [box-shadow] rather than [background] so
10121012+// the [:hover] / [.expanded] background rules still win.
10131013+function handleRowStyle(handles) {
10141014+ const primary = (handles || []).find(h => h && h !== "(none)");
10151015+ if (!primary) return "";
10161016+ const [h, s, l] = handleHsl(primary);
10171017+ return `box-shadow: inset 3px 0 0 hsl(${h} ${s}% ${l}% / 0.55);`;
10181018+}
10191019+10201020+// Group-header tint for [Sort: by overlay] mode.
10211021+function handleHeaderStyle(name) {
10221022+ const hsl = handleHsl(name);
10231023+ if (!hsl) return "";
10241024+ const [h, s, l] = hsl;
10251025+ return `background: color-mix(in srgb, hsl(${h} ${s}% ${l}%) 18%, var(--bg-sunken)); ` +
10261026+ `color: color-mix(in oklab, hsl(${h} ${s}% ${Math.max(l - 20, 28)}%) 80%, var(--fg)); ` +
10271027+ `border-left: 4px solid hsl(${h} ${s}% ${l}% / 0.7);`;
10281028+}
10291029+9121030// "alpine~3.23~x86_64" → "alpine 3.23". Used for tooltips and
9131031// dropdowns where the full distro name+version is wanted.
9141032function shortDistro(osKey) {
···11641282 `</summary>`;
11651283 const pop = document.createElement("div");
11661284 pop.className = "os-popover";
12851285+ // Snapshot the manifest order once so the "deselect = jump to the
12861286+ // next one" behaviour is stable regardless of insertion order.
12871287+ const osKeys = [...state.manifests.keys()];
11671288 for (const [key, mm] of state.manifests) {
11681289 const f = mm.summary.build_failed + mm.summary.install_failed
11691290 + mm.summary.fetch_failed + mm.summary.solve_failed;
11701291 const p = parseOsKey(key);
11711292 const btn = document.createElement("button");
11721172- if (key === state.active) btn.className = "current";
12931293+ const isCurrent = key === state.active;
12941294+ if (isCurrent) btn.className = "current";
12951295+ btn.title = isCurrent
12961296+ ? "click again to switch to the next OS"
12971297+ : `switch to ${p.full}`;
11731298 btn.innerHTML =
11741299 `${escapeHtml(p.full)} <span style="color:var(--fg-faint)">(${mm.n_packages})</span>` +
11751300 (f > 0 ? `<span class="fail-count">${f} failed</span>` : "");
11761301 btn.onclick = (e) => {
11771302 e.preventDefault();
11781178- state.active = key;
13031303+ let next = key;
13041304+ if (isCurrent && osKeys.length > 1) {
13051305+ // Toggle: cycle to the next manifest in the list, wrapping
13061306+ // around. Gives the popover a real "deselect" affordance —
13071307+ // there's always *some* OS active because the page has
13081308+ // nothing to render otherwise.
13091309+ const idx = osKeys.indexOf(key);
13101310+ next = osKeys[(idx + 1) % osKeys.length];
13111311+ }
13121312+ state.active = next;
11791313 state.expanded = null;
11801314 state.highlightUpstream = null;
11811315 writeHash(state.active, null, overlaysFromState());
···13791513 if (!callers || callers.length === 0) return "";
13801514 const rows = callers.map(c => {
13811515 const lbl = c.overlay
13821382- ? `<span class="overlay-tag">@${escapeHtml(c.overlay.handle)}${c.overlay.version ? " " + escapeHtml(c.overlay.version) : ""}</span>`
15161516+ ? `<span class="overlay-tag" style="${handleStyle(c.overlay.handle)}">@${escapeHtml(c.overlay.handle)}${c.overlay.version ? " " + escapeHtml(c.overlay.version) : ""}</span>`
13831517 : `<span class="overlay-tag none">(no overlay)</span>`;
13841518 const tc = c.toolchain ? ` · ${escapeHtml(c.toolchain)}` : "";
13851519 return `<tr>
···14421576 const c = ev.context || {};
14431577 const o = c.overlay;
14441578 return o
14451445- ? `<span class="overlay-tag">@${escapeHtml(o.handle)}${o.version ? " " + escapeHtml(o.version) : ""}</span>`
15791579+ ? `<span class="overlay-tag" style="${handleStyle(o.handle)}">@${escapeHtml(o.handle)}${o.version ? " " + escapeHtml(o.version) : ""}</span>`
14461580 : `<span class="overlay-tag none">(no overlay)</span>`;
14471581}
14481582···16651799 return `<div class="pkg-list">${headerRow}${rows.map(r => {
16661800 if (r.__header) {
16671801 const lbl = r.__header === "(none)" ? "(no overlay)" : "@" + r.__header;
16681668- return `<div class="group-header">${escapeHtml(lbl)}<span class="group-count">${r.count} pkg(s)</span></div>`;
18021802+ const style = handleHeaderStyle(r.__header);
18031803+ return `<div class="group-header"${style ? ` style="${style}"` : ""}>${escapeHtml(lbl)}<span class="group-count">${r.count} pkg(s)</span></div>`;
16691804 }
16701805 const grp = OUTCOME_GROUPS[r.headline_outcome] || "ok";
16711806 const expanded = state.expanded === r.layer_hash ? " expanded" : "";
···16781813 const renderHandleChip = (h) =>
16791814 h === "(none)"
16801815 ? `<span class="overlay-tag none">∅</span>`
16811681- : `<span class="overlay-tag">@${escapeHtml(h)}</span>`;
18161816+ : `<span class="overlay-tag" style="${handleStyle(h)}">@${escapeHtml(h)}</span>`;
16821817 let chips;
16831818 if (handles.length === 0) {
16841819 chips = `<span class="overlay-tag none">—</span>`;
···16931828 <div class="handle-dd-list">${list}</div>
16941829 </details>`;
16951830 }
16961696- const rowHtml = `<div class="pkg-row${expanded}${upstream}" data-hash="${escapeHtml(r.layer_hash)}">
18311831+ const rowStyle = handleRowStyle(handles);
18321832+ const rowHtml = `<div class="pkg-row${expanded}${upstream}" data-hash="${escapeHtml(r.layer_hash)}"${rowStyle ? ` style="${rowStyle}"` : ""}>
16971833 <span class="outcome-badge outcome-${grp}">${r.headline_outcome.replace(/_/g, " ")}</span>
16981834 <span><span class="pkg-name">${escapeHtml(r.pkg.name)}</span> <span class="pkg-version">${escapeHtml(r.pkg.version)}</span></span>
16991835 ${renderDistroChips(r.pkg)}
···17221858 }
17231859}
1724186018611861+// Capture which input/textarea has focus and its current selection so a
18621862+// re-render that blows the DOM away can put the caret back where the
18631863+// user left it. Without this, every keystroke in the filter box (which
18641864+// triggers a full re-render) drops focus after the first character.
18651865+function captureFocus() {
18661866+ const el = document.activeElement;
18671867+ if (!el || !el.id) return null;
18681868+ const tag = el.tagName;
18691869+ if (tag !== "INPUT" && tag !== "TEXTAREA") return null;
18701870+ return {
18711871+ id: el.id,
18721872+ start: el.selectionStart,
18731873+ end: el.selectionEnd,
18741874+ };
18751875+}
18761876+18771877+function restoreFocus(snap) {
18781878+ if (!snap) return;
18791879+ const el = document.getElementById(snap.id);
18801880+ if (!el) return;
18811881+ el.focus();
18821882+ if (snap.start != null && el.setSelectionRange) {
18831883+ try { el.setSelectionRange(snap.start, snap.end); } catch (_) { /* unsupported */ }
18841884+ }
18851885+}
18861886+17251887function render() {
18881888+ const focusSnap = captureFocus();
17261889 renderHeader();
17271890 const m = state.manifests.get(state.active);
17281891 if (!m) {
17291892 document.getElementById("content").innerHTML = `<div class="empty">No manifest found.</div>`;
18931893+ restoreFocus(focusSnap);
17301894 return;
17311895 }
17321896 document.getElementById("content").innerHTML =
17331897 renderStatusBar(m) + renderFilters(m) + renderPackages(m, state.active);
18981898+ restoreFocus(focusSnap);
1734189917351900 document.getElementById("filter").oninput = (e) => { state.filter = e.target.value; render(); };
17361901 document.getElementById("sort").onchange = (e) => { state.sortMode = e.target.value; render(); };
···19512116 state.audits.set(k, a);
19522117 }
19532118 }
21192119+ invalidateHandleColors();
19542120 if (state.manifests.size === 0) {
19552121 document.getElementById("content").innerHTML =
19562122 `<div class="empty">No manifests found at any of:<br><br>` +