wdi-method 0.6.19 → 0.6.25

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/lib/identity.mjs CHANGED
@@ -1,117 +1,246 @@
1
- // index.yaml editing. Every regex here is anchored on `\n`, and a Windows checkout hands us `\r\n` —
2
- // so every function normalises before matching and restores the file's own ending before writing.
3
- //
4
- // Skipping that is not a cosmetic bug. It silently returns "" for a name that IS set, which makes
5
- // `identityIsPlaceholder` true, which makes `setProductIdentity` return early: the installer reports
6
- // nothing and writes nothing, on every CRLF repo. Found 2026-08-18 on the first real install into a
7
- // Windows product repo, where `policy.doc_language` printed empty while the file plainly said `id`.
8
-
9
- const PRODUCT_BLOCK = /(?:^|\n)product:\n(?: .*\n)*/;
10
- const POLICY_BLOCK = /(?:^|\n)policy:\n(?: .*\n)*/;
11
-
12
- // FREE TEXT, not an enum. The consumer is a model, and a model does not need a list: "English",
13
- // "Bahasa Indonesia", "id", "Indonesia" all read the same to it. Fencing this into two values would
14
- // force the owner to translate their intent into the installer's vocabulary first, and nothing is
15
- // bought with that.
16
- export const DEFAULT_DOC_LANGUAGE = "English";
17
-
18
- /** LF view of the text, plus how to put the original endings back. */
19
- function lf(text) {
20
- const crlf = text.includes("\r\n");
21
- return { body: crlf ? text.replaceAll("\r\n", "\n") : text, crlf };
22
- }
23
-
24
- function restore(body, crlf) {
25
- return crlf ? body.replaceAll("\n", "\r\n") : body;
26
- }
27
-
28
- export function yamlQuote(value) {
29
- return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
30
- }
31
-
32
- export function readProductIdentity(text) {
33
- const { body } = lf(text);
34
- const name = body.match(/product:\n(?: .*\n)*? name:\s*"([^"]*)"/);
35
- const client = body.match(/product:\n(?: .*\n)*? client:\s*"([^"]*)"/);
36
- return {
37
- name: name ? name[1] : "",
38
- client: client ? client[1] : "",
39
- };
40
- }
41
-
42
- export function writeProductIdentity(text, { name, client }) {
43
- const { body, crlf } = lf(text);
44
- const block =
45
- `product:\n name: ${yamlQuote(name)}\n client: ${yamlQuote(client ?? "")}\n`;
46
- if (/^product:/m.test(body)) {
47
- return restore(
48
- body.replace(PRODUCT_BLOCK, (m) => (m.startsWith("\n") ? `\n${block}` : block)), crlf);
49
- }
50
- return restore(`${block}\n${body.replace(/^/, "")}`, crlf);
51
- }
52
-
53
- export function identityIsPlaceholder(name) {
54
- return !name || name === "{product}";
55
- }
56
-
57
- // ---------------------------------------------------------------------- language policy
58
- //
59
- // Two settings, and only two. Everything else about language is NOT a choice: method terminology,
60
- // document code prefixes, machine-facing markers, and code identifiers are always English —
61
- // `language-guide.md` owns that, and a caller MUST NOT ask about them.
62
- //
63
- // doc_language the PROSE of working documents in .what/ .how/ .control/
64
- // doc_filename_language the SLUG part of a document filename
65
- //
66
- // A corpus written before these existed is NOT migrated for them: the readers in validate.py accept
67
- // more than one language, so an existing document keeps working and only new writing follows the value.
68
-
69
- function readPolicyValue(body, key) {
70
- const quoted = body.match(new RegExp(`policy:\\n(?: .*\\n)*? ${key}:[ \\t]*"([^"]*)"`));
71
- if (quoted) return quoted[1].trim();
72
- // Bare scalars are accepted because early repos wrote `doc_language: id` unquoted. Stops at a
73
- // comment so a trailing `# note` never becomes part of the value.
74
- const bare = body.match(new RegExp(`policy:\\n(?: .*\\n)*? ${key}:[ \\t]*([^\\n#]*)`));
75
- return bare ? bare[1].trim() : "";
76
- }
77
-
78
- export function readLanguagePolicy(text) {
79
- const { body } = lf(text);
80
- return {
81
- docLanguage: readPolicyValue(body, "doc_language"),
82
- docFilenameLanguage: readPolicyValue(body, "doc_filename_language"),
83
- };
84
- }
85
-
86
- export function writeLanguagePolicy(text, { docLanguage, docFilenameLanguage }) {
87
- const { body, crlf } = lf(text);
88
- // Always quoted: free text MAY carry a space or a colon, and a bare YAML scalar breaks on both.
89
- const block =
90
- `policy:\n doc_language: ${yamlQuote(docLanguage)}\n` +
91
- ` doc_filename_language: ${yamlQuote(docFilenameLanguage)}\n`;
92
- if (/^policy:/m.test(body)) {
93
- return restore(
94
- body.replace(POLICY_BLOCK, (m) => (m.startsWith("\n") ? `\n${block}` : block)), crlf);
95
- }
96
- // Sits right after `product:` when that block exists — the two answer the same kind of question,
97
- // so a reader who finds one finds the other.
98
- if (/^product:/m.test(body)) {
99
- return restore(body.replace(/^product:\n(?: .*\n)*/m, (m) => `${m}\n${block}`), crlf);
100
- }
101
- return restore(`${block}\n${body.replace(/^/, "")}`, crlf);
102
- }
103
-
104
- // A first install has nowhere to read a product name from, so the folder is the best guess available —
105
- // and a guess the owner can accept with Enter beats a field they must type. `worship-presenter-web`
106
- // becomes `Worship Presenter Web`; camelCase splits too. It is a SUGGESTION: G1 confirms the real name.
107
- export function humaniseFolderName(name) {
108
- return String(name || "")
109
- .replace(/[._-]+/g, " ")
110
- .replace(/([a-z\d])([A-Z])/g, "$1 $2")
111
- .replace(/\s+/g, " ")
112
- .trim()
113
- .split(" ")
114
- .filter(Boolean)
115
- .map((w) => (w === w.toUpperCase() && w.length <= 4 ? w : w[0].toUpperCase() + w.slice(1)))
116
- .join(" ");
117
- }
1
+ // index.yaml editing. Every regex here is anchored on `\n`, and a Windows checkout hands us `\r\n` —
2
+ // so every function normalises before matching and restores the file's own ending before writing.
3
+ //
4
+ // Skipping that is not a cosmetic bug. It silently returns "" for a name that IS set, which makes
5
+ // `identityIsPlaceholder` true, which makes `setProductIdentity` return early: the installer reports
6
+ // nothing and writes nothing, on every CRLF repo. Found 2026-08-18 on the first real install into a
7
+ // Windows product repo, where `policy.doc_language` printed empty while the file plainly said `id`.
8
+
9
+ // FREE TEXT, not an enum. The consumer is a model, and a model does not need a list: "English",
10
+ // "Bahasa Indonesia", "id", "Indonesia" all read the same to it. Fencing this into two values would
11
+ // force the owner to translate their intent into the installer's vocabulary first, and nothing is
12
+ // bought with that.
13
+ export const DEFAULT_DOC_LANGUAGE = "English";
14
+
15
+ /** LF view of the text, plus how to put the original endings back. */
16
+ function lf(text) {
17
+ const crlf = text.includes("\r\n");
18
+ return { body: crlf ? text.replaceAll("\r\n", "\n") : text, crlf };
19
+ }
20
+
21
+ function restore(body, crlf) {
22
+ return crlf ? body.replaceAll("\n", "\r\n") : body;
23
+ }
24
+
25
+ export function yamlQuote(value) {
26
+ return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
27
+ }
28
+
29
+ /**
30
+ * Generic block-level key upsert that preserves comments, indentations, and existing sibling keys.
31
+ */
32
+ export function upsertBlockKeys(body, blockName, pairs) {
33
+ const entries = Array.isArray(pairs) ? pairs : Object.entries(pairs);
34
+ const validEntries = entries.filter(([_, v]) => v !== undefined);
35
+ if (validEntries.length === 0) {
36
+ return body;
37
+ }
38
+
39
+ const lines = body.split("\n");
40
+ // Header must start at column 0 to prevent matching nested keys
41
+ const headerRegex = new RegExp(`^${blockName}:[ \\t]*(?:#.*)?$`);
42
+ const headerIndex = lines.findIndex((l) => headerRegex.test(l));
43
+
44
+ if (headerIndex === -1) {
45
+ const blockContent = validEntries.map(([k, v]) => ` ${k}: ${v}`).join("\n");
46
+ const newBlockLines = [`${blockName}:`, ...validEntries.map(([k, v]) => ` ${k}: ${v}`)];
47
+
48
+ // If adding policy and product exists, place policy right after product block
49
+ if (blockName === "policy") {
50
+ const productHeaderRegex = /^product:[ \t]*(?:#.*)?$/;
51
+ const productHeaderIndex = lines.findIndex((l) => productHeaderRegex.test(l));
52
+ if (productHeaderIndex !== -1) {
53
+ let nextIndex = lines.length;
54
+ for (let i = productHeaderIndex + 1; i < lines.length; i++) {
55
+ if (/^[a-zA-Z0-9_-]+:|^---/.test(lines[i])) {
56
+ nextIndex = i;
57
+ break;
58
+ }
59
+ }
60
+ let lastIndented = productHeaderIndex;
61
+ for (let i = nextIndex - 1; i > productHeaderIndex; i--) {
62
+ if (/^\s/.test(lines[i])) {
63
+ lastIndented = i;
64
+ break;
65
+ }
66
+ }
67
+ if (lines[lastIndented + 1] === "") {
68
+ const insertLines = nextIndex < lines.length ? [...newBlockLines, ""] : newBlockLines;
69
+ lines.splice(lastIndented + 2, 0, ...insertLines);
70
+ } else {
71
+ const insertLines = nextIndex < lines.length ? ["", ...newBlockLines, ""] : ["", ...newBlockLines];
72
+ lines.splice(lastIndented + 1, 0, ...insertLines);
73
+ }
74
+ return lines.join("\n");
75
+ }
76
+ }
77
+
78
+ const cleanBody = body.replace(/^\uFEFF/, "");
79
+ return cleanBody ? `${blockName}:\n${blockContent}\n\n${cleanBody}` : `${blockName}:\n${blockContent}\n`;
80
+ }
81
+
82
+ // End of block is reached at the next top-level mapping key or document separator
83
+ let nextBlockIndex = lines.length;
84
+ for (let i = headerIndex + 1; i < lines.length; i++) {
85
+ if (/^[a-zA-Z0-9_-]+:|^---/.test(lines[i])) {
86
+ nextBlockIndex = i;
87
+ break;
88
+ }
89
+ }
90
+
91
+ let lastIndentedIndex = headerIndex;
92
+ for (let i = nextBlockIndex - 1; i > headerIndex; i--) {
93
+ if (/^\s/.test(lines[i])) {
94
+ lastIndentedIndex = i;
95
+ break;
96
+ }
97
+ }
98
+
99
+ const toAppend = [];
100
+ for (const [key, value] of validEntries) {
101
+ // Only match direct children at 2 spaces indentation
102
+ const keyRegex = new RegExp(`^ ${key}:[ \\t]*`);
103
+ let foundIndex = -1;
104
+ for (let i = headerIndex + 1; i < nextBlockIndex; i++) {
105
+ if (keyRegex.test(lines[i])) {
106
+ foundIndex = i;
107
+ break;
108
+ }
109
+ }
110
+
111
+ if (foundIndex !== -1) {
112
+ // Preserve any trailing inline comment
113
+ const commentMatch = lines[foundIndex].match(/(?:[ \t]+#.*)$/);
114
+ const inlineComment = commentMatch ? commentMatch[0] : "";
115
+ lines[foundIndex] = ` ${key}: ${value}${inlineComment}`;
116
+ } else {
117
+ toAppend.push(` ${key}: ${value}`);
118
+ }
119
+ }
120
+
121
+ if (toAppend.length > 0) {
122
+ lines.splice(lastIndentedIndex + 1, 0, ...toAppend);
123
+ }
124
+
125
+ return lines.join("\n");
126
+ }
127
+
128
+ function readBlockValue(body, blockName, key) {
129
+ const lines = body.split("\n");
130
+ const headerRegex = new RegExp(`^${blockName}:[ \\t]*(?:#.*)?$`);
131
+ const headerIndex = lines.findIndex((l) => headerRegex.test(l));
132
+ if (headerIndex === -1) return "";
133
+
134
+ let nextBlockIndex = lines.length;
135
+ for (let i = headerIndex + 1; i < lines.length; i++) {
136
+ if (/^[a-zA-Z0-9_-]+:|^---/.test(lines[i])) {
137
+ nextBlockIndex = i;
138
+ break;
139
+ }
140
+ }
141
+
142
+ const doubleQuotedRegex = new RegExp(`^ ${key}:[ \\t]*"((?:[^"\\\\]|\\\\.)*)"`);
143
+ const singleQuotedRegex = new RegExp(`^ ${key}:[ \\t]*'((?:[^'\\\\]|\\\\.)*)'`);
144
+ const bareRegex = new RegExp(`^ ${key}:[ \\t]*([^\\n#]*)`);
145
+
146
+ for (let i = headerIndex + 1; i < nextBlockIndex; i++) {
147
+ const dQuoted = lines[i].match(doubleQuotedRegex);
148
+ if (dQuoted) {
149
+ return dQuoted[1].replaceAll('\\"', '"').replaceAll('\\\\', '\\').trim();
150
+ }
151
+ const sQuoted = lines[i].match(singleQuotedRegex);
152
+ if (sQuoted) {
153
+ return sQuoted[1].replaceAll("''", "'").trim();
154
+ }
155
+ const bare = lines[i].match(bareRegex);
156
+ if (bare) return bare[1].trim();
157
+ }
158
+ return "";
159
+ }
160
+
161
+ export function readProductIdentity(text) {
162
+ const { body } = lf(text);
163
+ return {
164
+ name: readBlockValue(body, "product", "name"),
165
+ client: readBlockValue(body, "product", "client"),
166
+ };
167
+ }
168
+
169
+ export function writeProductIdentity(text, { name, client }) {
170
+ const { body, crlf } = lf(text);
171
+ const pairs = [
172
+ ["name", yamlQuote(name)],
173
+ ["client", yamlQuote(client ?? "")],
174
+ ];
175
+ const nextBody = upsertBlockKeys(body, "product", pairs);
176
+ return restore(nextBody, crlf);
177
+ }
178
+
179
+ export function identityIsPlaceholder(name) {
180
+ return !name || name === "{product}";
181
+ }
182
+
183
+ // ---------------------------------------------------------------------- policy (language & branches)
184
+ //
185
+ // Language settings:
186
+ // doc_language the PROSE of working documents in .what/ .how/ .control/
187
+ // doc_filename_language the SLUG part of a document filename
188
+ //
189
+ // Branch settings:
190
+ // primary_branch trunk / production branch (defaults to "main")
191
+ // development_branch active development branch (defaults to "main")
192
+
193
+ function readPolicyValue(body, key) {
194
+ return readBlockValue(body, "policy", key);
195
+ }
196
+
197
+ export function readLanguagePolicy(text) {
198
+ const { body } = lf(text);
199
+ return {
200
+ docLanguage: readPolicyValue(body, "doc_language"),
201
+ docFilenameLanguage: readPolicyValue(body, "doc_filename_language"),
202
+ };
203
+ }
204
+
205
+ export function writeLanguagePolicy(text, { docLanguage, docFilenameLanguage }) {
206
+ const { body, crlf } = lf(text);
207
+ const pairs = [];
208
+ if (docLanguage !== undefined) pairs.push(["doc_language", yamlQuote(docLanguage)]);
209
+ if (docFilenameLanguage !== undefined) pairs.push(["doc_filename_language", yamlQuote(docFilenameLanguage)]);
210
+ const nextBody = upsertBlockKeys(body, "policy", pairs);
211
+ return restore(nextBody, crlf);
212
+ }
213
+
214
+ export function readBranchPolicy(text) {
215
+ const { body } = lf(text);
216
+ const primary = readPolicyValue(body, "primary_branch");
217
+ const development = readPolicyValue(body, "development_branch");
218
+ return {
219
+ primaryBranch: primary || "main",
220
+ developmentBranch: development || "main",
221
+ };
222
+ }
223
+
224
+ export function writeBranchPolicy(text, { primaryBranch, developmentBranch }) {
225
+ const { body, crlf } = lf(text);
226
+ const pairs = [];
227
+ if (primaryBranch !== undefined) pairs.push(["primary_branch", yamlQuote(primaryBranch)]);
228
+ if (developmentBranch !== undefined) pairs.push(["development_branch", yamlQuote(developmentBranch)]);
229
+ const nextBody = upsertBlockKeys(body, "policy", pairs);
230
+ return restore(nextBody, crlf);
231
+ }
232
+
233
+ // A first install has nowhere to read a product name from, so the folder is the best guess available —
234
+ // and a guess the owner can accept with Enter beats a field they must type. `acme-billing-portal`
235
+ // becomes `Acme Billing Portal`; camelCase splits too. It is a SUGGESTION: G1 confirms the real name.
236
+ export function humaniseFolderName(name) {
237
+ return String(name || "")
238
+ .replace(/[._-]+/g, " ")
239
+ .replace(/([a-z\d])([A-Z])/g, "$1 $2")
240
+ .replace(/\s+/g, " ")
241
+ .trim()
242
+ .split(" ")
243
+ .filter(Boolean)
244
+ .map((w) => (w === w.toUpperCase() && w.length <= 4 ? w : w[0].toUpperCase() + w.slice(1)))
245
+ .join(" ");
246
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdi-method",
3
- "version": "0.6.19",
3
+ "version": "0.6.25",
4
4
  "description": "WDI Method — software delivery method that wraps BMad",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,8 +13,12 @@
13
13
  "kit-overlay/",
14
14
  "scaffold/",
15
15
  "README.md",
16
+ "README.id.md",
17
+ "README.ja.md",
18
+ "README.zh.md",
16
19
  "CHANGELOG.md",
17
20
  "LICENSE",
21
+ "NOTICE",
18
22
  "!**/__pycache__",
19
23
  "!**/*.pyc"
20
24
  ],
@@ -35,10 +39,10 @@
35
39
  "license": "MIT",
36
40
  "repository": {
37
41
  "type": "git",
38
- "url": "git+https://github.com/wiradigitalid/wdi-method.git"
42
+ "url": "git+https://github.com/wiradeltaid/wdi-method.git"
39
43
  },
40
44
  "bugs": {
41
- "url": "https://github.com/wiradigitalid/wdi-method/issues"
45
+ "url": "https://github.com/wiradeltaid/wdi-method/issues"
42
46
  },
43
- "homepage": "https://github.com/wiradigitalid/wdi-method#readme"
47
+ "homepage": "https://github.com/wiradeltaid/wdi-method#readme"
44
48
  }
@@ -0,0 +1,65 @@
1
+ # .control/custom-dispatch.yaml.example
2
+ # Optional machine-local runner and dispatch configuration for autonomous daily tier skills.
3
+ # When seeded to `.control/custom-dispatch.yaml`, it remains local and gitignored.
4
+ schema: 1
5
+
6
+ runners: {}
7
+ # Uncomment and customize runners on this machine:
8
+ #
9
+ # Auto runners (dispatches in-session if model is reachable from active session;
10
+ # otherwise executes shell-out command):
11
+ # sonnet:
12
+ # type: auto
13
+ # model: sonnet
14
+ # command: 'claude --model claude-sonnet-5 --effort high --dangerously-skip-permissions -p'
15
+ #
16
+ # opus:
17
+ # type: auto
18
+ # model: opus
19
+ # command: 'claude --model claude-opus-5 --effort high --dangerously-skip-permissions -p'
20
+ #
21
+ # External CLI runners (always shell-out):
22
+ # NOTE: For roles.reviewer, runner commands MUST enforce read-only execution where supported
23
+ # (e.g. `--trust-tools=fs_read` for kiro-cli, `--mode plan` for cursor-agent).
24
+ #
25
+ # terra:
26
+ # type: shell-out
27
+ # command: 'kiro-cli chat --model gpt-5.6-terra --effort high --trust-tools=fs_read --no-interactive'
28
+ # timeout_s: 600 # Hard wall-clock timeout in seconds for backgrounded dispatch (default: 600s)
29
+ #
30
+ # composer:
31
+ # type: shell-out
32
+ # command: 'cursor-agent --mode plan --model "composer-2.5"'
33
+ # timeout_s: 600
34
+ #
35
+ # sol:
36
+ # type: shell-out
37
+ # command: 'cursor-agent --mode plan --model "gpt-5.6-sol-high"'
38
+ # timeout_s: 600
39
+
40
+ roles:
41
+ # Builder configuration:
42
+ # builder is fixed to 'coordinator' (mandatory). The coordinating session implements code
43
+ # directly in the active run worktree (tight TDD cycles, direct verification). Coding delegation
44
+ # (in-session subagent or external builder runner) is prohibited in the daily routine to eliminate
45
+ # handoff latency and delegation hallucinations.
46
+ # Guardrail: builder: coordinator MUST NOT be paired with reviewer: none unless all touched
47
+ # components have risk_accepted: low and the decision is recorded. A builder cannot be its own sole judge.
48
+ builder: coordinator
49
+
50
+ # Reviewer options:
51
+ # - default: Resolves peer review via caller's active session / global agent rules
52
+ # - <runner-id>: Dispatches peer review to the named runner
53
+ # - none: Disables peer review pass (coordinator self-review only)
54
+ # NOTE: Reviewer runners in daily triage (wdi-daily-what-to-build) operate strictly as advisory /
55
+ # read-only critics. The coordinator alone applies edits to the spec/tickets and writes registry traces.
56
+ reviewer: default
57
+
58
+ # Deep analyst options (for architecture spine, complex debugging, or hard decisions):
59
+ # - default: Uses reviewer runner or coordinator for deep analysis
60
+ # - <runner-id>: Dispatches deep analysis to a dedicated high-reasoning runner
61
+ # - none: Disables separate deep analyst dispatch (handled by reviewer/coordinator)
62
+ deep_analyst: default
63
+
64
+ review_policy:
65
+ peer_review: true # Set to false (or set reviewer: none) to bypass peer review pass
@@ -9,6 +9,15 @@ product:
9
9
  name: "{product}"
10
10
  client: ""
11
11
 
12
+ # POLICY SETTINGS. Language and branching rules for the repository.
13
+ # Branch settings default to `main` if omitted. Set `development_branch` to match
14
+ # `primary_branch` for single-branch workflows.
15
+ policy:
16
+ doc_language: "English"
17
+ doc_filename_language: "English"
18
+ # primary_branch: main
19
+ # development_branch: development # Set equal to primary_branch for single-branch workflow
20
+
12
21
  # GLOBAL DEPTH. The only document-depth knob, and it MUST NOT also govern review intensity —
13
22
  # that is `risk_accepted` per component in components.yaml.
14
23
  # catalog UC list + three inventories + C4; zero extra files per component. G4 is skipped
@@ -0,0 +1,15 @@
1
+ # Desktop Smoke Test Target
2
+
3
+ ## Target Configuration
4
+ # Optional build and process settings for this repository:
5
+ # build_command: <build command from codebase-stack-guide.md>
6
+ # profile: debug | release (default: release for visual/performance, debug for fast logic loops)
7
+ # artifact_path: <path to compiled binary artifact>
8
+ # process_name: <application process name>
9
+
10
+ ## Verification Checklist Template
11
+
12
+ - [ ] Application launches cleanly without crashes or uncaught runtime panics
13
+ - [ ] Primary window displays expected initial view and layout
14
+ - [ ] User input, navigation, and window controls respond as expected
15
+ - [ ] Application logs are free from unexpected warnings or errors
@@ -0,0 +1,6 @@
1
+ # Mobile Smoke Test Checklist Template
2
+
3
+ - [ ] Application installs and launches on target device or emulator
4
+ - [ ] Initial view displays cleanly without crashing
5
+ - [ ] Touch gestures and primary navigation respond as expected
6
+ - [ ] Device runtime log displays no unhandled exceptions
@@ -0,0 +1,6 @@
1
+ # Web Smoke Test Checklist Template
2
+
3
+ - [ ] Application server starts and responds to HTTP requests
4
+ - [ ] Initial route renders without 404 or 500 status codes
5
+ - [ ] Interactive UI controls respond to user interaction
6
+ - [ ] Browser console log shows no uncaught exceptions