@bongos/core 1.24.27 → 1.24.29
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/.bongos-core.json +53 -58
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +1 -1
- package/docs/copy-inventory.md +173 -203
- package/docs/copy-registry.json +350 -622
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +8 -8
- package/docs/page-readings.json +346 -331
- package/modules/builder-settings/interaction-prefs.js +2 -2
- package/modules/hall-ui/public/my-agents.css +72 -1
- package/modules/hall-ui/public/my-agents.html +30 -6
- package/modules/hall-ui/public/my-agents.js +98 -56
- package/modules/hall-ui/public/my-agents.states.json +18 -0
- package/modules/hall-ui/public/palette.js +8 -0
- package/modules/hall-ui/public/settings-moved.js +41 -0
- package/modules/hall-ui/public/settings.css +4 -158
- package/modules/hall-ui/public/settings.html +19 -147
- package/modules/hall-ui/public/settings.js +20 -215
- package/modules/hall-ui/public/settings.states.json +2 -4
- package/modules/hall-ui/public/speciality-tools.css +140 -0
- package/modules/hall-ui/public/{settings-specialities.js → speciality-tools.js} +83 -6
- package/modules/hall-ui/records/my-agents.md +5 -2
- package/modules/hall-ui/records/panel-family.md +4 -0
- package/modules/lifecycle/ship-card.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/government_tidy_up.mjs +21 -24
- package/tests/hall_loading.mjs +3 -1
- package/tests/hall_my_agents.mjs +190 -2
- package/tests/hall_palette.mjs +12 -0
- package/tests/hall_settings_world.mjs +64 -100
- package/tests/helpers.mjs +162 -0
- package/tests/interaction_posture.mjs +3 -1
- package/tests/speciality_offer_ui.mjs +183 -149
- package/tests/speciality_walkthrough_ui.mjs +182 -161
- package/modules/hall-ui/public/settings-interaction.js +0 -132
- package/modules/hall-ui/public/settings-law-locks.js +0 -53
- package/modules/hall-ui/public/settings-render.js +0 -138
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// tests/speciality_walkthrough_ui.mjs — the
|
|
2
|
-
//
|
|
1
|
+
// tests/speciality_walkthrough_ui.mjs — the ADOPTER's side of the speciality
|
|
2
|
+
// tools: taking one on, the skills walkthrough it opens, and stopping it (task
|
|
3
|
+
// 1004073, part 2 of 3 of ADR 0310; on My agents too since task 1005052).
|
|
3
4
|
//
|
|
4
5
|
// The owner's ruling: an adopted speciality's skills start OFF, "and you should
|
|
5
6
|
// walk through what comes with each, people need to learn the system". The task
|
|
@@ -9,21 +10,21 @@
|
|
|
9
10
|
// every step, no control that decides without the person seeing each skill, and
|
|
10
11
|
// nothing written until the recap is saved.
|
|
11
12
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// THE SAFETY STEP. A speciality someone else wrote puts their prose into your
|
|
14
|
+
// sessions, so its full instructions are on the card, open, before the control
|
|
15
|
+
// that adopts it — pinned below on My agents.
|
|
16
|
+
//
|
|
17
|
+
// ONE COMPONENT, ONE HOST. modules/hall-ui/public/speciality-tools.js was mounted
|
|
18
|
+
// by Settings and by My agents (task 1005052); since task 1005044 My agents is the
|
|
19
|
+
// only page that edits a speciality, and Settings links there. Every case runs on
|
|
20
|
+
// each page in SPECIALITY_PAGES, booted on THAT page's own markup
|
|
21
|
+
// (tests/helpers.mjs bootSpecialityTools), so a host lost from it fails its run.
|
|
22
|
+
// tests/hall_settings_world.mjs pins that Settings no longer mounts it.
|
|
16
23
|
//
|
|
17
24
|
// Run: node tests/speciality_walkthrough_ui.mjs
|
|
18
25
|
import assert from 'node:assert/strict';
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
import vm from 'node:vm';
|
|
22
|
-
import { fileURLToPath } from 'node:url';
|
|
23
|
-
import { makeRunner } from './helpers.mjs';
|
|
24
|
-
|
|
25
|
-
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
-
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-specialities.js'), 'utf8');
|
|
26
|
+
import { makeRunner, bootSpecialityTools, SPECIALITY_PAGES } from './helpers.mjs';
|
|
27
|
+
|
|
27
28
|
const { test, summary } = makeRunner();
|
|
28
29
|
|
|
29
30
|
const explained = (name, extra = {}) => ({
|
|
@@ -56,163 +57,183 @@ const SKILLS_BODY = {
|
|
|
56
57
|
],
|
|
57
58
|
};
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const els = new Map();
|
|
64
|
-
const handlers = new Map();
|
|
65
|
-
const makeEl = (id) => ({
|
|
66
|
-
id, hidden: id === 'specialities-scroll', innerHTML: '', textContent: '', elements: {},
|
|
67
|
-
addEventListener(type, fn) { handlers.set(`${id}:${type}`, fn); },
|
|
68
|
-
});
|
|
69
|
-
for (const id of ['specialities-scroll', 'specialities-rows', 'speciality-suggestions', 'specialities-status']) els.set(id, makeEl(id));
|
|
70
|
-
let focused = null;
|
|
71
|
-
|
|
72
|
-
const fetch = async (url, opts = {}) => {
|
|
73
|
-
const method = opts.method || 'GET';
|
|
74
|
-
const p = url.replace('/api/bongos', '');
|
|
75
|
-
requests.push({ method, path: p, body: opts.body ? JSON.parse(opts.body) : undefined });
|
|
76
|
-
let body = {};
|
|
77
|
-
if (method === 'GET' && p === '/specialities') body = { specialities: catalog };
|
|
78
|
-
else if (method === 'GET' && p === '/me/specialities') body = { adoptions: [] };
|
|
79
|
-
else if (method === 'GET' && p === '/specialities/offered') body = { suggestions: [] };
|
|
80
|
-
else if (method === 'GET' && /\/skills$/.test(p)) body = skillsBody;
|
|
81
|
-
else if (method === 'POST' && /\/adopt$/.test(p)) body = { adoption: { active: true } };
|
|
82
|
-
else if (method === 'PUT' && /\/skills$/.test(p)) body = { adoption: { enabled_skills: JSON.parse(opts.body).enabled_skills } };
|
|
83
|
-
return { ok: true, status: 200, json: async () => body };
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const document = {
|
|
87
|
-
readyState: 'complete',
|
|
88
|
-
getElementById: (id) => els.get(id) || null,
|
|
89
|
-
addEventListener() {},
|
|
90
|
-
querySelector: (sel) => (sel.includes('data-walk-focus') ? { focus() { focused = 'walk-heading'; } } : null),
|
|
91
|
-
};
|
|
92
|
-
const window = {
|
|
93
|
-
OTB: {
|
|
94
|
-
escapeHtml: (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
|
95
|
-
$: () => null,
|
|
96
|
-
toast: (m) => toasts.push(m),
|
|
97
|
-
roleAxisLabel: () => 'Role',
|
|
98
|
-
},
|
|
99
|
-
location: { assign() {} },
|
|
100
|
-
};
|
|
101
|
-
vm.runInNewContext(SRC, { window, document, fetch, JSON, Promise, Map, Set, String, Number, Array, Object, Math, RegExp, console });
|
|
102
|
-
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
103
|
-
for (let i = 0; i < 5; i++) await settle();
|
|
104
|
-
|
|
105
|
-
const rows = els.get('specialities-rows');
|
|
106
|
-
// A click through the delegated handler, on the first button whose attribute matches.
|
|
107
|
-
async function click(attr, value) {
|
|
108
|
-
const re = new RegExp(`<button[^>]*${attr}="${value}"[^>]*>`);
|
|
109
|
-
const tag = re.exec(rows.innerHTML);
|
|
110
|
-
assert.ok(tag, `no ${attr}="${value}" button in:\n${rows.innerHTML}`);
|
|
111
|
-
const dataset = {};
|
|
112
|
-
for (const [, k, v] of tag[0].matchAll(/data-([a-z]+)="([^"]*)"/g)) dataset[k] = v;
|
|
113
|
-
const btn = { dataset };
|
|
114
|
-
const target = { closest: (sel) => (sel === `button[${attr}]` ? btn : null) };
|
|
115
|
-
handlers.get('specialities-rows:click')({ target });
|
|
116
|
-
for (let i = 0; i < 6; i++) await settle();
|
|
117
|
-
}
|
|
118
|
-
const text = () => rows.innerHTML.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
|
|
119
|
-
return { els, rows, requests, toasts, click, text, focused: () => focused };
|
|
60
|
+
async function boot(page, { catalog = [speciality()], skillsBody = SKILLS_BODY, ...rest } = {}) {
|
|
61
|
+
const h = bootSpecialityTools({ page, catalog, skills: { 9: skillsBody }, ...rest });
|
|
62
|
+
await h.ready();
|
|
63
|
+
return h;
|
|
120
64
|
}
|
|
65
|
+
const ROWS = '#specialities-rows';
|
|
66
|
+
const writes = (h) => h.writes();
|
|
67
|
+
const text = (h) => h.text('specialities-rows');
|
|
68
|
+
|
|
69
|
+
for (const page of SPECIALITY_PAGES) {
|
|
70
|
+
await test(`${page}: the specialities panel is revealed once its reads answer — it shipped hidden and nothing ever showed it`, async () => {
|
|
71
|
+
const h = await boot(page);
|
|
72
|
+
assert.equal(h.el('specialities-scroll').hidden, false);
|
|
73
|
+
});
|
|
121
74
|
|
|
122
|
-
|
|
75
|
+
await test(`${page}: adopting a speciality that offers skills opens the walkthrough on the FIRST skill, with nothing written yet`, async () => {
|
|
76
|
+
const h = await boot(page);
|
|
77
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
78
|
+
assert.ok(h.requests.some((r) => r.method === 'GET' && r.path === '/specialities/9/skills'), 'the walkthrough read its skills');
|
|
79
|
+
assert.match(text(h), /Skill 1 of 3 in Economics/, 'only INSTALLED skills are steps');
|
|
80
|
+
assert.ok(h.focused() && 'data-walk-focus' in h.focused().attrs, 'focus moves to the step, not left on a button that is gone');
|
|
81
|
+
assert.deepEqual(writes(h).map((r) => r.path), ['/specialities/9/adopt'], 'no enabled set was written by adopting');
|
|
82
|
+
});
|
|
123
83
|
|
|
124
|
-
await test(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
84
|
+
await test(`${page}: it is not a page of checkboxes: one skill per step, and no control decides for several at once`, async () => {
|
|
85
|
+
const h = await boot(page);
|
|
86
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
87
|
+
assert.ok(!/type="checkbox"/.test(h.html('specialities-rows')), 'no checkbox anywhere in the walkthrough');
|
|
88
|
+
assert.ok(!/(turn|enable|select) (them )?all/i.test(text(h)), 'no "turn them all on"');
|
|
89
|
+
assert.ok(text(h).includes('recall') && !text(h).includes('status does its one thing'), 'the next skill is not shown yet');
|
|
90
|
+
});
|
|
128
91
|
|
|
129
|
-
await test(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
});
|
|
92
|
+
await test(`${page}: every step answers what it does, when to reach for it, and what it costs`, async () => {
|
|
93
|
+
const h = await boot(page);
|
|
94
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
95
|
+
for (let step = 0; step < 3; step++) {
|
|
96
|
+
for (const q of ['What it does', 'When to reach for it', 'What it costs']) assert.ok(text(h).includes(q), `step ${step + 1} is missing "${q}"`);
|
|
97
|
+
await h.click(`${ROWS} button[data-walk="off"]`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
137
100
|
|
|
138
|
-
await test(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
101
|
+
await test(`${page}: a rank-gated skill says so BEFORE the choice — turning it on grants nothing`, async () => {
|
|
102
|
+
const h = await boot(page);
|
|
103
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
104
|
+
await h.click(`${ROWS} button[data-walk="off"]`);
|
|
105
|
+
await h.click(`${ROWS} button[data-walk="off"]`);
|
|
106
|
+
const t = text(h);
|
|
107
|
+
assert.match(t, /Skill 3 of 3/);
|
|
108
|
+
assert.match(t, /Metic rank or above.*does not change that/);
|
|
109
|
+
assert.match(t, /What it needs on your machine gh/);
|
|
110
|
+
});
|
|
145
111
|
|
|
146
|
-
await test(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
112
|
+
await test(`${page}: the recap names every skill with what it does and the choice made, then saves exactly the ones turned on`, async () => {
|
|
113
|
+
const h = await boot(page);
|
|
114
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
115
|
+
await h.click(`${ROWS} button[data-walk="on"]`);
|
|
116
|
+
await h.click(`${ROWS} button[data-walk="off"]`);
|
|
117
|
+
await h.click(`${ROWS} button[data-walk="on"]`);
|
|
118
|
+
const t = text(h);
|
|
119
|
+
assert.match(t, /Your choices for Economics/);
|
|
120
|
+
assert.match(t, /2 of 3 turned on/);
|
|
121
|
+
for (const n of ['recall', 'status', 'goal-review']) assert.ok(t.includes(`${n} does its one thing.`), `the recap restates what ${n} does`);
|
|
122
|
+
assert.ok(!t.includes('And a second sentence'), 'one line each, not the whole description again');
|
|
123
|
+
assert.match(t, /no longer on this project.*gone/, 'an offered skill removed since is named, not offered');
|
|
124
|
+
assert.equal(writes(h).length, 1, 'still nothing written before Save');
|
|
125
|
+
await h.click(`${ROWS} button[data-walk="save"]`);
|
|
126
|
+
const put = writes(h).find((r) => r.method === 'PUT');
|
|
127
|
+
assert.ok(put, 'Save wrote the set');
|
|
128
|
+
assert.equal(put.path, '/specialities/9/skills');
|
|
129
|
+
assert.deepEqual(put.body, { enabled_skills: ['recall', 'goal-review'] });
|
|
130
|
+
assert.ok(h.toasts.some((m) => /2 of 3/.test(m)));
|
|
131
|
+
});
|
|
155
132
|
|
|
156
|
-
await test(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
});
|
|
133
|
+
await test(`${page}: Back revisits a decision and the new choice is the one saved`, async () => {
|
|
134
|
+
const h = await boot(page);
|
|
135
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
136
|
+
await h.click(`${ROWS} button[data-walk="on"]`);
|
|
137
|
+
await h.click(`${ROWS} button[data-walk="back"]`);
|
|
138
|
+
assert.match(text(h), /Skill 1 of 3/);
|
|
139
|
+
assert.match(h.html('specialities-rows'), /data-walk="on" aria-pressed="true"/, 'the earlier choice shows when you step back to it');
|
|
140
|
+
await h.click(`${ROWS} button[data-walk="off"]`);
|
|
141
|
+
await h.click(`${ROWS} button[data-walk="off"]`);
|
|
142
|
+
await h.click(`${ROWS} button[data-walk="off"]`);
|
|
143
|
+
await h.click(`${ROWS} button[data-walk="save"]`);
|
|
144
|
+
assert.deepEqual(writes(h).find((r) => r.method === 'PUT').body, { enabled_skills: [] });
|
|
145
|
+
});
|
|
166
146
|
|
|
167
|
-
await test(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
assert.match(t, /2 of 3 turned on/);
|
|
176
|
-
for (const n of ['recall', 'status', 'goal-review']) assert.ok(t.includes(`${n} does its one thing.`), `the recap restates what ${n} does`);
|
|
177
|
-
assert.ok(!t.includes('And a second sentence'), 'one line each, not the whole description again');
|
|
178
|
-
assert.match(t, /no longer on this project.*gone/, 'an offered skill removed since is named, not offered');
|
|
179
|
-
assert.equal(writes(h).length, 1, 'still nothing written before Save');
|
|
180
|
-
await h.click('data-walk', 'save');
|
|
181
|
-
const put = writes(h).find((r) => r.method === 'PUT');
|
|
182
|
-
assert.ok(put, 'Save wrote the set');
|
|
183
|
-
assert.equal(put.path, '/specialities/9/skills');
|
|
184
|
-
assert.deepEqual(put.body, { enabled_skills: ['recall', 'goal-review'] });
|
|
185
|
-
assert.ok(h.toasts.some((m) => /2 of 3/.test(m)));
|
|
186
|
-
});
|
|
147
|
+
await test(`${page}: finishing later writes nothing, so every skill stays off — the default the ruling asked for`, async () => {
|
|
148
|
+
const h = await boot(page);
|
|
149
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
150
|
+
await h.click(`${ROWS} button[data-walk="on"]`);
|
|
151
|
+
await h.click(`${ROWS} button[data-walk="close"]`);
|
|
152
|
+
assert.ok(!/skill-walk/.test(h.html('specialities-rows')), 'the walkthrough closed');
|
|
153
|
+
assert.ok(!writes(h).some((r) => r.method === 'PUT'), 'a half-finished walk saves nothing');
|
|
154
|
+
});
|
|
187
155
|
|
|
188
|
-
await test(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
assert.match(h.rows.innerHTML, /data-walk="on" aria-pressed="true"/, 'the earlier choice shows when you step back to it');
|
|
195
|
-
await h.click('data-walk', 'off');
|
|
196
|
-
await h.click('data-walk', 'off');
|
|
197
|
-
await h.click('data-walk', 'off');
|
|
198
|
-
await h.click('data-walk', 'save');
|
|
199
|
-
assert.deepEqual(writes(h).find((r) => r.method === 'PUT').body, { enabled_skills: [] });
|
|
200
|
-
});
|
|
156
|
+
await test(`${page}: a speciality that offers no skills adopts without a walkthrough`, async () => {
|
|
157
|
+
const h = await boot(page, { catalog: [speciality({ skills: [] })] });
|
|
158
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
159
|
+
assert.ok(!h.requests.some((r) => /\/skills$/.test(r.path)), 'no skills read for a speciality with none');
|
|
160
|
+
assert.ok(!/skill-walk/.test(h.html('specialities-rows')));
|
|
161
|
+
});
|
|
201
162
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
await
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
});
|
|
163
|
+
// ---- the safety step, and the rest of the adopter's tools (task 1005052) ----
|
|
164
|
+
|
|
165
|
+
await test(`${page}: SAFETY — someone else's speciality shows its full instructions, open, in the same card as the only control that adopts it`, async () => {
|
|
166
|
+
const contract = 'Argue from mechanism, not vibes.\nAlways name the assumption a number rests on.';
|
|
167
|
+
const h = await boot(page, { catalog: [speciality({ contract_md: contract })], suggestions: [speciality({ id: 21, name: 'Rates', contract_md: contract })] });
|
|
168
|
+
for (const host of ['specialities-rows', 'speciality-suggestions']) {
|
|
169
|
+
const card = h.find(`#${host} li[data-id]`);
|
|
170
|
+
assert.ok(card, `${host}: the speciality has a card`);
|
|
171
|
+
const details = card.querySelector('details');
|
|
172
|
+
assert.ok(details && details.open, `${host}: its instructions are OPEN, not folded behind a summary`);
|
|
173
|
+
assert.equal(card.querySelector('pre').textContent, contract, `${host}: the whole instruction, word for word`);
|
|
174
|
+
assert.match(card.querySelector('summary').textContent, /written by someone else/, `${host}: and whose they are`);
|
|
175
|
+
assert.match(details.textContent, /You did not write this\. Read it before using the speciality/);
|
|
176
|
+
assert.ok(card.querySelector('button[data-act="adopt"]'), `${host}: the adopt control is in that same card`);
|
|
177
|
+
}
|
|
178
|
+
assert.equal(h.find('button[data-adopt]'), null, 'no other control adopts it');
|
|
179
|
+
});
|
|
210
180
|
|
|
211
|
-
await test(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
});
|
|
181
|
+
await test(`${page}: a speciality you use can be stopped: one POST /abandon, and the card offers it again`, async () => {
|
|
182
|
+
const h = await boot(page, { adoptions: [{ id: 9, active: true, enabled_skills: ['recall'] }] });
|
|
183
|
+
assert.match(text(h), /active/, 'the card says it is in use');
|
|
184
|
+
assert.match(text(h), /offers 4 skills, 1 on for you/, 'and how many of its skills are on');
|
|
185
|
+
assert.equal(h.find(`${ROWS} button[data-act="adopt"]`), null, 'in use: no "Use this"');
|
|
186
|
+
await h.click(`${ROWS} button[data-act="abandon"]`);
|
|
187
|
+
assert.deepEqual(writes(h).map((r) => `${r.method} ${r.path}`), ['POST /specialities/9/abandon']);
|
|
188
|
+
assert.ok(h.toasts.includes('Stopped using it.'));
|
|
189
|
+
assert.ok(h.find(`${ROWS} button[data-act="adopt"]`), 'stopped: it can be taken on again');
|
|
190
|
+
assert.equal(h.find(`${ROWS} button[data-act="abandon"]`), null);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test(`${page}: stopping a speciality closes its walkthrough if it was open`, async () => {
|
|
194
|
+
const h = await boot(page, { adoptions: [{ id: 9, active: true, enabled_skills: [] }] });
|
|
195
|
+
await h.click(`${ROWS} button[data-act="walk"]`);
|
|
196
|
+
assert.match(h.html('specialities-rows'), /skill-walk/);
|
|
197
|
+
await h.click(`${ROWS} button[data-act="abandon"]`);
|
|
198
|
+
assert.ok(!/skill-walk/.test(h.html('specialities-rows')), 'no walkthrough for a speciality you stopped using');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test(`${page}: "Choose its skills" reopens the walkthrough for one you use, and says which are on now`, async () => {
|
|
202
|
+
const h = await boot(page, { adoptions: [{ id: 9, active: true, enabled_skills: ['recall'] }], skillsBody: { ...SKILLS_BODY, enabled: ['recall'] } });
|
|
203
|
+
await h.click(`${ROWS} button[data-act="walk"]`);
|
|
204
|
+
assert.match(text(h), /Skill 1 of 3 in Economics/);
|
|
205
|
+
assert.match(text(h), /You have this one on at the moment/);
|
|
206
|
+
assert.equal(writes(h).length, 0, 'opening it writes nothing');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await test(`${page}: a suggestion adopts through the same act as the catalogue`, async () => {
|
|
210
|
+
const h = await boot(page, { suggestions: [speciality({ id: 21, name: 'Rates', skills: [] })] });
|
|
211
|
+
assert.match(h.text('speciality-suggestions'), /Suggested for you.*Rates/);
|
|
212
|
+
await h.click('#speciality-suggestions button[data-act="adopt"]');
|
|
213
|
+
assert.deepEqual(writes(h).map((r) => `${r.method} ${r.path}`), ['POST /specialities/21/adopt']);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await test(`${page}: adopting, stopping and saving its skills each tell the rest of the page, so what a session reads can be re-read`, async () => {
|
|
217
|
+
const h = await boot(page);
|
|
218
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
219
|
+
assert.deepEqual(h.events, ['otb:specialities-changed'], 'adopting');
|
|
220
|
+
for (let i = 0; i < 3; i++) await h.click(`${ROWS} button[data-walk="off"]`);
|
|
221
|
+
await h.click(`${ROWS} button[data-walk="save"]`);
|
|
222
|
+
assert.equal(h.events.length, 2, 'saving the skills');
|
|
223
|
+
await h.click(`${ROWS} button[data-act="abandon"]`);
|
|
224
|
+
assert.equal(h.events.length, 3, 'stopping');
|
|
225
|
+
const quiet = await boot(page, { catalog: [speciality({ mine: true })] });
|
|
226
|
+
await quiet.click(`${ROWS} button[data-act="train"]`);
|
|
227
|
+
assert.deepEqual(quiet.events, [], 'opening a tool that changes no session says nothing');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await test(`${page}: a refused adopt says why and changes nothing`, async () => {
|
|
231
|
+
const h = await boot(page, { answer: (m, p) => (m === 'POST' && /\/adopt$/.test(p) ? { status: 409, data: { message: 'already_adopted' } } : null) });
|
|
232
|
+
await h.click(`${ROWS} button[data-act="adopt"]`);
|
|
233
|
+
assert.equal(h.el('specialities-status').textContent, 'already_adopted');
|
|
234
|
+
assert.ok(!/skill-walk/.test(h.html('specialities-rows')), 'no walkthrough for an adopt that did not happen');
|
|
235
|
+
assert.deepEqual(h.events, [], 'and nothing told the page it changed');
|
|
236
|
+
});
|
|
237
|
+
}
|
|
217
238
|
|
|
218
239
|
summary();
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
// modules/hall-ui/public/settings-interaction.js — the "How Claude talks to you"
|
|
2
|
-
// panel on /builders/settings (task 2010's profile, delivered by task 1002514).
|
|
3
|
-
//
|
|
4
|
-
// WHY THE PANEL EXISTS. The interaction profile shipped complete except for a
|
|
5
|
-
// way to set it: interaction-prefs.js stored it, GET/PATCH /me/interaction served
|
|
6
|
-
// it, and the conductor hook already injected the resolved contract into every
|
|
7
|
-
// session — but settings.js had 18 `wandering` references and 0 `interaction`
|
|
8
|
-
// ones, so the profile was reachable only by a raw API call. In practice that
|
|
9
|
-
// meant nobody had one.
|
|
10
|
-
//
|
|
11
|
-
// Its own file rather than more of settings.js, on the settings-sessions.js
|
|
12
|
-
// precedent and for the same stated reason: settings.js sits against the
|
|
13
|
-
// 1500-line size ratchet (adding this inline pushed it to 1601 and regressed the
|
|
14
|
-
// budget). The panel is self-contained — it shares nothing with the rest of the
|
|
15
|
-
// page but the `window.OTB` toolkit and the generated API client, both globals
|
|
16
|
-
// loaded before it.
|
|
17
|
-
//
|
|
18
|
-
// THE ONE SHAPE DIFFERENCE FROM WANDERING, which is the sibling to read first:
|
|
19
|
-
// wandering is a single level, so it is one radiogroup. This payload is a FIELD
|
|
20
|
-
// CATALOG — several independent fields, each with its own value set — so each
|
|
21
|
-
// field is its own single-select and a PATCH carries only the field that changed
|
|
22
|
-
// ({ <field>: <value> }). The route merges, so the untouched fields keep their
|
|
23
|
-
// values and a field never explicitly set keeps falling back to its default.
|
|
24
|
-
|
|
25
|
-
(() => {
|
|
26
|
-
'use strict';
|
|
27
|
-
|
|
28
|
-
const API = '/api/bongos';
|
|
29
|
-
const { escapeHtml, $ } = window.OTB;
|
|
30
|
-
const toast = (msg) => window.OTB.toast(msg);
|
|
31
|
-
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
32
|
-
|
|
33
|
-
const toAuth = () => window.location.assign(`${API}/auth/web/start`);
|
|
34
|
-
// the project's laws on these fields (task 1004724): settings-law-locks.js
|
|
35
|
-
const Laws = window.OTBSettingsLaws || { lockFor: () => null, allows: () => true, lawLine: () => '', tagFor: () => '' };
|
|
36
|
-
|
|
37
|
-
function render(data) {
|
|
38
|
-
const wrap = $('#interaction-rows');
|
|
39
|
-
if (!wrap) return;
|
|
40
|
-
const fields = Array.isArray(data && data.fields) ? data.fields : [];
|
|
41
|
-
const eff = (data && data.effective) || {};
|
|
42
|
-
if (fields.length === 0) {
|
|
43
|
-
wrap.innerHTML = window.OTBKit.emptyStateHtml('No interaction settings registered.');
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
// A fieldset per field so the field's label is its radiogroup's accessible
|
|
47
|
-
// name — four independent single-selects, not one flat list of radios.
|
|
48
|
-
wrap.innerHTML = fields.map((f) => {
|
|
49
|
-
const chosenValue = eff[f.field];
|
|
50
|
-
const lock = Laws.lockFor(data, f.field);
|
|
51
|
-
const rows = (f.values || []).map((v) => {
|
|
52
|
-
const id = `interaction-${f.field}-${v.value}`;
|
|
53
|
-
const chosen = v.value === chosenValue;
|
|
54
|
-
const barred = !Laws.allows(lock, v.value);
|
|
55
|
-
// No "default" tag here, deliberately: interaction-prefs.js already ends
|
|
56
|
-
// every default value's LABEL with "(default)", so adding the row tag the
|
|
57
|
-
// wandering picker uses rendered "Non-technical (default) · DEFAULT".
|
|
58
|
-
// One source for the word, and it is the label the API already ships.
|
|
59
|
-
return (
|
|
60
|
-
`<label class="prow prow--pick" for="${id}"${barred ? ' aria-disabled="true"' : ''}>` +
|
|
61
|
-
`<input class="pradio" type="radio" name="interaction-${escapeHtml(f.field)}" id="${id}"` +
|
|
62
|
-
` value="${escapeHtml(v.value)}" data-field="${escapeHtml(f.field)}"${chosen ? ' checked' : ''}${barred ? ' disabled' : ''}>` +
|
|
63
|
-
'<span class="prow__main">' +
|
|
64
|
-
`<span class="prow__label">${escapeHtml(v.label)}</span>` +
|
|
65
|
-
'</span></label>'
|
|
66
|
-
);
|
|
67
|
-
}).join('');
|
|
68
|
-
return (
|
|
69
|
-
`<fieldset class="pgroup" id="interaction-field-${escapeHtml(f.field)}">` +
|
|
70
|
-
`<legend class="prow__label">${escapeHtml(f.label)}</legend>` +
|
|
71
|
-
`<p class="prow__desc">${escapeHtml(f.blurb || '')}</p>` +
|
|
72
|
-
(lock ? `<p class="prow__desc prow__law" data-law="${escapeHtml(String(lock.law_no || ''))}">${escapeHtml(Laws.lawLine(lock))}</p>` : '') +
|
|
73
|
-
rows +
|
|
74
|
-
'</fieldset>'
|
|
75
|
-
);
|
|
76
|
-
}).join('');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// The human label for a saved value, for the status line — falls back to the
|
|
80
|
-
// raw value rather than going blank if the catalog and the payload disagree.
|
|
81
|
-
function labelFor(data, field, value) {
|
|
82
|
-
const f = (Array.isArray(data && data.fields) ? data.fields : []).find((x) => x.field === field);
|
|
83
|
-
const v = f && (f.values || []).find((x) => x.value === value);
|
|
84
|
-
return (v && v.label) || value;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function save(field, value, input) {
|
|
88
|
-
const status = $('#interaction-status');
|
|
89
|
-
if (status) status.textContent = 'Saving…';
|
|
90
|
-
if (input) input.disabled = true;
|
|
91
|
-
try {
|
|
92
|
-
const res = await api.request('PATCH', `${API}/me/interaction`, { body: { [field]: value } });
|
|
93
|
-
if (res.status === 401) { toAuth(); return; }
|
|
94
|
-
if (!res.ok) throw new Error(`interaction PATCH → ${res.status}`);
|
|
95
|
-
render(res.data);
|
|
96
|
-
const msg = `Saved — ${labelFor(res.data, field, value)}.`;
|
|
97
|
-
if (status) status.textContent = msg;
|
|
98
|
-
toast(msg);
|
|
99
|
-
} catch (err) {
|
|
100
|
-
console.error('[settings] save interaction failed', err);
|
|
101
|
-
if (status) status.textContent = 'Could not save. Try again.';
|
|
102
|
-
toast('Could not save your interaction profile. Try again.');
|
|
103
|
-
if (input) input.disabled = false;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function setup() {
|
|
108
|
-
try {
|
|
109
|
-
const res = await api.request('GET', `${API}/me/interaction`);
|
|
110
|
-
if (res.status === 401) { toAuth(); return; }
|
|
111
|
-
if (!res.ok) throw new Error(`interaction GET → ${res.status}`);
|
|
112
|
-
render(res.data);
|
|
113
|
-
$('#interaction-scroll').hidden = false;
|
|
114
|
-
// Delegate the change so post-render radios stay wired.
|
|
115
|
-
$('#interaction-rows').addEventListener('change', (e) => {
|
|
116
|
-
const r = e.target.closest('.pradio');
|
|
117
|
-
if (!r || r.disabled || !r.checked) return;
|
|
118
|
-
const field = r.getAttribute('data-field');
|
|
119
|
-
if (field) save(field, r.value, r);
|
|
120
|
-
});
|
|
121
|
-
} catch (err) {
|
|
122
|
-
// Its own try/catch, like every other section: a failing endpoint shows this
|
|
123
|
-
// panel's own error rather than taking the settings page down with it.
|
|
124
|
-
console.error('[settings] failed to load interaction', err);
|
|
125
|
-
$('#interaction-scroll').hidden = false;
|
|
126
|
-
$('#interaction-rows').innerHTML =
|
|
127
|
-
'<p class="voices-empty">Your interaction profile could not be loaded. Reload the page to try again.</p>';
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
setup();
|
|
132
|
-
})();
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// modules/hall-ui/public/settings-law-locks.js — the project's LAWS on the
|
|
2
|
-
// /builders/settings rows (task 1004724).
|
|
3
|
-
//
|
|
4
|
-
// A law passed by the project's government may lock a personal setting: FIX it
|
|
5
|
-
// to one value, or CAP it. The server enforces the lock (law-locks.js, task
|
|
6
|
-
// 1004707) and every settings read already reports it as `law_locks` — this file
|
|
7
|
-
// is only how the page SAYS so: a value the lock does not allow is a disabled
|
|
8
|
-
// row tagged with the law, and the setting carries one line naming the law.
|
|
9
|
-
// Shared by the "How Claude talks to you" panel (settings-interaction.js) and the
|
|
10
|
-
// wandering picker (settings.js). The page decides nothing; a write the law
|
|
11
|
-
// forbids is refused by the route either way (409 locked_by_law).
|
|
12
|
-
//
|
|
13
|
-
// PURE: no DOM, no fetch. tests/settings_law_locks.mjs requires it in node.
|
|
14
|
-
|
|
15
|
-
(function (root) {
|
|
16
|
-
'use strict';
|
|
17
|
-
|
|
18
|
-
const str = (v) => (v == null ? '' : String(v));
|
|
19
|
-
|
|
20
|
-
// The lock a law puts on `setting`, from a settings read's `law_locks`, or null.
|
|
21
|
-
// Only an ENFORCED lock counts: one naming no real value locks nothing.
|
|
22
|
-
function lockFor(data, setting) {
|
|
23
|
-
const locks = data && Array.isArray(data.law_locks) ? data.law_locks : [];
|
|
24
|
-
return locks.find((l) => l && l.enforced === true && str(l.setting) === str(setting)) || null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// May `value` still be picked under `lock`? No lock: yes.
|
|
28
|
-
function allows(lock, value) {
|
|
29
|
-
if (!lock) return true;
|
|
30
|
-
if (!Array.isArray(lock.allowed)) return true;
|
|
31
|
-
return lock.allowed.map(str).includes(str(value));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// The one line the setting carries: which law, and what it does to the row.
|
|
35
|
-
function lawLine(lock) {
|
|
36
|
-
if (!lock) return '';
|
|
37
|
-
const n = lock.law_no != null ? 'Law ' + str(lock.law_no) : 'A law';
|
|
38
|
-
const text = str(lock.law_text).trim();
|
|
39
|
-
const what = Object.prototype.hasOwnProperty.call(lock, 'value')
|
|
40
|
-
? ' sets this for every agent in the project'
|
|
41
|
-
: ' caps this for every agent in the project';
|
|
42
|
-
return n + what + (text ? ': "' + text + '".' : '.') + ' Only a vote can change it.';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// The tag a row the lock rules out wears.
|
|
46
|
-
function tagFor(lock) {
|
|
47
|
-
return lock ? 'set by ' + (lock.law_no != null ? 'law ' + str(lock.law_no) : 'a law') : '';
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const api = { lockFor, allows, lawLine, tagFor };
|
|
51
|
-
root.OTBSettingsLaws = api;
|
|
52
|
-
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
53
|
-
}(typeof window !== 'undefined' ? window : globalThis));
|