@bongos/core 1.24.28 → 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.
Files changed (34) hide show
  1. package/.bongos-core.json +38 -48
  2. package/.claude/skills/builder-claim/SKILL.md +1 -1
  3. package/.claude/skills/builder-ship/SKILL.md +1 -1
  4. package/docs/copy-inventory.md +164 -194
  5. package/docs/copy-registry.json +250 -522
  6. package/docs/module-api-changelog.md +2 -0
  7. package/docs/page-inventory.json +2 -8
  8. package/docs/page-readings.json +243 -291
  9. package/modules/builder-settings/interaction-prefs.js +2 -2
  10. package/modules/hall-ui/public/my-agents.js +35 -2
  11. package/modules/hall-ui/public/palette.js +8 -0
  12. package/modules/hall-ui/public/settings-moved.js +41 -0
  13. package/modules/hall-ui/public/settings.css +4 -28
  14. package/modules/hall-ui/public/settings.html +19 -107
  15. package/modules/hall-ui/public/settings.js +20 -215
  16. package/modules/hall-ui/public/settings.states.json +2 -4
  17. package/modules/hall-ui/records/my-agents.md +3 -2
  18. package/modules/hall-ui/records/panel-family.md +2 -0
  19. package/modules/lifecycle/ship-card.js +2 -2
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/release-notes.json +6 -0
  23. package/src/module-api.js +1 -1
  24. package/tests/government_tidy_up.mjs +21 -24
  25. package/tests/hall_my_agents.mjs +88 -2
  26. package/tests/hall_palette.mjs +12 -0
  27. package/tests/hall_settings_world.mjs +64 -100
  28. package/tests/helpers.mjs +6 -5
  29. package/tests/interaction_posture.mjs +3 -1
  30. package/tests/speciality_offer_ui.mjs +6 -5
  31. package/tests/speciality_walkthrough_ui.mjs +7 -5
  32. package/modules/hall-ui/public/settings-interaction.js +0 -132
  33. package/modules/hall-ui/public/settings-law-locks.js +0 -53
  34. package/modules/hall-ui/public/settings-render.js +0 -138
@@ -41,6 +41,7 @@ import assert from 'node:assert/strict';
41
41
  import fs from 'node:fs';
42
42
  import path from 'node:path';
43
43
  import { fileURLToPath } from 'node:url';
44
+ import { createRequire } from 'node:module';
44
45
 
45
46
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
46
47
  const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
@@ -153,120 +154,83 @@ test('the switch clears the floor: a 46x38 target drawing a 44x24 track', () =>
153
154
  assert.match(on, /background:\s*var\(--accent-tint\)/, 'on is the tint, the accent arrives on the knob');
154
155
  });
155
156
 
156
- test('the wandering pick is a radio row, wired on change', () => {
157
- const render = settingsJs.match(/function renderWandering\([\s\S]*?\n \}\n/);
158
- assert.ok(render, 'renderWandering is a whole function');
159
- assert.match(render[0], /<label class="prow prow--pick" for="\$\{id\}"/, 'the whole row is the radio\'s label');
160
- assert.match(render[0], /<input class="pradio" type="radio" name="wandering"/, 'a real radio group');
161
- assert.match(render[0], /locked \? ' disabled' : ''/, 'a level above the rank ceiling is a disabled radio');
162
- assert.doesNotMatch(render[0], /Rank-locked<\/button>|Choose<\/button>/, 'the four "Choose" buttons are gone');
163
- const load = settingsJs.match(/async function loadWandering\([\s\S]*?\n \}\n/);
164
- assert.match(load[0], /addEventListener\('change'/, 'a radio fires change, not click');
165
- assert.match(load[0], /closest\('\.pradio'\)/);
157
+ test('the family radio clears the floor: a native radio is 13px, the family one is 24', () => {
166
158
  const radio = ruleOf(panelCss, '.pradio');
167
159
  assert.match(radio, /width:\s*24px/);
168
160
  assert.match(radio, /height:\s*24px/, 'a native radio is 13px; the floor is 24');
169
161
  });
170
162
 
171
- // ---- the interaction panel (task 1002514) ----------------------------------
163
+ // ---- the five agent settings live on My agents only (task 1005044) ----------
172
164
  //
173
- // Three facts a browser is silent about: the panel is rendered by a script that
174
- // is not settings.js, so the wiring between the two halves is the thing that can
175
- // silently break; and the carve exists because settings.js is against the
176
- // 1500-line ratchet, which is a fact a reader will otherwise undo.
177
-
178
- test('the interaction panel is wired end to end across its two files', () => {
165
+ // How Claude talks to you, Wandering, Rendering (session cards), Skill voices and
166
+ // Your specialities used to be edited on BOTH pages, on the same routes, with no
167
+ // hint that the two were one setting. The owner kept them on My agents only. What
168
+ // each old panel promised a builder is now proven on My agents instead
169
+ // (tests/hall_my_agents.mjs "MOVED:", tests/speciality_offer_ui.mjs,
170
+ // tests/speciality_walkthrough_ui.mjs); this pins that Settings no longer carries
171
+ // them, says where they went, and sends every old link there.
172
+
173
+ const MOVED_SECTIONS = ['interaction-scroll', 'specialities-scroll', 'wandering-scroll', 'render-scroll', 'voices-scroll'];
174
+ const requireHall = (f) => createRequire(import.meta.url)(path.join(HALL, f));
175
+
176
+ test('Settings carries none of the five agent settings: no section, no script, no write', () => {
179
177
  const html = read('settings.html');
180
- const panelJs = read('settings-interaction.js');
181
-
182
- // The markup half lives in settings.html and must join a group that already
183
- // exists — the rail renders groups, so a section in an unknown data-group is
184
- // markup nothing ever shows.
185
- const section = html.match(/<section class="scroll" id="interaction-scroll"[^>]*>/);
186
- assert.ok(section, 'the panel has a section');
187
- assert.match(section[0], /data-group="preferences"/, 'it joins a group the rail already renders');
188
- assert.match(section[0], /\bhidden\b/, 'hidden until its own read succeeds, like every other panel');
189
- assert.match(html, /<script src="\/builders\/settings-interaction\.js/, 'the page loads the panel script');
190
-
191
- // The script half self-boots and reveals the section. If it stopped doing
192
- // either, the panel would simply never appear and no test would notice.
193
- assert.match(panelJs, /\$\('#interaction-scroll'\)\.hidden = false/, 'the script reveals its own section');
194
- assert.match(panelJs, /setup\(\);/, 'it self-boots — settings.js does not call it');
195
-
196
- // settings.js keeps ONLY the anchor entry, so /settings#interaction activates
197
- // the group before the panel paints.
198
- assert.match(settingsJs, /'interaction': 'interaction-scroll'/, 'the deep-link anchor stays in settings.js');
199
- assert.doesNotMatch(settingsJs, /function renderInteraction\(/, 'the renderer moved out and must not come back inline');
178
+ for (const id of MOVED_SECTIONS) assert.doesNotMatch(html, new RegExp(`id="${id}"`), `${id} is gone from Settings`);
179
+ for (const f of ['settings-interaction.js', 'settings-render.js', 'settings-law-locks.js', 'speciality-tools.js', 'speciality-tools.css']) {
180
+ assert.doesNotMatch(html, new RegExp(f.replace('.', '\\.')), `Settings no longer loads ${f}`);
181
+ }
182
+ for (const f of ['settings-interaction.js', 'settings-render.js', 'settings-law-locks.js']) {
183
+ assert.ok(!fs.existsSync(path.join(HALL, f)), `${f} had one host, Settings, and went with its panel`);
184
+ }
185
+ for (const route of ['/me/skill-prefs', '/me/wandering', '/me/interaction', '/me/render', '/specialities']) {
186
+ assert.ok(!settingsJs.includes(route), `settings.js no longer reads or writes ${route}`);
187
+ }
188
+ assert.doesNotMatch(settingsJs, /function (renderVoices|renderWandering|loadVoices|loadWandering)\(/);
200
189
  });
201
190
 
202
- test('the interaction panel stays out of settings.js, which is against the size ratchet', () => {
203
- // Not style: inlining this panel put settings.js at 1601 lines and regressed
204
- // oversized_file_count 0→1 and excess_lines_total 0→102 (fitness-ratchets.js,
205
- // OVERSIZE_LINES = 1500). The settings-sessions.js precedent, same reason.
206
- const lines = settingsJs.split('\n').length;
207
- assert.ok(lines < 1500, `settings.js is ${lines} lines — at or over the 1500 ratchet; carve the next panel out too`);
191
+ test('Settings says where they went: one line to My agents in Preferences and one in Sound', () => {
192
+ const html = read('settings.html').replace(/<!--[\s\S]*?-->/g, '');
193
+ for (const [group, anchor] of [['preferences', '#talk'], ['sound', '#skills']]) {
194
+ const m = html.match(new RegExp(`<section class="scroll[^"]*" id="agents-moved-${group}"[^>]*>[\\s\\S]*?</section>`));
195
+ assert.ok(m, `a section in ${group} points to My agents`);
196
+ assert.match(m[0], new RegExp(`data-group="${group}"`), `it sits in the ${group} group`);
197
+ assert.doesNotMatch(m[0].match(/<section[^>]*>/)[0], /\bhidden\b/, 'drawn without waiting on any read');
198
+ assert.match(m[0], new RegExp(`href="/my-agents${anchor}"`), `it links to /my-agents${anchor}`);
199
+ }
200
+ // Sound still carries the sounds-off note (task 1004554), revealed at boot
201
+ assert.match(html, /id="sounds-off-scroll"[^>]*data-group="sound"/);
202
+ assert.match(settingsJs, /\$\('#sounds-off-scroll'\)\.hidden = false/);
208
203
  });
209
204
 
210
- // ---- the Rendering panel, carved out the same way (task 1002518) -----------
211
- //
212
- // Same two-file shape as the interaction panel above, and the same reason: the
213
- // knob became a four-rung ladder with a DERIVED default, which is more UI than
214
- // settings.js has room for. The extra fact this panel has to keep true is that
215
- // its "default" row explains itself — the default is computed from another
216
- // panel's answers, so a bare "default" tag would name a choice without naming
217
- // who made it.
218
-
219
- test('the rendering panel is wired end to end across its two files', () => {
205
+ test('every old link to those sections lands on My agents', () => {
206
+ const Moved = requireHall('settings-moved.js');
207
+ const expect = {
208
+ interaction: '/my-agents#talk', rendering: '/my-agents#talk', render: '/my-agents#talk',
209
+ wandering: '/my-agents#work', voices: '/my-agents#skills', specialities: '/my-agents#knows',
210
+ };
211
+ for (const [anchor, to] of Object.entries(expect)) {
212
+ assert.equal(Moved.movedTarget('#' + anchor), to, `/settings#${anchor}`);
213
+ }
214
+ // the old section ids resolved as deep links too (resolveHash falls back to an element id)
215
+ for (const id of MOVED_SECTIONS) assert.match(String(Moved.movedTarget('#' + id)), /^\/my-agents#/, `/settings#${id}`);
216
+ for (const kept of ['', '#preferences', '#sound', '#cli', '#art-key', '#software-update', '#account']) {
217
+ assert.equal(Moved.movedTarget(kept), null, `${kept || 'no hash'} stays on Settings`);
218
+ }
219
+ // the anchors it sends to are ones My agents lands
220
+ const MyAgents = requireHall('my-agents.js');
221
+ for (const to of new Set(Object.values(expect))) assert.ok(MyAgents.anchorFor(to.slice(to.indexOf('#'))), `${to} is a My agents anchor`);
222
+ // settings.js asks before it draws anything, and on every later hash
220
223
  const html = read('settings.html');
221
- const panelJs = read('settings-render.js');
222
-
223
- const section = html.match(/<section class="scroll" id="render-scroll"[^>]*>/);
224
- assert.ok(section, 'the panel has a section');
225
- assert.match(section[0], /data-group="preferences"/, 'it joins a group the rail already renders');
226
- assert.match(section[0], /\bhidden\b/, 'hidden until its own read succeeds');
227
- assert.match(html, /<script src="\/builders\/settings-render\.js/, 'the page loads the panel script');
228
-
229
- assert.match(panelJs, /\$\('#render-scroll'\)\.hidden = false/, 'the script reveals its own section');
230
- assert.match(panelJs, /setup\(\);/, 'it self-boots — settings.js does not call it');
231
-
232
- // settings.js keeps ONLY the anchor entry.
233
- assert.match(settingsJs, /'rendering': 'render-scroll'/, 'the deep-link anchor stays in settings.js');
234
- assert.doesNotMatch(settingsJs, /function renderRender\(/, 'the renderer moved out and must not come back inline');
235
- assert.doesNotMatch(settingsJs, /loadRender\(\)/, 'and settings.js no longer calls it');
236
- });
237
-
238
- test('the rendering panel is a radio group, wired on change, and can clear the override', () => {
239
- const panelJs = read('settings-render.js');
240
- const render = panelJs.match(/function render\([\s\S]*?\n \}\n/);
241
- assert.ok(render, 'render is a whole function');
242
- assert.match(render[0], /<input class="pradio" type="radio" name="render-level"/, 'a real radio group');
243
- assert.match(render[0], /<label class="prow prow--pick"/, 'the whole row is the radio\'s label');
244
- const setup = panelJs.match(/async function setup\([\s\S]*?\n \}\n/);
245
- assert.match(setup[0], /addEventListener\('change'/, 'a radio fires change, not click');
246
- assert.match(setup[0], /closest\('\.pradio'\)/);
247
- // The sentinel row must send null, not a level: pinning the rung that happens
248
- // to be derived today would silently stop tracking the interaction profile.
249
- assert.match(panelJs, /widgets: value === USE_DEFAULT \? null : value/,
250
- 'choosing "match my profile" CLEARS the override rather than pinning a level');
224
+ assert.ok(html.indexOf('settings-moved.js') > 0 && html.indexOf('settings-moved.js') < html.indexOf('/builders/settings.js'), 'loaded before settings.js');
225
+ assert.match(settingsJs, /location\.replace\(/, 'a replace, so Back does not bounce through Settings');
226
+ assert.doesNotMatch(settingsJs, /'(interaction|wandering|rendering|voices)': '[a-z-]+-scroll'/, 'no PANEL_ANCHORS entry for a section that left');
251
227
  });
252
228
 
253
- test('the derived default explains itself, because it is computed elsewhere', () => {
254
- // Unlike every other picker on the page, "default" here is not a constant —
255
- // render-prefs.js derives it from the interaction profile. A row that said
256
- // only "default" would name a choice the reader never made and cannot see.
257
- const panelJs = read('settings-render.js');
258
- assert.match(panelJs, /derived_reason/, 'the row states WHY the default landed where it did');
259
- assert.match(panelJs, /derived_level/, 'and WHICH rung that is');
260
- });
261
-
262
- test('a default value is named once, by the API, not twice by the row', () => {
263
- // The first render read "Non-technical (default) · DEFAULT": interaction-prefs.js
264
- // already ends every default value's LABEL with "(default)", and the row also
265
- // carried the wandering picker's .prow__tag. One source for the word.
266
- const panelJs = read('settings-interaction.js');
267
- const render = panelJs.match(/function render\([\s\S]*?\n \}\n/);
268
- assert.ok(render, 'render is a whole function');
269
- assert.doesNotMatch(render[0], /prow__tag/, 'the row must not re-state "default" beside a label that already says it');
229
+ test('settings.js stays under the size ratchet', () => {
230
+ // Not style: inlining a panel once put settings.js at 1601 lines and regressed
231
+ // oversized_file_count 0→1 (fitness-ratchets.js, OVERSIZE_LINES = 1500).
232
+ const lines = settingsJs.split('\n').length;
233
+ assert.ok(lines < 1500, `settings.js is ${lines} lines — at or over the 1500 ratchet; carve the next panel out too`);
270
234
  });
271
235
 
272
236
  test('.craft-pref is a 24px target with a 14px drawn box — it was 13x13, the smallest control in the hall', () => {
package/tests/helpers.mjs CHANGED
@@ -1016,13 +1016,14 @@ export function bootSkyPage({
1016
1016
  }
1017
1017
 
1018
1018
  // bootSpecialityTools — boot THE SPECIALITY TOOLS for real on a page's OWN markup
1019
- // (task 1005052). One component, modules/hall-ui/public/speciality-tools.js, is
1020
- // mounted by Settings and by My agents, and it finds every host by id. So, on the
1019
+ // (task 1005052). One component, modules/hall-ui/public/speciality-tools.js, was
1020
+ // mounted by Settings and by My agents; since task 1005044 My agents is its only
1021
+ // host (Settings links there). It finds every host by id. So, on the
1021
1022
  // bootSkyPage rule, the DOM here is the page's own: its specialities section
1022
1023
  // (`#specialities-scroll`) is parsed out of `<page>.html` into the mini-DOM above,
1023
1024
  // an id the page does not carry is null here too, and an id the component renders
1024
1025
  // itself (the create form's skills picker) exists only once its markup says so. A
1025
- // host lost from either page fails that page's run.
1026
+ // host lost from a page fails that page's run.
1026
1027
  //
1027
1028
  // The fake API keeps the builder's adoptions as STATE: adopt adds one, abandon
1028
1029
  // drops it, the walkthrough's PUT sets its enabled skills, so a reload after a
@@ -1034,7 +1035,7 @@ export function bootSkyPage({
1034
1035
  // await h.click('#specialities-rows button[data-act="adopt"]');
1035
1036
  // h.writes() → the non-GET requests, with their bodies
1036
1037
  // h.text('specialities-rows'), h.toasts, h.events (the DOM events it dispatched)
1037
- export const SPECIALITY_PAGES = ['settings', 'my-agents'];
1038
+ export const SPECIALITY_PAGES = ['my-agents'];
1038
1039
  function specialitySection(page) {
1039
1040
  const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', `${page}.html`), 'utf8').replace(/<!--[\s\S]*?-->/g, '');
1040
1041
  const at = html.indexOf('id="specialities-scroll"');
@@ -1043,7 +1044,7 @@ function specialitySection(page) {
1043
1044
  return html.slice(start, html.indexOf('</section>', at) + '</section>'.length);
1044
1045
  }
1045
1046
  export function bootSpecialityTools({
1046
- page = 'settings', catalog = [], adoptions = [], suggestions = [], skills = {}, installed = [], answer = null,
1047
+ page = 'my-agents', catalog = [], adoptions = [], suggestions = [], skills = {}, installed = [], answer = null,
1047
1048
  } = {}) {
1048
1049
  const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/speciality-tools.js'), 'utf8');
1049
1050
  let focused = null;
@@ -81,7 +81,9 @@ test('CLAUDE.md expresses the posture through the knob instead of restating it',
81
81
  const handWritten = claude.slice(0, b) + claude.slice(e + gen.END.length);
82
82
  // Clause 2: the kernel must NAME the knob (it named only the wandering one before)...
83
83
  assert.match(handWritten, /interaction profile/i, 'the kernel must reference the knob');
84
- assert.match(handWritten, /interaction-prefs\.js|\/settings#interaction/, 'and say where it lives');
84
+ // ...and say where it lives: on My agents since task 1005044 (Settings no longer edits it)
85
+ assert.match(handWritten, /\/my-agents/, 'and say where it lives');
86
+ assert.doesNotMatch(handWritten, /\/settings#interaction/, 'not the Settings panel it left');
85
87
  // ...and must not carry a hand-written second copy of the posture beside the generated one.
86
88
  assert.doesNotMatch(handWritten, /Don't assume software-engineering vocabulary/,
87
89
  'the old §2 prose copy is what drifted — it must not come back outside the generated block');
@@ -10,11 +10,12 @@
10
10
  // and that training adds a document in one request, refused inline when a field
11
11
  // is empty.
12
12
  //
13
- // TWO PAGES, ONE COMPONENT (task 1005052). modules/hall-ui/public/speciality-tools.js
14
- // is mounted by Settings and by My agents, and it renders its own create form, so
15
- // neither page carries a second copy of that markup. Every case runs once per
16
- // page, booted on THAT page's own markup (tests/helpers.mjs bootSpecialityTools),
17
- // so a host lost from either page fails that page's run.
13
+ // ONE COMPONENT, ONE HOST. modules/hall-ui/public/speciality-tools.js was mounted
14
+ // by Settings and by My agents (task 1005052); since task 1005044 My agents is the
15
+ // only page that edits a speciality, and Settings links there. Every case runs on
16
+ // each page in SPECIALITY_PAGES, booted on THAT page's own markup
17
+ // (tests/helpers.mjs bootSpecialityTools), so a host lost from it fails its run.
18
+ // tests/hall_settings_world.mjs pins that Settings no longer mounts it.
18
19
  //
19
20
  // Run: node tests/speciality_offer_ui.mjs
20
21
  import assert from 'node:assert/strict';
@@ -12,12 +12,14 @@
12
12
  //
13
13
  // THE SAFETY STEP. A speciality someone else wrote puts their prose into your
14
14
  // sessions, so its full instructions are on the card, open, before the control
15
- // that adopts it — pinned below on both pages.
15
+ // that adopts it — pinned below on My agents.
16
16
  //
17
- // TWO PAGES, ONE COMPONENT (task 1005052). modules/hall-ui/public/speciality-tools.js
18
- // is mounted by Settings and by My agents. Every case runs once per page, booted
19
- // on THAT page's own markup (tests/helpers.mjs bootSpecialityTools), so a host
20
- // lost from either page fails that page's run.
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.
21
23
  //
22
24
  // Run: node tests/speciality_walkthrough_ui.mjs
23
25
  import assert from 'node:assert/strict';
@@ -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));
@@ -1,138 +0,0 @@
1
- // modules/hall-ui/public/settings-render.js — the "Rendering" panel on
2
- // /builders/settings: how rich the deterministic session cards should be
3
- // (task 1279's on/off toggle, a four-rung ladder since task 1002518).
4
- //
5
- // WHY ITS OWN FILE. It began as one row with one switch inside settings.js. As a
6
- // ladder it needs a labelled option per rung plus a line explaining what the
7
- // default resolved to and why — and settings.js sits against the 1500-line size
8
- // ratchet, so the panel moved out on the settings-sessions.js / settings-
9
- // interaction.js precedent. Extracting it took settings.js from 1493 to 1425.
10
- //
11
- // THE SHAPE WORTH KNOWING: unlike every other picker on this page, "default"
12
- // here is not a constant — it is DERIVED from the builder's interaction profile
13
- // (render-prefs.js maps technical level, machinery and reply length onto a
14
- // rung). So the default option cannot just say "default"; it has to say which
15
- // rung it landed on and which profile fields put it there, or the reader sees a
16
- // choice the system made for reasons it never stated. The server ships both
17
- // facts (`derived_level`, `derived_reason`) precisely so this panel can.
18
- //
19
- // PATCH body is { widgets: <level> | null } — null clears the override and
20
- // returns the builder to the derived rung. The route still accepts the legacy
21
- // boolean, which is what lets an older hall build keep working against a newer
22
- // core; this panel only ever sends levels or null.
23
-
24
- (() => {
25
- 'use strict';
26
-
27
- const API = '/api/bongos';
28
- const { escapeHtml, $ } = window.OTB;
29
- const toast = (msg) => window.OTB.toast(msg);
30
- const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
31
-
32
- const toAuth = () => window.location.assign(`${API}/auth/web/start`);
33
-
34
- // The "let the profile decide" option carries the sentinel value '' rather
35
- // than a level, so the change handler can tell "clear my override" from
36
- // "pin me to the rung that happens to be the derived one today". They differ:
37
- // a pinned rung survives a later change to the interaction profile.
38
- const USE_DEFAULT = '';
39
-
40
- function labelForLevel(data, level) {
41
- const found = (Array.isArray(data && data.levels) ? data.levels : []).find((l) => l.value === level);
42
- return (found && found.label) || level;
43
- }
44
-
45
- function render(data) {
46
- const wrap = $('#render-rows');
47
- if (!wrap) return;
48
- const levels = Array.isArray(data && data.levels) ? data.levels : [];
49
- if (levels.length === 0) {
50
- wrap.innerHTML = window.OTBKit.emptyStateHtml('No rendering options registered.');
51
- return;
52
- }
53
- const override = data.override_level || null;
54
- const derived = data.derived_level;
55
-
56
- // The default row first, so the reader meets "you already have a sensible
57
- // answer, here is what it is" before the list of ways to overrule it.
58
- const defaultRow =
59
- `<label class="prow prow--pick" for="render-default">` +
60
- `<input class="pradio" type="radio" name="render-level" id="render-default"` +
61
- ` value="${USE_DEFAULT}"${override === null ? ' checked' : ''}>` +
62
- '<span class="prow__main">' +
63
- `<span class="prow__label">Match my interaction profile` +
64
- `<span class="prow__tag">default (${escapeHtml(labelForLevel(data, derived))})</span></span>` +
65
- `<span class="prow__desc">${escapeHtml(data.derived_reason || '')} Change that profile and this follows it.</span>` +
66
- '</span></label>';
67
-
68
- const rows = levels.map((l) => {
69
- const id = `render-level-${l.value}`;
70
- return (
71
- `<label class="prow prow--pick" for="${id}">` +
72
- `<input class="pradio" type="radio" name="render-level" id="${id}"` +
73
- ` value="${escapeHtml(l.value)}"${override === l.value ? ' checked' : ''}>` +
74
- '<span class="prow__main">' +
75
- `<span class="prow__label">${escapeHtml(l.label)}</span>` +
76
- `<span class="prow__desc">${escapeHtml(l.blurb)}</span>` +
77
- '</span></label>'
78
- );
79
- }).join('');
80
-
81
- wrap.innerHTML =
82
- '<fieldset class="pgroup" id="render-level-group">' +
83
- '<legend class="prow__label">Session card richness</legend>' +
84
- '<p class="prow__desc">How much the claim and ship cards show. Richer cards explain more and cost more tokens; the confirmation line is always there whichever you pick.</p>' +
85
- defaultRow + rows +
86
- '</fieldset>';
87
- }
88
-
89
- async function save(value, input) {
90
- const status = $('#render-status');
91
- if (status) status.textContent = 'Saving…';
92
- if (input) input.disabled = true;
93
- try {
94
- // The sentinel becomes an explicit null — the route's clear-the-override
95
- // signal. Anything else is a level and is stored as the override.
96
- const body = { widgets: value === USE_DEFAULT ? null : value };
97
- const res = await api.request('PATCH', `${API}/me/render`, { body });
98
- if (res.status === 401) { toAuth(); return; }
99
- if (!res.ok) throw new Error(`render PATCH → ${res.status}`);
100
- render(res.data);
101
- const msg = value === USE_DEFAULT
102
- ? `Saved — following your interaction profile (${labelForLevel(res.data, res.data.derived_level)}).`
103
- : `Saved — ${labelForLevel(res.data, value)}.`;
104
- if (status) status.textContent = msg;
105
- toast(msg);
106
- } catch (err) {
107
- console.error('[settings] save render failed', err);
108
- if (status) status.textContent = 'Could not save. Try again.';
109
- toast('Could not save your rendering preference. Try again.');
110
- if (input) input.disabled = false;
111
- }
112
- }
113
-
114
- async function setup() {
115
- try {
116
- const res = await api.request('GET', `${API}/me/render`);
117
- if (res.status === 401) { toAuth(); return; }
118
- if (!res.ok) throw new Error(`render GET → ${res.status}`);
119
- render(res.data);
120
- $('#render-scroll').hidden = false;
121
- // Delegate the change so post-render radios stay wired.
122
- $('#render-rows').addEventListener('change', (e) => {
123
- const r = e.target.closest('.pradio');
124
- if (!r || r.disabled || !r.checked) return;
125
- save(r.value, r);
126
- });
127
- } catch (err) {
128
- // Its own try/catch, like every other section: a failing endpoint shows
129
- // this panel's own error rather than taking the settings page down.
130
- console.error('[settings] failed to load render', err);
131
- $('#render-scroll').hidden = false;
132
- $('#render-rows').innerHTML =
133
- '<p class="voices-empty">Your rendering preference could not be loaded. Reload the page to try again.</p>';
134
- }
135
- }
136
-
137
- setup();
138
- })();