bmad-plus 0.14.0 → 0.17.0
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 +76 -0
- package/README.md +55 -18
- package/SECURITY.md +71 -0
- package/THIRD-PARTY-LICENSES.md +349 -0
- package/osint-agent-package/README.md +1 -1
- package/package.json +11 -3
- package/readme-international/README.de.md +20 -9
- package/readme-international/README.es.md +21 -10
- package/readme-international/README.fr.md +20 -9
- package/src/bmad-plus/agents/agent-architect-dev/SKILL.md +11 -13
- package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +147 -8
- package/src/bmad-plus/agents/agent-quality/SKILL.md +41 -11
- package/src/bmad-plus/data/role-triggers.yaml +19 -0
- package/src/bmad-plus/module-help.csv +1 -0
- package/src/bmad-plus/module.yaml +1 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/create-story.md +3 -1
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story-checklist.md +2 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story.md +5 -1
- package/src/bmad-plus/packs/pack-memory/README.md +29 -4
- package/src/bmad-plus/packs/pack-memory/memory-orchestrator.md +21 -1
- package/src/bmad-plus/packs/pack-memory/shared/karpathy-guardrails.md +3 -3
- package/src/bmad-plus/packs/pack-memory/shared/memory-protocol.md +27 -3
- package/src/bmad-plus/packs/pack-memory/zecher-agent.md +18 -2
- package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +47 -10
- package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +17 -3
- package/src/bmad-plus/skills/bmad-plus-sync/SKILL.md +76 -67
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +158 -0
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +60 -0
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-spec.schema.json +121 -0
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-triage.schema.json +60 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +628 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +442 -0
- package/src/bmad-plus/skills/bmad-plus-uat/templates/README.md +63 -0
- package/src/bmad-plus/skills/bmad-plus-uat/templates/example-uat-spec.json +95 -0
- package/src/bmad-plus/skills/bmad-plus-uat/templates/uat-release-gate.mjs +127 -0
- package/src/bmad-plus/skills/bmad-plus-uat/templates/uat-spec-present.mjs +77 -0
- package/tools/build/check-install-contract.js +202 -4
- package/tools/build/generate.js +16 -0
- package/tools/build/generated-adapters/.codex/AGENTS.md +1 -1
- package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +1 -1
- package/tools/build/generated-adapters/.opencode/AGENTS.md +1 -1
- package/tools/build/generated-adapters/AGENTS.md +1 -1
- package/tools/build/generated-adapters/CLAUDE.md +1 -1
- package/tools/build/generated-adapters/CONVENTIONS.md +1 -1
- package/tools/build/generated-adapters/GEMINI.md +1 -1
- package/tools/cli/bmad-plus-cli.js +15 -12
- package/tools/cli/commands/autoconfig.js +4 -2
- package/tools/cli/commands/doctor.js +1 -0
- package/tools/cli/commands/install.js +21 -2
- package/tools/cli/commands/memory-journal-cmd.js +119 -19
- package/tools/cli/commands/nexus.js +111 -0
- package/tools/cli/commands/uat.js +405 -0
- package/tools/cli/i18n.js +10 -0
- package/tools/cli/lib/README-memory-journal.md +19 -8
- package/tools/cli/lib/installation-health.js +6 -0
- package/tools/cli/lib/memory-journal.js +0 -0
- package/tools/cli/lib/memory-outcomes.js +293 -0
- package/tools/cli/lib/memory-store.js +139 -0
- package/tools/cli/lib/nexus-process.js +377 -0
- package/tools/cli/lib/nexus.js +1532 -0
- package/tools/cli/lib/pack-copy.js +39 -11
- package/tools/cli/lib/packs.js +17 -3
- package/tools/cli/lib/uat.js +887 -0
- package/tools/maintain/upstream-candidate.js +456 -0
- package/tools/release/publication-content.js +4 -1
- package/tools/release/supply-chain.js +282 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="__LANGUAGE__" dir="__DIR__">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>__TITLE__</title>
|
|
7
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Serif:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f6f5f2; --card: #ffffff; --ink: #1b1d1f; --muted: #6b6f73; --line: #d9d7d1; --line-strong: #b9b6ae;
|
|
11
|
+
--accent: #0f6f74; --accent-ink: #ffffff; --accent-soft: #e2f0f0;
|
|
12
|
+
--ok: #1f7a3a; --ok-soft: #e4f2e8; --ko: #b3261e; --ko-soft: #f8e3e1; --warn: #b26a00; --warn-soft: #fbeeda;
|
|
13
|
+
--mono-bg: #edece7; --focus: #0f6f74;
|
|
14
|
+
--sans: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
|
|
15
|
+
--serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;
|
|
16
|
+
--mono: "IBM Plex Mono", Consolas, "Courier New", monospace;
|
|
17
|
+
}
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
:root:not([data-theme="light"]) {
|
|
20
|
+
--bg: #151719; --card: #1d2023; --ink: #e8e6e1; --muted: #a2a5a9; --line: #2c3034; --line-strong: #454a4f;
|
|
21
|
+
--accent: #4fb3b8; --accent-ink: #0c1516; --accent-soft: #163537;
|
|
22
|
+
--ok: #5fc27a; --ok-soft: #173321; --ko: #f08a80; --ko-soft: #3a1c19; --warn: #e0a24a; --warn-soft: #3a2a12;
|
|
23
|
+
--mono-bg: #24282c; --focus: #4fb3b8;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
:root[data-theme="dark"] {
|
|
27
|
+
--bg: #151719; --card: #1d2023; --ink: #e8e6e1; --muted: #a2a5a9; --line: #2c3034; --line-strong: #454a4f;
|
|
28
|
+
--accent: #4fb3b8; --accent-ink: #0c1516; --accent-soft: #163537;
|
|
29
|
+
--ok: #5fc27a; --ok-soft: #173321; --ko: #f08a80; --ko-soft: #3a1c19; --warn: #e0a24a; --warn-soft: #3a2a12;
|
|
30
|
+
--mono-bg: #24282c; --focus: #4fb3b8;
|
|
31
|
+
}
|
|
32
|
+
* { box-sizing: border-box; }
|
|
33
|
+
/* A class that sets display wins over the hidden attribute: without this rule the steps
|
|
34
|
+
showed before any run existed, and a tester's answers were silently discarded. */
|
|
35
|
+
[hidden] { display: none !important; }
|
|
36
|
+
code.cmd { cursor: copy; }
|
|
37
|
+
code.cmd.copied::after { content: " \2713 " attr(data-copied); font-family: var(--sans); color: var(--ok); }
|
|
38
|
+
body { background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.5; margin: 0; padding-inline: 16px; padding-block: 0 64px; }
|
|
39
|
+
h1, h2, h3 { font-family: var(--serif); text-wrap: balance; margin: 0; }
|
|
40
|
+
h1 { font-size: 1.7rem; font-weight: 600; }
|
|
41
|
+
h2 { font-size: 1.2rem; font-weight: 600; }
|
|
42
|
+
p { margin: 0; }
|
|
43
|
+
a { color: var(--accent); }
|
|
44
|
+
code, .ecran { font-family: var(--mono); font-size: 0.88em; background: var(--mono-bg); padding: 1px 6px; border-radius: 3px; }
|
|
45
|
+
.wrap { max-width: 860px; margin: 0 auto; }
|
|
46
|
+
.eyebrow { font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
|
|
47
|
+
.bar { position: sticky; top: 0; z-index: 5; background: var(--bg); border-bottom: 1px solid var(--line); padding-block: 10px; margin-inline: -16px; padding-inline: 16px; }
|
|
48
|
+
.bar-in { max-width: 860px; margin: 0 auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; }
|
|
49
|
+
.count { display: flex; gap: 14px; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
|
|
50
|
+
.count b { font-weight: 600; }
|
|
51
|
+
.count .ok { color: var(--ok); } .count .ko { color: var(--ko); } .count .warn { color: var(--warn); } .count .todo { color: var(--muted); }
|
|
52
|
+
/* One segment per step: green seen, red not seen, orange blocked, grey skipped, empty to do. */
|
|
53
|
+
.strip { flex: 1 1 100%; display: flex; gap: 3px; min-height: 12px; }
|
|
54
|
+
.strip a { flex: 1 1 0; min-width: 10px; height: 12px; border-radius: 3px; background: var(--line); border: 1px solid transparent; }
|
|
55
|
+
.strip a:hover, .strip a:focus-visible { border-color: var(--ink); outline: none; }
|
|
56
|
+
.strip a[data-state="passed"] { background: var(--ok); }
|
|
57
|
+
.strip a[data-state="failed"] { background: var(--ko); }
|
|
58
|
+
.strip a[data-state="blocked"] { background: var(--warn); }
|
|
59
|
+
.strip a[data-state="skipped"] { background: var(--line-strong); }
|
|
60
|
+
.strip a[data-state="partial"] { background: linear-gradient(90deg, var(--ok) 50%, var(--line) 50%); }
|
|
61
|
+
.state { font-size: 0.82rem; color: var(--muted); }
|
|
62
|
+
.state.saved { color: var(--ok); }
|
|
63
|
+
.head { padding-block: 26px 18px; display: grid; gap: 10px; }
|
|
64
|
+
.head p { max-width: 65ch; }
|
|
65
|
+
.lang { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; color: var(--muted); }
|
|
66
|
+
select { font: inherit; font-size: 0.85rem; color: var(--ink); background: var(--card); border: 1px solid var(--line-strong); border-radius: 4px; padding: 4px 8px; }
|
|
67
|
+
.startbox { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 16px; display: grid; gap: 12px; margin-block: 10px 22px; }
|
|
68
|
+
.startbox label { display: grid; gap: 6px; font-weight: 500; }
|
|
69
|
+
input[type="text"], textarea { font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line-strong); border-radius: 4px; padding: 8px 10px; width: 100%; }
|
|
70
|
+
textarea { min-height: 64px; resize: vertical; }
|
|
71
|
+
input:focus-visible, textarea:focus-visible, button:focus-visible, select:focus-visible, .pill input:focus-visible + span { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
72
|
+
.btn { font: inherit; font-weight: 600; border-radius: 4px; padding: 8px 14px; border: 1px solid var(--line-strong); background: var(--card); color: var(--ink); cursor: pointer; }
|
|
73
|
+
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
|
74
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
75
|
+
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
76
|
+
.resume { display: grid; gap: 6px; }
|
|
77
|
+
.resume button { text-align: start; }
|
|
78
|
+
.warn-box { border-inline-start: 3px solid var(--warn); background: var(--warn-soft); padding: 10px 12px; border-radius: 4px; }
|
|
79
|
+
.info-box { border-inline-start: 3px solid var(--accent); background: var(--accent-soft); padding: 10px 12px; border-radius: 4px; }
|
|
80
|
+
.steps { display: grid; gap: 18px; }
|
|
81
|
+
.step { background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
|
82
|
+
.step[data-state="passed"] { border-color: var(--ok); }
|
|
83
|
+
.step[data-state="failed"] { border-color: var(--ko); }
|
|
84
|
+
.step[data-state="blocked"] { border-color: var(--warn); }
|
|
85
|
+
.step-head { display: flex; gap: 12px; align-items: baseline; padding: 14px 16px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
|
86
|
+
.num { font-family: var(--serif); font-size: 1.5rem; font-weight: 600; color: var(--accent); min-width: 2ch; line-height: 1; }
|
|
87
|
+
.step-head h2 { flex: 1 1 260px; }
|
|
88
|
+
.duration { font-size: 0.8rem; color: var(--muted); white-space: nowrap; }
|
|
89
|
+
.verdict { font-size: 0.78rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; padding: 3px 9px; border-radius: 12px; background: var(--mono-bg); color: var(--muted); }
|
|
90
|
+
.step[data-state="passed"] .verdict { background: var(--ok-soft); color: var(--ok); }
|
|
91
|
+
.step[data-state="failed"] .verdict { background: var(--ko-soft); color: var(--ko); }
|
|
92
|
+
.step[data-state="blocked"] .verdict { background: var(--warn-soft); color: var(--warn); }
|
|
93
|
+
.step-body { padding: 14px 16px 16px; display: grid; gap: 14px; }
|
|
94
|
+
.section { display: grid; gap: 6px; }
|
|
95
|
+
.section ol, .section ul { margin: 0; padding-inline-start: 22px; display: grid; gap: 5px; }
|
|
96
|
+
.section li { max-width: 68ch; }
|
|
97
|
+
.expects { display: grid; gap: 8px; }
|
|
98
|
+
.expect { display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; align-items: start; padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; }
|
|
99
|
+
.expect[data-state="passed"] { background: var(--ok-soft); border-color: transparent; }
|
|
100
|
+
.expect[data-state="failed"] { background: var(--ko-soft); border-color: transparent; }
|
|
101
|
+
.expect[data-state="blocked"] { background: var(--warn-soft); border-color: transparent; }
|
|
102
|
+
.expect[data-state="skipped"] { opacity: 0.6; }
|
|
103
|
+
.expect .text { max-width: 60ch; }
|
|
104
|
+
.expect .why { grid-column: 1 / -1; }
|
|
105
|
+
.expect .why label { font-size: 0.85rem; color: var(--muted); display: grid; gap: 4px; }
|
|
106
|
+
.choice { display: inline-flex; gap: 6px; flex-wrap: wrap; }
|
|
107
|
+
.pill { position: relative; }
|
|
108
|
+
.pill input { position: absolute; opacity: 0; width: 1px; height: 1px; }
|
|
109
|
+
.pill span { display: inline-block; font-size: 0.8rem; font-weight: 600; padding: 5px 10px; border-radius: 14px; border: 1px solid var(--line-strong); cursor: pointer; user-select: none; color: var(--muted); background: var(--card); }
|
|
110
|
+
.pill.passed input:checked + span { background: var(--ok); color: #fff; border-color: var(--ok); }
|
|
111
|
+
.pill.failed input:checked + span { background: var(--ko); color: #fff; border-color: var(--ko); }
|
|
112
|
+
.pill.blocked input:checked + span { background: var(--warn); color: #fff; border-color: var(--warn); }
|
|
113
|
+
.note label { font-size: 0.85rem; color: var(--muted); display: grid; gap: 4px; }
|
|
114
|
+
.end { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 16px; display: grid; gap: 12px; margin-top: 22px; }
|
|
115
|
+
.json { font-family: var(--mono); font-size: 0.8rem; min-height: 180px; white-space: pre; overflow: auto; direction: ltr; text-align: start; }
|
|
116
|
+
details > summary { cursor: pointer; font-weight: 600; }
|
|
117
|
+
@media (max-width: 480px) { .expect { grid-template-columns: 1fr; } .num { font-size: 1.25rem; } }
|
|
118
|
+
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
|
119
|
+
</style>
|
|
120
|
+
</head>
|
|
121
|
+
<body>
|
|
122
|
+
<div class="bar" id="bar" hidden>
|
|
123
|
+
<div class="bar-in">
|
|
124
|
+
<span class="eyebrow" id="bar-tester"></span>
|
|
125
|
+
<div class="count">
|
|
126
|
+
<span class="ok"><b id="c-passed">0</b> <span id="l-passed"></span></span>
|
|
127
|
+
<span class="ko"><b id="c-failed">0</b> <span id="l-failed"></span></span>
|
|
128
|
+
<span class="warn"><b id="c-blocked">0</b> <span id="l-blocked"></span></span>
|
|
129
|
+
<span class="todo"><b id="c-todo">0</b> <span id="l-todo"></span></span>
|
|
130
|
+
</div>
|
|
131
|
+
<nav class="strip" id="strip"></nav>
|
|
132
|
+
<span class="state" id="save-state">—</span>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div class="wrap">
|
|
137
|
+
<header class="head" id="head"></header>
|
|
138
|
+
<p class="lang"><label for="lang" id="t-language"></label><select id="lang"></select></p>
|
|
139
|
+
<section class="startbox" id="startbox">
|
|
140
|
+
<h2 id="t-before"></h2>
|
|
141
|
+
<label for="tester"><span id="t-tester"></span><input type="text" id="tester" autocomplete="name"></label>
|
|
142
|
+
<div class="row">
|
|
143
|
+
<button class="btn primary" id="btn-start" type="button"></button>
|
|
144
|
+
<span class="state" id="start-state"></span>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="resume" id="resume" hidden>
|
|
147
|
+
<span class="eyebrow" id="t-resume"></span>
|
|
148
|
+
<div id="resume-list" class="resume"></div>
|
|
149
|
+
</div>
|
|
150
|
+
</section>
|
|
151
|
+
<main class="steps" id="steps" hidden></main>
|
|
152
|
+
<section class="end" id="end" hidden>
|
|
153
|
+
<h2 id="t-finish"></h2>
|
|
154
|
+
<div id="closing" class="section"></div>
|
|
155
|
+
<label class="note" for="overall"><span id="t-overall"></span><textarea id="overall"></textarea></label>
|
|
156
|
+
<div class="row">
|
|
157
|
+
<button class="btn primary" id="btn-finish" type="button" disabled></button>
|
|
158
|
+
<button class="btn" id="btn-save" type="button" disabled></button>
|
|
159
|
+
<button class="btn" id="btn-copy" type="button" disabled></button>
|
|
160
|
+
<span class="state" id="end-state"></span>
|
|
161
|
+
</div>
|
|
162
|
+
<p class="info-box" id="mode-note"></p>
|
|
163
|
+
<details><summary>JSON</summary><textarea class="json" id="json" readonly aria-label="JSON"></textarea></details>
|
|
164
|
+
</section>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<script id="uat-spec" type="application/json">__SPEC_JSON__</script>
|
|
168
|
+
<script id="uat-strings" type="application/json">__STRINGS__</script>
|
|
169
|
+
<script>
|
|
170
|
+
(function () {
|
|
171
|
+
"use strict";
|
|
172
|
+
var SPEC = JSON.parse(document.getElementById("uat-spec").textContent);
|
|
173
|
+
var ALL = JSON.parse(document.getElementById("uat-strings").textContent);
|
|
174
|
+
var SPEC_SHA = "__SPEC_SHA__";
|
|
175
|
+
var RESULTS_SCHEMA = "bmad-plus/uat-results/2";
|
|
176
|
+
var STEPS = SPEC.steps;
|
|
177
|
+
var KEY = "uat-" + SPEC.id;
|
|
178
|
+
|
|
179
|
+
var run = null, runId = null, T = null, lang = null;
|
|
180
|
+
var db = null, dbDoc = null, unsub = null, downloads = null, serve = null;
|
|
181
|
+
var timer = null, lastWritten = "";
|
|
182
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
183
|
+
var lsGet = function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
|
184
|
+
var lsSet = function (k, v) { try { localStorage.setItem(k, v); } catch (e) {} };
|
|
185
|
+
|
|
186
|
+
// ── Language: the recipe's own, the tester's browser, or their choice ───
|
|
187
|
+
function pickLanguage() {
|
|
188
|
+
var saved = lsGet(KEY + "-lang");
|
|
189
|
+
if (saved && ALL[saved]) return saved;
|
|
190
|
+
if (SPEC.language && ALL[SPEC.language]) return SPEC.language;
|
|
191
|
+
var tags = (navigator.languages || [navigator.language || ""]).map(String);
|
|
192
|
+
for (var i = 0; i < tags.length; i++) {
|
|
193
|
+
var tag = tags[i];
|
|
194
|
+
if (ALL[tag]) return tag;
|
|
195
|
+
var base = tag.split("-")[0];
|
|
196
|
+
for (var code in ALL) { if (code === base || code.split("-")[0] === base) return code; }
|
|
197
|
+
}
|
|
198
|
+
return ALL.en ? "en" : Object.keys(ALL)[0];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function fillLanguageChoice() {
|
|
202
|
+
var select = $("lang");
|
|
203
|
+
select.innerHTML = "";
|
|
204
|
+
Object.keys(ALL).forEach(function (code) {
|
|
205
|
+
var option = document.createElement("option");
|
|
206
|
+
option.value = code;
|
|
207
|
+
option.textContent = ALL[code].name;
|
|
208
|
+
option.selected = code === lang;
|
|
209
|
+
select.appendChild(option);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyLanguage(code) {
|
|
214
|
+
lang = ALL[code] ? code : "en";
|
|
215
|
+
T = ALL[lang];
|
|
216
|
+
lsSet(KEY + "-lang", lang);
|
|
217
|
+
document.documentElement.lang = lang;
|
|
218
|
+
document.documentElement.dir = T.dir || "ltr";
|
|
219
|
+
$("t-language").textContent = T.uiLanguage;
|
|
220
|
+
$("t-before").textContent = T.before;
|
|
221
|
+
$("t-tester").textContent = T.tester;
|
|
222
|
+
$("btn-start").textContent = T.start;
|
|
223
|
+
$("t-resume").textContent = T.resume;
|
|
224
|
+
$("t-finish").textContent = T.finish;
|
|
225
|
+
$("t-overall").textContent = T.overall;
|
|
226
|
+
$("btn-finish").textContent = run && run.finishedAt ? T.finished : T.finishRun;
|
|
227
|
+
$("btn-save").textContent = T.saveJson;
|
|
228
|
+
$("btn-copy").textContent = T.copyJson;
|
|
229
|
+
$("l-passed").textContent = T.countSeen;
|
|
230
|
+
$("l-failed").textContent = T.countNotSeen;
|
|
231
|
+
$("l-blocked").textContent = T.countBlocked;
|
|
232
|
+
$("l-todo").textContent = T.todo;
|
|
233
|
+
$("mode-note").textContent = dbDoc ? T.dbMode : serve ? T.serveMode : T.localMode;
|
|
234
|
+
renderHead();
|
|
235
|
+
renderSteps();
|
|
236
|
+
renderStrip();
|
|
237
|
+
if (run) reflect();
|
|
238
|
+
listResumable();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderHead() {
|
|
242
|
+
var head = $("head");
|
|
243
|
+
head.innerHTML = "";
|
|
244
|
+
var subtitle = document.createElement("span");
|
|
245
|
+
subtitle.className = "eyebrow";
|
|
246
|
+
subtitle.textContent = SPEC.subtitle || (SPEC.product + " · " + SPEC.environment.name + " · " + SPEC.versions.join(" · ") + (SPEC.date ? " · " + SPEC.date : ""));
|
|
247
|
+
head.appendChild(subtitle);
|
|
248
|
+
var title = document.createElement("h1");
|
|
249
|
+
title.textContent = SPEC.title;
|
|
250
|
+
head.appendChild(title);
|
|
251
|
+
var summary = document.createElement("p");
|
|
252
|
+
summary.textContent = STEPS.length + " " + T.stepsLabel + (SPEC.estimate ? " · " + SPEC.estimate : "") + " · " + SPEC.environment.name + (SPEC.environment.url ? " — " + SPEC.environment.url : "");
|
|
253
|
+
head.appendChild(summary);
|
|
254
|
+
(SPEC.intro || []).forEach(function (text) { var p = document.createElement("p"); p.innerHTML = text; head.appendChild(p); });
|
|
255
|
+
(SPEC.warnings || []).forEach(function (text) { var p = document.createElement("p"); p.className = "warn-box"; p.innerHTML = text; head.appendChild(p); });
|
|
256
|
+
(SPEC.notes || []).forEach(function (text) { var p = document.createElement("p"); p.className = "info-box"; p.innerHTML = text; head.appendChild(p); });
|
|
257
|
+
var closing = $("closing");
|
|
258
|
+
closing.innerHTML = "";
|
|
259
|
+
((SPEC.closing && SPEC.closing.text) || []).forEach(function (text) { var p = document.createElement("p"); p.innerHTML = text; closing.appendChild(p); });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function newRun(tester) {
|
|
263
|
+
var steps = {};
|
|
264
|
+
STEPS.forEach(function (step) {
|
|
265
|
+
var expect = {};
|
|
266
|
+
step.expect.forEach(function (e) { expect[e.id] = { text: e.text, state: null, note: "" }; });
|
|
267
|
+
steps[step.id] = { title: step.title, state: null, note: "", expect: expect };
|
|
268
|
+
});
|
|
269
|
+
var now = new Date().toISOString();
|
|
270
|
+
return {
|
|
271
|
+
schema: RESULTS_SCHEMA, specId: SPEC.id, specSha256: SPEC_SHA, runId: null, tester: tester,
|
|
272
|
+
startedAt: now, updatedAt: now, finishedAt: null, steps: steps, overallNote: "",
|
|
273
|
+
summary: { passed: 0, failed: 0, blocked: 0, skipped: 0, unanswered: 0 },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function slug(s) { return String(s).normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 24) || "anonymous"; }
|
|
277
|
+
function newRunId(tester) {
|
|
278
|
+
var d = new Date(), p = function (n) { return String(n).padStart(2, "0"); };
|
|
279
|
+
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + "-" + slug(tester) + "-" + Math.random().toString(36).slice(2, 6);
|
|
280
|
+
}
|
|
281
|
+
function stepState(step) {
|
|
282
|
+
var values = Object.keys(step.expect).map(function (k) { return step.expect[k].state; });
|
|
283
|
+
if (values.indexOf("failed") >= 0) return "failed";
|
|
284
|
+
if (values.indexOf("blocked") >= 0) return "blocked";
|
|
285
|
+
if (values.length && values.every(function (v) { return v === "skipped"; })) return "skipped";
|
|
286
|
+
if (values.length && values.every(function (v) { return v === "passed" || v === "skipped"; })) return "passed";
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function recount() {
|
|
290
|
+
var s = { passed: 0, failed: 0, blocked: 0, skipped: 0, unanswered: 0 };
|
|
291
|
+
Object.keys(run.steps).forEach(function (id) {
|
|
292
|
+
var step = run.steps[id];
|
|
293
|
+
step.state = stepState(step);
|
|
294
|
+
Object.keys(step.expect).forEach(function (k) {
|
|
295
|
+
var v = step.expect[k].state;
|
|
296
|
+
if (v === null || v === undefined) s.unanswered++; else s[v]++;
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
run.summary = s;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Steps ───────────────────────────────────────────────────────────
|
|
303
|
+
function renderSteps() {
|
|
304
|
+
var main = $("steps");
|
|
305
|
+
main.innerHTML = "";
|
|
306
|
+
STEPS.forEach(function (step, index) {
|
|
307
|
+
var article = document.createElement("article");
|
|
308
|
+
article.className = "step";
|
|
309
|
+
article.id = "step-" + step.id;
|
|
310
|
+
var head = '<div class="step-head"><span class="num">' + index + '</span><h2></h2>'
|
|
311
|
+
+ (step.duration ? '<span class="duration"></span>' : "")
|
|
312
|
+
+ '<span class="verdict" id="v-' + step.id + '"></span></div>';
|
|
313
|
+
var body = '<div class="step-body">';
|
|
314
|
+
if (step.warning) body += '<p class="warn-box">' + step.warning + "</p>";
|
|
315
|
+
else if (step.writes) body += '<p class="warn-box"><b>' + T.writesWarning + " " + SPEC.environment.name + ".</b> " + T.intended + "</p>";
|
|
316
|
+
if (step.optional) body += '<p class="info-box">' + T.optional + ' — <button class="btn" type="button" data-skip="' + step.id + '">' + T.skip + "</button></p>";
|
|
317
|
+
body += '<div class="section"><span class="eyebrow">' + T.where + "</span><ul>" + step.where.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ul></div>";
|
|
318
|
+
body += '<div class="section"><span class="eyebrow">' + T.doIt + "</span><ol>" + step.do.map(function (x) { return "<li>" + x + "</li>"; }).join("") + "</ol></div>";
|
|
319
|
+
body += '<div class="section"><span class="eyebrow">' + T.expect + '</span><div class="expects">';
|
|
320
|
+
step.expect.forEach(function (e) {
|
|
321
|
+
var name = "e-" + step.id + "-" + e.id;
|
|
322
|
+
body += '<div class="expect" id="' + name + '" data-state=""><div class="text">' + e.text + "</div>"
|
|
323
|
+
+ '<div class="choice" role="radiogroup">'
|
|
324
|
+
+ '<label class="pill passed"><input type="radio" name="' + name + '" id="' + name + '-passed" value="passed"><span>' + T.seen + "</span></label>"
|
|
325
|
+
+ '<label class="pill failed"><input type="radio" name="' + name + '" id="' + name + '-failed" value="failed"><span>' + T.notSeen + "</span></label>"
|
|
326
|
+
+ '<label class="pill blocked"><input type="radio" name="' + name + '" id="' + name + '-blocked" value="blocked"><span>' + T.blocked + "</span></label>"
|
|
327
|
+
+ "</div>"
|
|
328
|
+
+ '<div class="why" id="why-' + step.id + "-" + e.id + '" hidden><label for="n-' + step.id + "-" + e.id + '"><span>' + T.whyNot + '</span><textarea id="n-' + step.id + "-" + e.id + '"></textarea></label></div>'
|
|
329
|
+
+ "</div>";
|
|
330
|
+
});
|
|
331
|
+
body += "</div></div>";
|
|
332
|
+
body += '<div class="section note"><label for="note-' + step.id + '"><span>' + T.note + '</span><textarea id="note-' + step.id + '"></textarea></label></div></div>';
|
|
333
|
+
article.innerHTML = head + body;
|
|
334
|
+
article.querySelector("h2").textContent = step.title;
|
|
335
|
+
if (step.duration) article.querySelector(".duration").textContent = step.duration;
|
|
336
|
+
// A command read inside a sentence gets copied with the sentence's punctuation
|
|
337
|
+
// ("doctor." instead of "doctor"): each command in "Do" copies itself exactly.
|
|
338
|
+
Array.prototype.forEach.call(article.querySelectorAll(".section ol code"), function (code) {
|
|
339
|
+
code.classList.add("cmd");
|
|
340
|
+
code.setAttribute("role", "button");
|
|
341
|
+
code.setAttribute("tabindex", "0");
|
|
342
|
+
code.title = T.copyCommand;
|
|
343
|
+
});
|
|
344
|
+
main.appendChild(article);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function renderStrip() {
|
|
349
|
+
var strip = $("strip");
|
|
350
|
+
strip.innerHTML = "";
|
|
351
|
+
STEPS.forEach(function (step, index) {
|
|
352
|
+
var segment = document.createElement("a");
|
|
353
|
+
segment.id = "s-" + step.id;
|
|
354
|
+
segment.href = "#step-" + step.id;
|
|
355
|
+
segment.title = index + " · " + step.title;
|
|
356
|
+
strip.appendChild(segment);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function copyCommand(code) {
|
|
361
|
+
var text = code.textContent.trim();
|
|
362
|
+
var done = function () {
|
|
363
|
+
code.setAttribute("data-copied", T.commandCopied);
|
|
364
|
+
code.classList.add("copied");
|
|
365
|
+
setTimeout(function () { code.classList.remove("copied"); }, 1500);
|
|
366
|
+
};
|
|
367
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () {});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Nothing is recorded before a run exists: say so, instead of discarding answers in silence. */
|
|
371
|
+
function requireRun(event) {
|
|
372
|
+
if (run) return true;
|
|
373
|
+
if (event && event.target instanceof HTMLInputElement && event.target.type === "radio") event.target.checked = false;
|
|
374
|
+
$("startbox").hidden = false;
|
|
375
|
+
$("start-state").textContent = T.startFirst;
|
|
376
|
+
$("startbox").scrollIntoView({ block: "center" });
|
|
377
|
+
$("tester").focus();
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function reflect() {
|
|
382
|
+
recount();
|
|
383
|
+
STEPS.forEach(function (step) {
|
|
384
|
+
var observed = run.steps[step.id];
|
|
385
|
+
if (!observed) return;
|
|
386
|
+
var article = $("step-" + step.id);
|
|
387
|
+
article.dataset.state = observed.state || "";
|
|
388
|
+
var verdict = observed.state === "passed" ? T.seen : observed.state === "failed" ? T.notSeen : observed.state === "blocked" ? T.blocked : observed.state === "skipped" ? T.skip : T.todo;
|
|
389
|
+
$("v-" + step.id).textContent = verdict;
|
|
390
|
+
step.expect.forEach(function (e) {
|
|
391
|
+
var name = "e-" + step.id + "-" + e.id, value = observed.expect[e.id].state;
|
|
392
|
+
["passed", "failed", "blocked"].forEach(function (state) { $(name + "-" + state).checked = value === state; });
|
|
393
|
+
$(name).dataset.state = value || "";
|
|
394
|
+
$("why-" + step.id + "-" + e.id).hidden = !(value === "failed" || value === "blocked");
|
|
395
|
+
var field = $("n-" + step.id + "-" + e.id);
|
|
396
|
+
if (document.activeElement !== field) field.value = observed.expect[e.id].note || "";
|
|
397
|
+
});
|
|
398
|
+
var note = $("note-" + step.id);
|
|
399
|
+
if (document.activeElement !== note) note.value = observed.note || "";
|
|
400
|
+
});
|
|
401
|
+
var s = run.summary;
|
|
402
|
+
var total = s.passed + s.failed + s.blocked + s.skipped + s.unanswered;
|
|
403
|
+
$("c-passed").textContent = s.passed;
|
|
404
|
+
$("c-failed").textContent = s.failed;
|
|
405
|
+
$("c-blocked").textContent = s.blocked;
|
|
406
|
+
$("c-todo").textContent = s.unanswered;
|
|
407
|
+
STEPS.forEach(function (step, index) {
|
|
408
|
+
var segment = $("s-" + step.id);
|
|
409
|
+
if (!segment) return;
|
|
410
|
+
var observed = run.steps[step.id];
|
|
411
|
+
var states = Object.keys(observed.expect).map(function (k) { return observed.expect[k].state; });
|
|
412
|
+
var answered = states.filter(function (v) { return v !== null && v !== undefined; }).length;
|
|
413
|
+
var state = observed.state || (answered ? "partial" : "");
|
|
414
|
+
segment.setAttribute("data-state", state);
|
|
415
|
+
segment.title = index + " · " + step.title + " — " + (state === "passed" ? T.seen : state === "failed" ? T.notSeen : state === "blocked" ? T.blocked : state === "skipped" ? T.skip : T.todo) + " (" + answered + "/" + states.length + ")";
|
|
416
|
+
});
|
|
417
|
+
$("strip").setAttribute("aria-label", total + " · " + s.passed + " " + T.seen + " · " + s.failed + " " + T.notSeen + " · " + s.blocked + " " + T.blocked);
|
|
418
|
+
// "Run by laurent · finished on 2026-09-16", as the recipes played in claude.ai always read.
|
|
419
|
+
$("bar-tester").textContent = T.runBy.replace("{tester}", run.tester) +
|
|
420
|
+
(run.finishedAt ? " · " + T.finishedOn.replace("{date}", String(run.finishedAt).slice(0, 10)) : "");
|
|
421
|
+
var overall = $("overall");
|
|
422
|
+
if (document.activeElement !== overall) overall.value = run.overallNote || "";
|
|
423
|
+
$("json").value = JSON.stringify(run, null, 2);
|
|
424
|
+
$("btn-finish").disabled = Boolean(run.finishedAt);
|
|
425
|
+
$("btn-finish").textContent = run.finishedAt ? T.finished : T.finishRun;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Persistence: artifact database, local server, or this browser ────
|
|
429
|
+
function persist(immediate) {
|
|
430
|
+
recount();
|
|
431
|
+
run.updatedAt = new Date().toISOString();
|
|
432
|
+
reflect();
|
|
433
|
+
lsSet(KEY + "-" + runId, JSON.stringify(run));
|
|
434
|
+
var body = JSON.stringify(run);
|
|
435
|
+
if (body === lastWritten) return;
|
|
436
|
+
var write = function () {
|
|
437
|
+
if (dbDoc) {
|
|
438
|
+
dbDoc.set(JSON.parse(body)).then(function () {
|
|
439
|
+
lastWritten = body;
|
|
440
|
+
$("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
|
|
441
|
+
$("save-state").className = "state saved";
|
|
442
|
+
}).catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (serve) {
|
|
446
|
+
fetch(serve + "/results/" + runId, { method: "PUT", headers: { "content-type": "application/json" }, body: body })
|
|
447
|
+
.then(function (response) {
|
|
448
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
449
|
+
lastWritten = body;
|
|
450
|
+
$("save-state").textContent = T.savedRemote + " " + new Date().toLocaleTimeString();
|
|
451
|
+
$("save-state").className = "state saved";
|
|
452
|
+
})
|
|
453
|
+
.catch(function () { $("save-state").textContent = T.savedLocal; $("save-state").className = "state"; });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
$("save-state").textContent = T.savedLocal;
|
|
457
|
+
};
|
|
458
|
+
clearTimeout(timer);
|
|
459
|
+
if (immediate) write(); else timer = setTimeout(write, 500);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function open(id, doc) {
|
|
463
|
+
runId = id;
|
|
464
|
+
run = doc;
|
|
465
|
+
run.runId = id;
|
|
466
|
+
lsSet(KEY + "-last", id);
|
|
467
|
+
$("startbox").hidden = true;
|
|
468
|
+
$("steps").hidden = false;
|
|
469
|
+
$("end").hidden = false;
|
|
470
|
+
$("bar").hidden = false;
|
|
471
|
+
$("btn-save").disabled = false;
|
|
472
|
+
$("btn-copy").disabled = false;
|
|
473
|
+
reflect();
|
|
474
|
+
if (db) {
|
|
475
|
+
dbDoc = db.doc("recettes/" + id);
|
|
476
|
+
if (unsub) unsub();
|
|
477
|
+
unsub = dbDoc.onSnapshot(function (snapshot) {
|
|
478
|
+
if (!snapshot.exists) return;
|
|
479
|
+
var data = snapshot.data();
|
|
480
|
+
if (!data || snapshot.metadata.hasPendingWrites) return;
|
|
481
|
+
var body = JSON.stringify(data);
|
|
482
|
+
if (body !== JSON.stringify(run) && data.updatedAt > run.updatedAt) { run = JSON.parse(body); lastWritten = body; reflect(); }
|
|
483
|
+
}, function () {});
|
|
484
|
+
}
|
|
485
|
+
$("mode-note").textContent = dbDoc ? T.dbMode : serve ? T.serveMode : T.localMode;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function start() {
|
|
489
|
+
var tester = $("tester").value.trim();
|
|
490
|
+
if (!tester) { $("start-state").textContent = T.startFirst; $("tester").focus(); return; }
|
|
491
|
+
var id = newRunId(tester);
|
|
492
|
+
open(id, newRun(tester));
|
|
493
|
+
persist(true);
|
|
494
|
+
window.scrollTo({ top: 0 });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function listResumable() {
|
|
498
|
+
var box = $("resume-list");
|
|
499
|
+
box.innerHTML = "";
|
|
500
|
+
var seen = {};
|
|
501
|
+
var add = function (id, doc) {
|
|
502
|
+
if (seen[id] || !doc) return;
|
|
503
|
+
seen[id] = true;
|
|
504
|
+
var button = document.createElement("button");
|
|
505
|
+
button.className = "btn";
|
|
506
|
+
button.type = "button";
|
|
507
|
+
button.textContent = (doc.tester || "?") + " — " + String(doc.startedAt || "").slice(0, 10) + (doc.finishedAt ? " · " + T.finished : "");
|
|
508
|
+
button.addEventListener("click", function () { open(id, doc); });
|
|
509
|
+
box.appendChild(button);
|
|
510
|
+
};
|
|
511
|
+
var pending = db
|
|
512
|
+
? db.collection("recettes").where("specId", "==", SPEC.id).limit(50).get()
|
|
513
|
+
.then(function (query) { query.docs.forEach(function (d) { add(d.id, d.data()); }); }).catch(function () {})
|
|
514
|
+
: Promise.resolve();
|
|
515
|
+
pending.then(function () {
|
|
516
|
+
var last = lsGet(KEY + "-last");
|
|
517
|
+
if (last) { var raw = lsGet(KEY + "-" + last); if (raw) { try { add(last, JSON.parse(raw)); } catch (e) {} } }
|
|
518
|
+
$("resume").hidden = box.children.length === 0;
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
document.addEventListener("change", function (event) {
|
|
523
|
+
var target = event.target;
|
|
524
|
+
if (target === $("lang")) { applyLanguage(target.value); return; }
|
|
525
|
+
if (!(target instanceof HTMLInputElement) || target.type !== "radio") return;
|
|
526
|
+
var m = /^e-(.+)-([a-z])$/.exec(target.name);
|
|
527
|
+
if (!m || !requireRun(event)) return;
|
|
528
|
+
run.steps[m[1]].expect[m[2]].state = target.value;
|
|
529
|
+
persist();
|
|
530
|
+
});
|
|
531
|
+
document.addEventListener("input", function (event) {
|
|
532
|
+
var target = event.target;
|
|
533
|
+
if (!(target instanceof HTMLTextAreaElement)) return;
|
|
534
|
+
if (!requireRun(event)) return;
|
|
535
|
+
if (target === $("overall")) { run.overallNote = target.value; persist(); return; }
|
|
536
|
+
var step = /^note-(.+)$/.exec(target.id);
|
|
537
|
+
if (step && run.steps[step[1]]) { run.steps[step[1]].note = target.value; persist(); return; }
|
|
538
|
+
var expect = /^n-(.+)-([a-z])$/.exec(target.id);
|
|
539
|
+
if (expect && run.steps[expect[1]]) { run.steps[expect[1]].expect[expect[2]].note = target.value; persist(); }
|
|
540
|
+
});
|
|
541
|
+
document.addEventListener("keydown", function (event) {
|
|
542
|
+
var code = event.target && event.target.closest && event.target.closest("code.cmd");
|
|
543
|
+
if (code && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); copyCommand(code); }
|
|
544
|
+
});
|
|
545
|
+
document.addEventListener("click", function (event) {
|
|
546
|
+
var code = event.target && event.target.closest && event.target.closest("code.cmd");
|
|
547
|
+
if (code) { copyCommand(code); return; }
|
|
548
|
+
var id = event.target && event.target.getAttribute && event.target.getAttribute("data-skip");
|
|
549
|
+
if (!id || !requireRun(event)) return;
|
|
550
|
+
Object.keys(run.steps[id].expect).forEach(function (k) { run.steps[id].expect[k].state = "skipped"; });
|
|
551
|
+
persist();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
$("btn-start").addEventListener("click", start);
|
|
555
|
+
$("tester").addEventListener("keydown", function (event) { if (event.key === "Enter") start(); });
|
|
556
|
+
$("btn-finish").addEventListener("click", function () {
|
|
557
|
+
if (!requireRun()) return;
|
|
558
|
+
if (run.summary.unanswered > 0 && !window.confirm(T.confirmUnanswered.replace("{n}", run.summary.unanswered))) return;
|
|
559
|
+
run.finishedAt = new Date().toISOString();
|
|
560
|
+
persist(true);
|
|
561
|
+
$("end-state").textContent = T.finished;
|
|
562
|
+
});
|
|
563
|
+
$("btn-copy").addEventListener("click", function () {
|
|
564
|
+
if (!requireRun()) return;
|
|
565
|
+
var text = JSON.stringify(run, null, 2);
|
|
566
|
+
var fallback = function () { $("json").parentElement.open = true; $("json").focus(); $("json").select(); };
|
|
567
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { $("end-state").textContent = T.copied; }, fallback);
|
|
568
|
+
else fallback();
|
|
569
|
+
});
|
|
570
|
+
$("btn-save").addEventListener("click", function () {
|
|
571
|
+
// Never export a run that does not exist: that is how a tester once saved "null.json".
|
|
572
|
+
if (!requireRun()) return;
|
|
573
|
+
var name = runId + ".json";
|
|
574
|
+
var text = JSON.stringify(run, null, 2);
|
|
575
|
+
if (downloads) {
|
|
576
|
+
downloads.save({ filename: name, data: text }).then(function () { $("end-state").textContent = name; }).catch(function () {});
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
var url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
|
581
|
+
var link = document.createElement("a");
|
|
582
|
+
link.href = url;
|
|
583
|
+
link.download = name;
|
|
584
|
+
document.body.appendChild(link);
|
|
585
|
+
link.click();
|
|
586
|
+
link.remove();
|
|
587
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
|
|
588
|
+
$("end-state").textContent = name;
|
|
589
|
+
} catch (e) { $("end-state").textContent = T.copyJson; }
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// ── Start: the page is complete without any capability ──────────────
|
|
593
|
+
lang = pickLanguage();
|
|
594
|
+
fillLanguageChoice();
|
|
595
|
+
applyLanguage(lang);
|
|
596
|
+
var resumeLast = function () {
|
|
597
|
+
var last = lsGet(KEY + "-last");
|
|
598
|
+
if (!last || run) return;
|
|
599
|
+
var raw = lsGet(KEY + "-" + last);
|
|
600
|
+
if (!raw) return;
|
|
601
|
+
try { open(last, JSON.parse(raw)); } catch (e) {}
|
|
602
|
+
};
|
|
603
|
+
fetch("__uat/ping", { method: "GET" })
|
|
604
|
+
.then(function (response) { return response.ok ? response.json() : null; })
|
|
605
|
+
.then(function (info) { if (info && info.uat) { serve = "__uat"; $("mode-note").textContent = T.serveMode; listResumable(); } })
|
|
606
|
+
.catch(function () {})
|
|
607
|
+
.then(function () {
|
|
608
|
+
var use = window.claude && typeof window.claude.use === "function" ? window.claude.use.bind(window.claude) : function () { return Promise.resolve(null); };
|
|
609
|
+
return use("db").then(function (namespace) {
|
|
610
|
+
db = namespace;
|
|
611
|
+
listResumable();
|
|
612
|
+
if (run && db && !dbDoc) { open(runId, run); persist(true); return; }
|
|
613
|
+
var last = lsGet(KEY + "-last");
|
|
614
|
+
if (last && !run && db) {
|
|
615
|
+
db.doc("recettes/" + last).get()
|
|
616
|
+
.then(function (snapshot) { if (snapshot.exists) open(last, snapshot.data()); else resumeLast(); })
|
|
617
|
+
.catch(resumeLast);
|
|
618
|
+
} else resumeLast();
|
|
619
|
+
});
|
|
620
|
+
})
|
|
621
|
+
.catch(resumeLast);
|
|
622
|
+
if (window.claude && typeof window.claude.use === "function") {
|
|
623
|
+
window.claude.use("downloads").then(function (namespace) { downloads = namespace; });
|
|
624
|
+
}
|
|
625
|
+
})();
|
|
626
|
+
</script>
|
|
627
|
+
</body>
|
|
628
|
+
</html>
|