---
title: Repositories
---

```js
import {API} from "./components/config.js";
```

```js
// Fast data — page renders as soon as this resolves (~200ms)
let _repos = [], _domains = [], _sbom = [], _eps = [], _tds = [], _workstreams = [];
try {
  [_repos, _domains, _sbom, _eps, _tds, _workstreams] = await Promise.all([
    fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []),
    fetch(`${API}/domains/`).then(r => r.ok ? r.json() : []),
    fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []),
    fetch(`${API}/extension-points/`).then(r => r.ok ? r.json() : []),
    fetch(`${API}/technical-debt/`).then(r => r.ok ? r.json() : []),
    fetch(`${API}/workplans/`).then(r => r.ok ? r.json() : []),
  ]);
} catch {}
```

```js
// DoI data — lazy-loaded. Starts as [] so the page renders immediately.
// When the fetch resolves (~6s), doiData updates and DoI badges appear.
import {Mutable} from "observablehq:stdlib";
const doiData = Mutable([]);
const doiLoading = Mutable(true);
fetch(`${API}/repos/doi/summary`)
  .then(r => r.ok ? r.json() : [])
  .catch(() => [])
  .then(data => { doiData.value = data; doiLoading.value = false; });
```

```js
const repos       = _repos       ?? [];
const domains     = _domains     ?? [];
const sbom        = _sbom        ?? [];
const eps         = _eps         ?? [];
const tds         = _tds         ?? [];
const workstreams = _workstreams ?? [];
const doi         = doiData;  // reactive — updates when lazy fetch completes

// DoI lookups
const doiBySlug = Object.fromEntries(doi.map(d => [d.repo_slug, d]));
const DOI_TIER_ORDER = {none: 0, core: 1, standard: 2, full: 3};
const DOI_TIER_COLOR = {none: "#ef4444", core: "#f97316", standard: "#eab308", full: "#22c55e"};
const DOI_TIER_BG    = {none: "#fef2f2", core: "#fff7ed", standard: "#fefce8", full: "#f0fdf4"};
const DOI_TIER_LABEL = {none: "None", core: "Core", standard: "Standard", full: "Full"};

// Lookups
const domainById   = Object.fromEntries(domains.map(d => [d.id, d]));
const domainBySlug = Object.fromEntries(domains.map(d => [d.slug, d]));

// Active "repo-integration-{slug}" workstreams — signals onboarding in progress
const integratingBySlug = Object.fromEntries(
  workstreams
    .filter(w => w.status === "active" && w.slug?.startsWith("repo-integration-"))
    .map(w => [w.slug.replace("repo-integration-", ""), w])
);

// Per-repo SBOM stats (from sbom entries)
const sbomByRepo = {};
for (const e of sbom) {
  if (!sbomByRepo[e.repo_id]) sbomByRepo[e.repo_id] = { count: 0, snapshot_at: e.snapshot_at };
  sbomByRepo[e.repo_id].count++;
}

// Per-domain counts
const epByDomain    = {};
const tdByDomain    = {};
const contribByDomain = {};

// EPs are domain-scoped
for (const ep of eps) {
  if (!ep.status || ep.status === "open" || ep.status === "in_progress") {
    epByDomain[ep.domain] = (epByDomain[ep.domain] ?? 0) + 1;
  }
}
for (const td of tds) {
  if (!td.status || td.status === "open" || td.status === "in_progress") {
    tdByDomain[td.domain] = (tdByDomain[td.domain] ?? 0) + 1;
  }
}
// Contributions: try to map via workstream → topic → domain (not available here; skip for now)
// Use domain slug from contributions' related_workstream if available — fallback: count by type only

// Build enriched repo rows
const repoRows = repos
  .filter(r => r.status === "active")
  .map(r => {
    const domain   = domainById[r.domain_id];
    const domSlug  = domain?.slug ?? "—";
    const domName  = domain?.name ?? "—";
    const sbomData = sbomByRepo[r.id];
    const hasSbom  = !!sbomData || !!r.last_sbom_at;
    const pkgCount = sbomData?.count ?? 0;
    const lastScan = r.last_sbom_at
      ? new Date(r.last_sbom_at).toLocaleDateString()
      : (sbomData?.snapshot_at ? new Date(sbomData.snapshot_at).toLocaleDateString() : null);
    const integrating = !!integratingBySlug[r.slug];
    const doiEntry = doiBySlug[r.slug] ?? null;
    const doiTier  = doiEntry?.tier ?? "none";
    const category = r.category ?? "—";
    const capList  = r.capability_tags ?? [];
    const stakeList = r.business_stake ?? [];
    const capTags  = capList.length
      ? capList.slice(0, 3).join(", ") + (capList.length > 3 ? "…" : "")
      : "—";
    const classifiedBy = r.classified_by ?? "—";
    return {
      _id:          r.id,
      _domSlug:     domSlug,
      _category:    category,
      _capList:     capList,
      _stakeList:   stakeList,
      _hasSbom:     hasSbom,
      _integrating: integrating,
      _doiTier:     doiTier,
      repo:         r.slug,
      domain:       domName,
      category:     category,
      capTags:      capTags,
      businessStake: stakeList.length ? stakeList.slice(0, 3).join(", ") : "—",
      classifiedBy: classifiedBy,
      status:       integrating ? "⚙ integrating" : "ready",
      path:         r.local_path ?? "—",
      sbom:         hasSbom ? `✓ ${lastScan}` : "⚠ not ingested",
      pkgs:         pkgCount || (hasSbom ? "—" : 0),
      eps:          epByDomain[domSlug] ?? 0,
      tds:          tdByDomain[domSlug] ?? 0,
    };
  })
  .sort((a, b) => a._domSlug.localeCompare(b._domSlug) || a.repo.localeCompare(b.repo));

const gapCount         = repoRows.filter(r => !r._hasSbom).length;
const coveredCount     = repoRows.filter(r => r._hasSbom).length;
const integratingCount = repoRows.filter(r => r._integrating).length;
const doiFullCount     = repoRows.filter(r => r._doiTier === "full").length;
const doiNoneCount     = repoRows.filter(r => r._doiTier === "none").length;
```

# Repositories

```js
import {withDocHelp} from "./components/doc-overlay.js";
const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/repos"); }
display(html`<p style="font-size:0.85rem;color:#6b7280;margin-top:-0.5rem;display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
  <span>DoI tiers: <strong style="color:#ef4444;">None</strong> →
  <strong style="color:#f97316;">Core</strong> →
  <strong style="color:#eab308;">Standard</strong> →
  <strong style="color:#22c55e;">Full</strong> —
  <a href="/policy/repo-doi" style="color:#1d4ed8;">Definition of Integrated policy ↗</a></span>
  ${doiLoading ? html`<span style="display:inline-flex;align-items:center;gap:0.35rem;color:#9ca3af;font-size:0.8rem;">
    <span class="doi-spinner"></span> Loading DoI tiers…
  </span>` : html`<span style="color:#16a34a;font-size:0.8rem;">✓ DoI tiers loaded</span>`}
</p>`);
```

```js
// Summary KPIs
display(html`<div class="kpi-row">
  <div class="card">
    <h3>Registered Repos</h3>
    <p class="big-num">${repoRows.length}</p>
  </div>
  <div class="card">
    <h3>Market Domains</h3>
    <p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
  </div>
  <div class="card">
    <h3>Categories</h3>
    <p class="big-num">${new Set(repoRows.map(r => r._category).filter(c => c !== "—")).size}</p>
  </div>
  <div class="card ${integratingCount > 0 ? 'card-integrating' : ''}">
    <h3>Integrating</h3>
    <p class="big-num">${integratingCount}</p>
    <small>${integratingCount === 0 ? "✓ All repos integrated" : `⚙ ${integratingCount} onboarding`}</small>
  </div>
  <div class="card ${coveredCount < repoRows.length ? '' : ''}">
    <h3>SBOM Ingested</h3>
    <p class="big-num">${coveredCount} / ${repoRows.length}</p>
  </div>
  <div class="card ${gapCount > 0 ? 'card-warn' : 'card-ok'}">
    <h3>SBOM Gaps</h3>
    <p class="big-num">${gapCount}</p>
    <small>${gapCount === 0 ? "✓ All repos covered" : `⚠ ${gapCount} repo(s) not ingested`}</small>
  </div>
  <div class="card ${doiLoading ? '' : doiNoneCount > 0 ? 'card-warn' : 'card-ok'}">
    <h3>DoI: Fully Integrated</h3>
    <p class="big-num">${doiLoading ? html`<span class="doi-spinner" style="width:1.4rem;height:1.4rem;"></span>` : `${doiFullCount} / ${repoRows.length}`}</p>
    <small>${doiLoading ? "Loading…" : doiNoneCount > 0 ? `⚠ ${doiNoneCount} at tier None` : "✓ All pass Core tier"}</small>
  </div>
</div>`);
```

## Coverage Map

```js
// Returns a new "⚠ not ingested" span with a ? help button each time it's called.
function _sbomGap() {
  const el = html`<span class="sbom-warn sbom-gap-hint">⚠ not ingested</span>`;
  withDocHelp(el, "/docs/sbom");
  return el;
}

function _doiBadge(tier) {
  if (doiLoading) return html`<span style="color:#d1d5db;font-size:0.72rem;">…</span>`;
  const color = DOI_TIER_COLOR[tier] || "#9ca3af";
  const bg    = DOI_TIER_BG[tier]    || "#f9fafb";
  const label = DOI_TIER_LABEL[tier] || tier;
  return html`<span style="background:${bg}; color:${color}; border:1px solid ${color}60;
    border-radius:4px; padding:1px 7px; font-size:0.72rem; font-weight:700; white-space:nowrap;">
    DoI: ${label}</span>`;
}

// Group by domain
const byDomain = {};
for (const r of repoRows) {
  (byDomain[r._domSlug] = byDomain[r._domSlug] ?? []).push(r);
}

const domainBlocks = Object.entries(byDomain).sort(([a], [b]) => a.localeCompare(b));

if (domainBlocks.length === 0) {
  display(html`<p style="color:gray">No repos registered. Run <code>make add-repo DOMAIN=&lt;slug&gt; SLUG=&lt;slug&gt; NAME="..." PATH=/path</code>.</p>`);
} else {
  display(html`<div class="domain-list">
    ${domainBlocks.map(([slug, rows]) => {
      const dom = domainBySlug[slug];
      const allCovered = rows.every(r => r._hasSbom);
      const doiWorst   = rows.map(r => DOI_TIER_ORDER[r._doiTier] ?? 0);
      const doiMin     = Math.min(...doiWorst);
      const doiMinKey  = Object.keys(DOI_TIER_ORDER).find(k => DOI_TIER_ORDER[k] === doiMin) ?? "none";
      const hasEps  = (epByDomain[slug] ?? 0) > 0;
      const hasTds  = (tdByDomain[slug] ?? 0) > 0;
      return html`
        <div class="domain-block ${allCovered ? '' : 'domain-gap'}">
          <div class="domain-header">
            <span class="domain-name">${dom?.name ?? slug}</span>
            <span class="domain-chips">
              ${allCovered
                ? html`<span class="chip chip-ok">SBOM ✓</span>`
                : html`<span class="chip chip-warn">SBOM ⚠</span>`}
              ${hasEps
                ? html`<span class="chip chip-ok">EPs: ${epByDomain[slug]}</span>`
                : html`<span class="chip chip-neutral">EPs: —</span>`}
              ${hasTds
                ? html`<span class="chip chip-ok">TDs: ${tdByDomain[slug]}</span>`
                : html`<span class="chip chip-neutral">TDs: —</span>`}
              <span style="font-size:0.72rem; color:${DOI_TIER_COLOR[doiMinKey]}; font-weight:600;">
                DoI min: ${DOI_TIER_LABEL[doiMinKey]}
              </span>
            </span>
          </div>
          <table class="repo-table">
            <thead><tr>
              <th>Repo</th>
              <th>Category</th>
              <th>Capabilities</th>
              <th>DoI Tier</th>
              <th>Status</th>
              <th>SBOM</th>
              <th>Packages</th>
              <th>Local path</th>
            </tr></thead>
            <tbody>
              ${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
                <td class="repo-cell"><code>${r.repo}</code></td>
                <td>${r.category}</td>
                <td class="path-cell" title=${r.capTags}>${r.capTags}</td>
                <td>${_doiBadge(r._doiTier)}</td>
                <td>${r._integrating
                  ? html`<span class="chip chip-integrating">⚙ integrating</span>`
                  : html`<span class="chip chip-ok">ready</span>`}</td>
                <td class="${r._hasSbom ? 'sbom-ok' : 'sbom-warn'}">${r._hasSbom ? r.sbom : _sbomGap()}</td>
                <td>${r.pkgs}</td>
                <td class="path-cell">${r.path}</td>
              </tr>`)}
            </tbody>
          </table>
        </div>
      `;
    })}
  </div>`);
}
```

## Portfolio by Category

```js
const byCategory = {};
for (const r of repoRows) {
  const key = r._category === "—" ? "unclassified" : r._category;
  (byCategory[key] = byCategory[key] ?? []).push(r);
}
const categoryBlocks = Object.entries(byCategory).sort(([a], [b]) => a.localeCompare(b));
if (categoryBlocks.length > 0) {
  display(html`<h2 style="margin-top:2rem">Portfolio by Category</h2>
    <div class="domain-list">
      ${categoryBlocks.map(([cat, rows]) => html`
        <div class="domain-block">
          <div class="domain-header">
            <span class="domain-name">${cat}</span>
            <span class="domain-chips">
              <span class="chip chip-neutral">${rows.length} repo(s)</span>
              <span class="chip chip-neutral">${new Set(rows.map(r => r._domSlug)).size} domain(s)</span>
            </span>
          </div>
          <table class="repo-table">
            <thead><tr>
              <th>Repo</th><th>Domain</th><th>Capabilities</th><th>Business stake</th><th>Classified</th>
            </tr></thead>
            <tbody>
              ${rows.map(r => html`<tr>
                <td class="repo-cell"><code>${r.repo}</code></td>
                <td>${r.domain}</td>
                <td class="path-cell">${r.capTags}</td>
                <td class="path-cell">${r.businessStake}</td>
                <td>${r.classifiedBy}</td>
              </tr>`)}
            </tbody>
          </table>
        </div>
      `)}
    </div>`);
}
```

## All Repos Table

```js
const domainFilter    = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Market domain", value: "all"});
const categoryFilter  = Inputs.select(["all", ...new Set(repoRows.map(r => r._category).filter(c => c !== "—")).values()], {label: "Category", value: "all"});
const capabilityFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._capList)).values()].sort(), {label: "Capability", value: "all"});
const stakeFilter     = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._stakeList)).values()].sort(), {label: "Business stake", value: "all"});
const doiFilter       = Inputs.select(["all", "none", "core", "standard", "full"], {label: "DoI tier", value: "all"});
const gapFilter       = Inputs.toggle({label: "Gaps only (no SBOM)", value: false});
display(html`<div class="filter-bar">${domainFilter}${categoryFilter}${capabilityFilter}${stakeFilter}${doiFilter}${gapFilter}</div>`);
```

```js
const filteredRows = repoRows.filter(r =>
  (domainFilter.value === "all" || r._domSlug === domainFilter.value) &&
  (categoryFilter.value === "all" || r._category === categoryFilter.value) &&
  (capabilityFilter.value === "all" || r._capList.includes(capabilityFilter.value)) &&
  (stakeFilter.value === "all" || r._stakeList.includes(stakeFilter.value)) &&
  (doiFilter.value === "all" || r._doiTier === doiFilter.value) &&
  (!gapFilter.value || !r._hasSbom)
);

display(Inputs.table(filteredRows.map(r => ({
  Repo:      r.repo,
  Domain:    r.domain,
  Category:  r.category,
  Capabilities: r.capTags,
  "Business stake": r.businessStake,
  Classified: r.classifiedBy,
  "DoI Tier": DOI_TIER_LABEL[r._doiTier] ?? r._doiTier,
  Status:    r.status,
  SBOM:      r.sbom,
  Pkgs:      r.pkgs,
  "EPs (domain)": r.eps || "—",
  "TDs (domain)": r.tds || "—",
  Path:      r.path,
})), {maxWidth: 1200}));
```

## Onboard a New Repo

```js
const _h2onboard = [...document.querySelectorAll("#observablehq-main h2")]
  .find(h => h.textContent.includes("Onboard a New Repo"));
if (_h2onboard) { _h2onboard.style.position = "relative"; withDocHelp(_h2onboard, "/docs/repo-integration"); }
```

```js
const onboardId = `onboard-${Math.random().toString(36).slice(2)}`;
const onboardDomainOptions = [...domains]
  .sort((a, b) => a.slug.localeCompare(b.slug))
  .map(d => html`<option value=${d.slug}>${d.slug}</option>`);

const onboardForm = html`<div class="onboard-action" id=${onboardId}>
  <div class="onboard-action-head">
    <div>
      <h3>Add Repo</h3>
      <p>Register an accessible git working copy and write the starter files for the selected agent profile.</p>
    </div>
    <button type="button" class="onboard-primary">Add Repo</button>
  </div>
  <div class="onboard-grid">
    <label>
      <span>Domain</span>
      <select class="onboard-domain">${onboardDomainOptions}</select>
    </label>
    <label>
      <span>Agent profile</span>
      <select class="onboard-agent">
        <option value="codex">Codex (AGENTS.md)</option>
        <option value="claude-code">Claude Code (CLAUDE.md + rules)</option>
      </select>
    </label>
    <label class="onboard-path-label">
      <span>Repo path visible to State Hub</span>
      <input class="onboard-path" type="text" placeholder="/home/worsch/example-repo" />
    </label>
    <label class="onboard-check">
      <input class="onboard-additional" type="checkbox" />
      <span>Additional repo for an existing domain</span>
    </label>
  </div>
  <p class="onboard-path-note">Paste the absolute path when the checkout is on the State Hub host or exposed via ops-bridge.</p>
  <pre class="onboard-status" aria-live="polite"></pre>
</div>`;

display(onboardForm);

{
  const button = onboardForm.querySelector(".onboard-primary");
  const status = onboardForm.querySelector(".onboard-status");
  const pathInput = onboardForm.querySelector(".onboard-path");

  button.addEventListener("click", async () => {
    const body = {
      domain_slug: onboardForm.querySelector(".onboard-domain").value,
      project_path: pathInput.value.trim(),
      agent_profile: onboardForm.querySelector(".onboard-agent").value,
      additional: onboardForm.querySelector(".onboard-additional").checked,
    };
    if (!body.project_path) {
      status.textContent = "Enter an absolute path that the State Hub API can access.";
      pathInput.focus();
      return;
    }

    button.disabled = true;
    status.textContent = "Onboarding started...";
    try {
      const response = await fetch(`${API}/repos/onboard`, {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify(body),
      });
      const payload = await response.json().catch(() => ({}));
      if (!response.ok) {
        const detail = payload.detail ?? payload;
        throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail, null, 2));
      }
      status.textContent = [
        `Onboarding complete${payload.repo_slug ? `: ${payload.repo_slug}` : ""}`,
        "",
        payload.stdout?.trim() ?? "",
        payload.stderr?.trim() ? `\nWarnings:\n${payload.stderr.trim()}` : "",
      ].join("\n").trim();
    } catch (error) {
      status.textContent = `Onboarding failed:\n${error.message}`;
    } finally {
      button.disabled = false;
    }
  });
}
```

```js
display(html`<div class="onboard-panel">
  <div class="onboard-step">
    <span class="onboard-num">1</span>
    <div>
      <strong>Make the working copy accessible</strong>
      <pre>git clone &lt;remote-url&gt; /path/to/repo</pre>
      <p class="onboard-note">The path must be visible from the State Hub API host, either as a local checkout or through an ops-bridge-exposed path.</p>
    </div>
  </div>
  <div class="onboard-step">
    <span class="onboard-num">2</span>
    <div>
      <strong>Choose an agent profile</strong>
      <pre>Codex       -> AGENTS.md + SCOPE.md
Claude Code -> CLAUDE.md + .claude/rules/</pre>
      <p class="onboard-note">The API keeps this as an agent profile so future native coding agents can get their own onboarding templates without changing the repo model.</p>
    </div>
  </div>
  <div class="onboard-step">
    <span class="onboard-num">3</span>
    <div>
      <strong>Run onboarding</strong>
      <pre>scripts/register_project.sh &lt;domain&gt; /path/to/repo --codex</pre>
      <p class="onboard-note">Use the Add Repo form above for the automatic path. The script verifies the domain, writes agent instructions, registers the repo, records host_paths for this machine, and logs a progress event.</p>
    </div>
  </div>
  <div class="onboard-step">
    <span class="onboard-num">4</span>
    <div>
      <strong>Start the agent and monitor here</strong>
      <p class="onboard-note">Open the repo in the chosen coding agent, complete the generated TODO stubs, then use the Repositories and DoI views to track integration gaps.</p>
    </div>
  </div>
</div>`);
```

<style>
.kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.card-warn { border: 2px solid #e53935; }
.card-ok   { border: 2px solid #2e7d32; }
.big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; }

.domain-list { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem; }
.domain-block { background: var(--theme-background-alt); border-radius: 8px; padding: 0.9rem 1rem; }
.domain-gap   { border-left: 4px solid #f0a500; }
.domain-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.65rem; flex-wrap: wrap; }
.domain-name { font-weight: 700; font-size: 1rem; }
.domain-chips { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.chip { font-size: 0.72rem; font-weight: 600; border-radius: 10px; padding: 0.1rem 0.5rem; }
.chip-ok      { background: #e8f5e9; color: #2e7d32; }
.chip-warn    { background: #fff3cd; color: #856404; }
.chip-neutral { background: var(--theme-background); color: gray; border: 1px solid var(--theme-foreground-faint); }

.repo-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.repo-table th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: gray; padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--theme-foreground-faint); }
.repo-table td { padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--theme-foreground-faint); }
.repo-table tr:last-child td { border-bottom: none; }
.row-gap { background: #fffbf0; }
.repo-cell { font-family: monospace; }
.sbom-ok       { color: #2e7d32; font-weight: 600; }
.sbom-warn     { color: #856404; font-weight: 600; }
.sbom-gap-hint { position: relative; display: inline-block; padding-right: 1.8rem; }
.path-cell { font-family: monospace; font-size: 0.78rem; color: gray; }

.card-integrating { border: 2px solid #7c3aed; }
.chip-integrating { background: #ede9fe; color: #5b21b6; }
.row-integrating { background: #faf5ff; }

.onboard-action { margin-top: 0.5rem; margin-bottom: 1rem; border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 8px; padding: 1rem; background: var(--theme-background); }
.onboard-action-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.9rem; }
.onboard-action h3 { margin: 0 0 0.2rem; font-size: 1rem; }
.onboard-action p { margin: 0; color: #6b7280; font-size: 0.85rem; }
.onboard-grid { display: grid; grid-template-columns: minmax(120px, 0.8fr) minmax(180px, 1fr) minmax(260px, 2fr); gap: 0.8rem; align-items: end; }
.onboard-grid label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.78rem; font-weight: 600; color: #4b5563; }
.onboard-grid input[type="text"], .onboard-grid select { min-height: 2.1rem; border: 1px solid var(--theme-foreground-faint, #d1d5db); border-radius: 4px; padding: 0.35rem 0.5rem; background: var(--theme-background); color: var(--theme-foreground); font: inherit; }
.onboard-check { flex-direction: row !important; align-items: center; grid-column: 1 / -1; font-weight: 500 !important; }
.onboard-primary { border: 0; border-radius: 4px; padding: 0.45rem 0.8rem; background: var(--theme-foreground-focus, #111827); color: var(--theme-background, white); font-weight: 700; cursor: pointer; white-space: nowrap; }
.onboard-primary:disabled { opacity: 0.6; cursor: progress; }
.onboard-path-note { margin-top: 0.65rem !important; font-size: 0.82rem !important; }
.onboard-status { min-height: 0; max-height: 18rem; overflow: auto; margin: 0.75rem 0 0; padding: 0.5rem 0.65rem; background: var(--theme-background-alt); border-radius: 4px; font-size: 0.78rem; white-space: pre-wrap; }
.onboard-panel { display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; background: var(--theme-background-alt); border-radius: 8px; overflow: hidden; }
.onboard-step { display: flex; gap: 1rem; align-items: flex-start; padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--theme-foreground-faint, #eee); }
.onboard-step:last-child { border-bottom: none; }
.onboard-num { flex-shrink: 0; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--theme-foreground-focus, #1a1a1a); color: var(--theme-background, white); font-size: 0.8rem; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-top: 0.1rem; }
.onboard-step strong { font-size: 0.9rem; display: block; margin-bottom: 0.3rem; }
.onboard-step pre { background: var(--theme-background); border-radius: 4px; padding: 0.4rem 0.7rem; font-size: 0.8rem; overflow-x: auto; margin: 0 0 0.35rem; }
.onboard-note { font-size: 0.82rem; color: var(--theme-foreground-muted, gray); margin: 0; line-height: 1.45; }

.doi-spinner {
  display: inline-block; width: 0.9rem; height: 0.9rem;
  border: 2px solid #e5e7eb; border-top-color: #9ca3af;
  border-radius: 50%; animation: doi-spin 0.7s linear infinite;
  vertical-align: middle;
}
@keyframes doi-spin { to { transform: rotate(360deg); } }
@media (max-width: 760px) {
  .onboard-action-head { flex-direction: column; }
  .onboard-primary { width: 100%; }
  .onboard-grid { grid-template-columns: 1fr; }
}
</style>
