Ctrl K
ComunidadGuías…

src-gui/index.html

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#080a11">
<title>Linux Doctor</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M2 13h4l2.5-7 4 12 3-8 1.5 3H22' fill='none' stroke='%233ee29a' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
<style>
/* ── base.css ── */
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* === CSS Variables, Reset, Typography ===
   The canon: a professional Linux desktop tool. One neutral surface ladder
   tinted from a single hue, one accent with exactly three jobs (primary
   action, selected row, focus), and three severity inks that are the only
   other colours on screen. Radii are two values. Severity inks live here so
   every component inherits them and no component re-defines a colour. */
:root {
  /* Native widgets (scrollbars, inputs) follow the dark chrome. */
  color-scheme: dark;
  /* Surface ladder — 1 the window, 2 the chrome, 3 the controls. */
  --bg: #101216; --bg2: #15181d; --card: #171b21; --card2: #1e222a;
  --border: #262b33;
  --text: #e7eaef; --muted: #98a1ad; --muted2: #737d8a;
  /* Severity inks — the only colours besides the accent. */
  --red: #f0616d; --yellow: #e0b341; --green: #3fb950; --blue: #46bfd4; --purple: #a371f7;
  --accent: #4c8dff; --accent2: #6fa2ff; --hover: #1b2028; --evidence-bg: #0d1014;
  /* Ink that sits on an accent or green fill — verified ≥4.5:1 in all themes. */
  --on-accent: #0b0e14; --on-ok: #06180d;
  --shadow: 0 1px 2px rgba(0,0,0,.30);
  --shadow-sm: 0 1px 2px rgba(0,0,0,.25);
  --shadow-lg: 0 12px 32px rgba(0,0,0,.46), 0 2px 6px rgba(0,0,0,.30);
  --radius: 10px; --radius-sm: 6px; --radius-lg: 12px;
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Cantarell, system-ui, sans-serif;
}
:root[data-theme="light"] {
  color-scheme: light;
  --bg: #f7f8fa; --bg2: #eef1f5; --card: #ffffff; --card2: #e9edf3;
  --border: #dfe3e9;
  --text: #171a1f; --muted: #59616d; --muted2: #6b7480;
  --red: #bf2c33; --yellow: #7d5407; --green: #0a6b3c; --blue: #0e7285; --purple: #7c3fd6;
  --accent: #2f6ad6; --accent2: #5c8ce0; --hover: #e9edf3; --evidence-bg: #f4f6f9;
  --on-accent: #ffffff; --on-ok: #ffffff;
  --shadow: 0 1px 2px rgba(20,30,50,.06);
  --shadow-sm: 0 1px 2px rgba(20,30,50,.08);
  --shadow-lg: 0 12px 32px rgba(20,30,50,.14), 0 2px 6px rgba(20,30,50,.08);
}
/* Terminal theme: phosphor-console look for sysadmins. Same layout, same
   contrast budget (all pairs ≥4.5:1, verified) — only variables change, so
   no component CSS needs theme-specific overrides. Mono takes over --sans,
   corners go sharp, shadows go flat. */
:root[data-theme="terminal"] {
  --bg: #060906; --bg2: #0a0d0a; --card: #0b100b; --card2: #0e140e; --border: #1d2b1d;
  --text: #d9ead9; --muted: #8fae8f; --muted2: #6f8f6f;
  --red: #ff5a5a; --yellow: #ffd433; --green: #3dff73; --blue: #6aa8ff; --purple: #b79aff;
  --accent: #3dff73; --accent2: #6dff92; --hover: #101a10; --evidence-bg: #070b07;
  --on-accent: #04120a; --on-ok: #04120a;
  --shadow: none; --shadow-sm: none; --shadow-lg: none;
  --radius: 4px; --radius-sm: 2px; --radius-lg: 6px;
  --sans: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
h1, h2, h3, h4 { text-wrap: balance; }
html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; scroll-behavior: smooth; }
body {
  background: var(--bg);
  color: var(--text);
  font: 13.5px/1.5 var(--sans);
  letter-spacing: 0;
  /* The shell owns the padding: header, sidebar and status bar run full-bleed
     to the window edge, so the page itself carries none. */
  padding: 0;
}
.wrap { max-width: none; margin: 0; }
.empty { text-align: center; color: var(--muted); padding: 48px 0; font-size: 13px; }
kbd { font-family: var(--mono); font-size: 11px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 1px 5px; background: var(--card2); }

/* === Accessibility === */
.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--accent); color: var(--on-accent); padding: 10px 16px;
  border-radius: 0 0 10px 0; font-size: 13px; font-weight: 650;
}
.skip-link:focus { left: 0; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
body { transition: background-color .15s ease, color .15s ease; }

/* Respect users who opt out of motion. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
}


/* ── layout.css ── */
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* === App shell — headerbar, rail, main column, status bar ===
   Grammar from GNOME Settings (sidebar + rows), density and restraint from
   Linear, the list-with-actions model from JetBrains Toolbox. The shell is
   edge-to-edge: chrome owns the window, nothing floats in a page. */

/* === Headerbar — 48px, identity as subtitle, one primary action === */
header {
  display: flex; align-items: center; gap: 12px;
  height: 48px; padding: 0 12px;
  position: sticky; top: 0; z-index: 30;
  background: var(--bg2); border-bottom: 1px solid var(--border);
  transition: border-color .2s ease, box-shadow .2s ease;
}
header.scrolled { border-bottom-color: var(--border); box-shadow: 0 1px 0 rgba(0,0,0,.3); }
.hdr-id { display: flex; flex-direction: column; justify-content: center; gap: 1px; min-width: 0; }
header .logo {
  width: 26px; height: 26px; display: grid; place-items: center; flex-shrink: 0;
  border-radius: var(--radius-sm); color: var(--accent);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
}
header .logo svg { width: 15px; height: 15px; }
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: inherit; }
.brand:hover h1 { text-decoration: none; color: var(--accent); }
.brand:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: var(--radius-sm); }
.brand h1 { margin: 0; }
h1 { font-size: 13.5px; font-weight: 600; letter-spacing: -.005em; line-height: 1.2; }
.sysinfo {
  font-family: var(--mono); font-size: 11px; color: var(--muted2);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
}
.sysinfo .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--green); vertical-align: 1px; margin-right: 5px; }
.sysinfo .dot.stale { background: var(--yellow); }
.sysinfo .ver { font-family: var(--mono); font-size: 11px; color: var(--muted2); }
.actions { margin-left: auto; display: flex; gap: 6px; align-items: center; }

/* === Dropdown menu === */
.dropdown { position: relative; }
.dropdown-menu {
  position: absolute; top: calc(100% + 6px); right: 0; min-width: 190px;
  background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow-lg); padding: 5px; display: none; z-index: 40;
}
.dropdown.open .dropdown-menu { display: block; animation: dropin .12s ease; }
@keyframes dropin { from { opacity:0; transform: translateY(-4px); } to { opacity:1; transform: none; } }
.dropdown-item {
  display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 9px;
  border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text);
  font-size: 12.5px; font-weight: 450; cursor: pointer; text-align: left;
}
.dropdown-item:hover { background: var(--hover); }
.dropdown-item kbd { margin-left: auto; font-size: 10px; }

/* === Buttons — flat controls, one primary === */
button {
  background: var(--card); color: var(--text); border: 1px solid var(--border);
  padding: 6px 11px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer;
  font-weight: 500; line-height: 1.5;
  transition: background .12s ease, border-color .12s ease, color .12s ease, opacity .12s ease;
}
button:hover { background: var(--card2); border-color: var(--border); color: var(--text); }
button:active { transform: none; }
button.primary {
  background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600;
}
button.primary:hover { background: var(--accent2); border-color: var(--accent2); }
button:disabled { opacity: .45; cursor: default; }
button.iconbtn { padding: 6px 8px; color: var(--muted); }
button.iconbtn:hover { color: var(--text); }

/* === Health block (in the rail) — the score, read at a glance ===
   Not a panel and not a dashboard hero: a label, the number, a progress
   hairline, the headline, one counts line, the freshness footer. */
.status { display: block; padding: 0 8px 2px; margin: 0; background: transparent; border: none; border-radius: 0; }
.status.running { display: flex; align-items: center; justify-content: flex-start; color: var(--muted2); font-size: 12px; padding: 6px 0; }
.sr-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
.sr-k { font-size: 10px; font-weight: 650; letter-spacing: .1em; text-transform: uppercase; color: var(--muted2); }
.sr-score { display: flex; align-items: baseline; gap: 3px; }
.sr-num { font-size: 32px; font-weight: 700; line-height: 1; letter-spacing: -.04em; font-variant-numeric: tabular-nums; color: var(--text); }
.sr-out { font-size: 11px; font-weight: 500; color: var(--muted2); font-variant-numeric: tabular-nums; }
.sr-pulse { position: relative; height: 26px; margin: 10px 0 9px; }
.sr-pulse svg { display: block; width: 100%; height: 100%; overflow: visible; }
.pulse-track { fill: none; stroke: var(--card2); stroke-width: 1.5; }
.pulse-live { fill: none; stroke: var(--green); stroke-width: 1.75; stroke-linecap: round; }
/* The dot is HTML, not a circle in the SVG: the viewBox is stretched
   non-uniformly (preserveAspectRatio="none"), so a circle would render as an
   ellipse. It marks the end of the trace — the current reading. */
.pulse-dot {
  position: absolute; top: 16.5px; left: 0; width: 7px; height: 7px; margin: -3.5px 0 0 -3.5px;
  border-radius: 50%; background: var(--green);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 20%, transparent);
}
.status.warn .pulse-live { stroke: var(--yellow); }
.status.warn .pulse-dot { background: var(--yellow); box-shadow: 0 0 0 3px color-mix(in srgb, var(--yellow) 20%, transparent); }
.status.bad .pulse-live { stroke: var(--red); }
.status.bad .pulse-dot { background: var(--red); box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 20%, transparent); }
.sr-msg { font-size: 12.5px; font-weight: 600; color: var(--text); line-height: 1.35; }
.sr-line { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
.sr-line b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
.sr-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin-top: 8px; }
.sr-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border); font-size: 10.5px; color: var(--muted2); }
.sr-fresh { display: inline-flex; align-items: center; gap: 5px; min-width: 0; }
.sr-checks { font-variant-numeric: tabular-nums; white-space: nowrap; }
.scoredelta { font-size: 10.5px; font-weight: 550; font-variant-numeric: tabular-nums; display: inline-flex; align-items: center; gap: 3px; padding: 1px 6px; border-radius: 999px; background: var(--card2); border: 1px solid var(--border); color: var(--muted); }
.delta-up { color: var(--green); } .delta-down { color: var(--red); } .delta-flat { color: var(--muted2); }
.sr-foot .dot { width: 6px; height: 6px; }
.spinner { display: inline-block; width: 13px; height: 13px; margin-right: 8px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -2px; }
@keyframes spin { to { transform: rotate(360deg); } }
.newbadge, .fixedbadge { font-size: 10px; font-weight: 600; letter-spacing: .03em; border-radius: 999px; padding: 2px 7px; flex-shrink: 0; font-variant-numeric: tabular-nums; border: 1px solid transparent; }
.newbadge { color: var(--purple); background: color-mix(in srgb, var(--purple) 12%, transparent); border-color: color-mix(in srgb, var(--purple) 22%, transparent); }
.fixedbadge { color: var(--green); background: color-mix(in srgb, var(--green) 12%, transparent); border-color: color-mix(in srgb, var(--green) 22%, transparent); }
.lowbadge { font-size: 10px; font-weight: 550; letter-spacing: .03em; color: var(--muted2); background: transparent; border: 1px solid var(--border); border-radius: 999px; padding: 2px 7px; flex-shrink: 0; cursor: pointer; }
.checkid { font-family: var(--mono); font-size: 10px; color: var(--muted2); border: none; background: var(--card2); border-radius: var(--radius-sm); padding: 2px 6px; flex-shrink: 0; }

/* === Shell: rail + main column === */
.layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; }
.maincol { min-width: 0; padding: 16px 16px 0; }

/* === Rail (≥1100px): the app's navigation, flush to the window edge === */
.sidebar {
  display: none;
  position: sticky; top: 48px; align-self: start;
  height: calc(100vh - 48px); overflow: auto;
  background: var(--bg2); border-right: 1px solid var(--border);
  padding: 10px 8px 12px;
  scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
.sidebar::-webkit-scrollbar { width: 8px; }
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; }
.sb-block { margin-bottom: 16px; }
.sb-block:last-child { margin-bottom: 4px; }
.sb-block h2 {
  font-size: 10px; text-transform: uppercase; letter-spacing: .1em;
  color: var(--muted2); font-weight: 650; margin: 4px 8px 6px;
}
.sb-nav { display: flex; flex-direction: column; gap: 1px; }
.sb-item {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 6px 8px; border-radius: var(--radius-sm); border: none; background: transparent;
  color: var(--muted); font-size: 12.5px; font-weight: 450; cursor: pointer;
  text-align: left; transition: background .1s ease, color .1s ease;
}
.sb-item:hover { background: var(--hover); color: var(--text); }
.sb-item .sb-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-item .sb-count {
  font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums;
  color: var(--muted2); background: transparent; border: none; padding: 0; flex-shrink: 0;
}
.sb-item.high .sb-count { color: var(--red); }
.sb-item.medium .sb-count { color: var(--yellow); }
.sb-item.active { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--text); }
@media (min-width: 1100px) {
  .layout { grid-template-columns: 236px minmax(0, 1fr); gap: 0; align-items: start; }
  .sidebar { display: block; }
  .maincol { padding: 18px 24px 20px; }
}

/* === Views nav: rail on desktop, scrollable tab strip on mobile === */
#sidebar .viewtabs { display: flex; flex-direction: column; gap: 1px; }
#sidebar .viewtab {
  display: flex; align-items: center; gap: 9px; width: 100%;
  padding: 7px 8px; border-radius: var(--radius-sm); border: none; background: transparent;
  color: var(--muted); font-size: 12.5px; font-weight: 450; cursor: pointer; text-align: left;
  transition: background .1s ease, color .1s ease;
}
#sidebar .viewtab:hover { background: var(--hover); color: var(--text); }
#sidebar .viewtab.active { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--text); font-weight: 500; }
#sidebar .viewtab.active svg { color: var(--accent); }
#sidebar .viewtab svg { flex-shrink: 0; color: var(--muted2); }
#sidebar .tabbadge { margin-left: auto; }

/* === Toolbar — filter pills + search === */
.toolbar {
  display: flex; gap: 8px; align-items: center;
  margin: 0 0 12px; padding: 0;
  background: transparent; border: none; flex-wrap: wrap;
  position: sticky; top: 48px; z-index: 20;
}
.toolbar.scrolled { background: transparent; }
.seg { display: flex; gap: 2px; background: var(--card2); border: 1px solid var(--border); padding: 2px; border-radius: var(--radius-sm); }
.segbtn { padding: 5px 10px; border-radius: 4px; font-size: 11.5px; font-weight: 500; border: none; background: transparent; color: var(--muted); cursor: pointer; }
.segbtn:hover { color: var(--text); }
.segbtn.active { background: var(--card); color: var(--text); }
.filters { display: flex; gap: 2px; background: var(--card2); border: 1px solid var(--border); padding: 2px; border-radius: 999px; }
.fpill { padding: 5px 11px; border-radius: 999px; font-size: 11.5px; font-weight: 500; border: none; background: transparent; color: var(--muted); cursor: pointer; transition: all .12s ease; display: inline-flex; align-items: center; gap: 5px; }
.fpill:hover { color: var(--text); background: var(--hover); }
.fpill.active { background: var(--card); color: var(--text); }
.fpill.high.active { color: var(--red); }
.fpill.medium.active { color: var(--yellow); }
.fpill.info.active { color: var(--blue); }
.fpill:disabled { opacity: .3; cursor: default; }
.pill-icon { font-size: 11px; }
#search {
  flex: 1; min-width: 180px;
  background: var(--card2); border: 1px solid var(--border); border-radius: var(--radius-sm);
  padding: 7px 12px 7px 32px; color: var(--text); font-size: 12.5px; font-weight: 400;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15' viewBox='0 0 24 24' fill='none' stroke='%23737d8a' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20L16.5 16.5'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: 10px 50%;
  transition: border-color .12s ease, box-shadow .12s ease;
}
#search::placeholder { color: var(--muted2); }
#search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); }
.toolbtn { padding: 6px 10px; font-size: 11.5px; border-radius: var(--radius-sm); background: var(--card); font-weight: 500; border: 1px solid var(--border); color: var(--muted); }
.toolbtn:hover { background: var(--card2); border-color: var(--border); color: var(--text); box-shadow: none; }
.toolbtn.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
#autorefresh.on { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 32%, transparent); }
#autorefresh.paused { background: color-mix(in srgb, var(--yellow) 12%, transparent); border-color: color-mix(in srgb, var(--yellow) 30%, transparent); color: var(--yellow); }

/* === Content === */
.content { display: grid; gap: 14px; max-width: none; margin: 0; width: 100%; }
#report { display: flex; flex-direction: column; gap: 10px; max-width: none; margin: 0; width: 100%; }
#nomatch { text-align: center; color: var(--muted2); padding: 32px 0; font-size: 13px; }
#drillhint { text-align: center; color: var(--muted2); padding: 20px 0; font-size: 12.5px; border: 1px dashed var(--border); border-radius: var(--radius); margin-bottom: 4px; }
.diff-sub { font-weight: 400; color: var(--muted2); font-size: 11px; }

/* === Groups — section headings, not boxes === */
.group { background: transparent; border: none; border-radius: 0; overflow: visible; box-shadow: none; }
.group summary {
  list-style: none; display: flex; align-items: center; gap: 9px;
  padding: 9px 2px; cursor: pointer; user-select: none;
  font-size: 12.5px; font-weight: 600; letter-spacing: 0; text-transform: none;
  color: var(--muted); border-bottom: 1px solid var(--border); transition: color .12s ease;
}
.group summary:hover { color: var(--muted); }
.group[open] summary { border-bottom-color: var(--border); }
.group.high summary, .group.medium summary { color: var(--muted2); }
.group.info summary { color: var(--muted2); }
.group summary::-webkit-details-marker { display: none; }
.group summary b { color: var(--text); }
.group summary .chev { margin-left: auto; font-size: 10px; transition: transform .15s ease; color: var(--muted2); }
.group[open] summary .chev { transform: rotate(90deg); }
.group-body { padding: 6px 0 6px; display: grid; gap: 1px; grid-template-columns: 1fr; }
.sevicon { font-size: 12px; line-height: 1; flex-shrink: 0; display: inline-flex; align-items: center; }

/* === Severity dots — the app's only colour besides the accent === */
.sev-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; background: var(--muted2); }
.sev-dot.high { background: var(--red); }
.sev-dot.medium { background: var(--yellow); }
.sev-dot.info { background: var(--blue); }
.sev-dot.all { background: transparent; border: 2px solid currentColor; }
.fpill .sev-dot { background: transparent; border: 2px solid currentColor; }
.sev-dot.ok { background: var(--green); }
.sev-dot.err { background: var(--red); }
.sev-dot.na { background: transparent; border: 2px solid var(--muted2); }
.sev-dot.atomic { background: var(--purple); }

/* === Category icons / icon fallback === */
svg.cat-ic { display: inline-block; vertical-align: -2px; }
.sb-ic { display: inline-flex; align-items: center; width: 16px; justify-content: center; flex-shrink: 0; }
.ico { position: relative; display: inline-flex; align-items: center; }
.ico svg { display: block; }
.ico .fb { display: none; font-size: 13px; line-height: 1; }
html.no-svg .ico svg { display: none !important; }
html.no-svg .ico .fb { display: inline !important; }

/* === Status bar (footer) === */
footer { margin: 0; color: var(--muted2); font-size: 11px; text-align: left; line-height: 1.4; }
footer .sbar-view, footer .sbar-check { display: none; }
/* Built-by credit: the author's mark, quiet, at the end of the status bar. The
   image is inlined at build time so the dashboard stays one self-contained file. */
.sbar-brand { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; margin-left: auto; font-family: var(--mono); font-size: 10.5px; letter-spacing: .02em; color: var(--muted2); }
.sbar-mark { height: 16px; width: auto; display: block; opacity: .85; flex: none; }

/* === Notices row === */
#notices { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: start; margin-bottom: 10px; }
#notices[hidden] { display: none; }
#notices.single > :not([hidden]) { grid-column: 1 / -1; }
@media (max-width: 900px) { #notices { grid-template-columns: 1fr; } }
.view[hidden] { display: none; }
/* === Checks view — the inventory read as a table ===
   Focus (3s): which checks ran, and which of them are not clean. A counts
   line, a filter that drops the clean rows, a sticky column rule, then the
   registry. */
#checksview .mx-tools { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; flex-wrap: wrap; }
#checksview .mx-sub { font-size: 12px; color: var(--muted2); margin: 0; flex: 1; min-width: 200px; }
#checksview .mx-filter { margin-left: auto; }
#checksview .mx-fcount { font-family: var(--mono); font-size: 10.5px; opacity: .8; font-variant-numeric: tabular-nums; }
#checksview .mx-cols {
  display: flex; align-items: center; padding: 6px 9px;
  border-bottom: 1px solid var(--border);
  font-size: 10px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; color: var(--muted2);
}
#checksview .mx-cols > :first-child { flex: 1; }
#checksview .mx-list { padding-top: 2px; }
#checksview .mx-none { padding: 24px 0; text-align: center; font-size: 13px; color: var(--muted); }
@media (min-width: 1100px) {
  /* The column rule sticks under the headerbar while the registry scrolls. */
  #checksview .mx-cols { position: sticky; top: 48px; z-index: 15; background: var(--bg); }
}
@media (max-width: 700px) {
  #checksview .mx-id { width: 86px; }
  #checksview .mx-why { display: none; }
  #checksview .mx-row { gap: 8px; padding: 7px 6px; }
}
.tabbadge {
  font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums;
  background: color-mix(in srgb, var(--text) 12%, transparent);
  border-radius: 999px; padding: 1px 6px; margin-left: 5px;
}

/* === View header + section rule (History, Checks, System, Schedule) ===
   The four views that inherited the system without a pass of their own open
   the same way: a title and one line of purpose, then content on the row
   grammar — sections as label + hairline + count (the rule the Overview
   groups already use), never a stack of competing cards. */
.view-head { margin: 2px 0 16px; max-width: 1080px; }
.vh-title { font-size: 15px; font-weight: 700; letter-spacing: -.012em; }
.vh-sub { font-size: 12px; color: var(--muted2); margin-top: 3px; }
.vsec { margin-top: 20px; }
.vsec:first-of-type { margin-top: 4px; }
.vsec-head { display: flex; align-items: center; gap: 8px; padding: 0 0 7px; border-bottom: 1px solid var(--border); font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted2); }
.vsec-head .vsec-count { margin-left: auto; font-family: var(--mono); font-size: 10.5px; font-weight: 500; letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums; }
.vsec-head .vsec-sub { font-size: 11px; font-weight: 400; letter-spacing: 0; text-transform: none; }

/* === Generic fact rows (System, Schedule) — flat, under a section rule === */
.viewcard { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; }
.sysfact { display: flex; gap: 12px; align-items: baseline; padding: 7px 0; border-top: 1px solid var(--border); font-size: 12.5px; }
.sysfact:first-of-type { border-top: none; }
.sysfact-k { width: 170px; flex-shrink: 0; color: var(--muted2); font-size: 12px; }
.sysfact-v { color: var(--text); min-width: 0; }
.sysfact-v .mono, .idval { font-family: var(--mono); font-size: 12px; }
.sched-static { margin: 12px 0 4px; }

/* === System view — one list of sections, not four stacked cards ===
   Focus (3s): what this machine IS. Grouped by how it is read: the identity
   you read once, then the live state you read when something's wrong. */
.syslist { max-width: 1080px; }
.sys-sub { font-size: 11px; font-weight: 650; letter-spacing: .04em; color: var(--muted); margin: 16px 0 0; }
.sys-sub:first-of-type { margin-top: 10px; }
.sys-sub-d { margin-left: 6px; font-weight: 400; letter-spacing: 0; color: var(--muted2); }
.sys-sub + .sysfact { border-top: none; }
/* Identifiers are mono and copyable — the value is the thing you paste into
   a bug report, so clicking it copies, with the glyph appearing on hover. */
.idval {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--mono); font-size: 12px; color: var(--text);
  background: transparent; border: none; padding: 0; min-height: 0; cursor: pointer; text-align: left;
}
.idval::after {
  content: ""; width: 11px; height: 11px; flex-shrink: 0; background: currentColor; opacity: 0;
  -webkit-mask: var(--ic-copy) center / contain no-repeat; mask: var(--ic-copy) center / contain no-repeat;
  transition: opacity .12s ease;
}
.idval:hover { background: transparent; border-color: transparent; color: var(--accent); }
.idval:hover::after, .idval:focus-visible::after { opacity: .7; }
.idval:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: var(--radius-sm); }

/* === Run ledger (History) — a real table under its section rule ===
   Fixed columns with a header row, so the eye reads down "when · score ·
   Δ · findings" instead of guessing what the second column means. */
.ledger-head { display: flex; align-items: center; gap: 8px; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted2); padding: 0 0 7px; border-bottom: 1px solid var(--border); margin: 20px 0 0; }
.ledger-sub { font-weight: 400; color: var(--muted2); font-size: 11px; letter-spacing: 0; text-transform: none; }
.ledger { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 4px 14px; margin-top: 10px; }
/* Fixed columns, not a flex row with holes in the middle: the ledger is a
   table, and the eye should be able to run down each column. */
.ledger-cols, .ledger-row { display: grid; grid-template-columns: 96px 58px 46px minmax(0, 1fr); gap: 0 14px; align-items: baseline; padding: 7px 0; }
.ledger-cols { border-bottom: 1px solid var(--border); font-size: 10px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--muted2); }
.ledger-cols > :last-child { text-align: right; }
.ledger-row { border-top: 1px solid var(--border); font-size: 12px; font-variant-numeric: tabular-nums; }
.ledger-row:first-of-type { border-top: none; }
.ledger-when { color: var(--muted2); font-family: var(--mono); font-size: 11px; }
.ledger-score { font-weight: 650; }
.ledger-delta { color: var(--muted2); }
.ledger-delta.up { color: var(--green); }
.ledger-delta.down { color: var(--red); }
.ledger-counts { color: var(--muted); font-size: 11px; text-align: right; }

/* === Motion ===
   Short and directional. Views fade up when switched, the health bar and
   the score animate in once, rows never bounce. */
.view { animation: viewIn .16s ease; }
@keyframes viewIn { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }

/* === Chrome depth ===
   One hairline highlight on the chrome so the window reads as layered,
   without any shadow theatre. */
header { box-shadow: inset 0 1px 0 rgba(255,255,255,.03); }
:root[data-theme="light"] header { box-shadow: inset 0 1px 0 rgba(255,255,255,.7); }
:root[data-theme="terminal"] header { box-shadow: none; }

/* Rail rhythm: a hairline between blocks, so the aside reads as rows too. */
.sb-block + .sb-block { border-top: 1px solid var(--border); padding-top: 11px; }

/* === Status bar (≥1100px) ===
   The footer becomes persistent desktop chrome: current view, freshness, the
   read-only promise, the keyboard map — always on screen. Declared after the
   base rule that hides these spans, so the cascade lands the right way. */
@media (min-width: 1100px) {
  footer {
    position: sticky; bottom: 0; z-index: 18;
    display: flex; align-items: center; gap: 14px;
    padding: 6px 24px;
    background: var(--bg2); border-top: 1px solid var(--border);
  }
  footer .sbar-view { display: inline; font-weight: 600; color: var(--muted); flex-shrink: 0; }
  footer .sbar-check { display: inline; font-family: var(--mono); flex-shrink: 0; }
  footer .sbar-check.stale { color: var(--yellow); }
  footer .sbar-check[hidden] { display: none; }
  footer .sbar-note { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  footer .sbar-hints { margin-left: auto; flex-shrink: 0; white-space: nowrap; }
  .sbar-brand { margin-left: 0; }
}

/* === Toolbar icons ===
   One set, one stroke weight, drawn as masks so a JS label change (e.g.
   "Expand all" → "Collapse all") never drops the icon, and the ink follows
   the theme through currentColor. */
:root {
  --ic-rows: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16M4 12h16M4 18h16'/%3E%3C/svg%3E");
  --ic-checks: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l1.5 1.5L8 5m4 1h8M4 12l1.5 1.5L8 11m4 1h8M4 18l1.5 1.5L8 17m4 1h8'/%3E%3C/svg%3E");
  --ic-x: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 6l12 12M18 6L6 18'/%3E%3C/svg%3E");
  --ic-expand: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 9l4-4 4 4M8 15l4 4 4-4'/%3E%3C/svg%3E");
  --ic-pulse: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12h4l2-6 4 12 2-6h6'/%3E%3C/svg%3E");
  --ic-prompt: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 8l4 4-4 4M12 18h7'/%3E%3C/svg%3E");
  --ic-copy: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='9' y='9' width='11' height='11' rx='2'/%3E%3Cpath d='M5 15V5a2 2 0 0 1 2-2h10'/%3E%3C/svg%3E");
}
.toolbtn { display: inline-flex; align-items: center; gap: 6px; }
#densitybtn::before, #checksmatrixbtn::before, #clearbtn::before, #expandall::before, #autorefresh::before {
  content: ""; width: 13px; height: 13px; flex-shrink: 0; background: currentColor;
  -webkit-mask: center / contain no-repeat; mask: center / contain no-repeat;
}
#densitybtn::before { -webkit-mask-image: var(--ic-rows); mask-image: var(--ic-rows); }
#checksmatrixbtn::before { -webkit-mask-image: var(--ic-checks); mask-image: var(--ic-checks); }
#clearbtn::before { -webkit-mask-image: var(--ic-x); mask-image: var(--ic-x); }
#expandall::before { -webkit-mask-image: var(--ic-expand); mask-image: var(--ic-expand); }
#autorefresh::before { -webkit-mask-image: var(--ic-pulse); mask-image: var(--ic-pulse); }


/* ── cards.css ── */
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* === Findings — flat rows in a list, severity as a left ink dot ===
   No per-item card. A row carries: severity dot · title · quiet code/NEW ·
   chevron; the body opens in place. This is the Linear density (rows at
   ~34px, hairline separators, hover) on the GNOME Settings row grammar. */

.card {
  background: transparent; border: none;
  border-bottom: 1px solid var(--border);
  border-radius: 0; overflow: visible;
  transition: background .12s ease;
}
.card:last-child { border-bottom: none; }
.card:hover { background: var(--hover); }
.card[open] { background: var(--card); }
.card summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 9px 10px; cursor: pointer; user-select: none; }
.card summary h3 { font-size: 13px; font-weight: 500; flex: 1; letter-spacing: -.005em; line-height: 1.35; color: var(--text); }
.card summary .chev { color: var(--muted2); font-size: 10px; transition: transform .15s ease; flex-shrink: 0; }
.card[open] summary .chev { transform: rotate(90deg); }
.card:focus { outline: none; }
.card:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-sm); }

/* Severity carried by a dot (SEV_ICONS renders .sev-dot) with a soft halo,
   so a row's severity reads before its title does. Group headings keep their
   category SVGs. */
.card summary .sevicon, .group summary .sevicon { display: inline-flex; align-items: center; justify-content: center; width: 10px; flex-shrink: 0; }
.card summary .sevicon .sev-dot.high { box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 15%, transparent); }
.card summary .sevicon .sev-dot.medium { box-shadow: 0 0 0 3px color-mix(in srgb, var(--yellow) 15%, transparent); }
.card summary .sevicon .sev-dot.info { box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 15%, transparent); }

/* Pills — quiet */
.badge { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 999px; flex-shrink: 0; border: 1px solid transparent; }
.badge.high { background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 26%, transparent); }
.badge.medium { background: color-mix(in srgb, var(--yellow) 16%, transparent); color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 26%, transparent); }
.badge.info { background: color-mix(in srgb, var(--blue) 16%, transparent); color: var(--blue); border-color: color-mix(in srgb, var(--blue) 26%, transparent); }
.codepill {
  font-family: var(--mono); font-size: 10.5px; font-weight: 400; letter-spacing: 0;
  color: var(--muted2); background: transparent; border: none;
  padding: 0; flex-shrink: 0; cursor: pointer;
}
.codepill:hover { color: var(--muted); text-decoration: underline; text-underline-offset: 2px; }
.codepill::before { content: ""; }
.durpill { display: none; }
.checkid { display: none; }
.card[open] .durpill { display: inline-flex; }

/* === Card body — evidence one click away === */
.card-body { display: flex; flex-direction: column; gap: 0; padding: 0 10px 12px; animation: fadein .15s ease; }
@keyframes fadein { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } }
.card .detail { font-size: 12.5px; line-height: 1.6; letter-spacing: 0; color: var(--muted); padding: 10px 0 12px; border-top: 1px solid var(--border); margin-top: 0; }
.card .detail b, .card .detail strong { color: var(--text); font-weight: 600; }

details.ev { background: transparent; border: none; padding: 0; overflow: hidden; margin: 0; border-top: 1px solid var(--border); }
details.ev summary { list-style: none; display: flex; align-items: center; gap: 8px; padding: 9px 0; font-size: 10.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--muted2); cursor: pointer; user-select: none; }
details.ev summary::before { content: "▸"; font-size: 10px; transition: transform .15s ease; }
details.ev[open] summary::before { transform: rotate(90deg); }
details.ev summary::-webkit-details-marker { display: none; }
.evidence {
  background: var(--evidence-bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
  max-height: 180px; overflow: auto; padding: 10px 12px; margin: 0 0 10px;
  font-family: var(--mono); font-size: 11.5px; color: var(--muted);
  white-space: pre-wrap; word-break: break-word;
}
.evidence.expanded { max-height: none; }
.evidence::-webkit-scrollbar { width: 6px; height: 6px; }
.evidence::-webkit-scrollbar-thumb { background: var(--border); border-radius: 6px; }
.ev-expand { margin: 0; align-self: flex-start; background: transparent; border: none; padding: 0; font-size: 12px; font-weight: 500; color: var(--accent); cursor: pointer; }
.ev-expand:hover { color: var(--accent2); }
.ev-copy { background: transparent; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 9px; font-size: 11px; font-weight: 500; color: var(--muted2); cursor: pointer; }
.ev-copy:hover { border-color: var(--muted2); color: var(--text); }

/* Fix — the prescription. The one block that borrows the Doctor's own frame:
   a mono slip with a margin rule and a prompt mark. The label wording stays
   plain English because the CLI prints the same words (output parity). */
.fix { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 0; padding: 12px 0 0; border-top: 1px solid var(--border); }
.fix-label { display: inline-flex; align-items: center; gap: 6px; font-size: 10px; font-weight: 650; letter-spacing: .07em; text-transform: uppercase; color: var(--muted2); width: 100%; margin-bottom: -1px; }
.fix-label::before { content: ""; width: 12px; height: 12px; flex-shrink: 0; background: currentColor; -webkit-mask: var(--ic-prompt) center / contain no-repeat; mask: var(--ic-prompt) center / contain no-repeat; }
.fix .fixtext {
  flex: 1 1 100%; min-width: 240px;
  background: var(--evidence-bg); color: var(--muted);
  border: 1px solid var(--border); border-left: 2px solid var(--accent);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  padding: 9px 11px; font-size: 12px; line-height: 1.6; font-family: var(--mono);
}
/* The commands are the point of the block — the prose around them is not. */
.fix .fixtext code, .nh-hint code {
  font-family: var(--mono); color: var(--text);
  background: color-mix(in srgb, var(--text) 9%, transparent);
  border-radius: 4px; padding: 0 4px;
}
.fix .fixtext::before { display: none; }
.fix button, .fix .reportbtn { height: 30px; border-radius: var(--radius-sm); font-weight: 500; font-size: 12px; display: inline-flex; align-items: center; gap: 6px; padding: 0 11px; }
.fix button[data-copy] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600; }
.fix button[data-copy]:hover { background: var(--accent2); border-color: var(--accent2); }
.fix button[data-ignore] { background: transparent; border-color: var(--border); color: var(--muted2); }
.fix button[data-ignore]:hover { border-color: var(--muted2); color: var(--text); background: var(--hover); }
.fix .reportbtn { background: transparent; border: none; color: var(--muted2); padding: 0 8px; text-decoration: none; font-size: 12px; height: auto; }
.fix .reportbtn:hover { color: var(--muted); background: transparent; text-decoration: underline; }
.fix .reportbtn::before { content: "↗"; font-size: 11px; }

/* Compact rows (info findings) — quiet one-liners that expand in place. */
.card.crow { border-bottom: 1px solid var(--border); border-radius: 0; margin-bottom: 0; background: transparent; }
.card.crow:last-child { border-bottom: none; }
.card.crow:hover { background: var(--hover); }
.card.crow summary { list-style: none; display: flex; align-items: center; gap: 9px; padding: 7px 10px; cursor: pointer; user-select: none; }
.card.crow summary::-webkit-details-marker { display: none; }
.card.crow summary h3 { font-size: 12.5px; font-weight: 450; flex: 1; letter-spacing: -.005em; color: var(--muted); }
.card.crow[open] summary h3 { color: var(--text); }
.card.crow summary .chev { color: var(--muted2); font-size: 10px; transition: transform .15s ease; }
.card.crow[open] summary .chev { transform: rotate(90deg); }
.card.crow[open] { background: var(--card); }
.card.crow .card-body { gap: 0; padding: 0 10px 10px; }
.card.crow .detail { font-size: 12.5px; padding: 8px 0; border-top: 1px solid var(--border); }

/* Search highlight */
mark.hl { background: color-mix(in srgb, var(--yellow) 28%, transparent); color: inherit; padding: 0 2px; border-radius: 3px; }

/* Compact density */
body.compact .group summary { padding: 7px 2px; }
body.compact .group-body { gap: 0; padding: 4px 0; }
body.compact .card summary { padding: 7px 10px; }
body.compact .card summary h3 { font-size: 12.5px; }
body.compact .card-body { padding: 0 10px 10px; }
body.compact .card .detail { font-size: 12px; }
body.compact .fix { padding-top: 10px; }
body.compact #report { gap: 8px; }


/* ── components.css ── */
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* === Toast === */
#toast-wrap { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { pointer-events: auto; display: flex; align-items: center; gap: 10px; background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; font-size: 13px; box-shadow: var(--shadow); animation: toastin .25s ease, toastout .25s ease 2.7s forwards; max-width: 92vw; }
.toast button { pointer-events: auto; background: var(--accent); color: var(--on-accent); border-color: transparent; padding: 4px 10px; font-size: 12px; }
@keyframes toastin { from { opacity:0; transform: translateY(8px);} to { opacity:1; transform:none;} }
@keyframes toastout { to { opacity:0; transform: translateY(8px);} }

/* === Threshold Panel === */
.thresh-panel {
  background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 12px; margin: -12px 0 20px; box-shadow: var(--shadow-sm);
  max-width: 1040px; margin-left: auto; margin-right: auto; width: 100%;
}
.thresh-head { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 13px; margin-bottom: 10px; }
.thresh-sub { font-weight: 500; color: var(--muted2); font-size: 11px; text-transform: none; letter-spacing: 0; }
.thresh-body { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
.thresh-field { display: flex; flex-direction: column; gap: 4px; }
.thresh-field label { font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted2); }
.thresh-field input { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px; color: var(--text); font-size: 12px; font-family: var(--mono); }
.thresh-field input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent); }
.thresh-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }

/* === History — one instrument: the score and its shape, then the runs ===
   Three seconds: has health held or slipped? The score sparkline sits right
   under the score it belongs to (not floating at the bottom of a tall
   column), each severity bar is one run with its date, and the ledger is a
   real table with a header row. */
.history { max-width: 1040px; margin: 0; width: 100%; }
.hist-empty { border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); padding: 18px 20px; font-size: 13px; line-height: 1.6; color: var(--muted); }
.hist-empty b { color: var(--text); font-variant-numeric: tabular-nums; }

/* === Trend — COMPACT BUT MEANINGFUL === */
#trend { background: transparent; border: none; padding: 0; max-width: 1040px; margin: 0; width: 100%; }
/* === Trend hero — matches render-charts.js classes (.trend-hero-*, up/down/flat) === */
.trend-hero { display: flex; align-items: center; gap: 18px; padding: 14px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
.trend-hero-score { display: flex; align-items: baseline; gap: 4px; }
.trend-hero-num { font-size: 42px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums; }
.trend-hero-num.up { color: var(--green); }
.trend-hero-num.down { color: var(--red); }
.trend-hero-num.flat { color: var(--text); }
.trend-hero-out { font-size: 13px; font-weight: 600; color: var(--muted2); }
.trend-hero-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.trend-hero-range { font-size: 11.5px; color: var(--muted2); font-variant-numeric: tabular-nums; }
.trend-direction { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; padding: 4px 8px; border-radius: 20px; }
.trend-direction.up { background: color-mix(in srgb, var(--green) 12%, transparent); color: var(--green); }
.trend-direction.down { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); }
.trend-direction.flat { background: var(--bg2); color: var(--muted2); }
.trendtitle { font-size: 12px; color: var(--muted2); }
.trendtitle b, .trendrange { font-variant-numeric: tabular-nums; }
.trendtitle b { color: var(--text); }
.trendrange { color: var(--muted2); font-size: 11px; }
#trend svg { flex-shrink: 0; }
/* The chart marks are buttons now (role="button" + tabindex): they need a
   pointer and a visible focus ring, since the renderer no longer inlines
   either. Without this a keyboard user tabs onto nothing they can see. */
.hist-dot, .hist-bar { cursor: pointer; }
.hist-dot:focus, .hist-bar:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
#trend .trend-wrap { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 22px; width: 100%; }
#trend .trendchart { display: flex; flex-direction: column; gap: 6px; }
#trend .sevlegend { font-size: 11px; color: var(--muted2); display: flex; align-items: center; gap: 4px 5px; flex-wrap: wrap; }
#trend .sevlegend .lg { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-left: 8px; }
#trend .sevlegend .lg.high { background: var(--red); }
#trend .sevlegend .lg.medium { background: var(--yellow); }
#trend .sevlegend .lg.info { background: var(--blue); }

/* === Notices row: schedule strip + security posture as compact one-liners ===
   Secondary banners share one container so the spine stays hero → START HERE
   → findings. The wrapper hides when both notices are hidden. */
#notices .sched { margin-bottom: 0; padding: 6px 12px; }
/* === Security posture — one line, louder only when high === */
.security-posture {
  max-width: 1040px; margin: 0; width: 100%;
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 6px 12px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
  background: var(--card); opacity: .92;
  font-size: 12px; color: var(--muted);
}
.security-posture.warn { border-color: var(--border); border-left: 3px solid var(--yellow); opacity: 1; }
.security-posture.high { border-color: color-mix(in srgb, var(--red) 20%, var(--border)); border-left: 3px solid var(--red); background: color-mix(in srgb, var(--red) 4%, var(--card)); opacity: 1; color: var(--text); }
.security-posture .sp-icon { flex-shrink: 0; display: grid; place-items: center; opacity: .7; }
.security-posture.high .sp-icon { opacity: 1; }
.security-posture .sp-text { flex: 1; min-width: 160px; }
.security-posture .sp-text b { font-weight: 650; }
.security-posture button { font-size: 11px; padding: 5px 10px; border-radius: var(--radius-sm); background: transparent; border: 1px solid var(--border); color: var(--muted2); }
.security-posture button:hover { color: var(--text); border-color: var(--muted2); }

/* === Start Here — BALANCED PREMIUM === */
#nexthep {
  max-width: 1040px; margin: 0 0 16px; width: 100%;
  border: 1px solid color-mix(in srgb, var(--green) 18%, var(--border));
  border-radius: var(--radius); padding: 0; overflow: hidden;
  background: var(--card);
  box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
#nexthep .nh-header {
  display: flex; align-items: center; gap: 8px; padding: 9px 14px;
  background: color-mix(in srgb, var(--green) 8%, var(--card));
  border-bottom: 1px solid color-mix(in srgb, var(--green) 12%, var(--border));
}
#nexthep .nh-icon { font-size: 9px; color: var(--on-ok); font-weight: 800; letter-spacing: .07em; flex-shrink: 0; background: var(--green); padding: 2px 7px; border-radius: 4px; }
#nexthep .nh-kicker { font-size: 9px; letter-spacing: .12em; text-transform: uppercase; font-weight: 700; color: var(--green); }
#nexthep .nh-body { padding: 14px 16px 14px; }
#nexthep .nh-title { font-size: 15px; font-weight: 700; letter-spacing: -0.015em; line-height: 1.35; color: var(--text); }
#nexthep .nh-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; font-family: var(--mono); font-size: 10px; color: var(--muted2); }
#nexthep .nh-section { margin-top: 12px; }
#nexthep .nh-section-label { font-size: 9px; letter-spacing: .09em; text-transform: uppercase; font-weight: 700; color: var(--muted2); margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
#nexthep .nh-section-label::before { content: ""; width: 10px; height: 1px; background: var(--border); }
#nexthep .nh-hint { font-size: 12px; color: var(--muted); line-height: 1.5; }
#nexthep .nh-hint code { font-family: var(--mono); font-size: 10.5px; background: var(--bg2); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--border); color: var(--text); }
#nexthep .nh-actions { display: flex; gap: 7px; margin-top: 14px; flex-wrap: wrap; }
#nexthep .nh-actions button { height: 32px; padding: 0 13px; border-radius: var(--radius-sm); font-weight: 600; font-size: 12px; }
#nexthep .nh-actions button[data-nhcopy] { background: var(--green); color: var(--on-ok); border-color: var(--green); }
#nexthep .nh-actions button[data-nhcopy]:hover { background: color-mix(in srgb, var(--green) 88%, #000); }
#nexthep .nh-actions button[data-nhjump] { background: var(--card); border: 1px solid var(--border); color: var(--text); }
#nexthep.flash { animation: nhflash 1.6s ease 1; }
@keyframes nhflash { 0%,60% { border-color: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); } 100% {} }

/* === Score breakdown bars === */
.bd-row { margin: 0 0 14px; }
.bd-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
.bd-title { flex: 1; min-width: 0; font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
.bd-pen { font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; }
.bd-pen.high { color: var(--red); }
.bd-pen.medium { color: var(--yellow); }
.bd-code { font-family: var(--mono); font-size: 10px; color: var(--muted2); }
.bd-bar { height: 3px; border-radius: 3px; background: var(--card2); overflow: hidden; margin-top: 5px; }
.bd-fill { height: 100%; border-radius: 3px; background: var(--red); }
.bd-fill.medium { background: var(--yellow); }
.bd-more { font-size: 11px; color: var(--muted2); margin-top: 4px; }
.bd-clean { font-size: 12px; color: var(--green); font-weight: 600; }

/* === Skeleton === */
.skel {
  height: 64px; border-radius: var(--radius); margin-bottom: 10px;
  background: linear-gradient(90deg, var(--card) 25%, var(--hover) 50%, var(--card) 75%);
  background-size: 200% 100%; animation: shimmer 1.2s linear infinite;
  border: 1px solid var(--border);
}
.skel-hero {
  height: 140px; border-radius: var(--radius-lg); margin-bottom: 24px;
  background: linear-gradient(90deg, var(--card) 25%, var(--card2) 50%, var(--card) 75%);
  background-size: 200% 100%; animation: shimmer 1.2s linear infinite;
  border: 1px solid var(--border);
}
.skel-card {
  height: 72px; border-radius: var(--radius); margin-bottom: 10px;
  background: linear-gradient(90deg, var(--card) 25%, var(--hover) 50%, var(--card) 75%);
  background-size: 200% 100%; animation: shimmer 1.2s linear infinite;
  border: 1px solid var(--border);
}
@keyframes shimmer { to { background-position: -200% 0; } }

/* === Segmented group-by control === */
.seg {
  display: inline-flex; gap: 2px; padding: 3px;
  background: var(--card); border: 1px solid var(--border); border-radius: 999px;
}
.segbtn {
  padding: 5px 11px; border-radius: 999px; font-size: 12px; font-weight: 550;
  border: none; background: transparent; color: var(--muted2); cursor: pointer;
}
.segbtn:hover { color: var(--text); }
.segbtn.active { background: var(--text); color: var(--bg); }

/* === Hero mini-sparkline chip === */
.chip-trend { display: inline-flex; align-items: center; }
.chip-trend svg { display: block; opacity: .9; }

/* === Auto-refresh pulse === */
#autorefresh.updated {
  border-color: var(--green);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 22%, transparent);
}

/* === Modal shell === */
.modal {
  position: fixed; inset: 0; z-index: 60;
  background: rgba(4,6,10,.60);
  backdrop-filter: blur(4px);
  display: flex; align-items: center; justify-content: center;
  padding: 24px;
}
.modal[hidden] { display: none; }
.modal-card {
  background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg);
  max-width: 740px; width: 100%;
  max-height: min(82vh, 780px); overflow: auto;
  box-shadow: var(--shadow-lg);
  padding: 20px 22px;
  position: relative;
}
.modal-x {
  position: absolute; top: 12px; right: 12px;
  padding: 4px 10px; font-size: 12px; border-radius: var(--radius-sm);
}
.mx-head { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 4px; padding-right: 36px; }
.mx-sub { font-size: 12px; color: var(--muted2); margin-bottom: 12px; }
.mx-cat {
  margin: 16px 0 6px; font-size: 10px; font-weight: 700;
  text-transform: uppercase; letter-spacing: .1em; color: var(--muted2);
  display: flex; align-items: center; gap: 6px;
}
.mx-cat:first-of-type { margin-top: 4px; }
.mx-cat-n { margin-left: 2px; font-family: var(--mono); font-size: 10px; font-weight: 500; letter-spacing: 0; color: var(--muted2); font-variant-numeric: tabular-nums; }
.mx-cat-n::before { content: "\00b7"; margin-right: 4px; color: var(--border); }
.mx-row {
  display: flex; gap: 10px; align-items: baseline;
  padding: 6px 9px; border-radius: var(--radius-sm); font-size: 12.5px;
}
.mx-row.link { cursor: pointer; }
.mx-row.link:hover { background: var(--hover); }
.mx-st { width: 20px; text-align: center; flex-shrink: 0; }
.mx-id { font-family: var(--mono); font-size: 10.5px; color: var(--muted2); flex-shrink: 0; width: 108px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mx-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Result cell: a fixed state word carrying the severity ink, plus a quiet
   reason. Never a sentence — the column vocabulary is the same in the modal
   and the view. */
.mx-res { display: inline-flex; align-items: baseline; gap: 6px; justify-content: flex-end; flex-shrink: 0; min-width: 0; }
.mx-state { font-size: 11px; font-weight: 650; color: var(--muted); white-space: nowrap; }
.mx-state.high { color: var(--red); }
.mx-state.medium { color: var(--yellow); }
.mx-state.info { color: var(--blue); }
.mx-state.ok { color: var(--green); }
.mx-why { font-size: 10.5px; color: var(--muted2); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.help-grid { display: grid; grid-template-columns: auto 1fr; gap: 10px 14px; align-items: center; font-size: 13px; margin-top: 6px; }

/* === All-clean hero state === */
.cleanhero {
  display: flex; align-items: center; gap: 18px;
  background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 28px 28px;
  position: relative;
}
.cleanhero::before {
  content: ""; position: absolute; top: 0; left: 24px; right: 24px;
  height: 2px; border-radius: 2px; background: var(--green);
}
.cleanhero .ch-icon {
  width: 44px; height: 44px; flex-shrink: 0;
  display: grid; place-items: center;
  background: color-mix(in srgb, var(--green) 12%, transparent);
  color: var(--green); font-size: 20px; font-weight: 800;
  border-radius: 50%; border: 1px solid color-mix(in srgb, var(--green) 30%, transparent);
}
.cleanhero .ch-title { font-size: 15px; font-weight: 700; color: var(--text); }
.cleanhero .ch-sub { font-size: 12.5px; color: var(--muted2); margin-top: 2px; }

/* === Schedule strip: daily-check state, quiet by design === */
.sched {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 8px 12px; margin-bottom: 12px;
  font-size: 12px; color: var(--muted);
}
.sched[hidden] { display: none; }
.sched b { color: var(--text); font-weight: 650; }
.sched .sched-sep { color: var(--muted2); }
.sched .sched-note { white-space: nowrap; }
.sched .mono { font-family: var(--mono); font-size: 11px; }
.sched .toolbtn { margin-left: auto; }

/* Ignore list (Checks modal) + Clear history (History ledger): the persistent
   state the GUI can otherwise only set via Dismiss. */
.ig-row { display: flex; align-items: center; gap: 10px; padding: 7px 2px; border-top: 1px solid var(--border); font-size: 13px; }
.ig-v { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ig-kind { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.ig-remove, .ledger-clear {
  height: 26px; padding: 0 10px; border-radius: 7px; border: 1px solid var(--border);
  background: var(--card); color: var(--text); font-weight: 600; font-size: 12px; cursor: pointer;
}
.ig-remove:hover, .ledger-clear:hover { border-color: var(--red); color: var(--red); }
.ledger-head { display: flex; align-items: baseline; gap: 10px; }
.ledger-clear { margin-left: auto; color: var(--muted); }
/* === Skeletons — the shape of what is coming, not a spinner === */
.skel-row { height: 38px; border-radius: var(--radius-sm); margin-bottom: 6px; }
.skel-k { height: 10px; width: 46px; border-radius: 3px; margin-bottom: 12px; }
.skel-v { height: 26px; width: 88px; border-radius: 6px; margin-bottom: 12px; }
.skel-b { height: 4px; border-radius: 999px; }
#status .skel { margin-bottom: 0; }

/* === Command palette (Ctrl/Cmd+K) === */
.palette { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px; background: rgba(6,8,12,.5); backdrop-filter: blur(3px); animation: palFade .12s ease; }
.palette[hidden] { display: none; }
@keyframes palFade { from { opacity: 0 } to { opacity: 1 } }
.palette-card { width: min(560px, 100%); background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); overflow: hidden; animation: palRise .14s cubic-bezier(.2,.8,.3,1); }
@keyframes palRise { from { transform: translateY(-6px); opacity: .6 } to { transform: none; opacity: 1 } }
#palette-input { width: 100%; border: none; background: transparent; color: var(--text); font: 15px/1.4 var(--sans); padding: 15px 16px; outline: none; border-bottom: 1px solid var(--border); }
#palette-input::placeholder { color: var(--muted2); }
.palette-list { max-height: 44vh; overflow: auto; padding: 6px; }
.pal-group { font-size: 10px; font-weight: 650; letter-spacing: .09em; text-transform: uppercase; color: var(--muted2); padding: 9px 10px 4px; }
.pal-item { display: flex; align-items: baseline; gap: 10px; width: 100%; text-align: left; border: none; background: transparent; color: var(--text); padding: 8px 10px; border-radius: var(--radius-sm); cursor: pointer; }
.pal-item .pal-label { font-size: 13px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pal-item .pal-hint { font: 10.5px/1 var(--mono); color: var(--muted2); flex-shrink: 0; }
.pal-item.on, .pal-item:hover { background: var(--hover); }
.pal-item.on { box-shadow: inset 2px 0 0 var(--accent); }
.pal-empty { padding: 20px 12px; font-size: 13px; color: var(--muted2); text-align: center; }
.palette-foot { display: flex; gap: 16px; padding: 8px 14px; border-top: 1px solid var(--border); font-size: 10.5px; color: var(--muted2); }
.palette-foot kbd { font-size: 10px; }

/* === Empty states — a short sentence and a way out === */
.empty-cta { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; padding: 5px 11px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--card); color: var(--text); font-size: 12px; cursor: pointer; }
.empty-cta:hover { background: var(--card2); }
.empty-big { font-size: 14px; font-weight: 600; color: var(--text); }
.empty-big.ok { color: var(--green); }

/* === History, polished ===
   The score and the severity mix sit side by side as two instruments. The
   line under the chart repeated the hero word for word ("92/100 Stable 12
   runs · 92↔92"), so the hero stands alone now. */
.trend-wrap { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 26px; align-items: start; }
#trend .trendchart { min-width: 0; }
/* The score, its direction chip and its sparkline are ONE instrument: the
   hairline closes the whole block, not just the number. */
.trend-hero { padding: 0 0 8px; margin-bottom: 0; border-bottom: none; }
.trend-hero-num { font-size: 38px; letter-spacing: -0.035em; }
.trend-hero ~ .trendtitle { display: none; }
/* The score sparkline lives inside the score's own instrument, right under
   the number and the direction chip — the shape comes from the data, the
   same trace language as the rail's vitals pulse. No hairline under it: the
   trace is part of the reading, not a chart parked at the bottom of a column.
   It takes the column's width so it lines up with the number above it. */
.hist-trace { padding: 2px 0 0; }
.hist-trace svg { display: block; width: 100%; height: auto; overflow: visible; }
.sev-axis { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 10px; color: var(--muted2); margin-top: -1px; }
@media (max-width: 900px) { .trend-wrap { grid-template-columns: 1fr; gap: 20px; } }

/* === Schedule view — is the machine watching itself, and when ===
   Focus (3s): will this machine check itself tomorrow? The instrument row
   answers last run / next run / timer state — times in mono so they read as
   readings, not prose — with the "nothing to do" outcome as its footer band.
   The commands are printed as recipes (the accent-ruled mono slip the
   findings already use) instead of being hidden behind buttons. */
#schedview { max-width: 1080px; }
.sched-instr { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin: 12px 0 10px; }
.si { background: var(--card); padding: 12px 14px; display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.si-k { font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted2); }
.si-v { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text); }
.si-vnum { font-family: var(--mono); font-size: 12.5px; font-weight: 600; letter-spacing: -.01em; }
.si-v .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--green); box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 18%, transparent); }
.si-v .dot.stale { background: var(--yellow); box-shadow: 0 0 0 3px color-mix(in srgb, var(--yellow) 18%, transparent); }
/* The outcome band: the instrument's own last row, full width. */
.sched-idle { grid-column: 1 / -1; display: flex; align-items: baseline; gap: 9px; background: var(--card); padding: 10px 14px; font-size: 12.5px; line-height: 1.55; color: var(--muted); }
.sched-idle-mark { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--green); transform: translateY(-1px); }
.sched-cap { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted2); margin-top: 9px; }
.sched-cap .sched-note { white-space: nowrap; }
.sched-cap .sched-sep { color: var(--muted2); }
.sched-none { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
/* The two command sets sit side by side on desktop — the view is short, and
   stacking them left half the surface empty. */
.sched-cmds { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0 32px; align-items: start; }
/* The primary setup slip is a call to action, not a table row: it keeps the
   command and its Copy together instead of stretching across the view. */
.sched-setup .recipe { max-width: 560px; }
/* A grid item is :first-of-type inside its own column, so the shared
   "first section sits at 4px" rule would glue MANAGE to the caption. */
.sched-cmds > .vsec { margin-top: 22px; }
.recipe { display: flex; align-items: stretch; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.recipe-cmd { flex: 1 1 260px; min-width: 0; display: flex; align-items: center; background: var(--evidence-bg); color: var(--text); border: 1px solid var(--border); border-left: 2px solid var(--accent); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; padding: 8px 11px; font-family: var(--mono); font-size: 12px; white-space: nowrap; overflow-x: auto; scrollbar-width: thin; }
.recipe-copy { flex-shrink: 0; padding: 0 12px; font-size: 11.5px; font-weight: 600; background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); }
.recipe-copy:hover { background: var(--card2); border-color: var(--border); }
.recipe-copy.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.recipe-copy.primary:hover { background: var(--accent2); border-color: var(--accent2); }
@media (max-width: 900px) { .sched-cmds { grid-template-columns: 1fr; gap: 0; } }
/* On a phone the three readings become one spec sheet: label left, value
   right. Stacked full-width blocks cost ~250px of a 900px screen for three
   words. */
@media (max-width: 560px) {
  .sched-instr { grid-template-columns: 1fr; }
  .si { flex-direction: row; align-items: baseline; justify-content: space-between; gap: 12px; padding: 10px 14px; }
  .si-v .dot { width: 7px; height: 7px; }
  /* A separator that starts a wrapped line reads as a stray bullet. */
  .sched-cap .sched-sep { display: none; }
  .sched-cap .sched-note { width: 100%; }
  .recipe-cmd { white-space: normal; overflow-wrap: anywhere; }
}


/* ── responsive.css ── */
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* === Below the rail breakpoint (1100px) === */
@media (max-width: 1099px) {
  .maincol { padding: 12px 14px 0; }
  /* The aside collapses to the views nav. The Health block and the views
     strip stay; the breakdown/nav sections are desktop-only. */
  #sidebar { display: block; position: static; top: auto; height: auto; max-height: none; overflow: visible; padding: 0; margin: 0 0 10px; background: transparent; border-right: none; }
  #sidebar .sb-block { display: none; }
  #sidebar .sb-health { display: block; margin-bottom: 10px; }
  #sidebar .sb-views { display: block; margin-bottom: 0; }
  #sidebar .sb-views h2 { display: none; }
  #sidebar .viewtabs {
    flex-direction: row; overflow-x: auto; width: 100%;
    background: var(--card2); border: 1px solid var(--border);
    padding: 2px; border-radius: var(--radius-sm);
  }
  #sidebar .viewtab { width: auto; flex-shrink: 0; padding: 6px 10px; }
  #sidebar .viewtab.active { background: var(--card); color: var(--text); }
  /* Below the rail breakpoint the status bar is content, not chrome: give it
     a gutter so it does not sit flush against the last row of the view. */
  footer { padding: 14px 14px 18px; }
}

/* === Mobile === */
@media (max-width: 700px) {
  header { flex-wrap: wrap; height: auto; gap: 8px; padding: 10px 14px; }
  .hdr-id { flex: 1 1 100%; }
  header .logo { width: 24px; height: 24px; }
  h1 { font-size: 13px; }
  .actions { margin-left: 0; width: 100%; gap: 6px; }
  .sr-num { font-size: 28px; }
  /* The toolbar wraps instead of stacking: a column turns every control into
     a full-width block, the worst possible first impression on a phone. */
  .toolbar { top: 48px; flex-wrap: wrap; gap: 8px; }
  .toolbar .seg { flex: 0 0 auto; }
  .filters { flex: 1 1 100%; justify-content: center; }
  #search { flex: 1 1 100%; min-width: 0; }
  .toolbar .toolbtn { flex: 1 1 auto; }
  .group-body { padding: 6px 0; }
  .card summary { padding: 10px; }
  .card-body { padding: 0 10px 12px; }
  .fix .fixtext { min-width: 0; font-size: 11.5px; }
  .fix button, .fix .reportbtn { height: 38px; font-size: 12px; }
  #nexthep { padding: 12px 14px; }
  .history { margin-top: 24px; }
  /* Touch targets: every interactive control reaches ~38px tall. */
  button { min-height: 36px; }
  .fpill, .segbtn { padding: 7px 12px; }
  .evidence { font-size: 10.5px; max-height: 160px; }
  .ev-copy, .ev-expand { padding: 8px 12px; min-height: 34px; }
}


/* ── wide.css ── */
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* === Wide desktop (≥1200px) ===
   The shell is already edge-to-edge from 1100px; what wide adds is the
   master-detail reading layout: the findings list on the left, the selected
   finding's full body pinned on the right. */
@media (min-width: 1200px) {
  /* The toolbar sticks under the header, so it must be opaque or the
     scrolling rows show through it. */
  html.wide .toolbar { background: var(--bg); margin-bottom: 12px; }
  html.wide header.scrolled { background: var(--bg2); }

  /* === Master-detail ===
     Below 1200 .reportgrid is a plain block and the pane stays hidden. */
  html.wide .reportgrid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) clamp(340px, 30vw, 460px);
    gap: 0 24px;
    align-items: start;
  }
  html.wide .reportgrid > #drillhint,
  html.wide .reportgrid > #report,
  html.wide .reportgrid > #nomatch { grid-column: 1; }
  html.wide .reportgrid > #detailpane { grid-column: 2; }
  html.wide .detailpane {
    position: sticky; top: 104px;
    max-height: calc(100vh - 152px); overflow: auto;
    background: var(--card); border: 1px solid var(--border);
    border-radius: var(--radius); padding: 14px 16px 16px;
  }
  html.wide .detailpane::-webkit-scrollbar { width: 8px; }
  html.wide .detailpane::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; }
  html.wide .dp-kicker {
    display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
    font-size: 10px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; color: var(--muted2);
  }
  html.wide .dp-tag { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); border-radius: 999px; padding: 2px 7px; }
  html.wide .dp-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
  html.wide .dp-head h3 { font-size: 15px; font-weight: 650; letter-spacing: -.015em; line-height: 1.3; flex: 1; min-width: 200px; }
  html.wide .dp-body .detail { padding: 10px 0 12px; border-top: 1px solid var(--border); margin-top: 10px; }
  html.wide .dp-lowconf { display: flex; gap: 6px; align-items: center; font-size: 11px; color: var(--muted2); margin-top: 6px; }
  html.wide .dp-evrow { display: flex; align-items: center; gap: 6px; margin: 0 0 10px; }
  html.wide .dp-body .fix { padding-top: 12px; }
  html.wide #report details.card.selected {
    background: var(--card);
    border-color: var(--border);
    outline: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
  }
}


/* ── print.css ── */
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* === Print ===
   The score block lives in the rail on screen; on paper there is no rail, so
   the Health block is pulled back into the flow and every other aside section
   is dropped. */
@media print {
  header .actions, .toolbar, #toast-wrap, footer, .history, #fixed, #skipped, #diff,
  #sidebar .sb-views, #sidebar .sb-block:not(.sb-health), .skel, .skip-link, .modal { display: none !important; }
  .layout { display: block !important; }
  body { background: #fff !important; color: #111 !important; padding: 12px !important; background-image: none !important; }
  .status, .group, .card, .crow { box-shadow: none !important; break-inside: avoid; border: 1px solid #ddd !important; }
  .card-body { animation: none !important; }

  /* The rail itself is gone on paper — only its Health block remains. */
  .sidebar { display: block !important; background: none !important; border: none !important; padding: 0 !important; height: auto !important; position: static !important; }
  .sb-health { display: block !important; margin-bottom: 12px; }

  /* Keep the brand mark and health colors in print — browsers strip
     backgrounds by default and may drop var()-driven fills. */
  .logo, .pulse-live, .pulse-dot { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
  .logo svg path { stroke: #0e9c5a !important; }   /* fixed brand green on paper */
  .pulse-track { stroke: #e5e5e5 !important; }
}

</style>
</head>
<body>
<a class="skip-link" href="#report">Skip to findings</a>
<div class="wrap">
  <header>
    <div class="hdr-id">
      <a id="brand" class="brand" href="#overview" aria-label="Linux Doctor — go to Overview">
        <span class="logo" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22"><path d="M2 13h4l2.5-7 4 12 3-8 1.5 3H22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
        <h1>Linux Doctor</h1>
      </a>
      <div class="sysinfo" id="sysinfo" role="status">Loading…</div>
    </div>
    <div class="actions">
      <button id="notifybtn" title="Enable desktop notifications when health degrades" aria-label="Enable desktop notifications when health degrades"><span class="ico" aria-hidden="true"><svg viewBox="0 0 24 24" width="15" height="15"><path d="M12 3a6 6 0 0 0-6 6v3.5L4.5 16h15L18 12.5V9a6 6 0 0 0-6-6Zm-2 15a2 2 0 0 0 4 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="fb">🔔</span></span></button>
      <button id="theme" title="Toggle theme" aria-label="Toggle theme"><span class="ico" aria-hidden="true"><svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg><span class="fb">🌓</span></span></button>
      <div class="dropdown" id="export-dropdown">
        <button id="export-trigger" class="toolbtn" aria-haspopup="true" aria-expanded="false" aria-controls="export-menu" title="Export report">Export ▾</button>
        <div class="dropdown-menu" id="export-menu" role="menu">
          <button class="dropdown-item" id="export" role="menuitem" title="Copy the report as Markdown (scrubbed)">📝 Copy as Markdown</button>
          <button class="dropdown-item" id="export-md-file" role="menuitem" title="Save scrubbed Markdown to file">💾 Save as Markdown file</button>
          <button class="dropdown-item" id="copyjson" role="menuitem" title="Copy the raw JSON report">{ } Copy JSON</button>
          <button class="dropdown-item" id="pdfbtn" role="menuitem" title="Export as PDF (print)">🖨 Save as PDF</button>
        </div>
      </div>
      <button id="helpbtn" title="Keyboard shortcuts (press ?)" aria-label="Keyboard shortcuts"><span class="ico" aria-hidden="true"><svg viewBox="0 0 24 24" width="14" height="14"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><text x="12" y="16.5" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">?</text></svg><span class="fb">❓</span></span></button>
      <button id="rerun" class="primary">↻ Re-run checks</button>
    </div>
  </header>
  <div class="layout">
    <aside id="sidebar" class="sidebar" hidden aria-label="Report overview">
      <section class="sb-block sb-health">
        <div id="status" class="status running" aria-live="polite" role="status">
          <div class="skel skel-k"></div>
          <div class="skel skel-v"></div>
          <div class="skel skel-b"></div>
        </div>
      </section>
      <section class="sb-block sb-views">
        <h2>Views</h2>
        <nav id="viewtabs" class="viewtabs" role="tablist" aria-label="Views">
          <button class="viewtab active" role="tab" aria-selected="true" data-view="overview"><svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="2"/><rect x="13" y="3" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="2"/><rect x="3" y="13" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="2"/><rect x="13" y="13" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="2"/></svg>Overview</button>
          <button class="viewtab" role="tab" aria-selected="false" data-view="history" tabindex="-1"><svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M3 20h18M5 16l4-5 3 3 4-6 3 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>History <span class="tabbadge" id="tabbadge-history" hidden></span></button>
          <button class="viewtab" role="tab" aria-selected="false" data-view="checks" tabindex="-1"><svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M4 6l1.5 1.5L8 5m4 1h8M4 12l1.5 1.5L8 11m4 1h8M4 18l1.5 1.5L8 17m4 1h8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>Checks <span class="tabbadge" id="tabbadge-checks" hidden></span></button>
          <button class="viewtab" role="tab" aria-selected="false" data-view="system" tabindex="-1"><svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>System</button>
          <button class="viewtab" role="tab" aria-selected="false" data-view="schedule" tabindex="-1"><svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 7v5l3.5 2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>Schedule</button>
        </nav>
      </section>
      <section class="sb-block">
        <h2>Why this score</h2>
        <div id="sb-breakdown"></div>
      </section>
      <section class="sb-block">
        <h2>Severity</h2>
        <nav id="sb-sevnav" class="sb-nav"></nav>
      </section>
      <section class="sb-block">
        <h2>Categories</h2>
        <nav id="sb-catnav" class="sb-nav"></nav>
      </section>
    </aside>
    <div class="maincol">
    <div id="view-overview" class="view" role="tabpanel" aria-label="Overview">
      <div class="toolbar" role="toolbar" aria-label="Report controls">
        <div class="seg" role="group" aria-label="Group findings by">
          <button class="segbtn active" data-groupby="severity">Severity</button>
          <button class="segbtn" data-groupby="category">Category</button>
        </div>
        <div class="filters" id="filters" role="group" aria-label="Severity filter"></div>
        <input id="search" type="search" placeholder="Filter findings…  (code:disk/full)" autocomplete="off" spellcheck="false" aria-label="Filter findings — prefix with code: to filter by stable code">
    <button id="autorefresh" class="toolbtn on" title="Auto-refresh every 5s">Auto</button>
    <button id="expandall" class="toolbtn">Expand all</button>
    <button id="densitybtn" class="toolbtn" title="Toggle compact density" aria-pressed="false">Density</button>
    <button id="checksmatrixbtn" class="toolbtn" title="See every check and its result">Checks</button>
        <button id="clearbtn" class="toolbtn" type="button" disabled>Clear</button>
        <button id="threshbtn" class="toolbtn" title="Edit thresholds" aria-label="Edit thresholds"><svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:-2px" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="15" cy="6" r="2.6" fill="currentColor"/><circle cx="8" cy="12" r="2.6" fill="currentColor"/><circle cx="17" cy="18" r="2.6" fill="currentColor"/></svg></button>
      </div>
      <div id="threshpanel" class="thresh-panel" hidden>
        <div class="thresh-head">Thresholds <span class="thresh-sub">— tune when to flag</span> <button id="threshclose" class="toolbtn" style="margin-left:auto; padding:2px 8px;" aria-label="Close">×</button></div>
        <div id="threshbody" class="thresh-body"></div>
        <div class="thresh-actions">
          <button id="threshsave" class="primary" style="padding:6px 12px; font-size:12px;">Save</button>
          <button id="threshcopy" class="toolbtn">Copy JSON</button>
          <span id="threshmsg" style="font-size:11px; color:var(--muted); margin-left:8px;"></span>
        </div>
      </div>
      <main class="content">
        <div id="nexthep" hidden></div>
        <div id="notices" hidden>
          <div id="schedstrip" class="sched" hidden></div>
          <div id="security-posture" hidden></div>
        </div>
        <div class="reportgrid">
          <div id="drillhint" class="empty" hidden></div>
          <div id="report" aria-busy="true"><div class="skel skel-row"></div><div class="skel skel-row"></div><div class="skel skel-row"></div><div class="skel skel-row"></div></div>
          <div id="nomatch" class="empty" hidden></div>
          <aside id="detailpane" class="detailpane" hidden aria-live="polite" aria-label="Finding detail"></aside>
        </div>
        <details id="fixed" class="group" hidden>
          <summary>Fixed since last check · <b id="fixed-count"></b><span class="chev">▸</span></summary>
          <div class="group-body" id="fixed-body"></div>
        </details>
        <details id="skipped" class="group" hidden>
          <summary>Skipped checks <b id="skipped-count"></b><span class="chev">▸</span></summary>
          <div class="group-body" id="skipped-body"></div>
        </details>
        <details id="checkerrors" class="group" hidden>
          <summary>Failed checks · <b id="checkerrors-count"></b><span class="chev">▸</span></summary>
          <div class="group-body" id="checkerrors-body"></div>
        </details>
        <details id="diff" class="group" hidden>
          <summary>Changes since last run · <b id="diff-count"></b> <span class="diff-sub">new vs fixed</span><span class="chev">▸</span></summary>
          <div class="group-body" id="diff-body"></div>
        </details>
      </main>
    </div>
  <div id="view-history" class="view" role="tabpanel" aria-label="History" hidden>
  <div class="view-head">
    <div class="vh-title">History</div>
    <div class="vh-sub">How this machine's health moved, run by run</div>
  </div>
  <section id="history" class="history" hidden>
    <div id="trend"></div>
    <div id="runledger"></div>
  </section>
  </div>
  <div id="view-checks" class="view" role="tabpanel" aria-label="All checks" hidden>
  <div class="view-head">
    <div class="vh-title">Checks</div>
    <div class="vh-sub">Every check this tool knows, and what it found on this machine</div>
  </div>
    <div id="checksview"></div>
  </div>
  <div id="view-system" class="view" role="tabpanel" aria-label="System" hidden>
  <div class="view-head">
    <div class="vh-title">System</div>
    <div class="vh-sub">The machine's profile, read straight from the report</div>
  </div>
    <div id="systemview"></div>
  </div>
  <div id="view-schedule" class="view" role="tabpanel" aria-label="Schedule" hidden>
  <div class="view-head">
    <div class="vh-title">Schedule</div>
    <div class="vh-sub">Will this machine check itself — and when</div>
  </div>
    <div id="schedview"></div>
  </div>
    </div>
  </div>
  <footer>
    <span id="sbar-view" class="sbar-view">Overview</span>
    <span id="sbar-check" class="sbar-check" hidden></span>
    <span class="sbar-note">Linux Doctor only reads system information — it never modifies anything.</span>
    <span class="sbar-hints"><kbd>↑</kbd> <kbd>↓</kbd> navigate · <kbd>Enter</kbd> open/close · <kbd>/</kbd> search · <kbd>Ctrl</kbd> <kbd>K</kbd> palette · <kbd>1</kbd>–<kbd>5</kbd> views · <kbd>Esc</kbd> clear</span>
    <span class="sbar-brand" title="Built by 7sh1d0w7x"><img class="sbar-mark" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADoAAAAkCAYAAADYZynDAAATCElEQVRo3rVZd3hUVdr/nXPL3DuZmUx6QhJKqKHXSFlBBGRdwIK4oYPIUlZZgggIiAoKIk0pKwgIKkUQF8VPER8FsSAtigIRFiEVCMkkk+nltvP9MSmEtuyn332eee6duWfO+f3e87bzvoR++q9FMJvHgoDCYAAhAFB3ZwBI9UPtMwEYqx5XPZbhut+q3xNKYLCg4XHPPBwbc01KStjOCXwyGMAY+83r9o6UzfIFnufw5f4DmJ8zB/9fF6G7tjeA3b4BIj+k/psbiVY/44bv5LZTVwuHgOnaOdFVNTa3S8csTpZXEUpMhsEQCoY+KC4onGS12dyGruOB7n1uC3TrxztRUFiAIUOGwCybYYCBVC/Ortsfxq4DWQ0hGAiC0E8/Bvz+poix7wTPZd1I9PpJ6pHFnUjWH8/AwBT1SJzTOeHQPV3H8LI0jxBCDcNgAX9g2ZFD3z2f0bKp5vN6Meqhx2+aYvXW9QiFQ5g9OQffnj3eU5bkNiBgBKQaDrvFwjVqSGjQH3ASwhjIwQOAx3cPs5jfJjyfBjCjHlZCQMh16nr9/Xop1JMICAArAK5aVcFUde+AgH/m8o7tFgiSaQIIgaEbQb/XP/2eVh02bdj+NjxuD+Y8/UztJHMXL8C5s3l4ff06FBUW9YuOsW/heb7h3agrA6BreqXb5Z5FuOfngXh80FavBv3soyYQhBhomg4CFhEUAyi9XjeqP4jIg3LX2WQdUWLoDLw4mgnCTJAIWTAGIxxa/wLBiqGtWqzmTeJgRMBc87o9oxOSEw+2iG+ICU9NxtY3N2LuK89jyfyXcebKBVSUO7raY2O3iaLYKiLFiF6yOxDVdSPk8/pm9WrdeR0BAG7VcsDlBeveCcTtzmB2+3AYhsjqFB4g5GZTJbczXlCEw/m4Wv4pmmYsh0l4onYIY7oRCr2y227b3TI1ZQsn8N0JIVAU9bTjWlm2NTr6vGwxY8fmrZi1YC4K8wtQ5axqlZCUuM1kMnWtI4mb1mbXOUzDYHowEFiWeyz3xbSGaWrtP0wAtI/2AKGQjcXYV8Mkjme187BbTn8rf1V7hRWFVTieo6qxm6WmvAOBH1A72DBChsc74+tGqbkx8XHbeYFvaRgMwWDws7JrZRMkyVRuj7HD7/Uh4A+kJSYnviPJcj9CCBiDrirKezzHHSOEUMaYDsYYCImYR2R1Ttd1b+nV0k9F0eRRVQVcDS4egDF2FBgvhOHzHYNoak842pSQCMUbiRBSx7Am4tSGGgCglCMEvViV6ywBeQey1AcclwjGwDSNJ+Fwn3fyzn87JiXpY07g+1FKrISSZsxgP8cnJpx1VlTA5XLFpqQ2WCvJ0mBKKRhjCAVDOwsuXJoWmxB31CRJufkF+VBVLSMYCPD+QIAPBIPi5aKSk207tvuirPRaWDSZMDCrd3383NPTYIwcCuLyAP5Aa8TYd4Hn2tXaZWRHdMaMfwMsULutBNX+7zpVZgTQdTsqnWbk549Ey5aUud07GNgF2KJ/QZWLR4WjAPNfWnc078e+nCgMVlWtzOFwrBdFweFxu8xNMjJWSLI8lVIKAFDCypeOMsc4s9lcKplFuF2eDjFxsTsFQWhdw0FR1NMV5RUjk1KS8p6fMQf7P/qkdiPrjHfdWnCxsbh30UJ8c+Twr7TKlcPM0nsgSK11OMzww+edSoLeU4zytJYYIQChtYkFI4RRwseDGRvBcyPvHzZicv60KS9kt2/bd0S3ThdUXSeaYVDj8UcnERBDVbXcooLCvdExdndxYaHYsXOn5yRZnlRLUlF/cVY6c6JjokvD4TAqKirTExITVoui2JpUq5SmakV+r//ptPTUvLapzSAIQv1gc+MlLH0VTBChPfMM6Mcf9IMktAOhDAYDNDUEr28vOOKos8+biYIBNBACnJWZ8HiydUkOZijano/GjXqSN4nz6myKgTEGgzEWCgTfWLd8zezJ06c8bo22bqWUMwGAqmolrqqqMYnJSd+UFBbC5/PZGzZutEGS5GxCCEAIdE2v8ro9kzPbtd6zdf0WmGQJ05+YdGeiAMC9tR7E44feIROwyHVpnqoAbjdACdjgx3Hb/+/YAqKqII5ys5HR7E2mKOOMgsJtwwVx7tzHH31VkMQxqHYeNc5d1w3F5/HMCofCJ2Pj43byAt9Y07Qqr9s7Jatt1w8++fITuJxVYtuO7Zeao8w5hFACAIZhhH0+/+w/te6yZvu+D8EYw5hH6mO7m9zmv764N98AzGaQy5d51rHDApikeYxQHlVOwygqWrY2M/Pt3m0zN/KC0AdgBmOMMEQco67pVW6XewKllLdYLWuUUHjZPa06vvHuvt0Y93A2OfJr7jNRFssSSqmIiJBYwB9YkXs8d35aepoaCoYwYtCjN2H6w4ny/3wDjAA9/p6DH/bt+Ttk8wpQKtfknXC7Sowzef3Pj8wOabKpM2MMwUCQMkq6y2Z5OiFEVFU1v9JRMVbXDNUf8J/RNT049IEh+CHv5HCr1bqB57noGt8YCoZ2Xi4qmWqxWjyKqmLwn/rfEtcfSlRYuRQIh6AOGQKan/8YZPNGUBobecvAGIJMUWZkH/3muyiT1GXLgle3H7/wMxN5ESeOHJc6de/yhiRLkwEgHFaOOh0Vw0VRLE5KT0HZldI+0Xb7NkEQ0mtUXgkrh8uvlY8xR5kvC6KAqaMn4vRPp26Jjf5RJLmcvyN55nPQevYAvXixN2R5FSiNrRElM2AwRVn5yPFvP0+Msa9q27LFWwdOfPtkVvMOOPb9UTTLbB4qLy17UQmHvwQAk0nsYY+NWXry2AnxSmFJa1t09BpB4NMj20OgqtpZt8v9j7iEuMstWjbHawtfuy1JAHUJw+8iOWQgjDmz4B38IKjL3QYWy9vghBa1JyAAUNRt+Pdv8/sT49mMRo3GdWvXXoiLiel+sbggr0NWl4tdmrSGM+jx+73+n0VJvA+EJGiqekCOks8lpSS9KYqmnjXRWte0y163d1JKWoPjHRu2RFrjxljz6rI7Yvzdqis++TS0x+4HVB0IBdNgj3kPvNCXMQCkOolQta/gco99ODWptO3pU0N6ZWVtTU9NjQMAVVEvOCucI6Kslp8e/cd0lH5xmH77y/FBnMA/UH6tbHlaw7SXJVkeW7Oerusur9s7tXPbLru2bH0bihrGzMlP/0ecv5sot/d9wDAAvz+GJSVtgGD6K2pCBiGArp+BxzsCUea8jad/xsTZC3Ds/M/TLFbL8poDuKaqRxzljtGN23cobNnn/hGc2Zz7+bMzS23NGy8wR0XNooSQSIpsBH0+/7xemZ1X7znwCVMUFaMeeuyucP4uG+W2bQYUBaSiQmLxCS9BEP8aeVPtEnXjCvyBHMTH5+HBh+BPTsZHB/fj/Om8t4LB4HrDMEAIwAt8r9i4uOWeM2ftsNm66y1bLjyoqUmiKPaldUckhEPKwVMnfnpr78H9jIHdNcnfRZR7bxMABvLlAWqkpc+ASZpaO10kXfQiGHwOWV0PIfdHYNkS5IybBIvVgoZNGyuOa+WL1LCyr2a8aBKH6VbzNCQl6rR5sxGvhZVhPo/vBV3Xy2oOYqIoZLXt2HZAl6yu+PTjfXhx+eK7x/t/Irn4RRBKoI8eD5ik8ZCkJaCcDBLJ5WEwFWFlEdm4eQNEkYFyYDkz8ejwxzBv+iwwmcO5M2dljnInJLPcneO4BrquO/VQeNPmgqJztEmTwTQ+7t4tjsp9T1osxzlRGEAJEQihUbwgdLtScvlY3wH9ro74y1AEiIYfDn/3xxPlliwELS6G8dhQ0NwTgyCb14Dja2NlJE3U3kJB/iJ066oCAJs4BTPmPovp82ah8FoJHGXlTVMbpW/RdN2hhpXdHM/1DAVCK3q06fLuSwMGXlp4Pk8jFstgIgqddhcWrRkTH++mPH8voYRQjosVBKFNRXnFoTJ3hat9xw5Qwgp++fHUnXH/NyTp05NgLFkBCBTEUd6NRVk2MY5rCMPwE03djoD/CwQDn8DlXY7GjXyQZCA6GmjVBi8N7odgIACX0xUfn5jwpiybB/GC0MPvD+yvqqza6HK6Dv1WeElL+uoQLRCFf581STFUEP4cYkbq1Uv5S3o3SE7ieK4dIQQcx6cLAp9WVnrtS03TQ80zW2LXO9v/GKLC8pUwBvQB/fUMSMDfjFltm8Hx7WEwhnB4BXGUvw9DNxMChQhce+L3ZdErV73gaMWHLRoCDCgrKzM3zmiyVJLlUYQQcDxvBVDar0uv9385f1YbNvAhnBT00b3TG0woKXesucRzrSkzBp4vKw/15uiqBLs9i3I0jRACnudbi6JJPH702GG73a4PG5mNPTt23Rb/XYUX08N/hjpxAqAbQDicCHvsFvDCIABgYWUn8brXMbt9AyjXHrS61BBWviaOijGGLF/ZbTPh47176bQZM+aZLVEvUsrxiKRwXzkrnON5nruSmJwAR3l5H2t09Hae49O0YGhp//MXP/FRsp3l56fqx0+MOT53VonJZt3D8XwaIjE1HPAFZvbI7PTPdz98H1VVTsyYdOuYenc7umAuCGMgAb8FcQkrwIvZIATQtO+Jq2ohYmIWEF64N5IJERDdOAevfyKJtl08VFqMQcOfQELj1HEWq3Uxz/ESACiKctZZ6fybPcZ+icGAo6y8VUxs7GZREFsQSkE4rusIi/mnTZeKPiVezyPEWeVMHjJwUyOXzymIwgBKqUgo5Xme73ah6FJex26df3uw5/2Yt/glfHfo8M1m9x/tctM6QNNALlwUWGzCfPDCeAaAafpFeDzzYLOOq9ldAIBuOFggOBMpKaeNQQ8jfWQ2vjl9rL8tOnoJx/OW6hTuqtflyclonnHW6axEZWVlQlxC/EpBNHWsaXdQjkpiVNTLB9q09Oku92tEEJT9+cXYv++z7eFgeLVhGAwAOJ5LsNpsKy6ev9Duh3M/YezEJ9Bn8MD/jii/diWIpiFj/GQY7dpNgWjKAQiFwZzw+eYQ2dQVJmkSalIEg4UQDL1AHx76OfsxF9+dPYniwsL2VpttNcfzKQBg6IbP5/XPva9j94OnfzyF0iuXpeTk5IUmSfpLJJHS3LquewEGjuMS42PtSxe3b/uVXunc7bh3AHrd9ye9pLhkmRJW/lVTxxJEIdMeG/O6x+1JKcjPR897sm7icvsKw+KFIEoY2rDHQPMvDWWyeRMoFwsGBQH/LGLoV2GxbGKU2qt3gSGsLMfPvzzPUhuoWy0SqpxVlk7dOq+MslgHAtABQNO0Tc+OzXntuRXz2UN9BpLv807OtlgtLxPKCbqu+9xV7n8IPBdjsVmXUkoFxgAlFPrsWvHl8fb42IpAMARRMkFT1Ayb3baRUto0Ij+DVFW5Nj54z33LVr611khLTUP24EfvTJSf8wy0pStBvz4AuD29EBW1g1GuERiAsPI6Cfr2Idq+hXFcRm11UNU+gMMxiUmS+0BKHIqulPJJSUkJSjiczK5rOYmiWG6xWcOKojCL1fIXm822jnJctKHrut8fWDR11OSX5yx8ztQis8UaSTL9raa2FAwE11ZVVi7meUGnHAUzGHO7XHG6blgIJQyMEd0wNIs56vKg3gOcb27dBH/Qj9lPzbg1Ue7e7jBWLQepdAI+Xyas0TsYx3UCACjqPuJ2LUZ87GrG8T1qimBQtaNwuUcyWSr8XKY4nJvLPTJs2CxJkkaBEAP1u4kkUiNioJSmE0rtABD0B7b++uu5aXFxcX6b1YpwMJiSkJS4UzSJ94EQGIahMMO4xACNkNpKc13JOnIjjDG/z+Odm5ScfLh5QjpGTBiLXVu31SfKz5sLo3vnSFkzHE5BtP1dxvMDqo9aucRZ+RRiY2YyQfxrbc9J0wuIzz+C2WzHd579CcNz5uL7vNzxVpt1HcdxUXfyATUthFAofLCy3DFGkuVSRVPRvHkzOCud8Hm8XaJj7LsFgW/K6rVAbjHXdd0TVVFPOSuc2bIs/cYLAvr26HNDAXvXe5Fc1e+zIaXBOiaaxlR70stwe54gUVJ/mKTZrOZEbTA3goGJSG/0IVuyBJc2r0VxQdGAaLv9XV7gU3CLCv+Nl6Kqec4K54hoe/SZ1NQGWLVkFeyxVhTlF+PlFYuRf/HS4xardTPHU1u9PkidtOo3m6qrlaFgaO/lwuIno6xRrmAwWBdHTfPmwUiMA/EFJKQkv8RM0pRIwdTwIBCYSUx8MiTpRUaoUE1SQTi8CE9O38wym+HI6GxUVlZ2ssfEbBIEoQnwH2hGarGlHrd7SnJK0tFOjVpBMElYvvAVfP3FV5ic8xSullzB0rkLz/d9sD/HC0JvUl3Nvqk5Wd3WJNW7TgjA8VymJEukpLD4G1EUjbom0+xZCA96AFxZ6XDDbNkAjjPDMBhU/XVa6dhnJCTsAM+nA9BBwEEz9qK0bALMsv8dEwd/MGTPbN9ma5TFMgSAdktudRAJA9PUsDqvZ+vOq9/evR0GASZlj6k3/l9f/A9MsoRQMByd1ih1Gy/wDxIQ/cbmL7t1H5jqmu6uKHeMb9S40Wf/C8GC6LvhNVMJAAAAAElFTkSuQmCC" alt="" width="29" height="18">7sh1d0w7x</span>
  </footer>
</div>
<div id="toast-wrap" aria-live="polite" aria-atomic="true"></div>
<div id="palette" class="palette" hidden role="dialog" aria-modal="true" aria-label="Command palette">
  <div class="palette-card">
    <input id="palette-input" type="text" placeholder="Search findings, views and actions…" autocomplete="off" spellcheck="false" aria-label="Command palette search" role="combobox" aria-expanded="true" aria-controls="palette-list">
    <div id="palette-list" class="palette-list" role="listbox" aria-label="Results"></div>
    <div class="palette-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> move</span><span><kbd>↵</kbd> run</span><span><kbd>esc</kbd> close</span></div>
  </div>
</div>
<div id="modal" class="modal" hidden role="dialog" aria-modal="true" aria-label="Details">
  <div class="modal-card">
    <button id="modal-x" class="modal-x" title="Close (Esc)" aria-label="Close">×</button>
    <div id="modal-body"></div>
  </div>
</div>
<script>
/* ── dom.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === DOM helpers === */
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
/* Fix strings quote commands in backticks. The interface shows them as code,
   not as backticks — and a stray one after a slice never reaches the screen. */
function cmdCode(text) {
  return esc(text)
    .replace(/`([^`]+)`/g, "<code>$1</code>")
    .replace(/`/g, "");
}
/* JS smooth-scrolls bypass the CSS reduced-motion guard, so gate the behavior here. */
function prefersReducedMotion() {
  try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch { return false; }
}
const scrollBehavior = () => (prefersReducedMotion() ? "auto" : "smooth");
let lastReportData = null;
let lastData = null;


/* ── state.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Central state store === */
const SEV = { high: { label: "High", cls: "high" }, medium: { label: "Medium", cls: "medium" }, info: { label: "Info", cls: "info" } };

/* Severity markers as inline SVG (theme-colored via CSS), not emoji —
   identical rendering everywhere, no font dependency, crisp at any scale.
   Inside filter pills they inherit currentColor so active states invert. */
/* Severity markers as pure CSS dots — no glyphs, no SVG, nothing that can
   render as a missing-glyph box in any WebView/font stack. */
const SEV_ICONS = {
  high: '<span class="sev-dot high"></span>',
  medium: '<span class="sev-dot medium"></span>',
  info: '<span class="sev-dot info"></span>',
};
const ICON_ALL = '<span class="sev-dot all"></span>';

const SEV_ORDER = ["high", "medium", "info"];

/* Category icons — small stroke SVGs, each with its own accent color so the
   sidebar, matrix and category groups are scannable at a glance. */
const CAT_ICONS = {
  system:   { c: "#5eb1ff", d: "M3 6h18M3 12h18M3 18h18M7 4v4M15 10v4M9 16v4" },
  software: { c: "#c08bff", d: "M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3Zm0 0v9m8-4.5L12 12 4 7.5" },
  security: { c: "#3ee29a", d: "M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3Zm-2.5 9l2 2 3.5-4" },
  network:  { c: "#74acff", d: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-9 9h18M12 3c-5 5.5-5 12.5 0 18 5-5.5 5-12.5 0-18Z" },
  updates:  { c: "#ffce5a", d: "M20 12a8 8 0 1 1-2.3-5.6M20 3v4h-4" },
  hardware: { c: "#ff9d66", d: "M8 8h8v8H8V8Zm-3 3H3m18 0h-2M11 5V3m2 18v-2M5 11H3m18 0h-2M11 19v2m2-18v2M8.5 3.5v1m7-1v1m-7 15v1m7-1v1M3.5 8.5h1m-1 7h1m15-7h1m-1 7h1" },
  data:     { c: "#4dd0e1", d: "M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3Zm0 0v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" },
  other:    { c: "#8a93a6", d: "M12 4v16M4 12h16" },
};
function catIcon(cat, size) {
  const s = size || 13;
  const ic = CAT_ICONS[cat] || CAT_ICONS.other;
  return '<svg class="cat-ic" width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true">' +
    '<path d="' + ic.d + '" fill="none" stroke="' + ic.c + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
const SEV_NAMES = { high: "High severity", medium: "Medium severity", info: "Informational" };
const THEME_ORDER = ["light", "dark", "terminal", "auto"];
const POLL_MS = 5000;
/* Category grouping: known check categories in display order; anything
   unmapped (plugins, older payloads) lands under "other". */
const CATEGORY_ORDER = ["system", "software", "security", "network", "updates", "hardware", "data", "other"];
const CATEGORY_LABELS = {
  system: "System", software: "Software", security: "Security",
  network: "Network", updates: "Updates", hardware: "Hardware",
  data: "Data", other: "Other",
};
/* Security posture: the checks that together describe the machine's
   security hygiene. Shown as a dedicated "Security posture" grouping in the
   report and sidebar — presentation grouping, not a new check. */
const SECURITY_POSTURE_CHECKS = ["security", "ssh", "autologin", "secureboot", "luks"];
function isSecurityPosture(checkId) { return SECURITY_POSTURE_CHECKS.includes(checkId); }
function securityPostureFindings(findings) {
  return (findings || []).filter((f) => isSecurityPosture(f.check));
}
/* How findings are grouped in the report: "severity" | "category". */
let groupBy = "severity";
/* check id → category, filled from /api/checks before the first render. */
let checksCategoryMap = new Map();


/* ── api.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Data fetching === */
/* Desktop detection must not rely on the injected Tauri global alone: on this
   stack (wry + WebKitGTK) it has been reported missing, and the page is served
   from tauri://localhost regardless. Any of these signals is enough. */
const isDesktop = () => {
  if (typeof window === "undefined") return false;
  if (window.__TAURI_INTERNALS__ || window.__TAURI__) return true;
  try {
    return location.protocol === "tauri:" || location.hostname === "tauri.localhost";
  } catch {
    return false;
  }
};
const STATIC_DATA = typeof window !== "undefined" && window.__DATA__ ? window.__DATA__ : null;

/* Ask the desktop shell's loopback service. Its errors carry the real reason
   (missing CLI, bad resource copy) — surface them, never swallow them. */
async function desktopFetch(path, q = "") {
  let res;
  try {
    res = await fetch("http://127.0.0.1:17321" + path + q, { cache: "no-store" });
  } catch {
    throw new Error("the Linux Doctor service is not reachable on 127.0.0.1:17321 (is the app still starting?)");
  }
  if (!res.ok) {
    const body = await res.text().catch(() => "");
    let msg = body;
    try { msg = JSON.parse(body).error || body; } catch {}
    throw new Error(msg || `report service error (HTTP ${res.status})`);
  }
  return res.json();
}

/* refresh=true forces a fresh scan; save=true records the run in history.
   Background polling passes neither (the servers serve a short cached report,
   and machine history only advances on an explicit run). */
async function fetchReport({ refresh = false, save = false } = {}) {
  if (STATIC_DATA) return STATIC_DATA;
  const p = new URLSearchParams();
  if (refresh) p.set("refresh", "1");
  if (save) p.set("save", "1");
  const q = p.toString() ? `?${p}` : "";
  if (isDesktop()) return desktopFetch("/report", q);
  const res = await fetch("/api/report" + q, { cache: "no-store" });
  if (!res.ok) throw new Error("HTTP " + res.status);
  return res.json();
}

async function fetchHistory() {
  if (STATIC_DATA) return [];
  if (isDesktop()) {
    try {
      const res = await fetch("http://127.0.0.1:17321/history", { cache: "no-store" });
      if (res.ok) return (await res.json()).runs || [];
    } catch {}
  }
  try {
    const res = await fetch("/api/history", { cache: "no-store" });
    if (res.ok) return (await res.json()).runs || [];
  } catch {}
  return [];
}

async function fetchChecks() {
  if (STATIC_DATA) return [];
  if (isDesktop()) {
    try {
      const res = await fetch("http://127.0.0.1:17321/checks", { cache: "no-store" });
      if (res.ok) return (await res.json()).checks || [];
    } catch {}
  }
  try {
    const res = await fetch("/api/checks", { cache: "no-store" });
    if (res.ok) return (await res.json()).checks || [];
  } catch {}
  return [];
}

/* check id → category map, fetched once and reused across renders.
   Fills the shared checksCategoryMap; never throws. */
async function loadCategoryMap() {
  try {
    const list = await fetchChecks();
    checksCategoryMap = new Map(list.map((c) => [c.id, c.category || "other"]));
  } catch {
    checksCategoryMap = new Map();
  }
  return checksCategoryMap;
}

/* Ignore list + history clearing. The desktop shell's loopback service answers
   the same shapes on 17321; in the browser the dashboard serves them directly.
   Both are best-effort: a missing route leaves the section empty, never throws. */
async function fetchIgnore() {
  if (STATIC_DATA) return { patterns: [], codes: [], path: "" };
  if (isDesktop()) {
    try {
      const res = await fetch("http://127.0.0.1:17321/api/ignore", { cache: "no-store" });
      if (res.ok) return await res.json();
    } catch {}
  }
  try {
    const res = await fetch("/api/ignore", { cache: "no-store" });
    if (res.ok) return await res.json();
  } catch {}
  return { patterns: [], codes: [], path: "" };
}

async function postJson(url, payload) {
  try {
    const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
    return res.ok && (await res.json()).ok !== false;
  } catch {
    return false;
  }
}

async function editIgnore(payload) {
  if (isDesktop() && await postJson("http://127.0.0.1:17321/api/ignore", payload)) return true;
  return postJson("/api/ignore", payload);
}

async function clearHistoryApi() {
  const tryPost = async (url) => {
    try {
      const res = await fetch(url, { method: "POST" });
      return res.ok && (await res.json()).ok !== false;
    } catch {
      return false;
    }
  };
  if (isDesktop() && await tryPost("http://127.0.0.1:17321/api/history/clear")) return true;
  return tryPost("/api/history/clear");
}


/* ── clipboard.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Clipboard === */
async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.select();
    let ok = false;
    try { ok = document.execCommand("copy"); } catch { ok = false; }
    ta.remove();
    return ok;
  }
}


/* ── ui-toast.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Toast notifications === */
function showToast(html, ms = 3000) {
  const wrap = $("#toast-wrap");
  if (!wrap) return;
  const el = document.createElement("div");
  el.className = "toast";
  el.innerHTML = html;
  wrap.appendChild(el);
  setTimeout(() => { el.style.opacity = "0"; el.style.transform = "translateY(8px)"; setTimeout(() => el.remove(), 260); }, ms);
  return el;
}


/* ── ui-modal.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Shared modal shell === */
let modalOpener = null;

function focusableInModal(modal) {
  return [...modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')]
    .filter((el) => !el.disabled && el.getClientRects().length > 0);
}

function openModal(html, label) {
  const modal = $("#modal");
  const body = $("#modal-body");
  if (!modal || !body) return;
  modalOpener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
  if (label) modal.setAttribute("aria-label", label);
  body.innerHTML = html;
  modal.hidden = false;
  $("#modal-x")?.focus();
}

function closeModal() {
  const modal = $("#modal");
  if (modal && !modal.hidden) modal.hidden = true;
  if (modalOpener && document.contains(modalOpener)) modalOpener.focus();
  modalOpener = null;
}

function isModalOpen() {
  const modal = $("#modal");
  return !!modal && !modal.hidden;
}

function setupModal() {
  const modal = $("#modal");
  if (!modal) return;
  // Backdrop click closes; clicks inside the card do not.
  modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); });
  $("#modal-x")?.addEventListener("click", closeModal);
  // Keep Tab inside the dialog while it is open.
  modal.addEventListener("keydown", (e) => {
    if (e.key !== "Tab") return;
    const items = focusableInModal(modal);
    if (!items.length) return;
    const first = items[0];
    const last = items[items.length - 1];
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  });
}

/* Keyboard shortcuts help (?). */
function openHelp() {
  openModal(
    '<div class="mx-head">Keyboard shortcuts</div>' +
    '<div class="mx-sub">Everything works with the mouse too.</div>' +
    '<div class="help-grid">' +
    "<div><kbd>\u2191</kbd> <kbd>\u2193</kbd></div><div>Navigate findings</div>" +
    "<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Open or close a finding</div>" +
    "<div><kbd>Ctrl</kbd> <kbd>K</kbd></div><div>Open the command palette</div>" +
    "<div><kbd>/</kbd></div><div>Focus the search box</div>" +
    "<div><kbd>1</kbd>–<kbd>5</kbd></div><div>Overview · History · Checks · System · Schedule views</div>" +
    "<div><kbd>Esc</kbd></div><div>Close this dialog \u00b7 clear search &amp; filters \u00b7 close thresholds</div>" +
    '<div><kbd>F11</kbd></div><div>Fullscreen (desktop app; the browser has its own)</div>' +
    '<div><kbd>?</kbd></div><div>Show this help</div>' +
    "</div>", "Keyboard shortcuts");
}


/* ── ui-theme.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Theme toggle === */
function currentTheme() { try { return localStorage.getItem("ld-theme") || "auto"; } catch { return "auto"; } }
/* Theme button icon: crescent for dark, sun for light — each wrapped in the
   .ico pattern so the emoji shows only if inline SVG ever fails to render. */
const THEME_ICON_MOON = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg><span class="fb">🌙</span>';
const THEME_ICON_SUN  = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><circle cx="12" cy="12" r="4.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5 5l2.1 2.1M16.9 16.9 19 19M19 5l-2.1 2.1M7.1 16.9 5 19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg><span class="fb">☀️</span>';

const THEME_ICON_TERM = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M4 5l7 7-7 7M12 19h8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="fb">&gt;_</span>';

/* Browser chrome (mobile address bar, PWA titlebar) follows the theme. */
const THEME_BG = { dark: "#080a11", light: "#f4f6fa", terminal: "#060906" };

function applyTheme() {
  const t = currentTheme();
  const theme = t === "terminal" ? "terminal"
    : t === "dark" || (t === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark"
    : "light";
  document.documentElement.setAttribute("data-theme", theme);
  const meta = document.querySelector('meta[name="theme-color"]');
  if (meta) meta.setAttribute("content", THEME_BG[theme] || THEME_BG.dark);
  const btn = $("#theme");
  if (btn) {
    btn.innerHTML = '<span class="ico" aria-hidden="true">' + (theme === "terminal" ? THEME_ICON_TERM : theme === "dark" ? THEME_ICON_MOON : THEME_ICON_SUN) + "</span>";
    btn.title = "Theme: " + t + " \u2014 click to change";
  }
}
function cycleTheme() {
  const next = THEME_ORDER[(THEME_ORDER.indexOf(currentTheme()) + 1) % THEME_ORDER.length];
  try { localStorage.setItem("ld-theme", next); } catch {}
  applyTheme();
}


/* ── ui-keyboard.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Keyboard navigation === */
function setupKeyboard() {
  document.addEventListener("keydown", (e) => {
    const typing = /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "");

    // F11: fullscreen, desktop app only. In a browser the key never reaches the
    // page (the browser keeps it), so `--web` users use the browser's own
    // fullscreen and this branch is simply absent for them.
    if (e.key === "F11") {
      const win = window.__TAURI__?.window?.getCurrentWindow?.();
      if (win) {
        e.preventDefault();
        win.isFullscreen().then((fs) => win.setFullscreen(!fs)).catch(() => {});
      }
      return;
    }

    // Ctrl/Cmd+K: the command palette (Linear's signature keystroke).
    if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
      e.preventDefault();
      if (isPaletteOpen()) closePalette(); else openPalette();
      return;
    }

    // "/" focuses search (unless the user is already typing somewhere).
    if (e.key === "/" && !typing) {
      e.preventDefault();
      $("#search")?.focus();
      return;
    }

    // "?" opens the shortcuts help (Shift+/ — same key without typing context).
    if (e.key === "?" && !typing) {
      e.preventDefault();
      openHelp();
      return;
    }

    // Escape: close the palette, then the modal, then the thresholds panel,
    // else clear search+filter.
    if (e.key === "Escape") {
      if (isPaletteOpen()) { closePalette(); return; }
      if (isModalOpen()) { closeModal(); return; }
      const panel = $("#threshpanel");
      if (panel && !panel.hidden) { panel.hidden = true; return; }
      if (!typing && (activeFilter !== "all" || $("#search").value)) {
        activeFilter = "all";
        $("#search").value = "";
        document.querySelectorAll(".fpill").forEach((b) => b.classList.toggle("active", b.dataset.sev === "all"));
        syncGroupsOpen();
        applyFilters();
      }
      return;
    }

    // "1..5" jump between app views (not while typing).
    if ((e.key >= "1" && e.key <= "5") && !typing) {
      e.preventDefault();
      switchView(["overview", "history", "checks", "system", "schedule"][Number(e.key) - 1]);
      return;
    }

    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      // Card navigation belongs to Overview — never steal focus from a
      // hidden report while the user reads History or Checks.
      if (document.getElementById("view-overview")?.hidden) return;
      const cards = [...document.querySelectorAll("#report .card, #report .crow")];
      if (!cards.length) return;
      let idx = cards.indexOf(document.activeElement);
      if (e.key === "ArrowDown") idx = idx < 0 ? 0 : Math.min(cards.length - 1, idx + 1);
      else idx = idx < 0 ? 0 : Math.max(0, idx - 1);
      e.preventDefault();
      cards[idx].focus({ preventScroll: true });
      cards[idx].scrollIntoView({ block: "nearest" });
    } else if ((e.key === "Enter" || e.key === " ") && document.activeElement && (document.activeElement.classList.contains("card") || document.activeElement.classList.contains("crow"))) {
      const el = document.activeElement;
      // Wide desktop: Enter selects into the detail pane instead of
      // expanding inline — same key, the pane is the expansion.
      if (wideModeOn() && el.classList.contains("card") && el.closest("#report")) {
        e.preventDefault();
        selectFinding(el);
        return;
      }
      if (el.open) el.removeAttribute("open");
      else el.setAttribute("open", "");
      e.preventDefault();
    }
  });
}

function setAllOpen(open) {
  document.querySelectorAll("#report .group, #report .card, #report .crow").forEach((el) => {
    if (open) el.setAttribute("open", "");
    else el.removeAttribute("open");
  });
  const btn = $("#expandall");
  if (btn) btn.textContent = open ? "\u229f Collapse all" : "\u229e Expand all";
}


/* ── ui-palette.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Command palette (Ctrl/Cmd+K) ===
   One keystroke to jump anywhere: views, actions and findings, filtered as
   you type. Arrow keys move, Enter runs, Esc closes. Linear's signature,
   wired to the same functions the toolbar already calls. */
let paletteItems = [];
let paletteSel = 0;

function isPaletteOpen() {
  const el = $("#palette");
  return !!el && !el.hidden;
}

/* Every entry is { group, label, hint, run }. Findings come from the report
   that is already in memory; nothing extra is fetched. */
function paletteCommands() {
  const cmds = [];
  for (const v of VIEWS) {
    const label = v.charAt(0).toUpperCase() + v.slice(1);
    cmds.push({ group: "Views", label, hint: "Open", run: () => switchView(v) });
  }
  cmds.push({ group: "Actions", label: "Re-run checks", run: () => $("#rerun")?.click() });
  cmds.push({ group: "Actions", label: "Toggle theme", run: () => $("#theme")?.click() });
  cmds.push({ group: "Actions", label: "Expand all findings", run: () => setAllOpen(true) });
  cmds.push({ group: "Actions", label: "Collapse all findings", run: () => setAllOpen(false) });
  cmds.push({ group: "Actions", label: "Toggle compact density", run: () => $("#densitybtn")?.click() });
  cmds.push({ group: "Actions", label: "Open the all-checks matrix", run: () => $("#checksmatrixbtn")?.click() });
  cmds.push({ group: "Actions", label: "Edit thresholds", run: () => $("#threshbtn")?.click() });
  cmds.push({ group: "Actions", label: "Export report", run: () => $("#export-trigger")?.click() });
  cmds.push({ group: "Actions", label: "Keyboard shortcuts", run: () => openHelp() });

  const findings = (typeof lastReportData !== "undefined" && lastReportData && lastReportData.findings) || [];
  for (const f of findings) {
    cmds.push({
      group: "Findings",
      label: f.title || f.code || "Finding",
      hint: (f.code ? f.code + " · " : "") + (SEV_NAMES[f.severity] || f.severity || ""),
      run: () => jumpToFinding(f),
    });
  }
  return cmds;
}

function jumpToFinding(f) {
  switchView("overview");
  const el = [...document.querySelectorAll("#report .card")].find((c) => (c.dataset.code || "") === (f.code || ""));
  if (!el) return;
  el.setAttribute("open", "");
  if (typeof wideModeOn === "function" && wideModeOn() && typeof selectFinding === "function") selectFinding(el);
  el.scrollIntoView({ block: "center" });
  el.focus({ preventScroll: true });
}

function paletteFilter(q) {
  const all = paletteCommands();
  const needle = q.trim().toLowerCase();
  if (!needle) return all.slice(0, 40);
  const out = [];
  for (const c of all) {
    const hay = (c.label + " " + (c.hint || "") + " " + c.group).toLowerCase();
    if (hay.includes(needle)) out.push(c);
  }
  return out.slice(0, 40);
}

function renderPalette(q) {
  paletteItems = paletteFilter(q);
  if (paletteSel >= paletteItems.length) paletteSel = 0;
  const list = $("#palette-list");
  if (!list) return;
  if (!paletteItems.length) {
    list.innerHTML = '<div class="pal-empty">Nothing matches \u201c' + esc(q.trim()) + "\u201d</div>";
    return;
  }
  let html = "";
  let last = "";
  paletteItems.forEach((c, i) => {
    if (c.group !== last) { html += '<div class="pal-group">' + esc(c.group) + "</div>"; last = c.group; }
    html += '<button type="button" class="pal-item' + (i === paletteSel ? " on" : "") + '" data-i="' + i + '" role="option">' +
      '<span class="pal-label">' + esc(c.label) + "</span>" +
      (c.hint ? '<span class="pal-hint">' + esc(c.hint) + "</span>" : "") +
      "</button>";
  });
  list.innerHTML = html;
}

function movePalette(d) {
  if (!paletteItems.length) return;
  paletteSel = (paletteSel + d + paletteItems.length) % paletteItems.length;
  const list = $("#palette-list");
  list.querySelectorAll(".pal-item").forEach((el, i) => el.classList.toggle("on", i === paletteSel));
  list.querySelector(".pal-item.on")?.scrollIntoView({ block: "nearest" });
}

function runPalette(i) {
  const cmd = paletteItems[i == null ? paletteSel : i];
  closePalette();
  if (cmd) setTimeout(() => cmd.run(), 0);
}

function openPalette() {
  const el = $("#palette");
  if (!el) return;
  el.hidden = false;
  paletteSel = 0;
  const input = $("#palette-input");
  if (input) input.value = "";
  renderPalette("");
  input?.focus();
}

function closePalette() {
  const el = $("#palette");
  if (el) el.hidden = true;
}

function setupPalette() {
  const el = $("#palette");
  if (!el) return;
  el.addEventListener("click", (e) => { if (e.target === el) closePalette(); });
  const input = $("#palette-input");
  const list = $("#palette-list");
  input?.addEventListener("input", (e) => { paletteSel = 0; renderPalette(e.target.value); });
  input?.addEventListener("keydown", (e) => {
    if (e.key === "ArrowDown") { e.preventDefault(); movePalette(1); }
    else if (e.key === "ArrowUp") { e.preventDefault(); movePalette(-1); }
    else if (e.key === "Enter") { e.preventDefault(); runPalette(); }
    else if (e.key === "Escape") { e.preventDefault(); closePalette(); }
  });
  list?.addEventListener("click", (e) => {
    const item = e.target.closest(".pal-item");
    if (item) runPalette(Number(item.dataset.i));
  });
}


/* ── ui-filters.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Severity filters + search === */
let activeFilter = "all";

const PILL_ICONS = { all: ICON_ALL, high: SEV_ICONS.high, medium: SEV_ICONS.medium, info: SEV_ICONS.info };

function renderFilters(counts) {
  const total = counts.high + counts.medium + counts.info;
  const defs = [
    { sev: "all", label: "All" + (total ? " (" + total + ")" : ""), icon: PILL_ICONS.all },
    { sev: "high", label: "High" + (counts.high ? " (" + counts.high + ")" : ""), icon: PILL_ICONS.high },
    { sev: "medium", label: "Medium" + (counts.medium ? " (" + counts.medium + ")" : ""), icon: PILL_ICONS.medium },
    { sev: "info", label: "Info" + (counts.info ? " (" + counts.info + ")" : ""), icon: PILL_ICONS.info },
  ];
  const empty = (sev) => sev !== "all" && (counts[sev] || 0) === 0;
  if (empty(activeFilter)) activeFilter = "all";
  $("#filters").innerHTML = defs.map((d) =>
    '<button class="fpill ' + d.sev + (activeFilter === d.sev ? " active" : "") + '" data-sev="' + d.sev + '"' +
    (empty(d.sev) ? " disabled" : "") + '><span class="pill-icon" aria-hidden="true">' + d.icon + '</span> ' + d.label + '</button>'
  ).join("");
  syncClear();
}

let filterTimer = null;
function applyFilters() {
  // Debounce: typing "network" shouldn't re-layout 40 cards 7 times
  if (filterTimer) clearTimeout(filterTimer);
  filterTimer = setTimeout(() => applyFiltersNow(), 60);
}
function applyFiltersNow() {
  const report = $("#report");
  const hint = $("#drillhint");
  report.hidden = !activeFilter;
  hint.hidden = !!activeFilter;
  if (activeFilter) {
    hint.textContent = "";
  } else {
    hint.textContent = groupBy === "category"
      ? "Select a category to see findings."
      : "Select a severity to see findings.";
  }
  const qRaw = $("#search").value.trim();
  const q = qRaw.toLowerCase();
  // code: prefix → filter by stable code only (e.g. "code:network/" or "code:disk/full")
  const codeFilter = q.startsWith("code:") ? q.slice(5).trim() : null;
  let visible = 0;
  document.querySelectorAll("#report .group").forEach((g) => {
    let any = false;
    g.querySelectorAll(".card, .crow").forEach((c) => {
      const sevOk = activeFilter === "all" || c.classList.contains(activeFilter);
      let match = sevOk;
      if (match && q) {
        if (codeFilter !== null) {
          const code = (c.dataset.code || "").toLowerCase();
          match = code.includes(codeFilter);
        } else {
          match = c.textContent.toLowerCase().includes(q);
        }
      }
      c.style.display = match ? "" : "none";
      if (match) {
        any = true;
        highlightMatches(c, codeFilter !== null ? "" : qRaw);
      } else {
        highlightMatches(c, "");
      }
    });
    if (!qRaw) g.querySelectorAll(".card, .crow").forEach(c => highlightMatches(c, ""));
    g.hidden = !any;
    if (any) visible++;
  });
  const nomatch = $("#nomatch");
  nomatch.hidden = !(activeFilter && qRaw && visible === 0);
  if (!nomatch.hidden) {
    nomatch.innerHTML = '<div class="empty-big">No findings match \u201c' + esc(qRaw) + '\u201d</div>' +
      '<button type="button" class="empty-cta" data-clear-filters>Clear filters</button>';
  }
  // Wide: if the pane's selection was filtered out, pick the next visible
  // one; while it stays visible the pane is left alone (no scroll churn).
  if (typeof wideModeOn === "function" && wideModeOn()) {
    const sel = document.querySelector("#report details.card.selected");
    if (!sel || sel.style.display === "none") autoSelectFinding();
  }
  syncClear();
  syncAutoPausedUI();
  syncUrlState();
}

function syncClear() {
  const clear = $("#clearbtn");
  const hasFilter = activeFilter && activeFilter !== "all";
  const hasSearch = $("#search").value.trim().length > 0;
  clear.disabled = !(hasFilter || hasSearch);
}

function highlightMatches(card, q) {
  card.querySelectorAll("mark.hl").forEach(m => {
    const t = document.createTextNode(m.textContent);
    m.replaceWith(t);
  });
  card.normalize();
  if (!q) return;
  const terms = q.trim().toLowerCase().split(/\s+/).filter(Boolean);
  if (!terms.length) return;
  const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT, {
    acceptNode(n) {
      if (!n.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
      if (n.parentElement && n.parentElement.closest("button, a")) return NodeFilter.FILTER_REJECT;
      return NodeFilter.FILTER_ACCEPT;
    }
  });
  const nodes = [];
  while (walker.nextNode()) nodes.push(walker.currentNode);
  const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const re = new RegExp("(" + terms.map(escRe).join("|") + ")", "gi");
  for (const n of nodes) {
    if (!re.test(n.nodeValue)) continue;
    re.lastIndex = 0;
    const frag = document.createDocumentFragment();
    let last = 0, m;
    const txt = n.nodeValue;
    while ((m = re.exec(txt)) !== null) {
      if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
      const mark = document.createElement("mark");
      mark.className = "hl";
      mark.textContent = m[0];
      frag.appendChild(mark);
      last = m.index + m[0].length;
    }
    if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last, txt.length)));
    n.replaceWith(frag);
  }
}

function syncGroupsOpen() {
  // Wide mode is a workbench: every group stays open, filtering never
  // collapses data the user can see at a glance. (wideModeOn is hoisted
  // from ui-wide.js; guarded for the headless sandbox.)
  const wideEl = typeof document !== "undefined" ? document.documentElement : null;
  if (wideEl && wideEl.classList && wideEl.classList.contains("wide")) {
    document.querySelectorAll("#report .group").forEach((g) => g.setAttribute("open", ""));
    syncAutoPausedUI();
    return;
  }
  // Severity mode: filtering reveals the group — open it so findings are
  // visible right away. On "All", only High opens (progressive disclosure:
  // Informational can hold dozens of items); a specific severity opens its
  // own group. Category mode: leave expansion to the user.
  document.querySelectorAll("#report .group").forEach((g) => {
    if (g.dataset.type === "cat") return;
    g.open = activeFilter === g.dataset.key || (activeFilter === "all" && g.dataset.key === "high");
  });
  syncAutoPausedUI();
}


/* ── ui-polling.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Auto-refresh polling === */
let autoRefresh = true;
let pollTimer = null;
let pollBusy = false;

function reportSignature(d) {
  if (!d) return "";
  return JSON.stringify([d.generatedAt || "", d.score || 0, d.scoreDelta || 0, d.findings || [], d.diffSinceLast || null]);
}

function pollPaused() {
  if (!autoRefresh || document.hidden || pollBusy) return true;
  if ($("#rerun").disabled) return true;
  if ($("#search").value.trim()) return true;
  if (activeFilter && activeFilter !== "all") return true;
  // Only cards the user can actually see pause the refresh — an open card
  // in a hidden Overview must not freeze updates forever.
  if (document.getElementById("view-overview")?.hidden) return false;
  return document.querySelectorAll("#report details[open]").length > 0;
}

function syncAutoPausedUI() {
  const autoBtn = $("#autorefresh");
  if (!autoBtn) return;
  const paused = pollPaused();
  autoBtn.classList.toggle("paused", paused && autoRefresh);
  if (!autoRefresh) autoBtn.title = "Auto-refresh off \u2014 click to enable";
  else if (paused) autoBtn.title = "Paused \u2014 clear filter/search or collapse cards to resume";
  else autoBtn.title = "Auto-refresh every 5s";
  autoBtn.setAttribute("aria-pressed", String(autoRefresh));
  autoBtn.setAttribute("aria-label", autoBtn.title);
}

async function poll() {
  if (pollPaused()) return;
  pollBusy = true;
  try {
    const data = await fetchReport();
    if (reportSignature(data) !== reportSignature(lastData)) {
      render(data);
      // Quiet confirmation that the background refresh changed something —
      // a green pulse on the Auto button, gone in ~1.5s. No toast noise.
      const btn = $("#autorefresh");
      if (btn) {
        btn.classList.add("updated");
        setTimeout(() => btn.classList.remove("updated"), 1600);
      }
    }
  } catch {}
  finally { pollBusy = false; }
}

function startPolling() {
  clearInterval(pollTimer);
  pollTimer = setInterval(poll, POLL_MS);
}

function setupPolling() {
  const autoBtn = $("#autorefresh");
  if (autoBtn) {
    autoBtn.setAttribute("aria-pressed", String(autoRefresh));
    autoBtn.addEventListener("click", () => {
      autoRefresh = !autoRefresh;
      autoBtn.classList.toggle("on", autoRefresh);
      syncAutoPausedUI();
      showToast(autoRefresh ? "Auto-refresh on" : "Auto-refresh off");
    });
  }
  setInterval(syncAutoPausedUI, 1000);
  document.addEventListener("toggle", (e) => {
    if (e.target.matches("#report details")) syncAutoPausedUI();
    if (e.target.classList.contains("ev") && e.target.open) {
      const ev = e.target.querySelector(".evidence");
      const btn = e.target.querySelector(".ev-expand");
      if (ev && btn && ev.scrollHeight > 228) btn.hidden = false;
    }
  }, true);
  document.addEventListener("visibilitychange", () => {
    if (document.hidden) { clearInterval(pollTimer); }
    else { startPolling(); }
  });
}


/* ── ui-notify.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Browser desktop notifications === */
function maybeNotify(data) {
  try {
    if (!("Notification" in window)) return;
    if (Notification.permission !== "granted") return;
    const hasHigh = (data.findings || []).some(f => f.severity === "high");
    const newHigh = ((data.diffSinceLast && data.diffSinceLast.added) || []).some(f => f.severity === "high" || f.severity === "medium");
    const degraded = (typeof data.scoreDelta === "number" && data.scoreDelta < 0 && data.score < 80) || (hasHigh && newHigh);
    if (!degraded) return;
    const key = "ld-notify-" + data.score + "-" + (data.generatedAt || "");
    const last = localStorage.getItem(key);
    if (last) return;
    localStorage.setItem(key, "1");
    setTimeout(() => localStorage.removeItem(key), 10 * 60 * 1000);
    const title = hasHigh ? "Linux Doctor: health degraded (" + data.score + "/100)" : "Linux Doctor: new findings";
    const body = (data.changeMessage || (data.newCount ? data.newCount + " new \u00b7 " + data.fixedCount + " fixed" : "")) + " \u2014 open dashboard for details";
    new Notification(title, { body, icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>\u{1fa7a}</text></svg>" });
  } catch {}
}

const BELL_ICON = '<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true"><path d="M12 3a6 6 0 0 0-6 6v3.5L4.5 16h15L18 12.5V9a6 6 0 0 0-6-6Zm-2 15a2 2 0 0 0 4 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="fb">🔔</span>';

function setupNotifyButton() {
  const notifyBtn = $("#notifybtn");
  if (!notifyBtn) return;
  const syncNotifyBtn = () => {
    if (!("Notification" in window)) { notifyBtn.style.display = "none"; return; }
    notifyBtn.innerHTML = '<span class="ico" aria-hidden="true">' + BELL_ICON + "</span>" +
      (Notification.permission === "granted" ? '<span style="font-size:10px;font-weight:800">\u2713</span>' : "");
    notifyBtn.title = Notification.permission === "granted" ? "Desktop notifications are enabled" : "Enable desktop notifications when health degrades";
    notifyBtn.classList.toggle("on", Notification.permission === "granted");
  };
  syncNotifyBtn();
  notifyBtn.addEventListener("click", async () => {
    if (!("Notification" in window)) { showToast("Notifications not supported in this browser"); return; }
    if (Notification.permission === "granted") { showToast("Notifications already enabled"); return; }
    const perm = await Notification.requestPermission();
    syncNotifyBtn();
    showToast(perm === "granted" ? "Notifications enabled" : "Notifications not granted");
    if (perm === "granted" && lastData) maybeNotify(lastData);
  });
}


/* ── ui-schedule.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Schedule strip: is the daily check actually scheduled? ===
   The dashboard used to know only "Re-run now". This strip answers the
   follow-up question — will the machine check itself tomorrow? Read-only:
   installing stays a CLI decision; the strip shows state plus the exact
   command to copy. Hidden when the backend has no schedule to report
   (static --html exports, unreachable API). */

async function fetchSchedule() {
  if (STATIC_DATA) return null;
  try {
    const res = await fetch("/api/schedule", { cache: "no-store" });
    if (!res.ok) return null;
    return (await res.json()).schedule || null;
  } catch {
    return null;
  }
}

function browserNotifyState() {
  try {
    if (!("Notification" in window)) return "unavailable";
    if (Notification.permission === "granted") return "on";
    return "off";
  } catch {
    return "unavailable";
  }
}

/* Pure (string in, string out) so dashboard-state.test.js can pin it. */
function scheduleHtml(s, notify) {
  if (!s) return "";
  const dot = (on) => '<span class="dot' + (on ? "" : " stale") + '" aria-hidden="true"></span>';
  // Dot and label wrap as one unit — a separator stranded at line end reads
  // as a dangling bullet on narrow screens.
  const notifyBit = notify === "unavailable" ? ""
    : '<span class="sched-note"><span class="sched-sep" aria-hidden="true">·</span><span>' + dot(notify === "on") +
      "Browser alerts " + (notify === "on" ? "on" : "off") + "</span></span>";
  if (!s.installed) {
    return dot(false) + "<span><b>Daily check off</b> — install the timer and the machine notifies you only when something new breaks.</span>" +
      '<button class="toolbtn" data-copy="linux-doctor --install-timer">Copy setup command</button>' + notifyBit;
  }
  if (s.active) {
    return dot(true) + "<span><b>Daily check on</b> — runs after boot, then daily · notifies only on new findings.</span>" + notifyBit;
  }
  return dot(false) + "<span><b>Daily check needs attention</b> — timer units exist but the timer isn't active. Re-run <span class=\"mono\">linux-doctor --install-timer</span>.</span>" +
    '<button class="toolbtn" data-copy="linux-doctor --install-timer">Copy setup command</button>' + notifyBit;
}

/* The shared notices row is visible when at least one notice is. Both
   renderers call this after updating, so async schedule fetches and sync
   posture renders converge on the same visibility. */
function syncNotices() {
  const wrap = $("#notices");
  if (!wrap) return;
  const kids = [...wrap.children];
  wrap.hidden = kids.length === 0 || kids.every((k) => k.hidden);
  syncNoticeGrid();
}

function renderSchedule(s) {
  const el = $("#schedstrip");
  if (!el) return;
  const html = scheduleHtml(s, browserNotifyState());
  if (!html) { el.hidden = true; el.innerHTML = ""; }
  else { el.hidden = false; el.innerHTML = html; }
  syncNotices();
}

/* Schedule view: the full picture — last run, next run, timer state, and the
   exact commands as recipes. Pure builders kept separate for tests. */

/* Coarse relative freshness for the "last run" cell. */
function schedAgo(iso) {
  const t = Date.parse(iso || "");
  if (!Number.isFinite(t)) return "\u2014";
  const s = Math.max(0, Math.round((Date.now() - t) / 1000));
  if (s < 60) return "just now";
  if (s < 3600) return Math.floor(s / 60) + "m ago";
  if (s < 86400) return Math.floor(s / 3600) + "h ago";
  return Math.floor(s / 86400) + "d ago";
}

/* The next run is read as a distance first ("in 24h") and a date second —
   the same reading order as "just now" for the last one. */
function schedAhead(ms) {
  if (!Number.isFinite(ms)) return "\u2014";
  const h = Math.round((ms - Date.now()) / 3600000);
  if (h <= 0) return "any moment";
  if (h < 48) return "in " + h + "h";
  return "in " + Math.round(h / 24) + "d";
}

/* The command IS the information, so it is printed, not hidden behind a
   button: an accent-ruled mono slip — the same recipe language as the
   findings' Recommended next step — with a quiet copy next to it. */
function recipeHtml(cmd, primary) {
  return '<div class="recipe"><code class="recipe-cmd">' + esc(cmd) + "</code>" +
    '<button class="recipe-copy' + (primary ? " primary" : "") + '" type="button" data-copy="' + esc(cmd) + '" title="Copy this command">Copy</button></div>';
}

function scheduleViewHtml(s, notify, generatedAt) {
  const notifyBit = notify === "unavailable" ? ""
    : '<span class="sched-note"><span class="sched-sep" aria-hidden="true">\u00b7</span><span>Browser alerts ' + (notify === "on" ? "on" : "off") + "</span></span>";
  if (!s) {
    return '<section class="vsec"><div class="vsec-head">Scheduled checks</div>' +
      '<div class="sched-none">Schedule state is unavailable (static export or unreachable API).</div></section>';
  }
  const on = !!s.active;
  const needs = s.installed && !s.active;
  const state = on ? "on" : needs ? "needs attention" : "off";
  const lastMs = Date.parse(generatedAt || "");
  const nextTxt = on && Number.isFinite(lastMs)
    ? schedAhead(lastMs + 86400000) + " \u00b7 " + fmtWhen(new Date(lastMs + 86400000).toISOString())
    : "\u2014";
  const nextTitle = "The installed timer runs 24h after its last activation; Persistent catches up runs missed while the machine was off.";
  /* The state band is the instrument: times are mono and tabular so they
     read as readings, not prose. "Nothing to do" is the instrument's own
     footer — the outcome of the three readings above it, not a sentence
     floating in the empty half of the view. */
  const idle = on
    ? '<div class="sched-idle"><span class="sched-idle-mark" aria-hidden="true"></span>' +
      "<span>Nothing to do \u2014 the timer runs the checks by itself and speaks up only when something new breaks.</span></div>"
    : "";
  const instr =
    '<div class="sched-instr">' +
      '<div class="si"><span class="si-k">Last run</span><span class="si-v si-vnum">' + esc(schedAgo(generatedAt)) + "</span></div>" +
      '<div class="si"><span class="si-k">Next run</span><span class="si-v si-vnum" title="' + esc(nextTitle) + '">' + esc(nextTxt) + "</span></div>" +
      '<div class="si"><span class="si-k">Timer</span><span class="si-v"><span class="dot' + (on ? "" : " stale") + '" aria-hidden="true"></span>' + esc(state) + "</span></div>" +
      idle +
    "</div>" +
    '<div class="sched-cap">' +
      (on ? "runs" : needs ? "once active, it runs" : "installing makes it run") + " shortly after boot, then every 24h \u00b7 Persistent \u2014 catches up runs missed while the machine was off" +
      notifyBit + "</div>";
  const setup = on ? "" :
    '<section class="vsec sched-setup"><div class="vsec-head">Set it up</div>' + recipeHtml("linux-doctor --install-timer", true) + "</section>";
  const manage = '<section class="vsec"><div class="vsec-head">Manage</div>' +
    recipeHtml("linux-doctor --install-timer") +
    recipeHtml("linux-doctor --uninstall-timer") + "</section>";
  const pro = '<section class="vsec"><div class="vsec-head">Remote delivery <span class="vsec-sub">[Pro]</span></div>' +
    recipeHtml("linux-doctor --alert https://ntfy.sh/your-topic") +
    recipeHtml("linux-doctor --heartbeat https://hc-ping.com/your-uuid") + "</section>";
  return '<section class="vsec"><div class="vsec-head">Scheduled checks</div>' + instr + "</section>" +
    setup + '<div class="sched-cmds">' + manage + pro + "</div>";
}

async function renderScheduleView() {
  const box = $("#schedview");
  if (!box) return;
  box.innerHTML = scheduleViewHtml(await fetchSchedule(), browserNotifyState(), (lastReportData || {}).generatedAt);
}

function refreshSchedule() {
  fetchSchedule().then(renderSchedule);
}


/* ── ui-thresholds.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Threshold editor panel === */
let threshData = null;

async function loadThresh() {
  try {
    const r = await fetch("/api/thresholds", { cache: "no-store" });
    if (!r.ok) throw new Error("" + r.status);
    const j = await r.json();
    threshData = j.thresholds || j.defaults || {};
    const defs = j.defaults || {};
    const fields = [
      ["diskFullPct", "Disk full %", "90"], ["diskWarnPct", "Disk warn %", "80"],
      ["memLowRatio", "Mem low ratio", "0.15"], ["memWarnRatio", "Mem warn ratio", "0.25"],
      ["loadWarnRatio", "Load warn", "0.7"], ["loadHighRatio", "Load high", "1.0"], ["loadCriticalRatio", "Load crit", "1.5"],
      ["tempWarnC", "Temp warn \u00b0C", "85"], ["tempHotC", "Temp hot \u00b0C", "95"],
      ["procWarnRatio", "Proc warn", "0.2"], ["procHighRatio", "Proc high", "0.4"],
      ["journalWarnBytes", "Journal bytes", "2147483648"], ["containerWarnGB", "Cont. warn GB", "20"], ["containerHighGB", "Cont. high GB", "50"],
      ["dnsSlowMs", "DNS slow ms", "500"],
      ["certWarnDays", "Cert warn days", "30"], ["certCritDays", "Cert crit days", "7"],
      ["backupStaleDays", "Backup stale days", "30"],
    ];
    $("#threshbody").innerHTML = fields.map(([k, label, def]) => {
      const v = threshData[k] ?? defs[k] ?? def;
      return '<div class="thresh-field"><label for="th-' + k + '">' + label + ' <span style="font-weight:400; text-transform:none; letter-spacing:0;">(' + k + ')</span></label><input id="th-' + k + '" data-k="' + k + '" type="number" step="any" value="' + esc(v) + '"></div>';
    }).join("");
    if (!threshData || Object.keys(threshData).length === 0) {
      try {
        const r2 = await fetch("http://127.0.0.1:17321/thresholds", { cache: "no-store" });
        if (r2.ok) { const j2 = await r2.json(); if (j2.thresholds) threshData = j2.thresholds; }
      } catch {}
    }
  } catch (e) { $("#threshbody").innerHTML = '<div style="color:var(--muted);font-size:12px;">Could not load thresholds: ' + esc(e.message) + '</div>'; }
}

function setupThresholds() {
  const threshBtn = $("#threshbtn");
  const threshPanel = $("#threshpanel");
  const threshBody = $("#threshbody");
  const threshMsg = $("#threshmsg");
  if (!threshBtn || !threshPanel) return;

  threshBtn.addEventListener("click", async () => {
    const hidden = threshPanel.hidden;
    threshPanel.hidden = !hidden;
    if (!hidden) return;
    await loadThresh();
  });

  $("#threshclose")?.addEventListener("click", () => threshPanel.hidden = true);

  $("#threshcopy")?.addEventListener("click", async () => {
    const data = {};
    threshBody.querySelectorAll("input[data-k]").forEach(inp => { const k = inp.dataset.k; const v = Number(inp.value); if (Number.isFinite(v)) data[k] = v; });
    await copyText(JSON.stringify({ thresholds: data }, null, 2));
    threshMsg.textContent = "\u2713 Copied JSON";
    showToast("\u2713 Thresholds JSON copied \u2014 paste into ~/.config/linux-doctor/config.json");
    setTimeout(() => threshMsg.textContent = "", 1500);
  });

  $("#threshsave")?.addEventListener("click", async () => {
    const data = {};
    threshBody.querySelectorAll("input[data-k]").forEach(inp => { const k = inp.dataset.k; const v = Number(inp.value); if (Number.isFinite(v)) data[k] = v; });
    threshMsg.textContent = "Saving\u2026";
    let ok = false;
    try { const r = await fetch("/api/thresholds", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ thresholds: data }) }); ok = r.ok; } catch {}
    if (!ok) { try { const r2 = await fetch("http://127.0.0.1:17321/thresholds", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ thresholds: data }) }); ok = r2.ok; } catch {} }
    threshMsg.textContent = ok ? "\u2713 Saved \u2014 re-run checks to apply" : "\u2717 Save failed";
    showToast(ok ? "\u2713 Thresholds saved" : "\u2717 Could not save thresholds");
    if (ok) setTimeout(() => threshPanel.hidden = true, 900);
  });
}


/* ── ui-history.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === History section + chart interaction === */
let statusTimer = null;

function setStatus(generatedAt) {
  const txt = $("#statuspill-txt");
  const dot = $("#statusdot");
  if (!txt || !dot) return;
  const ms = Date.now() - new Date(generatedAt).getTime();
  const secs = Math.max(0, Math.round(ms / 1000));
  let label;
  if (secs < 5) label = "checked just now";
  else if (secs < 60) label = "checked " + secs + "s ago";
  else if (secs < 3600) label = "checked " + Math.floor(secs / 60) + "m ago";
  else if (secs < 86400) label = "checked " + Math.floor(secs / 3600) + "h ago";
  else label = "checked " + Math.floor(secs / 86400) + "d ago";
  txt.textContent = label;
  dot.className = "dot" + (secs >= 3600 ? " stale" : "");
  // Status bar (wide desktop chrome) mirrors the hero's freshness line.
  const sbar = document.getElementById("sbar-check");
  if (sbar) {
    sbar.textContent = label;
    sbar.hidden = false;
    sbar.classList.toggle("stale", secs >= 3600);
  }
}

async function renderTrend() {
  if (STATIC_DATA) return;
  const trend = $("#trend");
  const runs = await fetchHistory();
  const data = runs.filter((r) => typeof r.score === "number").slice(-12);
  const histEl = $("#history");
  const ledger = $("#runledger");
  if (ledger) ledger.innerHTML = "";
  if (data.length < 2) {
    /* First recorded run: a bare hidden section reads as "broken". Explain
       what will grow here instead, using the score we already have. */
    if (data.length === 1 && !STATIC_DATA) {
      histEl.hidden = false;
      trend.innerHTML =
        '<div class="hist-empty">Only one run on record so far \u2014 it scored <b>' + data[0].score + "/100</b>." +
        "Run another check and your health-score trend appears here.</div>";
    } else {
      histEl.hidden = true;
    }
    return;
  }
  histEl.hidden = false;
  renderHistoryLedger(data);
  updateHistoryBadge(data.length);

  // Hero mini-sparkline: the score's recent direction, visible at a glance.
  const spark = $("#hero-spark");
  if (spark) {
    spark.innerHTML = miniSpark(data);
    spark.hidden = false;
  }

  trend.innerHTML = '<div class="trend-wrap">' + scoreChart(data) + severityChart(data) + '</div>';

  setTimeout(() => {
    const histData = data;
    const showRun = (idx) => {
      const r = histData[idx];
      if (!r) return;
      const prev = idx > 0 ? histData[idx - 1] : null;
      let msg = "Run " + (idx + 1) + "/" + histData.length + " \u00b7 " + fmtWhen(r.at) + " \u00b7 score " + r.score + "/100";
      if (prev) msg += " (prev " + prev.score + ")";
      showToast(msg + " \u2014 opening Changes");
      const diffEl = $("#diff");
      if (diffEl) { diffEl.hidden = false; diffEl.open = true; diffEl.scrollIntoView({ behavior: scrollBehavior(), block: "start" }); }
    };
    // Keyboard parity: the chart marks are role="button", so Enter and Space
    // must do what the click does — the finding cards behave the same way.
    const activate = (el) => {
      const idx = Number(el.dataset.idx || el.closest("[data-idx]")?.dataset.idx);
      if (Number.isFinite(idx)) showRun(idx);
    };
    trend.querySelectorAll(".hist-dot, .hist-bar").forEach(el => {
      el.addEventListener("click", () => activate(el));
      el.addEventListener("keydown", (e) => {
        if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
          e.preventDefault();
          activate(el);
        }
      });
    });
  }, 80);
}

/* Run ledger: every recorded run as a row — date, score, delta vs the
   previous run, severity counts. Newest first. Pure-ish (string building
   only) so dashboard-state.test.js can pin the vocabulary. */
function historyLedgerHtml(runs) {
  const rows = (runs || []).filter((r) => typeof r.score === "number").slice(-12);
  if (rows.length < 2) return "";
  let html = '<div class="ledger-head">Past runs <span class="ledger-sub">newest first</span>' +
    '<button class="ledger-clear" id="histclear" title="Delete all stored runs">Clear history</button></div>' +
    '<div class="ledger">' +
    '<div class="ledger-cols" aria-hidden="true"><span>When</span><span>Score</span><span>\u0394</span><span>Findings</span></div>';
  for (let i = rows.length - 1; i >= 0; i -= 1) {
    const r = rows[i];
    const prev = i > 0 ? rows[i - 1] : null;
    const d = prev ? r.score - prev.score : null;
    const dCls = d === null ? "" : d > 0 ? "up" : d < 0 ? "down" : "flat";
    const dTxt = d === null ? "—" : (d > 0 ? "+" + d : String(d));
    const c = r.counts || {};
    html += '<div class="ledger-row"><span class="ledger-when">' + esc(fmtWhen(r.at) || "—") + "</span>" +
      '<span class="ledger-score">' + r.score + "/100</span>" +
      '<span class="ledger-delta ' + dCls + '">' + dTxt + "</span>" +
      '<span class="ledger-counts">' + (c.high || 0) + " high · " + (c.medium || 0) + " med · " + (c.info || 0) + " info</span></div>";
  }
  return html + "</div>";
}

function renderHistoryLedger(runs) {
  const box = $("#runledger");
  if (!box) return;
  box.innerHTML = historyLedgerHtml(runs);
  $("#histclear")?.addEventListener("click", async () => {
    if (!window.confirm("Delete all stored runs? This cannot be undone.")) return;
    const ok = await clearHistoryApi();
    showToast(ok ? "History cleared" : "Could not clear history");
    if (ok) renderTrend(lastReportData ? lastReportData.score : undefined);
  });
}

function setTabBadge(id, text) {
  const b = document.getElementById(id);
  if (!b) return;
  if (text) { b.textContent = text; b.hidden = false; }
  else { b.textContent = ""; b.hidden = true; }
}

function updateHistoryBadge(n) {
  setTabBadge("tabbadge-history", n >= 2 ? String(n) : "");
}

/* Checks-tab badge: findings with a problem (high/medium) on this report. */
function updateChecksBadge(data) {
  const n = (data.findings || []).filter((f) => f.severity === "high" || f.severity === "medium").length;
  setTabBadge("tabbadge-checks", n > 0 ? String(n) : "");
}


/* ── ui-sidebar.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Overview sidebar: score breakdown bars + severity/category nav === */

function renderSidebar(data, counts) {
  const sidebar = $("#sidebar");
  if (!sidebar) return;
  const findings = data.findings || [];
  sidebar.hidden = false;

  renderBreakdownBars(data);
  renderSeverityNav(counts);
  renderCategoryNav(findings);
}

/* "Why this score": top penalized findings as proportional bars.
   Mirrors the CLI's SCORE line (top-3 + "+N more") using the same
   server-computed scoreBreakdown — never re-derived here. */
function renderBreakdownBars(data) {
  const el = $("#sb-breakdown");
  if (!el) return;
  const rows = (data.scoreBreakdown || []).filter((b) => b.penalty > 0)
    .sort((a, b) => b.penalty - a.penalty);
  if (!rows.length) {
    el.innerHTML = '<div class="bd-clean">\u2713 No score penalties</div>';
    return;
  }
  const TOP = 3;
  const max = rows[0].penalty;
  const shown = rows.slice(0, TOP);
  el.innerHTML = shown.map((b) => {
    const pct = Math.max(6, Math.round((b.penalty / max) * 100));
    return '<div class="bd-row">' +
      '<div class="bd-top"><span class="bd-title" title="' + esc(b.title || b.code || "") + '">' +
      esc(b.title || b.code || "") + '</span>' +
      '<span class="bd-pen ' + esc(b.severity) + '" title="' + esc(SEV_NAMES[b.severity] || b.severity) + " \u2014 this finding removed " + b.penalty + " points from 100\">" +
      "\u2212" + b.penalty + "</span></div>" +
      '<div class="bd-code">' + esc(b.code || "") + '</div>' +
      '<div class="bd-bar"><div class="bd-fill ' + esc(b.severity) + '" style="width:' + pct + '%"></div></div>' +
      "</div>";
  }).join("") +
    (rows.length > TOP ? '<div class="bd-more">+' + (rows.length - TOP) + " more</div>" : "");
}

function renderSeverityNav(counts) {
  const el = $("#sb-sevnav");
  if (!el) return;
  el.innerHTML = SEV_ORDER.map((sev) => {
    const n = counts[sev] || 0;
    if (!n) return "";
    return '<button class="sb-item ' + sev + (activeFilter === sev ? " active" : "") + '" data-nav-sev="' + sev + '">' +
      '<span class="sevicon" data-sev="' + sev + '" aria-hidden="true">' + (SEV_ICONS[sev] || "") + '</span>' +
      '<span class="sb-label">' + SEV_NAMES[sev] + '</span>' +
      '<span class="sb-count">' + n + "</span></button>";
  }).join("") || '<div class="bd-clean" style="font-size:11.5px;">Nothing needs attention</div>';
}

function renderCategoryNav(findings) {
  const el = $("#sb-catnav");
  if (!el) return;
  const tally = new Map();
  for (const f of findings) {
    const cat = categoryOf(f);
    tally.set(cat, (tally.get(cat) || 0) + 1);
  }
  el.innerHTML = CATEGORY_ORDER.map((cat) => {
    const n = tally.get(cat) || 0;
    if (!n) return "";
    return '<button class="sb-item" data-nav-cat="' + cat + '">' +
      '<span class="sb-ic" aria-hidden="true">' + catIcon(cat) + '</span>' +
      '<span class="sb-label">' + esc(CATEGORY_LABELS[cat] || cat) + '</span>' +
      '<span class="sb-count">' + n + "</span></button>";
  }).join("");
}

/* Switch grouping mode and re-render the current report in place.
   The choice persists across sessions. */
function setGroupBy(mode) {
  if (mode !== "severity" && mode !== "category") return;
  try { localStorage.setItem("ld-groupby", mode); } catch {}
  if (groupBy === mode) return;
  groupBy = mode;
  document.querySelectorAll(".segbtn").forEach((b) => {
    const on = b.dataset.groupby === mode;
    b.classList.toggle("active", on);
    b.setAttribute("aria-pressed", String(on));
  });
  if (lastData) render(lastData);
}

function setupSidebar() {
  // Restore the grouping chosen in a previous session, before the first
  // render paints the wrong mode and has to flip.
  try {
    const saved = localStorage.getItem("ld-groupby");
    if (saved === "severity" || saved === "category") {
      groupBy = saved;
      document.querySelectorAll(".segbtn").forEach((b) =>
        b.classList.toggle("active", b.dataset.groupby === saved));
    }
  } catch {}
  document.querySelectorAll(".segbtn").forEach((b) =>
    b.setAttribute("aria-pressed", String(b.dataset.groupby === groupBy)));

  document.addEventListener("click", (e) => {
    // Segmented control
    const seg = e.target.closest(".segbtn");
    if (seg) { setGroupBy(seg.dataset.groupby); return; }

    // Sidebar severity row → severity view + that filter
    const sevBtn = e.target.closest("[data-nav-sev]");
    if (sevBtn) {
      setGroupBy("severity");
      activeFilter = sevBtn.dataset.navSev;
      document.querySelectorAll(".fpill").forEach((b) => b.classList.toggle("active", b.dataset.sev === activeFilter));
      syncGroupsOpen();
      applyFilters();
      scrollToGroup("sev", activeFilter);
      return;
    }

    // Sidebar category row → category view + open that group
    const catBtn = e.target.closest("[data-nav-cat]");
    if (catBtn) {
      setGroupBy("category");
      requestAnimationFrame(() => scrollToGroup("cat", catBtn.dataset.navCat));
    }
  });
}

function scrollToGroup(type, key) {
  const g = document.querySelector('#report .group[data-type="' + type + '"][data-key="' + key.replace(/"/g, '\\"') + '"]');
  if (!g) return;
  g.setAttribute("open", "");
  g.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
}


/* ── ui-views.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === App views: Overview / History / Checks ===
   One page was growing into a long scroll (hero, findings, fixed, skipped,
   history…). Views give every area a home without rewriting any renderer:
   Overview keeps today's report exactly as-is (the default, zero
   retraining); History hosts the trend section; Checks renders the
   all-checks matrix inline. Choice persists across sessions. */

const VIEWS = ["overview", "history", "checks", "system", "schedule"];
let activeView = "overview";
// Set when the URL supplied ?view= — setupViews() must then not clobber the
// deep link with the remembered view.
let urlViewApplied = false;

/* === URL state (deep-linkable desktop state) ===
   view / severity filter / group / theme / density travel in the query
   string, so a refresh or a bookmark restores the workbench. replaceState
   only — no history spam while typing.

   The free-text search is deliberately NOT written here: #search holds
   system-derived strings (hostnames, unit/container names, paths), and
   baking those into the browser history conflicts with the tool's
   local-only promise. ?q= is still READ (an explicitly crafted link can
   pre-filter), it is just never produced automatically. */
function syncUrlState() {
  try {
    if (typeof history === "undefined" || !history.replaceState) return;
    const p = new URLSearchParams();
    if (activeView !== "overview") p.set("view", activeView);
    if (typeof activeFilter !== "undefined" && activeFilter && activeFilter !== "all") p.set("sev", activeFilter);
    if (groupBy === "category") p.set("group", "category");
    const th = typeof currentTheme === "function" ? currentTheme() : "auto";
    if (th && th !== "auto") p.set("theme", th);
    if (document.body && !document.body.classList.contains("compact")) p.set("d", "cozy");
    const s = p.toString();
    history.replaceState(null, "", s ? "?" + s : location.pathname);
  } catch {}
}

function readUrlState() {
  try {
    if (typeof location === "undefined" || !location.search) return null;
    const p = new URLSearchParams(location.search);
    const out = {};
    const view = p.get("view");
    if (view && VIEWS.includes(view)) out.view = view;
    const sev = p.get("sev");
    if (sev && ["all", "high", "medium", "info"].includes(sev)) out.sev = sev;
    const q = p.get("q");
    if (q) out.q = q.slice(0, 200);
    const group = p.get("group");
    if (group === "severity" || group === "category") out.group = group;
    const theme = p.get("theme");
    if (theme && typeof THEME_ORDER !== "undefined" && THEME_ORDER.includes(theme)) out.theme = theme;
    const d = p.get("d");
    if (d === "cozy" || d === "compact") out.d = d;
    return out;
  } catch { return null; }
}

function applyUrlState() {
  const st = readUrlState();
  if (!st) return;
  if (st.view) {
    activeView = st.view;
    urlViewApplied = true;
    // Persist so the next plain load lands on the same view (URL wins and
    // sticks, matching how group/theme already behave).
    try { localStorage.setItem("ld-view", st.view); } catch {}
  }
  if (st.sev !== undefined && typeof activeFilter !== "undefined") activeFilter = st.sev;
  // Read-only: an explicit ?q= link pre-filters. syncUrlState never writes it.
  if (st.q) { const s = $("#search"); if (s) s.value = st.q; }
  if (st.group) {
    groupBy = st.group;
    try { localStorage.setItem("ld-groupby", st.group); } catch {}
    document.querySelectorAll(".segbtn").forEach((b) => {
      const on = b.dataset.groupby === st.group;
      b.classList.toggle("active", on);
      b.setAttribute("aria-pressed", String(on));
    });
  }
  if (st.theme) {
    try { localStorage.setItem("ld-theme", st.theme); } catch {}
    if (typeof applyTheme === "function") applyTheme();
  }
  if (st.d) {
    try { localStorage.setItem("ld-density", st.d === "cozy" ? "comfortable" : "compact"); } catch {}
    if (document.body) document.body.classList.toggle("compact", st.d === "compact");
  }
}

function setupUrlSync() {
  applyUrlState();
  try {
    if (typeof window !== "undefined" && window.addEventListener) {
      window.addEventListener("popstate", () => {
        const st = readUrlState();
        applyUrlState();
        switchView(activeView);
        document.querySelectorAll(".fpill").forEach((b) =>
          b.classList.toggle("active", b.dataset.sev === (activeFilter || "all")));
        if (typeof applyFilters === "function") applyFilters();
        // applyUrlState set groupBy directly (no re-render) — repaint here.
        if (st && st.group && typeof lastData !== "undefined" && lastData) render(lastData);
      });
    }
  } catch {}
}

const VIEW_LABELS = { overview: "Overview", history: "History", checks: "Checks", system: "System", schedule: "Schedule" };

function viewTabs() {
  return [...document.querySelectorAll("#viewtabs .viewtab")];
}

function switchView(name, { focusTab = false } = {}) {
  if (!VIEWS.includes(name)) return;
  activeView = name;
  try { localStorage.setItem("ld-view", name); } catch {}
  for (const id of VIEWS) {
    const pane = document.getElementById("view-" + id);
    if (pane) pane.hidden = id !== name;
  }
  // The sidebar itself is persistent, but the report sections inside it
  // (breakdown, severity, categories) only make sense on Overview. The
  // Health block is global — it stays on every view.
  document.querySelectorAll("#sidebar .sb-block:not(.sb-views):not(.sb-health)").forEach((b) => {
    b.hidden = name !== "overview";
  });
  viewTabs().forEach((b) => {
    const on = b.dataset.view === name;
    b.classList.toggle("active", on);
    b.setAttribute("aria-selected", String(on));
    b.tabIndex = on ? 0 : -1;
    if (on && focusTab) b.focus();
  });
  // Status bar (wide desktop chrome) mirrors the active view.
  const sbarView = document.getElementById("sbar-view");
  if (sbarView) sbarView.textContent = VIEW_LABELS[name] || name;
  if (name === "checks") renderChecksView();
  if (name === "schedule") renderScheduleView();
  if (name === "overview") {
    // Coming back: keep the report exactly where the filters left it.
    syncGroupsOpen();
    applyFilters();
  }
  syncUrlState();
  syncAutoPausedUI();
}

/* The two notice rows (schedule strip, security posture) share a 2-column
   grid under the full-width START HERE card; when only one is visible it
   spans the full width instead of leaving a half-empty column. Called from
   both renderers (async schedule fetch and sync posture/next-step). */
function syncNoticeGrid() {
  const grid = $("#notices");
  if (!grid || !grid.children) return;
  const visible = [...grid.children].filter((k) => !k.hidden);
  grid.classList.toggle("single", visible.length < 2);
}

/* Brand home-link: logo + title go to Overview and back to top. The
   sidebar stays exactly where it is — only the main view switches. */
function setupBrand() {
  const brand = document.getElementById("brand");
  if (!brand || brand.dataset.bound) return;
  brand.dataset.bound = "1";
  brand.addEventListener("click", (e) => {
    e.preventDefault();
    switchView("overview");
    window.scrollTo({ top: 0, behavior: scrollBehavior() });
  });
}

function setupViews() {
  // Restore the last view before first paint settles — unless the URL
  // already named one (?view= wins over the remembered preference).
  if (!urlViewApplied) {
    try {
      const saved = localStorage.getItem("ld-view");
      if (VIEWS.includes(saved)) activeView = saved;
    } catch {}
  }
  const bar = document.getElementById("viewtabs");
  if (!bar) return;
  bar.addEventListener("click", (e) => {
    const btn = e.target.closest(".viewtab");
    if (btn) switchView(btn.dataset.view);
  });
  // Roving tabindex: arrows move between tabs, like a native tablist.
  bar.addEventListener("keydown", (e) => {
    if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
    const tabs = viewTabs();
    let i = tabs.indexOf(document.activeElement);
    if (i < 0) return;
    e.preventDefault();
    e.stopPropagation();
    i = (i + (e.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length;
    tabs[i].focus();
    switchView(tabs[i].dataset.view);
  });
  if (activeView !== "overview") switchView(activeView);
}


/* ── ui-wide.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Wide-desktop mode (>=1200px) ===
   A PC canvas gets desktop behavior: the shell uncaps (wide.css) and the
   findings groups open — a workbench shows its data, an accordion hides
   it. Below 1200px nothing changes. Guarded so environments without
   matchMedia (headless test sandboxes) keep the narrow behavior. */
const WIDE_QUERY = "(min-width: 1200px)";

function isWide() {
  try { return typeof matchMedia === "function" && matchMedia(WIDE_QUERY).matches; }
  catch { return false; }
}

/* The wide class is the single source of truth for CSS; the query drives
   it. Guarded: the headless test sandbox has no documentElement. */
function wideModeOn() {
  const el = document.documentElement;
  return !!(el && el.classList && el.classList.contains("wide"));
}

/* Groups: all open at wide (severity AND category — a workbench shows its
   data); at narrow, restore the syncGroupsOpen policy. */
function applyWideGroups() {
  const wide = wideModeOn();
  document.querySelectorAll("#report .group").forEach((g) => {
    if (wide) g.setAttribute("open", "");
    else if (g.dataset.type === "sev") g.open = activeFilter === g.dataset.key || (activeFilter === "all" && g.dataset.key === "high");
  });
}

/* Hook for the master-detail pane (phase 2): a real syncDetailPane()
   declared later in the bundle replaces this no-op by hoisting. */
function syncDetailPane() {}

function setupWideMode() {
  let mq = null;
  try { mq = typeof matchMedia === "function" ? matchMedia(WIDE_QUERY) : null; } catch { mq = null; }
  if (!mq) return;
  const apply = () => {
    const root = document.documentElement;
    // Guarded like wideModeOn(): headless sandboxes have no documentElement.
    if (root && root.classList) root.classList.toggle("wide", !!mq.matches);
    applyWideGroups();
    syncDetailPane();
  };
  mq.addEventListener("change", apply);
  apply();
}


/* ── ui-detailpane.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Detail pane (wide desktop): list + pinned detail ===
   On >=1440px the Overview becomes a two-pane workbench: the findings list
   stays put on the left, the selected finding's full body renders in a
   sticky pane on the right — the desktop master-detail pattern. Same
   vocabulary, same delegated handlers (data-copy / data-ignore / ev-copy
   keep working inside the pane); no renderer changes. Below 1440px the
   pane never exists and cards expand in place, exactly as before. */
let paneSelectedCode = null;

function detailPane() { return document.getElementById("detailpane"); }

function findingByCode(code) {
  const all = (typeof lastData !== "undefined" && lastData && lastData.findings) || [];
  return all.find((f) => f.code === code) || null;
}

function paneFindingHtml(f) {
  const sev = f.severity || "info";
  const badges =
    (f.code ? '<button class="codepill" data-copy="' + esc(f.code) + '" title="Copy ' + esc(f.code) + '">' + esc(f.code) + "</button>" : "") +
    (f.isNew ? '<span class="newbadge">NEW</span>' : "");
  const dur = durFor(f.check);
  let html = '<div class="dp-head"><span class="sevicon" data-sev="' + esc(sev) + '" aria-hidden="true">' + (SEV_ICONS[sev] || "") + "</span>" +
    "<h3>" + esc(f.title) + "</h3>" + badges + "</div>";
  html += '<div class="dp-body">';
  if (f.detail) {
    html += '<div class="detail"><b>What happened:</b> ' + esc(f.detail) + "</div>";
    if (f.confidence === "low") {
      html += '<div class="dp-lowconf"><span class="sev-dot medium" aria-hidden="true"></span> Low confidence — may be a false positive</div>';
    }
  }
  if (f.evidence) {
    const evId = "evp-" + (f.code || f.check || "x").replace(/[^a-z0-9-]/gi, "");
    html += '<details class="ev" open><summary>Evidence</summary>' +
      '<div class="evidence" id="' + evId + '">' + esc(f.evidence) + "</div>" +
      '<div class="dp-evrow"><button class="ev-copy" data-evcopy="' + evId + '">Copy evidence</button>' +
      (dur != null ? '<span class="durpill" style="display:inline-flex;margin-left:auto" title="Check time">' + esc(String(dur)) + "ms</span>" : "") +
      "</div></details>";
  }
  html += '<div class="fix"><div class="fix-label">Recommended next step</div>' +
    '<div class="fixtext">' + cmdCode(f.fix || "No automated fix — review details above.") + "</div>" +
    '<button data-copy="' + esc(f.fix || f.detail || f.title) + '">Copy fix</button>' +
    '<button data-ignore="' + esc(f.title) + '" title="Hide this finding in future runs">Dismiss</button>' +
    '<a class="reportbtn" href="' + wrongUrl(f) + '" target="_blank" rel="noopener">Report</a></div>';
  html += "</div>";
  return html;
}

function selectFinding(card) {
  const pane = detailPane();
  if (!pane) return;
  const code = card && card.dataset.code ? card.dataset.code : "";
  const f = code ? findingByCode(code) : null;
  paneSelectedCode = f ? f.code : null;
  document.querySelectorAll("#report details.card.selected").forEach((c) => c.classList.remove("selected"));
  if (card && f) {
    card.classList.add("selected");
    pane.hidden = false;
    pane.innerHTML =
      '<div class="dp-kicker"><span class="dp-tag">Detail</span><span class="dp-sev">' +
      esc(SEV_NAMES[f.severity] || "") + "</span></div>" + paneFindingHtml(f);
  } else {
    pane.hidden = true;
    pane.innerHTML = "";
  }
}

/* Pick a sensible selection after data/filter changes: keep the current
   pick while it stays visible, else first visible high → medium → first. */
function autoSelectFinding() {
  const pane = detailPane();
  if (!pane || !isWide()) return;
  const cards = [...document.querySelectorAll("#report details.card")];
  const visible = cards.filter((c) => c.style.display !== "none" && !c.closest("[hidden]"));
  if (!visible.length) { selectFinding(null); return; }
  const current = visible.find((c) => c.dataset.code === paneSelectedCode);
  if (current) { selectFinding(current); return; }
  const pick = visible.find((c) => c.classList.contains("high")) ||
    visible.find((c) => c.classList.contains("medium")) || visible[0];
  selectFinding(pick);
}

function syncDetailPane() {
  const pane = detailPane();
  if (!pane) return;
  const wide = wideModeOn();
  pane.hidden = !wide;
  // START HERE is the narrow-screen guide; at wide the pane takes over
  // (the selected row + its detail is the guidance). renderNextStep keeps
  // setting hidden=false on every render, so re-assert while wide.
  const nh = $("#nexthep");
  if (nh && wide) nh.hidden = true;
  if (wide) autoSelectFinding();
  else if (paneSelectedCode !== null) {
    paneSelectedCode = null;
    document.querySelectorAll("#report details.card.selected").forEach((c) => c.classList.remove("selected"));
    if (typeof lastData !== "undefined" && lastData && typeof renderNextStep === "function") renderNextStep(lastData);
  }
}

function setupDetailPane() {
  // Click a row at wide → select into the pane (no inline expansion).
  // Only the card's own summary triggers selection; codepill, buttons and
  // the evidence disclosure keep their own behavior.
  document.addEventListener("click", (e) => {
    if (!wideModeOn()) return;
    const sum = e.target.closest("#report details.card > summary");
    if (!sum) return;
    if (e.target.closest(".codepill")) return;
    e.preventDefault();
    selectFinding(sum.parentElement);
  });
}


/* ── ui-checks.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === All-checks matrix ===
   A diagnostic tool earns trust by showing what it looked AT, not only
   what it found. This modal lists every registered check with its live
   outcome on this machine: findings (worst severity first), clean,
   not-applicable for this profile, atomic skips, or runtime errors.
   Rows with findings deep-link back to their cards in the report. */

/* Result states — a fixed vocabulary, not free prose. The three finding
   words are the ones the report and the severity legend already use, so a
   result column reads the same everywhere in the product. */
const MX_STATE = {
  high: "needs attention", medium: "warning", info: "observation",
  ok: "clean", na: "not applicable", skip: "skipped", err: "failed to run",
};
const MX_CLS = { high: "high", medium: "medium", info: "info", ok: "ok", na: "na", skip: "na", err: "err" };

/* Build the matrix once, returned in parts: the view frames it with its own
   header and column rule, the modal keeps the "All checks" heading. */
function checksMatrixBody(data, checks, opts) {
  const problemsOnly = !!(opts && opts.problemsOnly);
  const findingsByCheck = new Map();
  for (const f of data.findings || []) {
    if (!findingsByCheck.has(f.check)) findingsByCheck.set(f.check, []);
    findingsByCheck.get(f.check).push(f);
  }
  const errors = new Map((data.checkErrors || []).map((e) => [e.check, e.error]));
  const atomic = new Set((data.skippedChecks || []).map((s) => s.id));

  const SEV_RANK = { high: 0, medium: 1, info: 2 };
  const dot = (cls) => '<span class="sev-dot ' + cls + '"></span>';
  function statusOf(c) {
    if (errors.has(c.id)) return { rank: 0, key: "err", icon: dot("err"), why: esc(errors.get(c.id) || "failed to run") };
    const fs = findingsByCheck.get(c.id) || [];
    if (fs.length) {
      fs.sort((a, b) => (SEV_RANK[a.severity] ?? 9) - (SEV_RANK[b.severity] ?? 9));
      const sev = fs[0].severity;
      return { rank: 1, key: sev, icon: dot(sev), why: fs.length > 1 ? fs.length + " findings" : "", goto: true };
    }
    if (!c.appliesHere) return { rank: 3, key: "na", icon: dot("na"), why: "runs on: " + (c.appliesTo || []).join(", ") };
    if (c.skipOnAtomic || atomic.has(c.id)) return { rank: 3, key: "skip", icon: dot("atomic"), why: c.atomicReason || "skipped on immutable/atomic systems" };
    return { rank: 2, key: "ok", icon: dot("ok"), why: "" };
  }

  const cats = new Map();
  for (const c of checks) {
    const cat = c.category || "other";
    if (!cats.has(cat)) cats.set(cat, []);
    cats.get(cat).push(c);
  }

  // Stats are computed over the whole registry, so the summary never changes
  // shape when the "problems only" filter is on.
  const all = new Map();
  let problemCount = 0, errorCount = 0, cleanCount = 0, naCount = 0;
  for (const [cat, list] of cats) {
    const rows = list.map((c) => ({ c, s: statusOf(c) }))
      .sort((a, b) => a.s.rank - b.s.rank || a.c.id.localeCompare(b.c.id));
    for (const { s } of rows) {
      if (s.rank === 1) problemCount++;
      else if (s.rank === 0) errorCount++;
      else if (s.rank === 2) cleanCount++;
      else naCount++;
    }
    all.set(cat, rows);
  }

  let body = "";
  for (const cat of CATEGORY_ORDER) {
    const rows = (all.get(cat) || []).filter((r) => !problemsOnly || r.s.rank <= 1);
    if (!rows.length) continue;
    body += '<div class="mx-cat">' + catIcon(cat, 12) + " " + esc(CATEGORY_LABELS[cat] || cat) +
      '<span class="mx-cat-n">' + rows.length + "</span></div>";
    for (const { c, s } of rows) {
      const gotoAttr = s.goto
        ? ' data-goto-code="' + esc(((findingsByCheck.get(c.id) || [])[0] || {}).code || "") + '" data-goto-check="' + esc(c.id) + '" tabindex="0" role="button" aria-label="Show ' + esc(c.id) + ' finding"'
        : "";
      const why = s.why ? '<span class="mx-why" title="' + esc(s.why) + '">' + s.why + "</span>" : "";
      const cls = MX_CLS[s.key];
      body += '<div class="mx-row' + (s.goto ? " link" : "") + '"' + gotoAttr + ">" +
        '<span class="mx-st" aria-hidden="true">' + s.icon + '</span>' +
        '<span class="mx-id">' + esc(c.id) + '</span>' +
        '<span class="mx-title">' + esc(c.title) + '</span>' +
        '<span class="mx-res ' + cls + '"><span class="mx-state ' + cls + '">' + MX_STATE[s.key] + "</span>" + why + "</span></div>";
    }
  }

  const total = checks.length;
  const summary = total + " registered \u00b7 " +
    problemCount + " with findings \u00b7 " +
    cleanCount + " clean \u00b7 " +
    (errorCount ? errorCount + " failed to run \u00b7 " : "") +
    naCount + " not applicable here" +
    (STATIC_DATA ? " \u00b7 static report: live check list unavailable" : "");
  return { body, summary, problems: problemCount + errorCount };
}

function checksMatrixHtml(data, checks, opts) {
  const m = checksMatrixBody(data, checks, opts);
  const head = opts && opts.head === false ? "" : '<div class="mx-head">All checks</div>';
  return head + '<div class="mx-sub">' + m.summary + "</div>" + m.body;
}

/* The persistent state the GUI can otherwise only set (Dismiss) lives here:
   the ignore list, with a remove for each entry and the config path. */
function ignoredSectionHtml(ig) {
  const rows = [];
  for (const p of ig.patterns || []) rows.push({ kind: "title", v: p });
  for (const c of ig.codes || []) rows.push({ kind: "code", v: c });
  const list = rows.length
    ? rows.map((r) =>
        '<div class="ig-row"><span class="ig-v">' + esc(r.v) + '</span>' +
        '<span class="ig-kind">' + (r.kind === "code" ? "code" : "title") + '</span>' +
        '<button class="ig-remove" data-ig-remove="' + esc(r.v) + '" data-ig-kind="' + r.kind + '">Remove</button></div>'
      ).join("")
    : '<div class="mx-sub">Nothing is ignored. Dismiss a finding to hide it here.</div>';
  return '<div class="mx-head" style="margin-top:20px">Ignored findings</div>' +
    '<div class="mx-sub">Dismissed findings and patterns, saved to <code>' + esc(ig.path || "~/.config/linux-doctor/config.json") + '</code></div>' + list;
}

async function openChecksMatrix() {
  openModal('<div class="mx-head">All checks</div><div class="mx-sub">Loading\u2026</div>', "All checks");
  const checks = STATIC_DATA ? [] : await fetchChecks();
  const ignore = await fetchIgnore();
  openModal(checksMatrixHtml(lastReportData || {}, checks) + ignoredSectionHtml(ignore), "All checks");
}

function gotoFinding(check, code) {
  closeModal();
  activeFilter = "all";
  document.querySelectorAll(".fpill").forEach((b) => b.classList.toggle("active", b.dataset.sev === "all"));
  syncGroupsOpen();
  applyFilters();
  const card = (code && document.querySelector('#report details[data-code="' + code.replace(/"/g, '\\"') + '"]'))
    || document.querySelector('#report details[data-check="' + check.replace(/"/g, '\\"') + '"]');
  if (card) {
    card.setAttribute("open", "");
    card.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
    if (card.tabIndex >= 0) card.focus({ preventScroll: true });
    showToast("Showing " + check);
  }
}

function setupChecksMatrix() {
  $("#checksmatrixbtn")?.addEventListener("click", openChecksMatrix);
  // Deep-link rows: mouse and keyboard share one persistent handler.
  const body = $("#modal-body");
  if (!body) return;
  body.addEventListener("click", async (e) => {
    const rm = e.target.closest("[data-ig-remove]");
    if (rm && isModalOpen()) {
      rm.disabled = true;
      const payload = rm.dataset.igKind === "code"
        ? { code: rm.dataset.igRemove, remove: true }
        : { pattern: rm.dataset.igRemove, remove: true };
      const ok = await editIgnore(payload);
      showToast(ok ? "Removed from ignore list" : "Could not remove");
      if (ok) openChecksMatrix();
      return;
    }
    const row = e.target.closest("[data-goto-check]");
    if (!row || !isModalOpen()) return;
    gotoFinding(row.dataset.gotoCheck, row.dataset.gotoCode);
  });
  body.addEventListener("keydown", (e) => {
    if (e.key !== "Enter" && e.key !== " ") return;
    const row = e.target.closest("[data-goto-check]");
    if (!row || !isModalOpen()) return;
    e.preventDefault();
    gotoFinding(row.dataset.gotoCheck, row.dataset.gotoCode);
  });
}

/* Checks view: the same matrix, inline and framed as its own view. The
   inventory needs to be scannable (a real column rule), countable (a count
   per category), and quietable (a filter that drops everything clean).
   Cached so the filter repaints without another fetch. */
let lastChecks = null;
let checksProblemsOnly = false;

function paintChecksView() {
  const box = $("#checksview");
  if (!box) return;
  const data = lastReportData || {};
  const checks = lastChecks || [];
  const m = checksMatrixBody(data, checks, { problemsOnly: checksProblemsOnly });
  const empty = checksProblemsOnly && m.problems === 0
    ? '<div class="mx-none">Nothing needs attention — every check came back clean.</div>'
    : "";
  box.innerHTML =
    '<div class="mx-tools">' +
      '<div class="mx-sub">All checks \u00b7 ' + m.summary + "</div>" +
      '<button class="fpill mx-filter' + (checksProblemsOnly ? " active" : "") + '" id="mx-problems" type="button" aria-pressed="' + (checksProblemsOnly ? "true" : "false") + '">Problems only <span class="mx-fcount">' + m.problems + "</span></button>" +
    "</div>" +
    '<div class="mx-cols" aria-hidden="true"><span>Check</span><span>Result</span></div>' +
    empty +
    '<div class="mx-list">' + m.body + "</div>";
}

async function renderChecksView() {
  const box = $("#checksview");
  if (!box) return;
  const checks = STATIC_DATA ? [] : await fetchChecks();
  lastChecks = checks;
  paintChecksView();
}

function setupChecksView() {
  // Deep-link rows inside the Checks view: same goto as the modal, but the
  // finding lives in Overview, so switch views first. Bound once.
  const cv = $("#view-checks");
  if (cv && !cv.dataset.bound) {
    cv.dataset.bound = "1";
    cv.addEventListener("click", (e) => {
      if (e.target.closest("#mx-problems")) {
        checksProblemsOnly = !checksProblemsOnly;
        paintChecksView();
        return;
      }
      const row = e.target.closest("[data-goto-check]");
      if (!row) return;
      switchView("overview");
      gotoFinding(row.dataset.gotoCheck, row.dataset.gotoCode);
    });
    cv.addEventListener("keydown", (e) => {
      if (e.key !== "Enter" && e.key !== " ") return;
      const row = e.target.closest("[data-goto-check]");
      if (!row) return;
      e.preventDefault();
      switchView("overview");
      gotoFinding(row.dataset.gotoCheck, row.dataset.gotoCode);
    });
  }
}


/* ── export.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Markdown export — scrubbed, share-ready ===
   Keep this in sync with src/support.js scrub(): linear IPv6 patterns only
   (the old (?:[0-9A-Fa-f]{0,4}:){2,} shape backtracked quadratically). */
function scrub(text) {
  if (!text) return text;
  return String(text)
    .replace(/\b(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?\b/g, "<ip-redacted>")
    .replace(/(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6}(?![0-9A-Fa-f:])/g, "<ip-redacted>")
    .replace(/::[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6}(?![0-9A-Fa-f:])/g, "<ip-redacted>")
    .replace(/(?:[0-9A-Fa-f]{1,4}:){2,}[0-9A-Fa-f]{1,4}(?![0-9A-Fa-f:])/g, (m) => /[A-Fa-f]/.test(m) ? "<ip-redacted>" : m)
    .replace(/\bfe80::[0-9A-Fa-f:]*\b/gi, "<ip-redacted>")
    .replace(/::1\b/g, "<ip-redacted>")
    .replace(/\/(home|Users)\/[^\/\s]+/g, "/$1/<user-redacted>")
    .replace(/\/(run\/media|media)\/[^\/\s]+/g, "/$1/<user-redacted>")
    .replace(/\/run\/user\/\d+/g, "/run/user/<uid-redacted>");
}
function reportMarkdown(data) {
  const { system = {}, findings = [] } = data;
  const lines = [];
  // Markdown viewers (GitHub, VSCode…) render emoji from their own fonts,
  // so the stethoscope is safe — and wanted — here, unlike inside the app UI.
  lines.push("# \u{1fa7a} Linux Doctor report");
  lines.push("");
  lines.push("- **System:** " + [scrub(system.distro), scrub(system.kernel), (system.cores || "?") + " core(s)", "up " + scrub(system.uptime)].filter(Boolean).join(" \u00b7 "));
  if (typeof data.score === "number") {
    lines.push("- **Health:** " + data.score + "/100" +
      (typeof data.scoreDelta === "number" ? " (" + (data.scoreDelta > 0 ? "+" + data.scoreDelta : data.scoreDelta) + " since last check)" : ""));
  }
  lines.push("- **Checks:** " + (data.checksRun ?? "?") + " ran, " + (data.checksSkipped ?? 0) + " skipped");
  lines.push("");
  lines.push("_IPs and home paths are redacted — safe to paste in public._");
  lines.push("");
  const names = { high: "High severity", medium: "Medium severity", info: "Informational" };
  let count = 0;
  for (const sev of SEV_ORDER) {
    const group = findings.filter((f) => f.severity === sev);
    if (!group.length) continue;
    lines.push("## " + names[sev] + " (" + group.length + ")");
    lines.push("");
    for (const f of group) {
      count++;
      lines.push((count) + ". **" + scrub(f.title || "") + "**" + (f.code ? " (`" + scrub(f.code) + "`)" : ""));
      if (f.detail) lines.push("   - Detail: " + scrub(f.detail));
      if (f.evidence) lines.push("   - Evidence: " + scrub(f.evidence));
      if (f.fix) lines.push("   - Fix: " + scrub(f.fix));
      lines.push("");
    }
  }
  if (!count) lines.push("No findings.");
  return lines.join("\n");
}
function downloadMarkdown(data) {
  const text = reportMarkdown(data);
  const blob = new Blob([text], { type: "text/markdown;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  const ts = new Date().toISOString().slice(0, 10);
  a.href = url;
  a.download = "linux-doctor-" + ts + ".md";
  document.body.appendChild(a);
  a.click();
  setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
}


/* ── render-charts.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === SVG charts: score sparkline + severity bars — premium, compact, diagnostic === */
function fmtWhen(at) {
  const d = new Date(at);
  if (isNaN(d.getTime())) return "";
  const p = (n) => String(n).padStart(2, "0");
  return (d.getMonth() + 1) + "/" + d.getDate() + " " + p(d.getHours()) + ":" + p(d.getMinutes());
}

/* Tiny inline sparkline for the hero meta row */
function miniSpark(data) {
  const W = 80, H = 18, pad = 2;
  const scores = data.map((r) => r.score);
  if (scores.length < 2) return "";
  const min = Math.min(...scores), max = Math.max(...scores);
  const span = Math.max(1, max - min);
  const X = (i) => pad + (i * (W - 2 * pad)) / (scores.length - 1);
  const Y = (s) => H - pad - ((s - min) / span) * (H - 2 * pad);
  const line = scores.map((s, i) => X(i).toFixed(1) + "," + Y(s).toFixed(1)).join(" ");
  const last = scores[scores.length - 1], first = scores[0];
  const color = last > first ? "var(--green)" : last < first ? "var(--red)" : "var(--muted2)";
  return '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Trend, last ' + scores.length + ' runs">' +
    '<polyline points="' + line + '" fill="none" stroke="' + color + '" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}

function scoreChart(data) {
  const W = 240, H = 34, pad = 4;
  const scores = data.map((r) => r.score);
  const min = Math.min(...scores), max = Math.max(...scores);
  const span = Math.max(1, max - min);
  /* A flat series has no shape to show; pin it to the middle of the trace
     instead of drawing a hairline along the floor, which reads as a border.
     "Middle" is the score's own height though: a flat 100 belongs at the top
     of the plot, a flat 60 just above the floor. */
  const flat = max === min;
  const X = (i) => pad + (i * (W - 2 * pad)) / (scores.length - 1);
  const Y = (s) => (flat
    ? H - pad - (Math.max(0, Math.min(100, s)) / 100) * (H - 2 * pad)
    : H - pad - ((s - min) / span) * (H - 2 * pad));
  const pts = scores.map((s, i) => [X(i).toFixed(1), Y(s).toFixed(1)]);
  const last = scores[scores.length - 1];
  const first = scores[0];
  const dir = last > first ? { txt: "Improving", icon: "↗", cls: "up", col: "var(--green)" }
    : last < first ? { txt: "Declining", icon: "↘", cls: "down", col: "var(--red)" }
    : { txt: "Stable", icon: "→", cls: "flat", col: "var(--muted2)" };
  const range = scores.length + " runs · " + first + " → " + last;
  const line = pts.map((p) => p.join(",")).join(" ");
  const area = "M" + line.replace(/ /g, " L") + " L" + (W - pad) + "," + (H - pad) + " L" + pad + "," + (H - pad) + " Z";
  const dots = pts.map((p, i) =>
    // A flat series has no shape, so each run gets a visible mark: twelve
    // readings in a row read as twelve readings, not as a stray line. The
    // mark is decorative; the transparent circle on top is the control.
    (flat ? '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="2.2" fill="' + dir.col + '" opacity="0.75" class="hist-dotmark" pointer-events="none"/>' : "") +
    '<circle data-idx="' + i + '" cx="' + p[0] + '" cy="' + p[1] + '" r="5" fill="transparent" class="hist-dot" tabindex="0" role="button" aria-label="Run ' + (i + 1) + " of " + scores.length + ": " + scores[i] + "/100, " + fmtWhen(data[i].at) + ' — open the diff">' +
    '<title>' + scores[i] + "/100 · " + fmtWhen(data[i].at) + ' — click to show diff</title></circle>').join("");
  const end = pts[pts.length - 1];
  const thresh = (min < 50 && max > 50)
    ? '<line x1="' + pad + '" y1="' + Y(50).toFixed(1) + '" x2="' + (W - pad) + '" y2="' + Y(50).toFixed(1) + '" stroke="var(--muted2)" stroke-width="1" stroke-dasharray="3 3" opacity="0.5"/>'
    : "";
  // A floor under the trace: without it a flat series (same score every run)
  // draws a line in mid-air and reads as a divider rather than a reading.
  const floor = '<line x1="' + pad + '" y1="' + (H - pad) + '" x2="' + (W - pad) + '" y2="' + (H - pad) + '" stroke="var(--border)" stroke-width="1"/>';
  // Hero for history: large score + direction, and the trace directly under
  // it — one instrument, the same shape-from-data language as the rail's
  // vitals pulse. The run window is printed under the severity chart beside
  // it, so the trace does not repeat it.
  const hero = '<div class="trend-hero"><div class="trend-hero-score"><span class="trend-hero-num ' + dir.cls + '">' + last + '</span><span class="trend-hero-out">/100</span></div>' +
    '<div class="trend-hero-meta"><span class="trend-direction ' + dir.cls + '">' + dir.icon + ' ' + dir.txt + '</span><span class="trend-hero-range">' + range + '</span></div></div>';
  const from = data[0] && fmtWhen(data[0].at);
  const to = data[data.length - 1] && fmtWhen(data[data.length - 1].at);
  return '<div class="trendchart">' +
    hero +
    '<div class="hist-trace"><svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Health score trend, ' + from + ' to ' + to + '">' +
    (flat ? "" : floor + thresh + '<path d="' + area + '" fill="' + dir.col + '" opacity="0.10"/>') +
    '<polyline points="' + line + '" fill="none" stroke="' + dir.col + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
    dots + '<circle cx="' + end[0] + '" cy="' + end[1] + '" r="2.5" fill="' + dir.col + '" stroke="var(--card)" stroke-width="1.5"/></svg></div>' +
    '<div class="trendtitle" style="margin-top:6px"><b>' + last + '/100</b> <span class="' + dir.cls + '">' + dir.txt + '</span> <span class="trendrange">' + range + '</span></div>' +
    '</div>';
}

function severityChart(data) {
  const W = 280, H = 52, spad = 6, sgap = 3;
  const counts = data.map((r) => ({
    high: (r.counts && r.counts.high) || 0,
    medium: (r.counts && r.counts.medium) || 0,
    info: (r.counts && r.counts.info) || 0,
    at: r.at,
  }));
  const maxTotal = Math.max(1, ...counts.map((c) => c.high + c.medium + c.info));
  const n = counts.length;
  const bw = (W - 2 * spad - (n - 1) * sgap) / n;
  const totalH = H - 2 * spad;
  const bars = counts.map((c, i) => {
    const x = (spad + i * (bw + sgap)).toFixed(1);
    const iH = totalH * c.info / maxTotal;
    const mH = totalH * c.medium / maxTotal;
    const hH = totalH * c.high / maxTotal;
    const yInfo = (H - spad - iH).toFixed(1);
    const yMed = (H - spad - iH - mH).toFixed(1);
    const yHigh = (H - spad - iH - mH - hH).toFixed(1);
    const tip = "high: " + c.high + " · medium: " + c.medium + " · info: " + c.info;
    const when = fmtWhen(c.at);
    // Every run is one bar and every bar is dated: the segments carry the
    // counts, and a full-height transparent target on top of them makes the
    // whole column hoverable — a run with one observation is a 6-pixel bar,
    // which is not a target anyone can point at. The date leads the tooltip
    // because "which run is this?" is the question the chart cannot answer
    // by itself.
    return '<g data-idx="' + i + '" class="hist-bar" tabindex="0" role="button" aria-label="Run ' + (i + 1) + ": " + tip + (when ? " · " + when : "") + ' — open the diff">' +
      '<rect class="sev-seg" x="' + x + '" y="' + yInfo + '" width="' + bw.toFixed(1) + '" height="' + iH.toFixed(1) + '" rx="2" fill="var(--blue)" opacity="0.7"/>' +
      '<rect class="sev-seg" x="' + x + '" y="' + yMed + '" width="' + bw.toFixed(1) + '" height="' + mH.toFixed(1) + '" rx="2" fill="var(--yellow)" opacity="0.85"/>' +
      '<rect class="sev-seg" x="' + x + '" y="' + yHigh + '" width="' + bw.toFixed(1) + '" height="' + hH.toFixed(1) + '" rx="2" fill="var(--red)" opacity="0.9"/>' +
      '<rect class="sev-hit" x="' + x + '" y="' + spad + '" width="' + bw.toFixed(1) + '" height="' + totalH.toFixed(1) + '" fill="transparent" pointer-events="all">' +
      "<title>" + (when ? when + " \u00b7 " : "") + tip + " \u2014 click to show the diff</title></rect></g>";
  }).join("");
  const oldest = counts.length ? fmtWhen(counts[0].at) : "";
  const newest = counts.length ? fmtWhen(counts[counts.length - 1].at) : "";
  const axis = (oldest || newest)
    ? '<div class="sev-axis"><span>' + (oldest || "\u2014") + "</span><span>" + (newest || "\u2014") + "</span></div>"
    : "";
  return '<div class="trendchart">' +
    '<div class="trendtitle" style="font-size:10px;letter-spacing:.06em;text-transform:uppercase;font-weight:700;color:var(--muted2)">Findings by severity</div>' +
    '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Severity over time">' + bars + '</svg>' +
    axis +
    '<div class="sevlegend"><span class="lg high"></span>needs attention <span class="lg medium"></span>warnings <span class="lg info"></span>observations</div>' +
    '</div>';
}


/* ── render-findings.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Finding cards and groups — premium diagnostic storytelling === */
function durFor(check) {
  try {
    if (!check || typeof lastReportData === 'undefined' || !lastReportData) return null;
    const d = lastReportData.durations || lastReportData.checkDurations || lastReportData.durationsMap;
    if (!d) return null;
    if (Array.isArray(d)) { const hit = d.find(x => x.check === check); return hit ? hit.ms : null; }
    if (typeof d === 'object') return d[check] ?? null;
  } catch {}
  return null;
}
function renderCard(f, sev) {
  const sevIcon = SEV_ICONS[sev] || "";
  // Only quiet code + NEW in header — durpill/checkid/lowconf moved to body to reduce chrome
  const codeBadge = f.code ? '<button class="codepill" data-copy="' + esc(f.code) + '" title="Copy ' + esc(f.code) + ' — type code:' + esc(f.code) + ' in search to filter">' + esc(f.code) + '</button>' : "";
  const badges = codeBadge + (f.isNew ? '<span class="newbadge">NEW</span>' : "");
  const dur = durFor(f.check);
  const SEV_COST = { high: "15 points", medium: "8 points", info: "info" };
  // Informational findings render as compact rows (Uptime Kuma / alert-list
  // pattern): dot + title + code, scannable in a second, full body on open.
  // The .card class stays so every selector, counter, and keyboard flow that
  // targets cards keeps working unchanged.
  let html = '<details class="card ' + SEV[sev].cls + (sev === "info" ? " crow" : "") + '" tabindex="0" data-code="' + esc(f.code || "") + '"' +
    (f.check ? ' data-check="' + esc(f.check) + '"' : "") + ">";
  html += '<summary><span class="sevicon" data-sev="' + sev + '" aria-hidden="true" title="' + esc(SEV_NAMES[sev] + " — " + (SEV_COST[sev] || "")) + '">' + sevIcon + '</span><h3>' + esc(f.title) + '</h3>' + badges + '<span class="chev" aria-hidden="true">▸</span></summary>';
  html += '<div class="card-body">';

  // Problem — what happened (detail first sentence)
  if (f.detail) {
    const firstDot = f.detail.indexOf(". ");
    const problem = firstDot > 30 ? f.detail.slice(0, firstDot + 1) : f.detail.split("\n")[0];
    const rest = firstDot > 30 ? f.detail.slice(firstDot + 2) : "";
    html += '<div class="detail"><b>What happened:</b> ' + esc(problem) + '</div>';
    if (rest) html += '<div class="detail" style="margin-top:8px;color:var(--muted)"><span style="font-weight:600;color:var(--text)">Why it matters:</span> ' + esc(rest) + '</div>';
    if (f.confidence === "low") html += '<div style="font-size:11px;color:var(--muted2);margin-top:6px;display:flex;gap:6px;align-items:center"><span style="width:6px;height:6px;border-radius:50%;background:var(--yellow);display:inline-block"></span> Low confidence — may be a false positive</div>';
  }

  if (f.evidence) {
    const evId = "ev-" + (f.id || f.code || Math.random().toString(36).slice(2));
    const evLines = String(f.evidence).split("\n").length;
    html += '<details class="ev"><summary>Evidence — how we detected it · ' + evLines + (evLines === 1 ? " line" : " lines") + "</summary>" +
      '<div class="evidence" id="' + evId + '">' + esc(f.evidence) + '</div>' +
      '<div style="display:flex; gap:6px; margin:6px 12px 10px; flex-wrap:wrap;">' +
      '<button class="ev-expand" data-ev="' + evId + '" hidden>Show more</button>' +
      '<button class="ev-copy" data-evcopy="' + evId + '">Copy evidence</button>' +
      (dur != null ? '<span class="durpill" style="margin-left:auto" title="Check time">' + dur + 'ms</span>' : '') +
      '</div></details>';
  } else if (dur != null) {
    html += '<div style="font-family:var(--mono);font-size:10px;color:var(--muted2);margin-top:2px">' + dur + 'ms</div>';
  }

  html += '<div class="fix">';
  html += '<div class="fix-label">Recommended next step</div>';
  if (f.fix) html += '<div class="fixtext">' + cmdCode(f.fix) + '</div>';
  else html += '<div class="fixtext" style="color:var(--muted2)">No automated fix — review details above.</div>';
  html += '<button data-copy="' + esc(f.fix || f.detail || f.title) + '">Copy fix</button>';
  html += '<button data-ignore="' + esc(f.title) + '" title="Hide this finding in future runs">Dismiss</button>';
  html += '<a class="reportbtn" href="' + wrongUrl(f) + '" target="_blank" rel="noopener">Report</a>';
  html += '</div>';

  html += "</div></details>";
  return html;
}

function groupSummaryHtml(icon, label, count, sev) {
  const ds = sev ? ' data-sev="' + sev + '"' : "";
  // Human language per brief: distinguish issues needing action
  let humanLabel = label;
  if (sev === "high") humanLabel = (count === 1 ? "1 issue needs attention" : count + " issues need attention");
  else if (sev === "medium") humanLabel = count + " " + (count === 1 ? "finding" : "findings") + " — review soon";
  else if (sev === "info") humanLabel = count + " observation" + (count === 1 ? "" : "s");
  return '<summary><span class="sevicon"' + ds + ' aria-hidden="true">' + icon + '</span> ' + esc(humanLabel) + ' <span style="margin-left:6px;font-weight:400;color:var(--muted2);font-size:11px">' + esc(label) + '</span><span class="chev" aria-hidden="true">▸</span></summary>';
}

function wrapGroup(cls, type, key, summaryInner, cardsHtml, open) {
  return '<details class="group ' + cls + '"' + (open ? " open" : "") +
    ' data-type="' + type + '" data-key="' + esc(key) + '">' +
    summaryInner + '<div class="group-body">' + cardsHtml + "</div></details>";
}

/* Severity mode: high first, high pre-opened — but info collapsed by default to reduce noise */
function renderReportBySeverity(findings) {
  let html = "";
  for (const sev of SEV_ORDER) {
    const group = findings.filter((f) => f.severity === sev);
    if (!group.length) continue;
    const cards = group.map((f) => renderCard(f, sev)).join("");
    const isInfo = sev === "info";
    html += wrapGroup(SEV[sev].cls, "sev", sev,
      groupSummaryHtml(SEV_ICONS[sev] || "", SEV_NAMES[sev], group.length, sev), cards, sev === "high" && !isInfo);
  }
  return html;
}

/* Category mode */
function renderReportByCategory(findings) {
  const buckets = new Map();
  for (const f of findings) {
    const cat = checksCategoryMap.get(f.check) || "other";
    if (!buckets.has(cat)) buckets.set(cat, []);
    buckets.get(cat).push(f);
  }
  let html = "";
  for (const cat of CATEGORY_ORDER) {
    const group = buckets.get(cat);
    if (!group || !group.length) continue;
    group.sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity));
    const cards = group.map((f) => renderCard(f, f.severity)).join("");
    html += wrapGroup("", "cat", cat,
      groupSummaryHtml(catIcon(cat), CATEGORY_LABELS[cat] || cat, group.length), cards, false);
  }
  return html;
}

function renderReport(findings) {
  const html = groupBy === "category"
    ? renderReportByCategory(findings)
    : renderReportBySeverity(findings);
  return html || "";
}

function categoryOf(f) {
  return checksCategoryMap.get(f.check) || "other";
}

function wrongUrl(f) {
  return "https://github.com/7sh1d0w7x/linux-doctor/issues/new?" +
    "title=" + encodeURIComponent("Wrong finding: " + (f.code || f.title)) +
    "&body=" + encodeURIComponent(
      "**Finding:** " + (f.title || "") + "\n" +
      "**Severity:** " + (f.severity || "") + "\n" +
      "**Check:** " + (f.check || "") + "\n" +
      "**Code:** " + (f.code || "") + "\n" +
      "**Version:** " + (typeof lastReportData !== "undefined" && lastReportData ? lastReportData.version || "" : "") + "\n\n" +
      "This finding looks wrong because:\n\n[describe what you expected]\n\n---\n" +
      "Full report: `linux-doctor --json`");
}


/* ── render-sections.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Sections: next step, fixed, diff, skipped, check errors === */
function firstSentence(text) {
  return String(text || "").split(/(?<=\.)\s/)[0].trim();
}

function renderSecurityPosture(data) {
  const el = $("#security-posture");
  if (!el) return;
  const sec = securityPostureFindings(data.findings || []);
  if (!sec.length) { el.hidden = true; el.innerHTML = ""; syncNotices(); return; }
  const counts = { high: 0, medium: 0, info: 0 };
  for (const f of sec) counts[f.severity] = (counts[f.severity] || 0) + 1;
  // Only visually compete if high-severity security issue is present
  const hasHigh = counts.high > 0;
  const hasMedium = counts.medium > 0;
  if (!hasHigh && !hasMedium) {
    // Quiet informational: one subtle line inside the shared notices row.
    el.hidden = false;
    el.className = "security-posture";
    el.innerHTML =
      '<div class="sp-icon" aria-hidden="true" style="opacity:.5">' + catIcon("security", 16) + '</div>' +
      '<span class="sp-text">' + sec.length + ' security observations — no action needed</span>' +
      '<button data-spjump>View</button>';
  } else {
    const tone = hasHigh ? "high" : "warn";
    el.hidden = false;
    el.className = "security-posture " + tone;
    const label = hasHigh ? counts.high + " high" : hasMedium ? counts.medium + " warning" + (counts.medium > 1 ? "s" : "") : "";
    el.innerHTML =
      '<div class="sp-icon" aria-hidden="true">' + catIcon("security", 18) + '</div>' +
      '<span class="sp-text"><b>Security posture</b> · ' +
      esc(label + (counts.info ? " · " + counts.info + " info" : "") + ' — firewall, SSH, login & boot') + '</span>' +
      '<button data-spjump>Review security</button>';
  }
  syncNotices();
  el.querySelector("[data-spjump]")?.addEventListener("click", () => {
    activeFilter = "all";
    document.querySelectorAll(".fpill").forEach((b) => b.classList.toggle("active", b.dataset.sev === "all"));
    setGroupBy("category");
    requestAnimationFrame(() => {
      applyFilters();
      scrollToGroup("cat", "security");
    });
  }, { once: true });
}

function renderNextStep(data) {
  const el = $("#nexthep");
  if (!el) return;
  const order = { high: 0, medium: 1, info: 2 };
  const pick = data.nextAction
    || [...(data.findings || [])].sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9)).find((f) => f.fix);
  if (!pick || !pick.fix) { el.hidden = true; syncNoticeGrid(); return; }

  // Find the full finding for evidence/detail
  const full = (data.findings || []).find((f) => f.code === pick.code) || pick;
  const detail = full.detail || pick.detail || "";
  // "Why it matters" must add context, never echo: it shows everything
  // AFTER the first sentence (What happened already prints the full text).
  // Single-sentence details hide the section instead of repeating line one.
  const whyText = String(detail).split(/(?<=\.)\s/).slice(1).join(" ").trim();
  const evidence = full.evidence || "";
  const code = pick.code || full.code || "";
  const sev = pick.severity || full.severity || "medium";
  const sevLabel = sev === "high" ? "High priority" : sev === "medium" ? "Recommended" : "Informational";

  el.hidden = false;
  el.innerHTML =
    '<div class="nh-header"><span class="nh-icon">START HERE</span><span class="nh-kicker">Most urgent · ' + esc(sevLabel) + '</span><span style="margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted2)">' + esc(code) + '</span></div>' +
    '<div class="nh-body">' +
      '<div class="nh-title">' + esc(pick.title) + '</div>' +
      (code ? '<div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-top:4px">' + esc(code) + '</div>' : '') +
      '<div class="nh-section"><div class="nh-section-label">What happened</div><div class="nh-hint">' + esc(detail || "An issue was detected that needs attention.") + '</div></div>' +
      (evidence ? '<div class="nh-section"><div class="nh-section-label">Evidence</div><div style="font-family:var(--mono);font-size:11px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;overflow:auto;max-height:120px;white-space:pre-wrap;color:var(--muted)">' + esc(String(evidence).split("\n").slice(0,5).join("\n")) + '</div></div>' : '') +
      (whyText ? '<div class="nh-section"><div class="nh-section-label">Why it matters</div><div class="nh-hint">' + esc(whyText) + '</div></div>' : '') +
      '<div class="nh-section"><div class="nh-section-label">Recommended next step</div><div class="nh-hint">' + cmdCode(firstSentence(pick.fix).slice(0, 160)) + '</div></div>' +
      '<div class="nh-actions">' +
        // Primary: actually inspect — jumps to the finding card (delegated handler)
        (code ? '<button data-nhjump="' + esc(code) + '" style="background:var(--green);border-color:var(--green);color:var(--on-ok)">Inspect details</button>' : '') +
        // Secondary: single copy path via the delegated [data-nhcopy] handler in init.js
        '<button data-nhcopy="' + esc(pick.fix) + '" style="background:var(--card);border:1px solid var(--border);color:var(--text)">Copy fix</button>' +
      '</div>' +
    '</div>';
  syncNoticeGrid();
}

function renderCheckErrors(data) {
  const el = $("#checkerrors");
  if (!el) return;
  const errors = data.checkErrors || [];
  el.hidden = !errors.length;
  if (!errors.length) return;
  $("#checkerrors-count").textContent = errors.length;
  $("#checkerrors-body").innerHTML = errors.map((e) =>
    '<details class="card medium"><summary><span class="badge medium">failed</span>' +
    '<h3>' + esc(e.check) + '</h3><span class="chev">▸</span></summary>' +
    '<div class="card-body"><div class="detail">' + esc(e.error || "unknown error") + '</div></div></details>'
  ).join("");
}

function renderFixed(data) {
  const el = $("#fixed");
  const fixed = (data.diffSinceLast && data.diffSinceLast.fixed) || [];
  el.hidden = !fixed.length;
  if (!fixed.length) return;
  $("#fixed-count").textContent = fixed.length;
  $("#fixed-body").innerHTML = fixed.map((f) =>
    '<details class="card ' + (f.severity || "") + '"><summary><span class="badge fixedbadge">fixed</span>' +
    '<h3>' + esc(f.title) + '</h3>' +
    (f.code ? '<span class="checkid">' + esc(f.code) + "</span>" : "") +
    '<span class="chev">▸</span></summary>' +
    '<div class="card-body"><div class="detail">This no longer shows up in the report.</div></div></details>'
  ).join("");
}

function renderDiff(data) {
  const el = $("#diff");
  if (!el) return;
  const added = (data.diffSinceLast && data.diffSinceLast.added) || [];
  const fixed = (data.diffSinceLast && data.diffSinceLast.fixed) || [];
  const total = added.length + fixed.length;
  el.hidden = total === 0;
  if (!total) return;
  $("#diff-count").textContent = total + " (" + added.length + " new · " + fixed.length + " fixed)";
  const row = (f, cls, badge) => '<details class="card ' + (f.severity || "") + '"><summary><span class="badge ' + cls + '">' + badge + '</span>' +
    '<h3>' + esc(f.title) + '</h3>' + (f.code ? '<span class="checkid">' + esc(f.code) + '</span>' : "") + '<span class="chev">▸</span></summary>' +
    '<div class="card-body"><div class="detail">' + esc(f.detail || (cls === "high" ? "New since last run" : "Fixed since last run")) + '</div></div></details>';
  let html = "";
  if (added.length) html += '<div style="grid-column:1/-1; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--red); margin-bottom:4px;">● New (' + added.length + ')</div>' + added.map(f => row(f, f.severity || "high", "NEW")).join("");
  if (fixed.length) html += '<div style="grid-column:1/-1; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--green); margin: 10px 0 4px;">● Fixed (' + fixed.length + ')</div>' + fixed.map(f => row(f, f.severity || "info", "FIXED")).join("");
  $("#diff-body").innerHTML = html;
}

async function renderSkipped() {
  const box = $("#skipped");
  const byId = new Map();
  // Profile-based skips need the live check list; atomic skips travel in the
  // report payload itself, so a static --html export can still show them
  // (it used to drop the whole section).
  if (!STATIC_DATA) {
    const checks = await fetchChecks();
    for (const c of checks.filter((c) => !c.appliesHere)) {
      byId.set(c.id, { id: c.id, title: c.title, reason: "Only runs on: " + c.appliesTo.join(", ") + "." });
    }
  }
  const atomic = (typeof lastReportData !== "undefined" && lastReportData && lastReportData.skippedChecks) || [];
  for (const s of atomic) {
    if (!byId.has(s.id)) byId.set(s.id, { id: s.id, title: s.title, reason: s.reason || "Not applicable on this immutable/atomic system." });
  }
  const skipped = [...byId.values()];
  box.hidden = !skipped.length;
  if (!skipped.length) return;
  $("#skipped-count").textContent = skipped.length;
  $("#skipped-body").innerHTML = skipped.map((c) =>
    '<details class="card info"><summary><span class="badge info">skipped</span>' +
    '<h3>' + esc(c.title) + '</h3><span class="checkid">' + esc(c.id) + '</span>' +
    '<span class="chev">▸</span></summary>' +
    '<div class="card-body"><div class="detail">' + esc(c.reason) + '</div></div></details>'
  ).join("");
}


/* ── render-status.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Status zone: gauge, score, status message — PREMIUM HIERARCHY === */
const SCORE_RING_R = 26;
const SCORE_RING_C = 2 * Math.PI * SCORE_RING_R;

function renderGauge(score, size) {
  const s = size || 96;
  const scoreColor = score >= 80 ? "var(--green)" : score >= 50 ? "var(--yellow)" : "var(--red)";
  return '<div class="gauge' + (s > 96 ? ' large' : '') + '" style="width:' + s + 'px;height:' + s + 'px">' +
    '<svg viewBox="0 0 62 62" role="img" aria-label="Health score ' + score + ' of 100">' +
    '<circle class="track" cx="31" cy="31" r="' + SCORE_RING_R + '"></circle>' +
    '<circle class="ring" id="scorering" cx="31" cy="31" r="' + SCORE_RING_R + '" ' +
    'transform="rotate(-90 31 31)" stroke="' + scoreColor + '" ' +
    'stroke-dasharray="' + SCORE_RING_C + '" stroke-dashoffset="' + SCORE_RING_C + '"></circle>' +
    '</svg>' +
    '<div class="gauge-center"><div class="scorenum" id="scorenum">0</div><div class="scoreout">/100</div></div>' +
    '</div>';
}

function animateScore(target) {
  const num = $("#scorenum");
  const live = $("#scorering");
  if (!num) return;
  let reduce = false;
  try { reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch {}
  /* The trace is drawn up to the score — the machine's own vital sign.
     pathLength="100" makes the dash arithmetic trivial. The dot that marks the
     end of the trace is placed by renderStatus itself (an inline `left`), so
     nothing here can leave it behind: it is HTML, not a circle with a `cx`. */
  const setPulse = (v) => {
    if (live) live.style.strokeDashoffset = String(100 - v);
  };
  if (reduce) {
    num.textContent = target;
    setPulse(target);
    return;
  }
  const t0 = performance.now();
  const dur = 900;
  const step = (now) => {
    const p = Math.min(1, (now - t0) / dur);
    const v = target * (1 - Math.pow(1 - p, 3));
    num.textContent = Math.round(v);
    setPulse(v);
    if (p < 1) requestAnimationFrame(step);
  };
  requestAnimationFrame(step);
}

/* The product's own motif: a heartbeat, not a dashboard gauge. Same waveform
   as the brand mark and the favicon, but the shape comes from the report —
   a steady monitoring rhythm, with one taller beat for every finding that
   needs attention (taller still for a high severity). A clean machine draws
   the rhythm alone. */
function pulsePath(w, amps) {
  const mid = 14;
  if (!amps.length) return "M0 " + mid + " H" + w;
  const step = w / amps.length;
  let d = "M0 " + mid;
  amps.forEach((amp, i) => {
    const a = 3.5 + 9 * amp;
    const peak = 16 - a;
    const trough = peak + a * 0.6 + 3;
    d += " H" + (i * step + step * 0.42).toFixed(1);
    d += " l2 2 l1 " + (-a).toFixed(1) + " l1 " + (a * 0.6 + 3).toFixed(1) + " l2 " + (-(trough - mid)).toFixed(1);
    d += " H" + ((i + 1) * step).toFixed(1);
  });
  return d;
}

/* What the trace is made of: a fixed monitoring rhythm, with the beats that
   carry a finding made taller in proportion to its severity. */
function pulseTrace(findings) {
  const high = findings.filter((f) => f.severity === "high").length;
  const med = findings.filter((f) => f.severity === "medium").length;
  const issues = Math.min(5, high + med);
  const amp = high ? 1 : med ? 0.55 : 0;
  return pulsePath(120, [1, 2, 3, 4, 5].map((_, i) => (i < issues ? amp : 0.16)));
}

function renderStatusMessage(findings) {
  const high = findings.filter((f) => f.severity === "high").length;
  const med = findings.filter((f) => f.severity === "medium").length;
  const inf = findings.filter((f) => f.severity === "info").length;
  let headline, sublabel, level;
  if (high + med === 0) {
    if (findings.length === 0) {
      headline = "Healthy";
      sublabel = "No high or medium issues — no issues detected";
    } else {
      headline = "Healthy";
      sublabel = "No high or medium issues — " + inf + " informational " + (inf === 1 ? "note" : "notes") + " — no action needed";
    }
    level = "ok";
  } else if (high > 0) {
    headline = high === 1 ? "Needs attention" : "Needs attention";
    sublabel = high + " high-severity " + (high === 1 ? "issue" : "issues") + (med ? " · " + med + " warnings" : "");
    level = "critical";
  } else {
    headline = "Needs attention";
    sublabel = med + " " + (med === 1 ? "issue needs" : "issues need") + " attention";
    level = "warn";
  }
  return { headline, sublabel, level, high, med, inf };
}

function renderStatus(data) {
  const { findings = [], system = {} } = data;
  const ver = (data.version || "").replace(/^v/, "");
  $(".sysinfo").innerHTML =
    [esc(system.distro), esc(system.kernel), esc((system.cores || "?") + " core(s)"), "up " + esc(system.uptime)].filter(Boolean).join(" · ") +
    (ver ? ' · <span class="ver">v' + esc(ver) + "</span>" : "");

  const high = findings.filter((f) => f.severity === "high").length;
  const med = findings.filter((f) => f.severity === "medium").length;
  const inf = findings.filter((f) => f.severity === "info").length;

  const { headline, sublabel } = renderStatusMessage(findings);

  const scoreDelta = typeof data.scoreDelta === "number" ? data.scoreDelta : null;
  const deltaCls = scoreDelta === null ? "" : scoreDelta > 0 ? "delta-up" : scoreDelta < 0 ? "delta-down" : "delta-flat";
  const deltaIcon = scoreDelta === null ? "" : scoreDelta > 0 ? "↗" : scoreDelta < 0 ? "↘" : "→";
  const deltaTxt = scoreDelta === null ? "no baseline yet" : (scoreDelta > 0 ? "+" + scoreDelta : String(scoreDelta)) + " since last check";
  const score = typeof data.score === "number" ? data.score : 0;

  const chips =
    (data.newCount > 0 ? '<span class="newbadge">NEW ' + data.newCount + "</span>" : "") +
    (data.fixedCount > 0 ? '<span class="fixedbadge">FIXED ' + data.fixedCount + "</span>" : "");

  const status = $("#status");
  /* The score lives in the rail now: a compact Health block — number, a
     progress hairline, the headline and one counts line. No gauge, no KPI
     tiles: those belong to a dashboard, and this is an application. */
  const band = score >= 80 ? "ok" : score >= 50 ? "warn" : "bad";
  status.className = "status " + band;
  const deltaShort = scoreDelta === null ? "" : (scoreDelta > 0 ? "+" + scoreDelta : String(scoreDelta));
  const trace = pulseTrace(findings);

  status.innerHTML =
    '<div class="sr-head">' +
      '<span class="sr-k">Health</span>' +
      (scoreDelta === null ? "" :
        '<span class="scoredelta ' + deltaCls + '" title="' + esc(deltaTxt) + '">' + deltaIcon + " " + deltaShort + "</span>") +
    "</div>" +
    '<div class="sr-score"><span class="sr-num" id="scorenum">0</span><span class="sr-out">/100</span></div>' +
    '<div class="sr-pulse" role="img" aria-label="Health score ' + score + " of 100\">" +
      '<svg viewBox="0 0 120 22" preserveAspectRatio="none" aria-hidden="true">' +
        '<path class="pulse-track" d="' + trace + '" pathLength="100" vector-effect="non-scaling-stroke"></path>' +
        '<path class="pulse-live" id="scorering" d="' + trace + '" pathLength="100" vector-effect="non-scaling-stroke" style="stroke-dashoffset:100"></path>' +
      "</svg>" +
      '<span class="pulse-dot" id="pulsedot" aria-hidden="true" style="left:' + score + '%"></span>' +
    "</div>" +
    '<div class="sr-msg status-headline">' + esc(headline) + "</div>" +
    '<div class="sr-line">' +
      (high + med > 0
        ? '<b>' + high + "</b> high · <b>" + med + "</b> medium" + (inf ? " · " + inf + " info" : "")
        : esc(sublabel)) +
    "</div>" +
    '<div class="sr-meta">' +
      chips +
      '<span id="hero-spark" class="chip-trend" hidden></span>' +
      '<button id="checkschip" type="button" class="lowbadge" hidden></button>' +
    "</div>" +
    '<div class="sr-foot">' +
      '<span class="sr-fresh"><span class="dot" id="statusdot"></span> <span id="statuspill-txt"></span></span>' +
    "</div>";

  animateScore(score);
  return { high, medium: med, info: inf, score };
}


/* ── ui-system.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === System view: the machine wiki ===
   A complete, readable profile from the report payload — no new probes, no
   new endpoints. Sections mirror how admins actually ask about a box: what
   OS, what hardware, what session. Rendered on every report so the view
   never goes stale behind Overview. */

function fmtGB(bytes) {
  if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return null;
  return (bytes / 1024 ** 3).toFixed(1) + " GB";
}

function systemType(sys) {
  if (sys.atomicVariant) return "immutable (" + sys.atomicVariant + ")";
  if (sys.imageBased || sys.immutable) return "immutable";
  return "classic";
}

/* A fact row. Values that are identifiers (kernel, hostname, version, the
   profile, the desktop) are mono and copyable — clicking copies, the same
   delegated data-copy handler the findings use. */
function systemFactsHtml(data) {
  const sys = data.system || {};
  const counts = { high: 0, medium: 0, info: 0 };
  for (const f of data.findings || []) counts[f.severity] = (counts[f.severity] || 0) + 1;
  const fact = (k, v, opts) => {
    opts = opts || {};
    const val = v == null || v === "" ? "\u2014" : String(v);
    const cell = opts.copy
      ? '<button class="idval" type="button" data-copy="' + esc(val) + '" title="Copy ' + esc(val) + '" aria-label="Copy ' + esc(k) + '">' + esc(val) + "</button>"
      : '<span' + (opts.mono ? ' class="mono"' : "") + ">" + esc(val) + "</span>";
    return '<div class="sysfact"><span class="sysfact-k">' + k + '</span><span class="sysfact-v">' + cell + "</span></div>";
  };
  const sub = (t, d) => '<div class="sys-sub">' + t + (d ? '<span class="sys-sub-d">' + d + "</span>" : "") + "</div>";
  const rows = (n) => '<span class="vsec-count">' + n + (n === 1 ? " fact" : " facts") + "</span>";
  const group = (label, n, body) => '<section class="vsec"><div class="vsec-head">' + label + rows(n) + "</div>" + body + "</section>";

  const mem = fmtGB(sys.memTotalBytes);
  const cpu = [sys.cpuModel, sys.cores && sys.cores !== "unknown" ? sys.cores + " cores" : null]
    .filter(Boolean).join(" \u00b7 ") || null;

  const os = sub("Operating system", "what software this machine runs") +
    fact("Distribution", sys.distro) +
    fact("Version", (sys.osRelease || {}).VERSION_ID, { copy: true }) +
    fact("Family", sys.family) +
    fact("Architecture", sys.arch, { mono: true }) +
    fact("Kernel", sys.kernel, { copy: true }) +
    fact("System type", systemType(sys)) +
    fact("Package manager", (sys.atomic || {}).pkg, { mono: true });
  const hw = sub("Hardware", "What this machine is") +
    fact("CPU", cpu) +
    fact("Memory", mem, { mono: true }) +
    fact("Hostname", sys.hostname, { copy: true });

  const session = sub("Session", "how this machine is running right now") +
    fact("Uptime", sys.uptime) +
    fact("Profile", sys.kind, { copy: true }) +
    fact("Desktop", sys.desktop, { copy: true }) +
    fact("Session type", sys.sessionType);
  const report = sub("This report", "how this run turned out") +
    fact("Health score", typeof data.score === "number" ? data.score + "/100" : "\u2014", { mono: true }) +
    fact("Findings", counts.high + " high \u00b7 " + counts.medium + " medium \u00b7 " + counts.info + " info") +
    fact("Checks", (data.checksRun != null ? data.checksRun + " ran" : "\u2014") +
      (data.checksSkipped ? " \u00b7 " + data.checksSkipped + " skipped" : "")) +
    fact("Generated", data.generatedAt, { mono: true });

  return group("Read once \u2014 what this machine is", 10, os + hw) +
    group("Read when something's wrong", 8, session + report);
}

function renderSystemView(data) {
  const box = $("#systemview");
  if (!box) return;
  box.innerHTML = systemFactsHtml(data || {});
}


/* ── render.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Main render orchestrator === */

function render(data) {
  lastReportData = data;
  lastData = data;
  const { findings = [] } = data;

  const counts = renderStatus(data);
  renderFilters(counts);
  renderSidebar(data, counts);

  // "35 checks · 2 skipped" chip — same vocabulary as the CLI's Checks line.
  const chip = $("#checkschip");
  if (chip) {
    if (data.checksRun != null) {
      chip.textContent = data.checksRun + " checks" + (data.checksSkipped ? " \u00b7 " + data.checksSkipped + " skipped" : "");
      chip.hidden = false;
      chip.title = "Open the all-checks matrix";
    } else {
      chip.hidden = true;
    }
  }

  if (data.generatedAt) {
    setStatus(data.generatedAt);
    clearInterval(statusTimer);
    statusTimer = setInterval(() => setStatus(data.generatedAt), 30000);
  }

  const reportHtml = renderReport(findings);
  if (reportHtml) {
    $("#report").innerHTML = reportHtml;
  } else if (counts.high + counts.medium === 0) {
    // Celebratory all-clean state — earned, so show it properly.
    $("#report").innerHTML =
      '<div class="cleanhero"><div class="ch-icon" aria-hidden="true">\u2713</div><div>' +
      '<div class="ch-title">Everything is clean</div>' +
      '<div class="ch-sub">' + (data.cleanStreak >= 2
        ? esc(String(data.cleanStreak)) + " clean runs in a row. Keep it up."
        : "No issues found on this machine.") + "</div></div></div>";
  } else {
    $("#report").innerHTML = '<div class="empty">Nothing to show.</div>';
  }

  document.querySelectorAll(".evidence").forEach(ev => {
    const btn = ev.parentElement.querySelector(".ev-expand");
    if (!btn) return;
    if (ev.textContent.length > 500 || ev.scrollHeight > 228) btn.hidden = false;
  });

  $("#report").removeAttribute("aria-busy");
  applyFilters();
  refreshSchedule();
  renderSystemView(data);
  updateChecksBadge(data);
  renderSecurityPosture(data);
  renderNextStep(data);
  renderCheckErrors(data);
  renderFixed(data);
  renderDiff(data);
  maybeNotify(data);
  renderTrend();
  renderSkipped();
  applyWideGroups();
  syncDetailPane();
}


/* ── init.js ── */
// SPDX-License-Identifier: GPL-3.0-or-later
/* === Entry point: load, bind, start === */

// Inline-SVG capability probe. Icons ship as inline SVG (never font glyphs);
// every icon carries a hidden emoji fallback (.ico .fb). If SVG ever fails
// to render — hostile CSP, stripped build, exotic sandbox — this flips the
// root to .no-svg and CSS swaps every fallback in at once.
(function svgProbe() {
  try {
    const el = document.createElement("div");
    el.innerHTML = '<svg width="8" height="8"><rect width="8" height="8"/></svg>';
    el.style.position = "absolute";
    el.style.opacity = "0";
    document.body.appendChild(el);
    const r = el.firstChild.getBoundingClientRect();
    el.remove();
    if (!(r.width > 0 && r.height > 0)) markNoSvg();
  } catch {
    markNoSvg();
  }
  function markNoSvg() {
    const root = document.documentElement;
    if (root && root.classList && typeof root.classList.add === "function") root.classList.add("no-svg");
  }
})();

// Theme
applyTheme();
$("#theme").addEventListener("click", cycleTheme);
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme);

// Sticky header/toolbar
const hdr = document.querySelector("header");
const tbar = document.querySelector(".toolbar");
window.addEventListener("scroll", () => {
  const sc = window.scrollY > 8;
  if (hdr) hdr.classList.toggle("scrolled", sc);
  if (tbar) tbar.classList.toggle("scrolled", sc);
}, { passive: true });

// Filters
$("#filters").addEventListener("click", (e) => {
  const btn = e.target.closest(".fpill");
  if (!btn) return;
  activeFilter = activeFilter === btn.dataset.sev ? null : btn.dataset.sev;
  document.querySelectorAll(".fpill").forEach((b) => b.classList.toggle("active", b.dataset.sev === activeFilter));
  syncGroupsOpen();
  applyFilters();
});
$("#search").addEventListener("input", applyFilters);
$("#clearbtn").addEventListener("click", () => {
  activeFilter = "all";
  $("#search").value = "";
  document.querySelectorAll(".fpill").forEach((b) => b.classList.toggle("active", b.dataset.sev === activeFilter));
  syncGroupsOpen();
  applyFilters();
});

// Expand all
$("#expandall").addEventListener("click", () => {
  const anyClosed = [...document.querySelectorAll("#report .group, #report .card, #report .crow")].some((el) => !el.open);
  setAllOpen(anyClosed);
  syncAutoPausedUI();
});

// Export dropdown
(function setupExportDropdown() {
  const dd = $("#export-dropdown");
  const trigger = $("#export-trigger");
  if (!dd || !trigger) return;
  trigger.addEventListener("click", (e) => {
    e.stopPropagation();
    const open = dd.classList.toggle("open");
    trigger.setAttribute("aria-expanded", String(open));
  });
  document.addEventListener("click", () => {
    dd.classList.remove("open");
    trigger.setAttribute("aria-expanded", "false");
  });
  const menuItems = () => [...dd.querySelectorAll(".dropdown-item")];
  const closeMenu = (focusTrigger) => {
    dd.classList.remove("open");
    trigger.setAttribute("aria-expanded", "false");
    if (focusTrigger) trigger.focus();
  };
  dd.querySelectorAll(".dropdown-item").forEach(btn => {
    btn.addEventListener("click", () => closeMenu(true));
  });
  dd.addEventListener("keydown", (e) => {
    if (!dd.classList.contains("open")) return;
    const list = menuItems();
    const i = list.indexOf(document.activeElement);
    // Stop the global card-navigation handler on document from also acting.
    if (e.key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); (list[i + 1] || list[0])?.focus(); }
    else if (e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); (list[i - 1] || list[list.length - 1])?.focus(); }
    else if (e.key === "Home") { e.preventDefault(); e.stopPropagation(); list[0]?.focus(); }
    else if (e.key === "End") { e.preventDefault(); e.stopPropagation(); list[list.length - 1]?.focus(); }
  });
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && dd.classList.contains("open")) {
      closeMenu(true);
      e.stopImmediatePropagation();
    }
  });
})();

// Re-run
async function load({ saveHistory = false } = {}) {
  const status = $("#status");
  const report = $("#report");
  const rerun = $("#rerun");
  status.className = "status running";
  status.innerHTML = '<span class="spinner" aria-hidden="true"></span>Reading your system\u2026';
  $("#expandall").textContent = "\u229e Expand all";
  report.innerHTML = '<div class="skel-hero"></div><div class="skel-card"></div><div class="skel-card"></div><div class="skel-card"></div>';
  rerun.disabled = true;
  rerun.textContent = "↻ Running…";
  try {
    // Category grouping needs the check→category map; fetch it once before
    // the first render. Never fatal — grouping falls back to "Other".
    await loadCategoryMap();
    // Explicit load (first paint / Re-run) asks for a fresh scan; only an
    // explicit Re-run records the run in history — background polling never
    // does (it would churn the trend).
    render(await fetchReport({ refresh: true, save: saveHistory }));
    if (saveHistory) showToast("\u2713 Run recorded in history");
  } catch (err) {
    status.className = "status warn";
    status.innerHTML = "";
    status.textContent = "\u26a0\ufe0f Could not run checks: " + (err && err.message ? err.message : err);
    report.innerHTML = '<div class="empty">Run failed \u2014 see the message above. ' +
      (isDesktop()
        ? "The desktop app ships its own Node runtime, so a reinstall usually fixes a broken install."
        : "Make sure the dashboard server is still running, then press Re-run checks.") +
      "</div>";
  } finally {
    rerun.disabled = false;
    rerun.textContent = "↻ Re-run checks";
  }
}
$("#rerun").addEventListener("click", () => load({ saveHistory: true }));

// PDF export
const pdfBtn = $("#pdfbtn");
if (pdfBtn) {
  pdfBtn.addEventListener("click", () => {
    document.querySelectorAll(".group, .card, .crow, details.ev").forEach(el => el.setAttribute("open", ""));
    setTimeout(() => window.print(), 120);
    showToast("Print dialog opened \u2014 save as PDF");
  });
}

// Markdown export — scrubbed, share-ready (same scrub() as CLI --md)
$("#export").addEventListener("click", async () => {
  if (!lastData) return;
  await copyText(reportMarkdown(lastData));
  const btn = $("#export");
  const old = btn.textContent;
  btn.textContent = "\u2713 Copied";
  showToast("\u2713 Report copied as Markdown (scrubbed)");
  setTimeout(() => (btn.textContent = old), 1200);
});
$("#export-md-file")?.addEventListener("click", () => {
  if (!lastData) return;
  downloadMarkdown(lastData);
  showToast("\u2713 Markdown file downloaded (scrubbed)");
});

// Banner buttons: copy fix / jump to finding
document.addEventListener("click", async (e) => {
  // NH copy
  const copyBtn = e.target.closest("[data-nhcopy]");
  if (copyBtn) {
    await copyText(copyBtn.dataset.nhcopy);
    showToast("\u2713 Fix copied to clipboard");
    return;
  }
  // NH jump
  const jumpBtn = e.target.closest("[data-nhjump]");
  if (jumpBtn) {
    const code = jumpBtn.dataset.nhjump;
    activeFilter = "all";
    document.querySelectorAll(".fpill").forEach((b) => b.classList.toggle("active", b.dataset.sev === "all"));
    syncGroupsOpen();
    applyFilters();
    const card = code ? document.querySelector('#report details[data-code="' + code.replace(/"/g, '\\"') + '"]') : null;
    if (card) {
      card.setAttribute("open", "");
      card.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
      setTimeout(() => { card.classList.remove("flash"); }, 50);
      const nh = $("#nexthep");
      if (nh) nh.classList.add("flash");
      setTimeout(() => nh && nh.classList.remove("flash"), 1700);
    }
    return;
  }
  // Evidence copy
  if (e.target.classList.contains("ev-copy")) {
    const id = e.target.dataset.evcopy;
    const ev = document.getElementById(id);
    if (ev) {
      await copyText(ev.textContent);
      const old = e.target.textContent;
      e.target.textContent = "\u2713 Copied";
      showToast("\u2713 Evidence copied");
      setTimeout(() => e.target.textContent = old, 1200);
    }
    return;
  }
  // Evidence expand
  if (e.target.classList.contains("ev-expand")) {
    const id = e.target.dataset.ev;
    const ev = document.getElementById(id);
    if (ev) {
      const expanded = ev.classList.toggle("expanded");
      e.target.textContent = expanded ? "Show less" : "Show more";
    }
    return;
  }
  // Generic copy
  if (e.target.dataset.copy !== undefined) {
    // A code pill lives inside the card <summary>: copying must not also
    // toggle the finding open/closed.
    if (e.target.closest && e.target.closest(".codepill")) e.preventDefault();
    await copyText(e.target.dataset.copy);
    const old = e.target.textContent;
    e.target.textContent = "\u2713 Copied";
    showToast("\u2713 Copied to clipboard");
    setTimeout(() => (e.target.textContent = old), 1200);
  }
  // Ignore
  if (e.target.dataset.ignore !== undefined) {
    await ignoreFinding(e.target.dataset.ignore, e.target);
  }
  // Undo
  if (e.target.dataset.undo !== undefined) {
    if (lastUndo && lastData) {
      lastData.findings = [...lastUndo.findings];
      render(lastData);
      showToast("\u21a9 Restored");
      lastUndo = null;
    }
  }
});

// Ignore finding
let lastUndo = null;
async function ignoreFinding(title, btn) {
  const payload = JSON.stringify({ pattern: title });
  const tryPost = async (url) => {
    const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload });
    return res.ok;
  };
  let ok = false;
  try { ok = await tryPost("http://127.0.0.1:17321/api/ignore"); } catch { ok = false; }
  if (!ok) { try { ok = await tryPost("/api/ignore"); } catch { ok = false; } }
  btn.disabled = true;
  btn.textContent = ok ? "\u2713 Ignored" : "\u2717 Could not save";
  if (ok && lastData) {
    lastUndo = { findings: [...lastData.findings], title };
    lastData.findings = lastData.findings.filter((f) => f.title !== title);
    render(lastData);
    showToast('Ignored "' + esc(title).slice(0, 40) + (title.length > 40 ? "\u2026" : "") + '" <button data-undo>Undo</button>', 4000);
    setTimeout(() => { if (lastUndo && lastUndo.title === title) lastUndo = null; }, 4200);
  } else if (!ok) {
    showToast("\u2717 Could not save \u2014 check permissions");
  }
}

// Setup modules
setupKeyboard();
setupPalette();
setupPolling();
setupThresholds();
setupNotifyButton();
setupSidebar();
setupModal();
setupChecksMatrix();
setupChecksView();
setupUrlSync();
setupViews();
setupBrand();

// JSON export — the same versioned envelope /api/report serves.
$("#copyjson")?.addEventListener("click", async () => {
  if (!lastData) return;
  await copyText(JSON.stringify(lastData, null, 2));
  showToast("\u2713 JSON report copied");
});

// Keyboard shortcuts help (?)
$("#helpbtn")?.addEventListener("click", openHelp);
/* Empty-state escape hatches: the filtered-out message and any future one
   carry a control, so a dead end is always one click from resolving. */
document.addEventListener("click", (e) => {
  if (e.target.closest("[data-clear-filters]")) $("#clearbtn")?.click();
});

// The hero "N checks" chip opens the matrix too.
$("#checkschip")?.addEventListener("click", openChecksMatrix);

// Density toggle: compact card spacing, remembered across sessions.
// Technical users get compact by default (more findings per viewport, less scroll).
const densityBtn = $("#densitybtn");
if (densityBtn) {
  try {
    const pref = localStorage.getItem("ld-density");
    if (pref === "compact" || pref === null) document.body.classList.add("compact");
  } catch { document.body.classList.add("compact"); }
  const syncDensityBtn = () => {
    const compact = document.body.classList.contains("compact");
    densityBtn.setAttribute("aria-pressed", String(compact));
    densityBtn.title = compact ? "Density: compact — click for comfortable" : "Density: comfortable — click for compact";
  };
  syncDensityBtn();
  densityBtn.addEventListener("click", () => {
    const compact = document.body.classList.toggle("compact");
    try { localStorage.setItem("ld-density", compact ? "compact" : "comfortable"); } catch {}
    syncDensityBtn();
    showToast(compact ? "Compact density" : "Comfortable density");
  });
}

// Initial load
setupWideMode();
setupDetailPane();
if (STATIC_DATA) {
  render(STATIC_DATA);
  const rerun = $("#rerun");
  if (rerun) { rerun.disabled = true; rerun.title = "Static report \u2014 regenerate with linux-doctor --html <path>"; }
} else {
  load();
  startPolling();
}

</script>
</body>
</html>