skillscript-runtime 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +110 -380
  3. package/dist/dashboard/spa/app.js +214 -28
  4. package/dist/dashboard/spa/index.html +16 -0
  5. package/dist/dashboard/spa/styles.css +182 -44
  6. package/dist/mcp-server.d.ts.map +1 -1
  7. package/dist/mcp-server.js +14 -1
  8. package/dist/mcp-server.js.map +1 -1
  9. package/dist/skill-catalog.d.ts.map +1 -1
  10. package/dist/skill-catalog.js +5 -1
  11. package/dist/skill-catalog.js.map +1 -1
  12. package/dist/skill-surface.d.ts +41 -0
  13. package/dist/skill-surface.d.ts.map +1 -1
  14. package/dist/skill-surface.js +195 -0
  15. package/dist/skill-surface.js.map +1 -1
  16. package/docs/adopter-agent-guide.md +157 -0
  17. package/docs/connector-contract-reference.md +2 -0
  18. package/examples/skillscripts/classify-support-ticket.skill.provenance.json +1 -1
  19. package/examples/skillscripts/data-store-roundtrip.skill.provenance.json +1 -1
  20. package/examples/skillscripts/doc-qa-with-citations.skill.provenance.json +1 -1
  21. package/examples/skillscripts/feedback-sentiment-scan.skill.provenance.json +1 -1
  22. package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
  23. package/examples/skillscripts/morning-brief.skill.provenance.json +1 -1
  24. package/examples/skillscripts/queue-length-monitor.skill.provenance.json +1 -1
  25. package/examples/skillscripts/service-health-watch.skill.provenance.json +1 -1
  26. package/examples/skillscripts/skill-store-roundtrip.skill.provenance.json +1 -1
  27. package/examples/skillscripts/youtrack-morning-sweep.skill.provenance.json +1 -1
  28. package/package.json +2 -1
@@ -224,7 +224,7 @@ async function renderApprovals() {
224
224
  return `
225
225
  <tr>
226
226
  <td><a href="#skill/${encodeURIComponent(s.name)}"><strong>${esc(s.name)}</strong></a> ${whyBadge}<br>
227
- <span style="color:#6c757d;font-size:0.85em;">${esc(s.description ?? "—")}</span></td>
227
+ <span style="color:var(--text-muted);font-size:0.85em;">${esc(s.description ?? "—")}</span></td>
228
228
  <td>${sigBadges}</td>
229
229
  <td>
230
230
  <a href="#skill/${encodeURIComponent(s.name)}">Review →</a>
@@ -463,7 +463,7 @@ async function renderSkillDetail(name) {
463
463
  ? `<div class="remediation" style="margin-top: 12px;"><strong>Approval token stale.</strong> ${esc(approval.reason ?? "")}. Re-transition to Approved to stamp a fresh token.</div>`
464
464
  : "";
465
465
  return `
466
- <div style="margin-bottom: 8px;"><a href="#" onclick="event.preventDefault(); window.history.back();" style="font-size: 0.9em; color: #6c757d;">← Back</a></div>
466
+ <div style="margin-bottom: 8px;"><a href="#" onclick="event.preventDefault(); window.history.back();" style="font-size: 0.9em; color: var(--text-muted);">← Back</a></div>
467
467
  <h2>Skill: ${esc(metadata.name)} <span class="badge ${esc(metadata.status)}">${esc(metadata.status)}</span>${approvalBadge}</h2>
468
468
 
469
469
  <section>
@@ -471,14 +471,16 @@ async function renderSkillDetail(name) {
471
471
  <p>${esc(metadata.description ?? "(no description)")}</p>
472
472
  ${approvalBanner}
473
473
  ${renderStatusActions(name, metadata, approval)}
474
- <div style="margin-top: 16px; border-top: 1px solid #eee; padding-top: 12px;">
474
+ <div style="margin-top: 16px; border-top: 1px solid var(--border); padding-top: 12px;">
475
475
  <button class="danger" onclick="deleteSkill('${esc(name).replace(/'/g, "\\'")}')">Delete skill</button>
476
- <span style="color:#6c757d; font-size:0.85em; margin-left:8px;">permanent — no undo; warns if other skills reference it, then frees the name</span>
476
+ <span style="color:var(--text-muted); font-size:0.85em; margin-left:8px;">permanent — no undo; warns if other skills reference it, then frees the name</span>
477
477
  </div>
478
478
  </section>
479
479
 
480
480
  ${renderEffectfulFootprintPanel(contract?.effectful_footprint)}
481
481
 
482
+ ${renderSkillFlow(contract?.flow)}
483
+
482
484
  ${source ? renderSecuritySignalsPanel(source) : ""}
483
485
 
484
486
  <section>
@@ -488,8 +490,8 @@ async function renderSkillDetail(name) {
488
490
 
489
491
  ${renderComposesSection(name, source)}
490
492
 
491
- <section>
492
- <h2>Triggers (${triggersForSkill.length})</h2>
493
+ <details class="section collapsible">
494
+ <summary>Triggers (${triggersForSkill.length})</summary>
493
495
  ${triggersForSkill.length === 0
494
496
  ? `<div class="empty">No triggers registered for this skill.</div>`
495
497
  : `<table>
@@ -504,10 +506,10 @@ async function renderSkillDetail(name) {
504
506
  `).join("")}
505
507
  </tbody>
506
508
  </table>`}
507
- </section>
509
+ </details>
508
510
 
509
- <section>
510
- <h2>Metrics (24h)</h2>
511
+ <details class="section collapsible">
512
+ <summary>Metrics (24h)</summary>
511
513
  ${metrics
512
514
  ? `<dl class="kv">
513
515
  <dt>Fires</dt><dd>${metrics.fireCount}</dd>
@@ -516,10 +518,10 @@ async function renderSkillDetail(name) {
516
518
  <dt>Success rate</dt><dd>${(metrics.successRate * 100).toFixed(1)}%</dd>
517
519
  </dl>`
518
520
  : `<div class="empty">No traces recorded in window.</div>`}
519
- </section>
521
+ </details>
520
522
 
521
- <section>
522
- <h2>Recent fires (${recent_fires.length})</h2>
523
+ <details class="section collapsible">
524
+ <summary>Recent fires (${recent_fires.length})</summary>
523
525
  ${recent_fires.length === 0
524
526
  ? `<div class="empty">No fires recorded.</div>`
525
527
  : recent_fires.map((fire) => {
@@ -528,26 +530,26 @@ async function renderSkillDetail(name) {
528
530
  ? `<span class="badge ok">ok</span>`
529
531
  : `<span class="badge error">err</span>`;
530
532
  return `
531
- <div style="border-bottom: 1px solid #e6e8eb; padding: 12px 0;">
533
+ <div style="border-bottom: 1px solid var(--border); padding: 12px 0;">
532
534
  <div style="display: flex; align-items: center; gap: 12px;">
533
535
  ${status}
534
- <code style="font-size: 11px; color: #6c757d;">${esc(fire.trace_id.slice(0, 8))}</code>
536
+ <code style="font-size: 11px; color: var(--text-muted);">${esc(fire.trace_id.slice(0, 8))}</code>
535
537
  <span>${ts}</span>
536
- <span style="color: #6c757d; margin-left: auto;">${fire.duration_ms}ms · ${fire.ops.length} ops</span>
538
+ <span style="color: var(--text-muted); margin-left: auto;">${fire.duration_ms}ms · ${fire.ops.length} ops</span>
537
539
  </div>
538
540
  ${fire.errors.map((e) => `
539
541
  <div class="remediation">
540
542
  <strong>${esc(e.class)}</strong> in ${esc(e.target)}/${esc(e.opKind)}: ${esc(e.message)}
541
- ${e.remediation ? `<div style="margin-top: 4px; color: #4a5158;">→ ${esc(e.remediation)}</div>` : ""}
543
+ ${e.remediation ? `<div style="margin-top: 4px; color: var(--text-dim);">→ ${esc(e.remediation)}</div>` : ""}
542
544
  </div>
543
545
  `).join("")}
544
546
  </div>
545
547
  `;
546
548
  }).join("")}
547
- </section>
549
+ </details>
548
550
 
549
- <section>
550
- <h2>Version history (${versions.length})</h2>
551
+ <details class="section collapsible">
552
+ <summary>Version history (${versions.length})</summary>
551
553
  <table>
552
554
  <thead><tr><th>Version</th><th>Status</th><th>Changed at</th></tr></thead>
553
555
  <tbody>
@@ -560,7 +562,7 @@ async function renderSkillDetail(name) {
560
562
  `).join("")}
561
563
  </tbody>
562
564
  </table>
563
- </section>
565
+ </details>
564
566
  `;
565
567
  } catch (err) {
566
568
  return `<h2>Skill: ${esc(name)}</h2><section><div class="empty">Failed to load: ${esc(err.message)}</div></section>`;
@@ -710,6 +712,153 @@ function renderHighlightedSkillBody(source) {
710
712
  return body;
711
713
  }
712
714
 
715
+ // ─── Control-flow "what it does" diagram ─────────────────────────────────
716
+ // The orientation layer for a NON-PROGRAMMER approver: an SVG flowchart of the
717
+ // skill's actual steps, built from skill_preflight's contract.flow (targets →
718
+ // boxes, ops → step rows, loops/branches indented). Each target is a box; the
719
+ // boxes stack top-to-bottom in dependency order, joined by arrows. It sits in
720
+ // the always-open decision surface under "What this skill touches" — but it is
721
+ // NOT the decision surface. A tidy diagram can still gloss a dangerous arg, so
722
+ // the concrete effects (the footprint + Source) remain the artifact to approve;
723
+ // this answers "what is it doing, in order?" for someone who can't read
724
+ // skillscript. Steps that change data / run commands get a colored marker.
725
+ function renderSkillFlow(flow) {
726
+ if (!flow || !Array.isArray(flow.lanes) || flow.lanes.length === 0) return ""; // body-only skill — nothing to diagram
727
+ return `
728
+ <section>
729
+ <h2>What it does, step by step</h2>
730
+ <p class="flow-caption">A diagram of the actual steps, top to bottom. A ● marks steps that <em>change data</em> or <em>run commands</em>. The exact effects to approve are in “What this skill touches” and the source below.</p>
731
+ <div class="flow-scroll">${flowSvg(flow)}</div>
732
+ ${flow.truncated ? `<p class="flow-caption">Large skill — showing the first 40 stages.</p>` : ""}
733
+ </section>
734
+ `;
735
+ }
736
+
737
+ // Flatten a lane's nested steps into flat rows with an indent depth, so the SVG
738
+ // lays them out as simple text lines. Loop bodies and branch arms become deeper
739
+ // rows under their heading.
740
+ function flattenFlowRows(steps, depth, out) {
741
+ for (const s of steps) {
742
+ // A conditional: its "If …" / "Otherwise" arms are self-describing, so skip
743
+ // the redundant container heading and show the arms directly.
744
+ if (Array.isArray(s.branches)) {
745
+ for (const b of s.branches) {
746
+ out.push({ label: b.label, tone: "normal", depth, isBranch: true });
747
+ flattenFlowRows(b.steps, depth + 1, out);
748
+ }
749
+ continue;
750
+ }
751
+ out.push({ label: s.label, detail: s.detail, tone: s.tone, ref: s.ref, produces: s.produces, depth });
752
+ // A loop keeps its "For each …" heading, with the body indented under it.
753
+ if (Array.isArray(s.children)) flattenFlowRows(s.children, depth + 1, out);
754
+ }
755
+ return out;
756
+ }
757
+
758
+ function truncate(s, max) {
759
+ if (max < 2) return "";
760
+ return s.length <= max ? s : s.slice(0, max - 1) + "…";
761
+ }
762
+
763
+ function flowSvg(flow) {
764
+ const BOX_W = 340, ROW_H = 22, TITLE_H = 30, PAD_Y = 10, GAP_X = 30, GAP_Y = 46, MARGIN = 10, INDENT = 14, CHAR_W = 6.6;
765
+
766
+ const byId = new Map();
767
+ const boxes = flow.lanes.map((lane) => {
768
+ const rows = flattenFlowRows(lane.steps, 0, []);
769
+ const h = TITLE_H + Math.max(rows.length, 1) * ROW_H + PAD_Y;
770
+ const box = { lane, rows, h };
771
+ byId.set(lane.id, box);
772
+ return box;
773
+ });
774
+
775
+ // Longest-path layering: a target's dependencies sit in the layer above it, so
776
+ // reading top-to-bottom follows execution order (deps run first). Siblings (a
777
+ // shared dependency's dependents) land in the same layer, side by side — the
778
+ // real branch/merge shape rather than a fake vertical chain.
779
+ const layer = new Map(boxes.map((b) => [b.lane.id, 0]));
780
+ for (let it = 0; it < boxes.length; it++) {
781
+ let changed = false;
782
+ for (const b of boxes) {
783
+ let mx = 0;
784
+ for (const d of b.lane.deps || []) if (byId.has(d)) mx = Math.max(mx, layer.get(d) + 1);
785
+ if (mx !== layer.get(b.lane.id)) { layer.set(b.lane.id, mx); changed = true; }
786
+ }
787
+ if (!changed) break;
788
+ }
789
+ const maxLayer = Math.max(0, ...boxes.map((b) => layer.get(b.lane.id)));
790
+ const byLayer = Array.from({ length: maxLayer + 1 }, () => []);
791
+ boxes.forEach((b) => byLayer[layer.get(b.lane.id)].push(b));
792
+ const layerW = byLayer.map((ls) => (ls.length ? ls.length * BOX_W + (ls.length - 1) * GAP_X : 0));
793
+ const maxLayerW = Math.max(0, ...layerW);
794
+
795
+ const pos = new Map();
796
+ let ly = MARGIN;
797
+ byLayer.forEach((ls, L) => {
798
+ const startX = MARGIN + (maxLayerW - layerW[L]) / 2;
799
+ ls.forEach((b, i) => pos.set(b.lane.id, { x: startX + i * (BOX_W + GAP_X), y: ly }));
800
+ ly += Math.max(0, ...ls.map((b) => b.h)) + GAP_Y;
801
+ });
802
+ const totalW = maxLayerW + MARGIN * 2;
803
+ const totalH = ly - GAP_Y + MARGIN;
804
+
805
+ // Arrows point from a dependency (above) down into the step that needs it.
806
+ const arrows = boxes.flatMap((b) => {
807
+ const p = pos.get(b.lane.id);
808
+ return (b.lane.deps || []).map((d) => {
809
+ const pd = pos.get(d);
810
+ if (!pd) return "";
811
+ const x1 = pd.x + BOX_W / 2, y1 = pd.y + byId.get(d).h;
812
+ const x2 = p.x + BOX_W / 2, y2 = p.y;
813
+ const my = (y1 + y2) / 2;
814
+ return `<path class="flow-arrow" d="M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}" marker-end="url(#flow-arrowhead)"/>`;
815
+ });
816
+ });
817
+
818
+ // The entry/default target is what runs when you invoke the skill — always
819
+ // flag it so it's never ambiguous which box is the one. When it has
820
+ // dependencies it runs LAST (everything feeds it), so it's the "result";
821
+ // when it has none it's simply the "default" (single-target skills).
822
+ const boxSvg = boxes.map((b) => {
823
+ const p = pos.get(b.lane.id);
824
+ const { lane } = b;
825
+ const entryWord = lane.deps && lane.deps.length ? "result" : "default";
826
+ const badge = lane.isEntry ? `<tspan class="flow-box-badge"> · ${entryWord}</tspan>` : "";
827
+ const tip = lane.isEntry ? `<title>The target that runs when you invoke this skill${lane.deps && lane.deps.length ? " — after the steps it depends on" : ""}.</title>` : "";
828
+ let g = `<g class="flow-box${lane.isEntry ? " flow-box-entry" : ""}">
829
+ ${tip}<rect x="${p.x}" y="${p.y}" width="${BOX_W}" height="${b.h}" rx="8"/>
830
+ <line class="flow-box-rule" x1="${p.x}" y1="${p.y + TITLE_H}" x2="${p.x + BOX_W}" y2="${p.y + TITLE_H}"/>
831
+ <text class="flow-box-title" x="${p.x + 12}" y="${p.y + 20}">${esc(lane.id)}${badge}</text>`;
832
+ (b.rows.length ? b.rows : [{ label: "Nothing to do.", tone: "normal", depth: 0 }]).forEach((r, ri) => {
833
+ const rowY = p.y + TITLE_H + ri * ROW_H + 16;
834
+ const dot = (r.tone === "mutation" || r.tone === "shell")
835
+ ? `<tspan class="flow-dot flow-dot-${r.tone}">● </tspan>` : "";
836
+ const rowX = p.x + 14 + r.depth * INDENT;
837
+ const producesTxt = r.produces ? ` → ${r.produces}` : "";
838
+ // Budget the main text in PIXELS, reserving room for the ● marker and the
839
+ // monospace "→ VAR" so nothing overruns the box's right border. CHAR_W is
840
+ // deliberately generous (chars render narrower) so we under-fill rather
841
+ // than let the border bisect the text.
842
+ const reservedPx = 18 + (dot ? 13 : 0) + producesTxt.length * 6.8;
843
+ const budget = Math.max(6, Math.floor((BOX_W - (rowX - p.x) - reservedPx) / CHAR_W));
844
+ const text = truncate(r.label + (r.detail ? " — " + r.detail : ""), budget);
845
+ const cls = "flow-row" + (r.isBranch ? " flow-row-branch" : "");
846
+ const produces = r.produces ? `<tspan class="flow-produces">${esc(producesTxt)}</tspan>` : "";
847
+ const rowSvg = `<text class="${cls}" x="${rowX}" y="${rowY}">${dot}${esc(text)}${produces}</text>`;
848
+ g += r.ref && r.ref.skill
849
+ ? `<a href="#skill/${encodeURIComponent(r.ref.skill)}" class="flow-row-link">${rowSvg}</a>`
850
+ : rowSvg;
851
+ });
852
+ return g + `</g>`;
853
+ });
854
+
855
+ return `<svg class="flow-svg" viewBox="0 0 ${totalW} ${totalH}" width="${totalW}" height="${totalH}" role="img" aria-label="Skill step diagram">
856
+ <defs><marker id="flow-arrowhead" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto">
857
+ <path class="flow-arrowhead-path" d="M0 0 L10 5 L0 10 z"/></marker></defs>
858
+ ${arrows.join("")}${boxSvg.join("")}
859
+ </svg>`;
860
+ }
861
+
713
862
  // v0.18.9 — security signals summary panel. Glance-first surface
714
863
  // showing aggregated counts so a reviewer knows WHAT to look for before
715
864
  // scanning the body. Pairs with renderHighlightedSkillBody() (the
@@ -1056,8 +1205,12 @@ function extractCompositionRefs(source) {
1056
1205
 
1057
1206
  function renderComposesSection(currentName, source) {
1058
1207
  const refs = extractCompositionRefs(source);
1208
+ // Conditional decision-surface section: a composed child is part of the blast
1209
+ // radius, so when present it renders as an open section. When the skill
1210
+ // composes nothing there's nothing approval-relevant to show — hide it
1211
+ // entirely rather than spend a section on an empty state.
1059
1212
  if (refs.length === 0) {
1060
- return `<section><h2>Composes</h2><div class="empty">This skill doesn't compose other skills (no <code>execute_skill</code> or <code>inline</code> refs).</div></section>`;
1213
+ return "";
1061
1214
  }
1062
1215
  return `
1063
1216
  <section>
@@ -1065,12 +1218,12 @@ function renderComposesSection(currentName, source) {
1065
1218
  <div style="display: flex; flex-direction: column; gap: 6px;">
1066
1219
  ${refs.map((r) => `
1067
1220
  <details data-compose-ref="${esc(r.kind)}:${esc(r.name)}" data-parent="${esc(currentName)}">
1068
- <summary style="cursor: pointer; padding: 8px 12px; background: #f6f8fa; border-radius: 4px; font-family: monospace;">
1221
+ <summary style="cursor: pointer; padding: 8px 12px; background: var(--surface-2); border-radius: 4px; font-family: monospace;">
1069
1222
  <span class="badge ${r.kind === "execute_skill" ? "ok" : "info"}" style="margin-right: 8px;">${esc(r.kind)}</span>
1070
1223
  <strong>${esc(r.name)}</strong>
1071
- <span style="color: #6c757d; margin-left: 8px; font-size: 0.85em;">${r.kind === "execute_skill" ? "(runtime invocation)" : "(compile-time data inline)"}</span>
1224
+ <span style="color: var(--text-muted); margin-left: 8px; font-size: 0.85em;">${r.kind === "execute_skill" ? "(runtime invocation)" : "(compile-time data inline)"}</span>
1072
1225
  </summary>
1073
- <div class="compose-panel" style="padding: 12px 16px; border-left: 2px solid #e6e8eb; margin-left: 12px; margin-top: 8px;">
1226
+ <div class="compose-panel" style="padding: 12px 16px; border-left: 2px solid var(--border); margin-left: 12px; margin-top: 8px;">
1074
1227
  <div class="empty">Loading contract…</div>
1075
1228
  </div>
1076
1229
  </details>
@@ -1115,10 +1268,10 @@ function renderContractPanel(name, kind, meta, inlinedBody) {
1115
1268
  const returns = Array.isArray(meta.returns) && meta.returns.length > 0
1116
1269
  ? `<dl class="kv"><dt>Returns (<code># Returns:</code>)</dt><dd>${meta.returns.map((r) => `<code>${esc(r)}</code>`).join(", ")}</dd></dl>`
1117
1270
  : `<dl class="kv"><dt>Returns</dt><dd><span class="empty">(no declared <code># Returns:</code> — caller sees <code>outputs</code> + <code>transcript</code> only)</span></dd></dl>`;
1118
- const authorBadge = meta.author ? ` <span style="color: #6c757d; font-size: 0.85em;">by ${esc(meta.author)}</span>` : "";
1271
+ const authorBadge = meta.author ? ` <span style="color: var(--text-muted); font-size: 0.85em;">by ${esc(meta.author)}</span>` : "";
1119
1272
  const drillLink = `<a href="#skill/${encodeURIComponent(name)}" style="font-size: 0.85em;">Open in detail view →</a>`;
1120
1273
  const inlineBodyBlock = inlinedBody !== null
1121
- ? `<details style="margin-top: 12px;"><summary style="cursor: pointer; color: #6c757d; font-size: 0.85em;">Inlined body (bakes at compile)</summary><pre style="margin-top: 8px; font-size: 12px;">${esc(inlinedBody)}</pre></details>`
1274
+ ? `<details style="margin-top: 12px;"><summary style="cursor: pointer; color: var(--text-muted); font-size: 0.85em;">Inlined body (bakes at compile)</summary><pre style="margin-top: 8px; font-size: 12px;">${esc(inlinedBody)}</pre></details>`
1122
1275
  : "";
1123
1276
  return `
1124
1277
  <div style="font-size: 0.95em;">
@@ -1153,18 +1306,30 @@ async function renderCurrentView() {
1153
1306
  link.classList.toggle("active", link.getAttribute("href") === `#${hash}`);
1154
1307
  }
1155
1308
 
1309
+ // Preserve scroll across a same-view re-render (the 30s poll) — and skip the
1310
+ // "Loading…" flash in that case. On navigation to a different view, let scroll
1311
+ // reset to the top as usual.
1312
+ const newView = hash.startsWith("skill/")
1313
+ ? `skill/${decodeURIComponent(hash.slice("skill/".length))}`
1314
+ : hash;
1315
+ const sameView = newView === currentView;
1316
+ const scrollY = window.scrollY;
1317
+ const restore = () => { if (sameView) window.scrollTo(0, scrollY); };
1318
+
1156
1319
  if (hash.startsWith("skill/")) {
1157
1320
  const name = decodeURIComponent(hash.slice("skill/".length));
1158
1321
  currentView = `skill/${name}`;
1159
- main.innerHTML = "Loading…";
1322
+ if (!sameView) main.innerHTML = "Loading…";
1160
1323
  main.innerHTML = await renderSkillDetail(name);
1324
+ restore();
1161
1325
  return;
1162
1326
  }
1163
1327
 
1164
1328
  currentView = hash;
1165
1329
  if (hash === "approvals") {
1166
- main.innerHTML = "Loading…";
1330
+ if (!sameView) main.innerHTML = "Loading…";
1167
1331
  main.innerHTML = await renderApprovals();
1332
+ restore();
1168
1333
  return;
1169
1334
  }
1170
1335
  switch (hash) {
@@ -1175,13 +1340,34 @@ async function renderCurrentView() {
1175
1340
  case "security": main.innerHTML = renderSecurity(); break;
1176
1341
  default: main.innerHTML = `<section><div class="empty">Unknown view: ${esc(hash)}</div></section>`;
1177
1342
  }
1343
+ restore();
1178
1344
  }
1179
1345
 
1180
1346
  window.addEventListener("hashchange", renderCurrentView);
1181
1347
  window.addEventListener("DOMContentLoaded", () => {
1348
+ initThemeToggle();
1182
1349
  startPolling();
1183
1350
  });
1184
1351
 
1352
+ // Dark/light toggle. The initial theme is set before paint by the inline script
1353
+ // in index.html; this wires the header button to flip + persist it.
1354
+ function initThemeToggle() {
1355
+ const btn = document.getElementById("theme-toggle");
1356
+ if (!btn) return;
1357
+ const paint = (theme) => {
1358
+ document.documentElement.setAttribute("data-theme", theme);
1359
+ btn.textContent = theme === "dark" ? "☀" : "☾";
1360
+ btn.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode");
1361
+ btn.setAttribute("title", theme === "dark" ? "Switch to light mode" : "Switch to dark mode");
1362
+ };
1363
+ paint(document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light");
1364
+ btn.addEventListener("click", () => {
1365
+ const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
1366
+ try { localStorage.setItem("skillscript-theme", next); } catch (e) { /* private mode */ }
1367
+ paint(next);
1368
+ });
1369
+ }
1370
+
1185
1371
  // ─── Utils ──────────────────────────────────────────────────────────────────
1186
1372
 
1187
1373
  function esc(s) {
@@ -5,6 +5,21 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1" />
6
6
  <title>skillscript-runtime · dashboard</title>
7
7
  <link rel="stylesheet" href="/styles.css" />
8
+ <script>
9
+ // Set the theme before first paint (no flash). Stored preference wins;
10
+ // otherwise follow the OS setting. The header toggle updates the same key.
11
+ (function () {
12
+ try {
13
+ var t = localStorage.getItem("skillscript-theme");
14
+ if (t !== "light" && t !== "dark") {
15
+ t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
16
+ }
17
+ document.documentElement.setAttribute("data-theme", t);
18
+ } catch (e) {
19
+ document.documentElement.setAttribute("data-theme", "light");
20
+ }
21
+ })();
22
+ </script>
8
23
  </head>
9
24
  <body>
10
25
  <header>
@@ -18,6 +33,7 @@
18
33
  <a href="#security">Security</a>
19
34
  </nav>
20
35
  <div id="poll-status" aria-live="polite"></div>
36
+ <button id="theme-toggle" class="theme-toggle" type="button" aria-label="Toggle dark mode" title="Toggle dark mode"></button>
21
37
  </header>
22
38
  <div id="secured-banner"></div>
23
39
  <main id="main"></main>