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.
- package/CHANGELOG.md +19 -0
- package/README.md +110 -380
- package/dist/dashboard/spa/app.js +214 -28
- package/dist/dashboard/spa/index.html +16 -0
- package/dist/dashboard/spa/styles.css +182 -44
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +14 -1
- package/dist/mcp-server.js.map +1 -1
- package/dist/skill-catalog.d.ts.map +1 -1
- package/dist/skill-catalog.js +5 -1
- package/dist/skill-catalog.js.map +1 -1
- package/dist/skill-surface.d.ts +41 -0
- package/dist/skill-surface.d.ts.map +1 -1
- package/dist/skill-surface.js +195 -0
- package/dist/skill-surface.js.map +1 -1
- package/docs/adopter-agent-guide.md +157 -0
- package/docs/connector-contract-reference.md +2 -0
- package/examples/skillscripts/classify-support-ticket.skill.provenance.json +1 -1
- package/examples/skillscripts/data-store-roundtrip.skill.provenance.json +1 -1
- package/examples/skillscripts/doc-qa-with-citations.skill.provenance.json +1 -1
- package/examples/skillscripts/feedback-sentiment-scan.skill.provenance.json +1 -1
- package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
- package/examples/skillscripts/morning-brief.skill.provenance.json +1 -1
- package/examples/skillscripts/queue-length-monitor.skill.provenance.json +1 -1
- package/examples/skillscripts/service-health-watch.skill.provenance.json +1 -1
- package/examples/skillscripts/skill-store-roundtrip.skill.provenance.json +1 -1
- package/examples/skillscripts/youtrack-morning-sweep.skill.provenance.json +1 -1
- 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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
509
|
+
</details>
|
|
508
510
|
|
|
509
|
-
<section>
|
|
510
|
-
<
|
|
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
|
-
</
|
|
521
|
+
</details>
|
|
520
522
|
|
|
521
|
-
<section>
|
|
522
|
-
<
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
</
|
|
549
|
+
</details>
|
|
548
550
|
|
|
549
|
-
<section>
|
|
550
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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>
|